diff --git a/release.py b/release.py index e466a79..fca2cae 100755 --- a/release.py +++ b/release.py @@ -2,7 +2,8 @@ """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. +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 @@ -10,19 +11,24 @@ Beispiele: ./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 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. +Veroeffentlichen pusht Release-Commit und Tag nach origin und spricht dann die +Gitea-API an. Einmalig noetig: ein Gitea-Token in der Umgebungsvariablen +GITEA_TOKEN (Gitea -> Einstellungen -> Anwendungen -> Token generieren, +Scope write:repository): + export GITEA_TOKEN=xxxxxxxxxxxxxxxx """ from __future__ import annotations import argparse import datetime as dt +import json +import os import re -import shutil import subprocess import sys from pathlib import Path @@ -46,6 +52,7 @@ def fail(msg: str) -> None: sys.exit(1) +# --------------------------------------------------------------- Versionen def read_version() -> tuple[int, int, int]: m = VERSION_RE.search(INIT.read_text(encoding="utf-8")) if not m: @@ -68,6 +75,7 @@ def write_version(ver: tuple[int, int, int]) -> None: INIT.write_text(text, encoding="utf-8") +# --------------------------------------------------------------- CHANGELOG def update_changelog(ver_str: str) -> str: """[Unreleased]-Inhalt in einen datierten Versionsabschnitt ueberfuehren. @@ -79,23 +87,113 @@ def update_changelog(ver_str: str) -> str: 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." + notes = body.strip() if body.strip() else "Wartungs-Release." today = dt.date.today().isoformat() - new_block = ( - f"{marker}\n\n" - f"## [{ver_str}] - {today}\n\n" - f"{body}\n" - ) + 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) + + +def _curl(args: list[str]) -> tuple[int, str]: + """curl aufrufen; (HTTP-Status, Body) zurueckgeben. Token wird nicht geloggt.""" + res = subprocess.run(["curl", "-sS", "-w", "\n%{http_code}", *args], + 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(tag: str, notes: str) -> None: + token = os.environ.get("GITEA_TOKEN") + if not token: + fail("Umgebungsvariable GITEA_TOKEN nicht gesetzt.\n" + "In Gitea: Einstellungen -> Anwendungen -> Token generieren " + "(Scope write:repository), dann z.B.:\n" + " export GITEA_TOKEN=xxxxxxxxxxxxxxxx") + if not ISO.exists(): + fail(f"ISO fehlt: {ISO}") + + host, owner, repo = remote_info() + api = f"https://{host}/api/v1/repos/{owner}/{repo}" + auth = ["-H", f"Authorization: token {token}"] + + # 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}).") + 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() @@ -105,6 +203,7 @@ def git_clean_or_die() -> None: + "\n".join(dirty)) +# ------------------------------------------------------------------- main def main() -> int: ap = argparse.ArgumentParser(description="PC-Unlocker Release") g = ap.add_mutually_exclusive_group() @@ -114,10 +213,26 @@ def main() -> int: 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() - part = args.part or "patch" + # --- 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 + # Tag muss auf origin liegen, damit das Release darauf zeigen kann + run(["git", "push", "origin", tag], check=False) + gitea_publish(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)) @@ -131,9 +246,9 @@ def main() -> int: 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.") + if not os.environ.get("GITEA_TOKEN"): + fail("GITEA_TOKEN nicht gesetzt (fuer Upload noetig). Entweder setzen " + "oder mit --no-publish nur lokal arbeiten.") # 1) Version + CHANGELOG print("\n==> Version und CHANGELOG aktualisieren") @@ -156,8 +271,7 @@ def main() -> int: 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}") + f" GITEA_TOKEN=... ./release.py --attach-only") return 0 # 4) Push (noetig, damit Gitea das Release kennt) @@ -166,12 +280,8 @@ def main() -> int: 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("\n==> Gitea-Release anlegen und ISO hochladen") + gitea_publish(tag, notes) print(f"\nFertig: Release {tag} veroeffentlicht, ISO angehaengt.") return 0