#!/usr/bin/env python3 """Release-Skript: aus den Bruchstücken unter `unreleased/` wird eine neue Version. python scripts/release.py 0.5.0 [--title "Kurzer Titel"] [--date 2026-10-01] [--dry-run] Was passiert (alles oder nichts – vorher wird alles geprüft, erst dann geschrieben): 1. Alle `backend/src/nodvard_deck/changelog/unreleased/*.toml` werden STRENG gelesen (eine kaputte Datei bricht ab, statt still zu fehlen) und zu `backend/src/nodvard_deck/changelog/versions/.toml` zusammengefasst, mit dem heutigen Datum (oder `--date`) und optional `--title`. 2. Die Bruchstücke werden gelöscht (die README.md bleibt). 3. Die Versionsnummer wird an allen Stellen angehoben: `backend/src/nodvard_deck/version.py`, `backend/pyproject.toml`, `frontend/package.json`, die beiden Wurzel-Einträge in `frontend/package-lock.json` (sonst zeigt der nächste `npm install` einen Unterschied) und die Version des Update-Helfers (`deploy/updater/nodvard_deck_updater/__init__.py`, er wird mit jedem Release zusammen gebaut und meldet dieselbe Nummer wie das Dashboard). 4. `docs/release-notes/dock-.md` wird aus den passenden Einträgen erzeugt. Vorhandene Dock-Release-Notes werden nicht überschrieben. Abgelehnt wird, wenn die Version nicht größer ist als die aktuelle, wenn es keine Bruchstücke gibt oder wenn die bisherigen Versionsnummern nicht im Gleichschritt sind (dafür gibt es auch einen Test). Eine schon vorhandene Versionsdatei fällt unter "nicht größer": die neueste Datei nennt immer die aktuelle Version. Abgelehnt wird auch ein Bruchstück, dessen Eintrag schon in einer Versionsdatei steht (etwa nach einem Patch vom Release-Zweig). Versionsnummern: größere Funktionsrunden erhöhen die mittlere Zahl (0.5.0 -> 0.6.0), reine Fehlerkorrekturen die letzte (0.5.0 -> 0.5.1). Ein Patch bleibt in der Reihe der aktuellen Version und nimmt nur Einträge `behoben` und `sicherheit` auf; Ablauf in docs/VEROEFFENTLICHEN.md. Nur Standardbibliothek plus `nodvard_deck.changelog` (selbst nur Standardbibliothek); das Skript ruft kein git auf – Commit und PR macht der Mensch bzw. die Sitzung danach. """ from __future__ import annotations import argparse import json import re import sys import tomllib from dataclasses import dataclass from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError REPO_ROOT = Path(__file__).resolve().parent.parent # Vor dem Import: das Skript soll den Stand DIESES Repositorys prüfen, nicht ein # eventuell älteres, installiertes `nodvard_deck`. sys.path.insert(0, str(REPO_ROOT / "backend" / "src")) from nodvard_deck.changelog import ( # noqa: E402 ChangelogError, Entry, KINDS, Release, filter_entries, parse_release, parse_version, read_fragment, read_release, ) PATCH_KINDS = ("behoben", "sicherheit") class ReleaseError(Exception): """Das Release wird abgelehnt; die Meldung sagt warum (Exit-Code 1).""" def today() -> str: """Heute als JJJJ-MM-TT in deutscher Ortszeit (die Sitzung läuft meist in UTC – kurz nach Mitternacht wäre sonst das Datum von gestern eingetragen).""" try: return datetime.now(ZoneInfo("Europe/Berlin")).date().isoformat() except ZoneInfoNotFoundError: return datetime.now().date().isoformat() @dataclass(frozen=True) class Tree: """Alle Pfade, die das Skript anfasst – als Parameter, damit Tests auf einer Kopie in einem Temp-Ordner laufen können.""" root: Path @property def changelog_dir(self) -> Path: return self.root / "backend" / "src" / "nodvard_deck" / "changelog" @property def versions_dir(self) -> Path: return self.changelog_dir / "versions" @property def unreleased_dir(self) -> Path: return self.changelog_dir / "unreleased" @property def release_notes_dir(self) -> Path: return self.root / "docs" / "release-notes" @property def version_py(self) -> Path: return self.root / "backend" / "src" / "nodvard_deck" / "version.py" @property def updater_init(self) -> Path: return self.root / "deploy" / "updater" / "nodvard_deck_updater" / "__init__.py" @property def release_workflow(self) -> Path: return self.root / ".github" / "workflows" / "release.yml" @property def pyproject(self) -> Path: return self.root / "backend" / "pyproject.toml" @property def package_json(self) -> Path: return self.root / "frontend" / "package.json" @property def package_lock(self) -> Path: return self.root / "frontend" / "package-lock.json" # --- Versionsstellen lesen und anheben ----------------------------------------------- _VERSION_PY_RE = re.compile(r'^(__version__\s*=\s*")([^"]*)(")', re.MULTILINE) _PYPROJECT_PROJECT_RE = re.compile(r"(?ms)^\[project\][^\n]*\n(.*?)(?=^\[|\Z)") _PYPROJECT_VERSION_RE = re.compile(r'(?m)^(version\s*=\s*")([^"]*)(")') _JSON_VERSION_RE = re.compile(r'(?m)^( "version":\s*")([^"]*)(")') _LOCK_ROOT_RE = re.compile(r'(?s)("packages":\s*\{\s*"":\s*\{.*?"version":\s*")([^"]*)(")') def _read(path: Path) -> str: # Bytes statt read_text: Zeilenenden der Datei bleiben unangetastet. try: return path.read_bytes().decode("utf-8") except OSError as exc: raise ReleaseError(f"{path} laesst sich nicht lesen: {exc}") from exc def _write(path: Path, text: str) -> None: path.write_bytes(text.encode("utf-8")) def _pyproject_version(text: str) -> str | None: block = _PYPROJECT_PROJECT_RE.search(text) match = _PYPROJECT_VERSION_RE.search(block.group(1)) if block else None return match.group(2) if match else None def _lock_versions(text: str) -> tuple[str | None, str | None]: try: data = json.loads(text) return data.get("version"), data.get("packages", {}).get("", {}).get("version") except (ValueError, AttributeError): return None, None def read_versions(tree: Tree) -> dict[str, str | None]: """Die Versionsnummer an jeder Stelle (None = nicht gefunden).""" found: dict[str, str | None] = {} match = _VERSION_PY_RE.search(_read(tree.version_py)) found["backend/src/nodvard_deck/version.py"] = match.group(2) if match else None match = _VERSION_PY_RE.search(_read(tree.updater_init)) found["deploy/updater/nodvard_deck_updater/__init__.py"] = match.group(2) if match else None found["backend/pyproject.toml"] = _pyproject_version(_read(tree.pyproject)) try: found["frontend/package.json"] = json.loads(_read(tree.package_json)).get("version") except ValueError: found["frontend/package.json"] = None if tree.package_lock.exists(): top, root_pkg = _lock_versions(_read(tree.package_lock)) found["frontend/package-lock.json"] = top found["frontend/package-lock.json (packages)"] = root_pkg return found def released_versions(tree: Tree) -> list[Release]: """Alle Versionsdateien, streng gelesen.""" if not tree.versions_dir.is_dir(): return [] released = [] for path in sorted(tree.versions_dir.glob("*.toml")): try: released.append(read_release(path)) except ChangelogError as exc: raise ReleaseError(f"Versionsdatei ungueltig: {exc}") from exc return released def current_version(tree: Tree) -> str: """Die aktuelle Version – nur, wenn ALLE Stellen (auch die neueste Versionsdatei) dieselbe nennen. Sonst weiß niemand, von welcher Nummer aus hochgezählt wird.""" found = read_versions(tree) released = released_versions(tree) if released: newest = max(released, key=lambda r: parse_version(r.version)) found[f"neueste Versionsdatei ({newest.version}.toml)"] = newest.version distinct = {v for v in found.values()} if len(distinct) != 1 or None in distinct: lines = "\n".join(f" {where}: {value or 'nicht gefunden'}" for where, value in found.items()) raise ReleaseError(f"Die Versionsnummern sind nicht im Gleichschritt:\n{lines}") return distinct.pop() # type: ignore[return-value] def _sub_once(pattern: re.Pattern[str], text: str, version: str, where: str) -> str: new, count = pattern.subn(lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1) if count != 1: raise ReleaseError(f"In {where} wurde keine Versionsnummer gefunden.") return new def bumped_files(tree: Tree, version: str) -> dict[Path, str]: """Neuer Inhalt aller Dateien mit Versionsnummer (noch nichts geschrieben).""" out: dict[Path, str] = {} out[tree.version_py] = _sub_once(_VERSION_PY_RE, _read(tree.version_py), version, "version.py") out[tree.updater_init] = _sub_once(_VERSION_PY_RE, _read(tree.updater_init), version, "Update-Helfer (__init__.py)") pyproject = _read(tree.pyproject) block = _PYPROJECT_PROJECT_RE.search(pyproject) if not block: raise ReleaseError("In pyproject.toml fehlt der Abschnitt [project].") new_block = _sub_once(_PYPROJECT_VERSION_RE, block.group(1), version, "pyproject.toml") out[tree.pyproject] = pyproject[: block.start(1)] + new_block + pyproject[block.end(1):] out[tree.package_json] = _sub_once(_JSON_VERSION_RE, _read(tree.package_json), version, "package.json") if tree.package_lock.exists(): lock = _read(tree.package_lock) lock = _sub_once(_JSON_VERSION_RE, lock, version, "package-lock.json") out[tree.package_lock] = _sub_once(_LOCK_ROOT_RE, lock, version, "package-lock.json (packages)") # Gegenprobe im Speicher: jede Stelle muss danach die neue Nummer nennen. if _VERSION_PY_RE.search(out[tree.version_py]).group(2) != version: # type: ignore[union-attr] raise ReleaseError("version.py liess sich nicht richtig anheben.") if _VERSION_PY_RE.search(out[tree.updater_init]).group(2) != version: # type: ignore[union-attr] raise ReleaseError("Die Version des Update-Helfers liess sich nicht richtig anheben.") if _pyproject_version(out[tree.pyproject]) != version: raise ReleaseError("pyproject.toml liess sich nicht richtig anheben.") try: if json.loads(out[tree.package_json]).get("version") != version: raise ValueError if tree.package_lock.exists() and _lock_versions(out[tree.package_lock]) != (version, version): raise ValueError except ValueError as exc: raise ReleaseError("package.json/package-lock.json liessen sich nicht richtig anheben.") from exc return out # --- Versionsdatei erzeugen ------------------------------------------------------------ def toml_string(text: str) -> str: """Text als TOML-Basisstring (JSON-Maskierung ist dafür gültig; DEL kommt dazu).""" return json.dumps(text, ensure_ascii=False).replace("\x7f", "\\u007f") def render_release(version: str, day: str, title: str | None, entries: list[Entry]) -> str: lines = [f"version = {toml_string(version)}", f"date = {toml_string(day)}"] if title: lines.append(f"title = {toml_string(title)}") for entry in entries: lines += ["", "[[entries]]", f"kind = {toml_string(entry.kind)}", f"text = {toml_string(entry.text)}"] if entry.prs: lines.append(f"prs = [{', '.join(str(n) for n in entry.prs)}]") if entry.bereich is not None: lines.append(f"bereich = {toml_string(entry.bereich)}") return "\n".join(lines) + "\n" def render_dock_notes(release: Release) -> str: lines = [f"# Nodvard Dock {release.version}", "", release.date] if release.title: lines += ["", release.title] entries = filter_entries(release.entries, "dock") labels = {"neu": "Neu", "verbessert": "Verbessert", "behoben": "Behoben", "sicherheit": "Sicherheit", "dokumentation": "Dokumentation"} for kind in KINDS: items = [entry for entry in entries if entry.kind == kind] if items: lines += ["", f"## {labels[kind]}", ""] for entry in items: prs = " " + " ".join(f"#{number}" for number in entry.prs) if entry.prs else "" lines.append(f"- {entry.text}{prs}") if not entries: lines += ["", "Für Nodvard Dock gibt es in dieser Version keine eigenen Änderungen."] return "\n".join(lines) + "\n" # --- Ablauf ---------------------------------------------------------------------------- @dataclass(frozen=True) class Plan: version: str previous: str release_path: Path release_text: str dock_notes_path: Path dock_notes_text: str fragments: tuple[Path, ...] entry_count: int bumps: dict[Path, str] def _keeps_latest_on_newest(tree: Tree) -> bool: workflow = tree.release_workflow text = workflow.read_text(encoding="utf-8") if workflow.is_file() else "" return "scripts/release_newest.py" in text and (tree.root / "scripts" / "release_newest.py").is_file() def plan_release(tree: Tree, version: str, *, title: str | None = None, day: str | None = None) -> Plan: """Prüft alles und berechnet den neuen Stand – schreibt nichts.""" try: new_key = parse_version(version) except ChangelogError as exc: raise ReleaseError(str(exc)) from exc previous = current_version(tree) old_key = parse_version(previous) if new_key <= old_key: raise ReleaseError(f"Die Version {version} ist nicht groesser als die aktuelle ({previous}).") patch = new_key[2] > 0 if patch and new_key[:2] != old_key[:2]: raise ReleaseError( f"{version} ist ein Patch, gehoert aber nicht zur Reihe {old_key[0]}.{old_key[1]} der aktuellen Version " f"{previous}. Eine neue Reihe beginnt mit .0." ) # Der Tag startet die release.yml des getaggten Commits; ein älterer Zweig gäbe dem Patch sonst immer `latest`. if patch and not _keeps_latest_on_newest(tree): raise ReleaseError( "Auf diesem Zweig ruft .github/workflows/release.yml scripts/release_newest.py nicht auf. Ohne den " "Schritt bekaeme der Patch das Tag latest, auch wenn es schon eine hoehere Version gibt. Bitte die " "Release-Werkzeuge zuerst von main per cherry-pick uebernehmen " "(git log HEAD..origin/main -- .github/workflows/release.yml scripts/)." ) release_path = tree.versions_dir / f"{version}.toml" dock_notes_path = tree.release_notes_dir / f"dock-{version}.md" if dock_notes_path.exists(): raise ReleaseError(f"Die Dock-Release-Notes bestehen bereits: {dock_notes_path}") fragments = tuple(sorted(tree.unreleased_dir.glob("*.toml"))) if tree.unreleased_dir.is_dir() else () if not fragments: raise ReleaseError("Es gibt keine Eintraege unter unreleased/ -- so ein Release waere leer.") by_fragment: list[tuple[Path, tuple[Entry, ...]]] = [] for path in fragments: try: by_fragment.append((path, tuple(read_fragment(path)))) except ChangelogError as exc: raise ReleaseError(f"Bruchstueck ungueltig: {exc}") from exc entries = [entry for _, found in by_fragment for entry in found] if patch: wrong = [ f"{path.name} ({', '.join(sorted({e.kind for e in found} - set(PATCH_KINDS)))})" for path, found in by_fragment if {e.kind for e in found} - set(PATCH_KINDS) ] if wrong: raise ReleaseError( f"Ein Patch nimmt nur Eintraege {' und '.join(PATCH_KINDS)} auf. Andere Arten stehen in: " f"{', '.join(wrong)}. Die gehoeren in die naechste Funktionsversion." ) # Ein Patch vom Release-Zweig steht danach auch auf main; seine Bruchstücke dürfen dort nicht noch einmal mitgehen. published = {(e.kind, e.text): r.version for r in released_versions(tree) for e in r.entries} twice = sorted({ f"{path.name} (schon in {published[(e.kind, e.text)]})" for path, found in by_fragment for e in found if (e.kind, e.text) in published }) if twice: raise ReleaseError( f"Schon veroeffentlicht: {', '.join(twice)}. Diese Bruchstuecke bitte loeschen, oder den Text aendern, " "wenn der Eintrag wirklich neu ist." ) day = day or today() title = (title or "").strip() or None text = render_release(version, day, title, entries) # Gegenprobe: was geschrieben würde, muss sich wieder einlesen lassen und gleich sein. try: parsed = parse_release(tomllib.loads(text)) except (ChangelogError, tomllib.TOMLDecodeError) as exc: raise ReleaseError(f"Die neue Versionsdatei waere ungueltig: {exc}") from exc if parsed.entries != tuple(entries) or parsed.version != version or parsed.date != day or parsed.title != title: raise ReleaseError("Die neue Versionsdatei enthaelt nicht dasselbe wie die Bruchstuecke.") return Plan( version=version, previous=previous, release_path=release_path, release_text=text, dock_notes_path=dock_notes_path, dock_notes_text=render_dock_notes(parsed), fragments=fragments, entry_count=len(entries), bumps=bumped_files(tree, version), ) def apply_plan(plan: Plan) -> None: plan.dock_notes_path.parent.mkdir(parents=True, exist_ok=True) _write(plan.dock_notes_path, plan.dock_notes_text) plan.release_path.parent.mkdir(parents=True, exist_ok=True) _write(plan.release_path, plan.release_text) for path, text in plan.bumps.items(): _write(path, text) for path in plan.fragments: path.unlink() def main(argv: list[str] | None = None, *, root: Path | None = None) -> int: parser = argparse.ArgumentParser(description="Fasst die Bruchstuecke unter unreleased/ zu einer neuen Version zusammen.") parser.add_argument("version", help="neue Versionsnummer, z. B. 0.5.0") parser.add_argument("--title", help="optionaler kurzer Titel der Version") parser.add_argument("--date", help="Datum JJJJ-MM-TT (Standard: heute)") parser.add_argument("--dry-run", action="store_true", help="nur zeigen, was passieren wuerde") parser.add_argument("--root", type=Path, help="Repository-Wurzel (Standard: dieses Repository)") args = parser.parse_args(argv) tree = Tree((args.root or root or REPO_ROOT).resolve()) try: plan = plan_release(tree, args.version, title=args.title, day=args.date) if not args.dry_run: apply_plan(plan) except ReleaseError as exc: print(f"Abgebrochen: {exc}", file=sys.stderr) return 1 def rel(path: Path) -> str: return path.relative_to(tree.root).as_posix() print(f"{'Wuerde anlegen' if args.dry_run else 'Angelegt'}: {rel(plan.release_path)} " f"({plan.entry_count} Eintraege aus {len(plan.fragments)} Bruchstuecken, {plan.previous} -> {plan.version})") print(f"{'Wuerde anheben' if args.dry_run else 'Angehoben'}: " + ", ".join(rel(p) for p in plan.bumps)) print(f"{'Würde anlegen' if args.dry_run else 'Angelegt'}: {rel(plan.dock_notes_path)}") print(f"{'Wuerde loeschen' if args.dry_run else 'Geloescht'}: " + ", ".join(rel(p) for p in plan.fragments)) if not args.dry_run: print("Als Naechstes: alles committen und als eigenen kleinen PR anlegen.") return 0 if __name__ == "__main__": sys.exit(main())