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]
### 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
### Added
Binary file not shown.
+178 -24
View File
@@ -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,27 @@ 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. 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 shutil
import subprocess
import sys
from pathlib import Path
@@ -46,6 +55,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 +78,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 +90,149 @@ 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)
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()
@@ -105,6 +242,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 +252,28 @@ 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
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))
@@ -129,11 +285,13 @@ def main() -> int:
print("--dry-run: es wird nichts geaendert.")
return 0
auth: Auth | None = None
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.")
# 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")
@@ -156,8 +314,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" ./release.py --attach-only (fragt Benutzer/Passwort ab)")
return 0
# 4) Push (noetig, damit Gitea das Release kennt)
@@ -166,12 +323,9 @@ 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")
assert auth is not None
gitea_publish(auth, tag, notes)
print(f"\nFertig: Release {tag} veroeffentlicht, ISO angehaengt.")
return 0