3 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 5f8fc453a1 release.py: Anmeldung per Benutzer/Passwort oder Token
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>
2026-10-22 13:19:53 +02:00
duffyduckandClaude Opus 4.8 d5395fc950 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>
2026-10-22 13:16:37 +02:00
duffyduckandClaude Opus 4.8 def3c47634 ISO aus Versionierung nehmen (Build-Artefakt)
Die 804 MB grosse out/pc-unlocker.iso war versehentlich im ersten Commit
getrackt, wodurch out/.gitignore nicht griff. Datei bleibt lokal erhalten
und wird kuenftig ignoriert; Auslieferung erfolgt als Gitea-Release-Asset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-22 13:10:48 +02:00
3 changed files with 188 additions and 24 deletions
+10
View File
@@ -6,6 +6,16 @@ Versionierung nach [SemVer](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Changed
- `release.py`: Veröffentlichung über die Gitea-REST-API (curl) statt `tea`.
Anmeldung per `GITEA_TOKEN`, `GITEA_USER`/`GITEA_PASSWORD` oder interaktiver
Abfrage von Benutzername/Passwort; Zugangsdaten gehen über eine curl-Config
auf stdin (nicht in der Prozessliste sichtbar).
### Added
- `release.py --attach-only`: ISO an das Release der aktuellen Version hängen,
ohne Versionssprung/Build/Commit.
## [0.1.2] - 2026-10-22 ## [0.1.2] - 2026-10-22
### Added ### Added
Binary file not shown.
+178 -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,27 @@ 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. Anmeldung in dieser Reihenfolge:
Einmalig noetig: `tea login add` fuer git.hacker-net.de. 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 from __future__ import annotations
import argparse import argparse
import datetime as dt import datetime as dt
import getpass
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 +55,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 +78,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 +90,149 @@ 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)
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: 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 +242,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 +252,28 @@ 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
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() old = read_version()
new = bump(old, part) new = bump(old, part)
old_s = ".".join(map(str, old)) old_s = ".".join(map(str, old))
@@ -129,11 +285,13 @@ def main() -> int:
print("--dry-run: es wird nichts geaendert.") print("--dry-run: es wird nichts geaendert.")
return 0 return 0
auth: Auth | None = None
if not args.no_publish: if not args.no_publish:
git_clean_or_die() git_clean_or_die()
if shutil.which("tea") is None: # Zugangsdaten frueh klaeren (ggf. abfragen), damit der Build nicht
fail("`tea` (Gitea CLI) nicht gefunden. Mit --no-publish nur lokal " # umsonst laeuft, falls keine Anmeldung moeglich ist.
"arbeiten oder tea installieren.") auth = Auth()
auth.ensure()
# 1) Version + CHANGELOG # 1) Version + CHANGELOG
print("\n==> Version und CHANGELOG aktualisieren") print("\n==> Version und CHANGELOG aktualisieren")
@@ -156,8 +314,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" ./release.py --attach-only (fragt Benutzer/Passwort ab)")
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 +323,9 @@ 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", assert auth is not None
"--tag", tag, gitea_publish(auth, tag, notes)
"--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