release.py: Gitea-REST-API statt tea, --attach-only

Das auf dem System vorhandene /usr/bin/tea ist ein Texteditor, nicht die
Gitea-CLI. Veroeffentlichung laeuft jetzt direkt ueber die Gitea-REST-API
(curl): Release zum Tag anlegen/finden, gleichnamiges Asset ersetzen, ISO
hochladen. Token via GITEA_TOKEN. Neuer Modus --attach-only haengt die ISO
an das Release der aktuellen Version, ohne bump/build/commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-10-22 13:16:37 +02:00
co-authored by Claude Opus 4.8
parent def3c47634
commit d5395fc950
+134 -24
View File
@@ -2,7 +2,8 @@
"""Release-Automatik fuer PC-Unlocker. """Release-Automatik fuer PC-Unlocker.
Zaehlt die Version hoch, pflegt das CHANGELOG, committet + taggt, baut die ISO 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: Beispiele:
./release.py # Patch-Release (0.1.0 -> 0.1.1), bauen + publizieren ./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 --major # 0.1.0 -> 1.0.0
./release.py --no-build # vorhandene out/pc-unlocker.iso verwenden ./release.py --no-build # vorhandene out/pc-unlocker.iso verwenden
./release.py --no-publish # nur lokal: bump, commit, tag, (build) ./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 ./release.py --dry-run # nur anzeigen, nichts aendern
Veroeffentlichen pusht den Release-Commit und das Tag nach origin (zwingend, Veroeffentlichen pusht Release-Commit und Tag nach origin und spricht dann die
damit Gitea das Release anlegen kann) und ruft dann `tea release create`. Gitea-API an. Einmalig noetig: ein Gitea-Token in der Umgebungsvariablen
Einmalig noetig: `tea login add` fuer git.hacker-net.de. GITEA_TOKEN (Gitea -> Einstellungen -> Anwendungen -> Token generieren,
Scope write:repository):
export GITEA_TOKEN=xxxxxxxxxxxxxxxx
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import datetime as dt import datetime as dt
import json
import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -46,6 +52,7 @@ def fail(msg: str) -> None:
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------- Versionen
def read_version() -> tuple[int, int, int]: def read_version() -> tuple[int, int, int]:
m = VERSION_RE.search(INIT.read_text(encoding="utf-8")) m = VERSION_RE.search(INIT.read_text(encoding="utf-8"))
if not m: if not m:
@@ -68,6 +75,7 @@ def write_version(ver: tuple[int, int, int]) -> None:
INIT.write_text(text, encoding="utf-8") INIT.write_text(text, encoding="utf-8")
# --------------------------------------------------------------- CHANGELOG
def update_changelog(ver_str: str) -> str: def update_changelog(ver_str: str) -> str:
"""[Unreleased]-Inhalt in einen datierten Versionsabschnitt ueberfuehren. """[Unreleased]-Inhalt in einen datierten Versionsabschnitt ueberfuehren.
@@ -79,23 +87,113 @@ def update_changelog(ver_str: str) -> str:
if idx == -1: if idx == -1:
fail("'## [Unreleased]' nicht im CHANGELOG gefunden") fail("'## [Unreleased]' nicht im CHANGELOG gefunden")
after = idx + len(marker) after = idx + len(marker)
# Inhalt bis zur naechsten Versionsueberschrift (## [..]) oder Dateiende
nxt = text.find("\n## [", after) nxt = text.find("\n## [", after)
end = nxt if nxt != -1 else len(text) end = nxt if nxt != -1 else len(text)
body = text[after:end].strip("\n") 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() today = dt.date.today().isoformat()
new_block = ( new_block = f"{marker}\n\n## [{ver_str}] - {today}\n\n{body}\n"
f"{marker}\n\n"
f"## [{ver_str}] - {today}\n\n"
f"{body}\n"
)
new_text = text[:idx] + new_block + text[end:] new_text = text[:idx] + new_block + text[end:]
CHANGELOG.write_text(new_text, encoding="utf-8") CHANGELOG.write_text(new_text, encoding="utf-8")
return notes 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: def git_clean_or_die() -> None:
res = run(["git", "status", "--porcelain"], capture=True) res = run(["git", "status", "--porcelain"], capture=True)
dirty = [l for l in res.stdout.splitlines() dirty = [l for l in res.stdout.splitlines()
@@ -105,6 +203,7 @@ def git_clean_or_die() -> None:
+ "\n".join(dirty)) + "\n".join(dirty))
# ------------------------------------------------------------------- main
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser(description="PC-Unlocker Release") ap = argparse.ArgumentParser(description="PC-Unlocker Release")
g = ap.add_mutually_exclusive_group() 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-build", action="store_true", help="ISO nicht neu bauen")
ap.add_argument("--no-publish", action="store_true", ap.add_argument("--no-publish", action="store_true",
help="nur lokal: kein push, kein Gitea-Release") 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") ap.add_argument("--dry-run", action="store_true", help="nur anzeigen")
args = ap.parse_args() 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() old = read_version()
new = bump(old, part) new = bump(old, part)
old_s = ".".join(map(str, old)) old_s = ".".join(map(str, old))
@@ -131,9 +246,9 @@ def main() -> int:
if not args.no_publish: if not args.no_publish:
git_clean_or_die() git_clean_or_die()
if shutil.which("tea") is None: if not os.environ.get("GITEA_TOKEN"):
fail("`tea` (Gitea CLI) nicht gefunden. Mit --no-publish nur lokal " fail("GITEA_TOKEN nicht gesetzt (fuer Upload noetig). Entweder setzen "
"arbeiten oder tea installieren.") "oder mit --no-publish nur lokal arbeiten.")
# 1) Version + CHANGELOG # 1) Version + CHANGELOG
print("\n==> Version und CHANGELOG aktualisieren") print("\n==> Version und CHANGELOG aktualisieren")
@@ -156,8 +271,7 @@ def main() -> int:
if args.no_publish: if args.no_publish:
print(f"\nLokal fertig. Veroeffentlichen spaeter z.B.:\n" print(f"\nLokal fertig. Veroeffentlichen spaeter z.B.:\n"
f" git push origin HEAD && git push origin {tag}\n" f" git push origin HEAD && git push origin {tag}\n"
f" tea release create --tag {tag} --title {tag} " f" GITEA_TOKEN=... ./release.py --attach-only")
f"--asset {ISO}")
return 0 return 0
# 4) Push (noetig, damit Gitea das Release kennt) # 4) Push (noetig, damit Gitea das Release kennt)
@@ -166,12 +280,8 @@ def main() -> int:
run(["git", "push", "origin", tag]) run(["git", "push", "origin", tag])
# 5) Gitea-Release mit ISO-Anhang # 5) Gitea-Release mit ISO-Anhang
print("\n==> Gitea-Release anlegen") print("\n==> Gitea-Release anlegen und ISO hochladen")
run(["tea", "release", "create", gitea_publish(tag, notes)
"--tag", tag,
"--title", tag,
"--note", notes,
"--asset", str(ISO)])
print(f"\nFertig: Release {tag} veroeffentlicht, ISO angehaengt.") print(f"\nFertig: Release {tag} veroeffentlicht, ISO angehaengt.")
return 0 return 0