#!/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 (ueber die Gitea-REST-API, curl) 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 --attach-only # nur die ISO an das Release der AKTUELLEN # Version haengen (kein bump/build/commit) ./release.py --dry-run # nur anzeigen, nichts aendern Veroeffentlichen pusht Release-Commit und Tag nach origin und spricht dann die Gitea-API an. Anmeldung in dieser Reihenfolge: 1. Token aus GITEA_TOKEN (export GITEA_TOKEN=xxxx) 2. GITEA_USER / GITEA_PASSWORD (aus der Umgebung) 3. interaktive Abfrage von Benutzername + Passwort (ohne Echo) Bei aktivierter 2FA funktioniert Passwort-Login nicht -- dann einen Token verwenden (Gitea -> Einstellungen -> Anwendungen -> Token, Scope write:repository). """ from __future__ import annotations import argparse import datetime as dt import getpass import json import os import re 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) # --------------------------------------------------------------- Versionen 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") # --------------------------------------------------------------- CHANGELOG 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) nxt = text.find("\n## [", after) end = nxt if nxt != -1 else len(text) body = text[after:end].strip("\n") notes = body.strip() if body.strip() else "Wartungs-Release." today = dt.date.today().isoformat() new_block = f"{marker}\n\n## [{ver_str}] - {today}\n\n{body}\n" new_text = text[:idx] + new_block + text[end:] CHANGELOG.write_text(new_text, encoding="utf-8") return notes def changelog_section(ver_str: str) -> str: """Notizen eines bereits vorhandenen Versionsabschnitts auslesen.""" text = CHANGELOG.read_text(encoding="utf-8") m = re.search(rf"^## \[{re.escape(ver_str)}\][^\n]*\n", text, re.M) if not m: return "" start = m.end() nxt = text.find("\n## [", start) end = nxt if nxt != -1 else len(text) return text[start:end].strip() # ------------------------------------------------------------------ Gitea def remote_info() -> tuple[str, str, str]: """(host, owner, repo) aus der origin-URL ableiten.""" url = run(["git", "remote", "get-url", "origin"], capture=True).stdout.strip() # https://user@host/owner/repo(.git) m = re.match(r"https?://(?:[^@/]+@)?([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$", url) if not m: # git@host:owner/repo(.git) m = re.match(r"(?:ssh://)?[^@]+@([^:/]+)[:/]([^/]+)/([^/]+?)(?:\.git)?$", url) if not m: fail(f"origin-URL nicht verstanden: {url}") return m.group(1), m.group(2), m.group(3) class Auth: """Gitea-Authentifizierung: Token (bevorzugt) oder Benutzername/Passwort. Reihenfolge: GITEA_TOKEN -> GITEA_USER/GITEA_PASSWORD -> interaktive Abfrage. Die Zugangsdaten werden curl ueber eine Config auf stdin (-K -) uebergeben, damit weder Token noch Passwort in der Prozessliste (argv) auftauchen. """ def __init__(self) -> None: self.token = os.environ.get("GITEA_TOKEN") or "" self.user = os.environ.get("GITEA_USER") or "" self.password = os.environ.get("GITEA_PASSWORD") or "" def ensure(self) -> None: if self.token or (self.user and self.password): return if not sys.stdin.isatty(): fail("Keine Zugangsdaten: GITEA_TOKEN oder GITEA_USER/GITEA_PASSWORD " "setzen (keine interaktive Eingabe moeglich).") print("Gitea-Anmeldung (leer lassen beim Benutzernamen, um einen Token " "einzugeben):") self.user = input(" Benutzername: ").strip() if self.user: self.password = getpass.getpass(" Passwort: ") if not self.password: fail("Kein Passwort eingegeben.") else: self.token = getpass.getpass(" Token: ").strip() if not self.token: fail("Weder Benutzername noch Token eingegeben.") def curl_config(self) -> str: if self.token: return f'header = "Authorization: token {self.token}"\n' return f'user = "{self.user}:{self.password}"\n' def _curl(auth: "Auth", args: list[str]) -> tuple[int, str]: """curl aufrufen; (HTTP-Status, Body). Auth kommt per stdin-Config (-K -).""" res = subprocess.run( ["curl", "-sS", "-K", "-", "-w", "\n%{http_code}", *args], input=auth.curl_config(), capture_output=True, text=True, ) out = res.stdout nl = out.rfind("\n") body = out[:nl] if nl != -1 else out code = out[nl + 1:].strip() if nl != -1 else "" try: status = int(code) except ValueError: status = 0 if res.stderr: body = (body + "\n" + res.stderr).strip() return status, body def gitea_publish(auth: "Auth", tag: str, notes: str) -> None: if not ISO.exists(): fail(f"ISO fehlt: {ISO}") host, owner, repo = remote_info() api = f"https://{host}/api/v1/repos/{owner}/{repo}" # 1) Release zu diesem Tag holen oder anlegen status, body = _curl(auth, [f"{api}/releases/tags/{tag}"]) if status == 200: rid = json.loads(body)["id"] print(f" Release existiert bereits (id {rid}).") elif status == 404: payload = json.dumps({"tag_name": tag, "name": tag, "body": notes}) status, body = _curl(auth, ["-H", "Content-Type: application/json", "-X", "POST", "-d", payload, f"{api}/releases"]) if status not in (200, 201): fail(f"Release anlegen fehlgeschlagen (HTTP {status}):\n{body}") rid = json.loads(body)["id"] print(f" Release angelegt (id {rid}).") elif status == 401: fail("Anmeldung abgelehnt (HTTP 401). Falsche Zugangsdaten -- oder 2FA " "aktiv: dann einen Token statt Passwort verwenden.") else: fail(f"Release-Abfrage fehlgeschlagen (HTTP {status}):\n{body}") # 2) Vorhandenes Asset gleichen Namens entfernen (idempotent) name = ISO.name status, body = _curl(auth, [f"{api}/releases/{rid}/assets"]) if status == 200: for a in json.loads(body): if a.get("name") == name: _curl(auth, ["-X", "DELETE", f"{api}/releases/{rid}/assets/{a['id']}"]) print(f" Altes Asset '{name}' entfernt.") # 3) ISO hochladen size_mb = ISO.stat().st_size // (1024 * 1024) print(f" Lade '{name}' hoch ({size_mb} MB) ...") status, body = _curl(auth, ["-X", "POST", "-F", f"attachment=@{ISO};type=application/octet-stream", f"{api}/releases/{rid}/assets?name={name}"]) if status not in (200, 201): fail(f"Upload fehlgeschlagen (HTTP {status}):\n{body}") print(f" Upload ok -> https://{host}/{owner}/{repo}/releases/tag/{tag}") # -------------------------------------------------------------------- git 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)) # ------------------------------------------------------------------- main 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("--attach-only", action="store_true", help="nur die ISO an das Release der aktuellen Version haengen") ap.add_argument("--dry-run", action="store_true", help="nur anzeigen") args = ap.parse_args() # --- Nur ISO an bestehendes Release der aktuellen Version haengen -------- if args.attach_only: cur = ".".join(map(str, read_version())) tag = f"v{cur}" print(f"Attach-only: ISO an Release {tag} haengen") if args.dry_run: print("--dry-run: es wird nichts geaendert.") return 0 auth = Auth() auth.ensure() # Tag muss auf origin liegen, damit das Release darauf zeigen kann run(["git", "push", "origin", tag], check=False) gitea_publish(auth, tag, changelog_section(cur) or tag) print(f"\nFertig: ISO an {tag} angehaengt.") return 0 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 auth: Auth | None = None if not args.no_publish: git_clean_or_die() # Zugangsdaten frueh klaeren (ggf. abfragen), damit der Build nicht # umsonst laeuft, falls keine Anmeldung moeglich ist. auth = Auth() auth.ensure() # 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" ./release.py --attach-only (fragt Benutzer/Passwort ab)") 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 und ISO hochladen") assert auth is not None gitea_publish(auth, tag, notes) print(f"\nFertig: Release {tag} veroeffentlicht, ISO angehaengt.") return 0 if __name__ == "__main__": raise SystemExit(main())