#!/usr/bin/env python3 """Release-Automatik fuer PC-Unlocker. Zaehlt die Version hoch, pflegt das CHANGELOG, committet + taggt, baut die ISO und veroeffentlicht sie als Gitea-Release (via `tea`) mit der ISO als Anhang. Beispiele: ./release.py # Patch-Release (0.1.0 -> 0.1.1), bauen + publizieren ./release.py --minor # 0.1.0 -> 0.2.0 ./release.py --major # 0.1.0 -> 1.0.0 ./release.py --no-build # vorhandene out/pc-unlocker.iso verwenden ./release.py --no-publish # nur lokal: bump, commit, tag, (build) ./release.py --dry-run # nur anzeigen, nichts aendern Veroeffentlichen pusht den Release-Commit und das Tag nach origin (zwingend, damit Gitea das Release anlegen kann) und ruft dann `tea release create`. Einmalig noetig: `tea login add` fuer git.hacker-net.de. """ from __future__ import annotations import argparse import datetime as dt import re import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent INIT = ROOT / "app" / "puk" / "__init__.py" CHANGELOG = ROOT / "CHANGELOG.md" ISO = ROOT / "out" / "pc-unlocker.iso" VERSION_RE = re.compile(r'^__version__\s*=\s*"(\d+)\.(\d+)\.(\d+)"', re.M) def run(cmd: list[str], *, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess: print(f" $ {' '.join(cmd)}") return subprocess.run(cmd, check=check, text=True, capture_output=capture, cwd=ROOT) def fail(msg: str) -> None: print(f"\nFEHLER: {msg}", file=sys.stderr) sys.exit(1) def read_version() -> tuple[int, int, int]: m = VERSION_RE.search(INIT.read_text(encoding="utf-8")) if not m: fail(f"__version__ nicht in {INIT} gefunden") return tuple(int(x) for x in m.groups()) # type: ignore[return-value] def bump(ver: tuple[int, int, int], part: str) -> tuple[int, int, int]: major, minor, patch = ver if part == "major": return major + 1, 0, 0 if part == "minor": return major, minor + 1, 0 return major, minor, patch + 1 def write_version(ver: tuple[int, int, int]) -> None: new = f'__version__ = "{ver[0]}.{ver[1]}.{ver[2]}"' text = VERSION_RE.sub(new, INIT.read_text(encoding="utf-8"), count=1) INIT.write_text(text, encoding="utf-8") def update_changelog(ver_str: str) -> str: """[Unreleased]-Inhalt in einen datierten Versionsabschnitt ueberfuehren. Gibt die Notizen (den verschobenen Inhalt) fuer die Release-Beschreibung zurueck. """ text = CHANGELOG.read_text(encoding="utf-8") marker = "## [Unreleased]" idx = text.find(marker) if idx == -1: fail("'## [Unreleased]' nicht im CHANGELOG gefunden") after = idx + len(marker) # Inhalt bis zur naechsten Versionsueberschrift (## [..]) oder Dateiende nxt = text.find("\n## [", after) end = nxt if nxt != -1 else len(text) body = text[after:end].strip("\n") notes = body if body else "Wartungs-Release." today = dt.date.today().isoformat() new_block = ( f"{marker}\n\n" f"## [{ver_str}] - {today}\n\n" f"{body}\n" ) new_text = text[:idx] + new_block + text[end:] CHANGELOG.write_text(new_text, encoding="utf-8") return notes def git_clean_or_die() -> None: res = run(["git", "status", "--porcelain"], capture=True) dirty = [l for l in res.stdout.splitlines() if l and not l.endswith(("CHANGELOG.md", "__init__.py"))] if dirty: fail("Arbeitsverzeichnis nicht sauber. Bitte erst committen/stashen:\n" + "\n".join(dirty)) def main() -> int: ap = argparse.ArgumentParser(description="PC-Unlocker Release") g = ap.add_mutually_exclusive_group() g.add_argument("--major", action="store_const", const="major", dest="part") g.add_argument("--minor", action="store_const", const="minor", dest="part") g.add_argument("--patch", action="store_const", const="patch", dest="part") ap.add_argument("--no-build", action="store_true", help="ISO nicht neu bauen") ap.add_argument("--no-publish", action="store_true", help="nur lokal: kein push, kein Gitea-Release") ap.add_argument("--dry-run", action="store_true", help="nur anzeigen") args = ap.parse_args() part = args.part or "patch" old = read_version() new = bump(old, part) old_s = ".".join(map(str, old)) new_s = ".".join(map(str, new)) tag = f"v{new_s}" print(f"Release: {old_s} -> {new_s} (tag {tag})") if args.dry_run: print("--dry-run: es wird nichts geaendert.") return 0 if not args.no_publish: git_clean_or_die() if shutil.which("tea") is None: fail("`tea` (Gitea CLI) nicht gefunden. Mit --no-publish nur lokal " "arbeiten oder tea installieren.") # 1) Version + CHANGELOG print("\n==> Version und CHANGELOG aktualisieren") write_version(new) notes = update_changelog(new_s) # 2) ISO bauen if not args.no_build: print("\n==> ISO bauen") run(["bash", str(ROOT / "build.sh")]) if not ISO.exists(): fail(f"ISO fehlt: {ISO} (ohne --no-build bauen lassen)") # 3) Commit + Tag print("\n==> Commit und Tag") run(["git", "add", str(INIT), str(CHANGELOG)]) run(["git", "commit", "-m", f"release: {tag}"]) run(["git", "tag", "-a", tag, "-m", f"PC-Unlocker {tag}"]) if args.no_publish: print(f"\nLokal fertig. Veroeffentlichen spaeter z.B.:\n" f" git push origin HEAD && git push origin {tag}\n" f" tea release create --tag {tag} --title {tag} " f"--asset {ISO}") return 0 # 4) Push (noetig, damit Gitea das Release kennt) print("\n==> Push nach origin") run(["git", "push", "origin", "HEAD"]) run(["git", "push", "origin", tag]) # 5) Gitea-Release mit ISO-Anhang print("\n==> Gitea-Release anlegen") run(["tea", "release", "create", "--tag", tag, "--title", tag, "--note", notes, "--asset", str(ISO)]) print(f"\nFertig: Release {tag} veroeffentlicht, ISO angehaengt.") return 0 if __name__ == "__main__": raise SystemExit(main())