Auth-Reihenfolge: GITEA_TOKEN, dann GITEA_USER/GITEA_PASSWORD, dann interaktive Abfrage (Passwort ohne Echo via getpass). Zugangsdaten werden curl ueber eine Config auf stdin (-K -) uebergeben, damit Token/Passwort nicht in der Prozessliste auftauchen. 401 weist bei aktiver 2FA auf Token-Nutzung hin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
13 KiB
Python
Executable File
336 lines
13 KiB
Python
Executable File
#!/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())
|