#!/usr/bin/env python3 """Publie/met à jour une release Gitea : corps = section CHANGELOG de la version, APK en pièce jointe (optionnel). Réutilisable à chaque release, multi-instances. Usage : python3 scripts/gitea-release.py [instance] vX.Y.Z [chemin/vers/app.apk] - instance : « cloudyfy » (défaut) ou « farewell » — voir INSTANCES ci-dessous ; - le corps est extrait de docs/CHANGELOG.md (section « ## [X.Y.Z] », SANS « v » — le tag git porte le « v », la CHANGELOG non : piège corrigé, cf §16.bis) ; - si la release existe déjà, son corps est mis à jour (PATCH) ; - si un chemin d'APK est fourni, il est attaché avec VÉRIFICATION PAR TÉLÉCHARGEMENT (cf ensure_asset). Auth : le token Gitea est lu dans le trousseau macOS (security find-internet-password -s -w) — scope requis : write:repository (suffit pour releases + assets, pas pour créer un repo). Pour ajouter un token au trousseau : security add-internet-password -s gitea.farewell.dev -a Siphonight \ -w -U """ import json import os import re import subprocess import sys import urllib.error import urllib.request import uuid # Instances connues : clé = nom court, hôte = service du trousseau INSTANCES = { "cloudyfy": {"url": "https://gitea.cloudyfy.fr", "owner": "Siphonight", "host": "gitea.cloudyfy.fr"}, "farewell": {"url": "https://gitea.farewell.dev", "owner": "Siphonight", "host": "gitea.farewell.dev"}, } # Nom du dépôt (identique sur les deux instances) REPO_NAME = "HormoneTrack" CHANGELOG = "docs/CHANGELOG.md" def gitea_token(host: str) -> str: out = subprocess.run( ["security", "find-internet-password", "-s", host, "-w"], capture_output=True, text=True ).stdout.strip() if not out: sys.exit( f"Token Gitea introuvable dans le trousseau pour {host}\n" f" Ajoute-le avec : security add-internet-password -s {host} " f"-a Siphonight -w -U" ) return out def request(url: str, method: str = "GET", data=None, token: str = "") -> dict: """Requête API JSON. Les réponses vides (DELETE → 204) renvoient {}.""" req = urllib.request.Request(url, method=method) req.add_header("Authorization", f"token {token}") if data is not None: req.add_header("Content-Type", "application/json") req.data = json.dumps(data).encode() with urllib.request.urlopen(req, timeout=120) as resp: payload = resp.read().decode() return json.loads(payload) if payload.strip() else {} def _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict: """POST multipart de l'APK (le ?name= peut être normalisé par l'instance).""" boundary = uuid.uuid4().hex head = ( f"--{boundary}\r\n" f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n" f"Content-Type: application/vnd.android.package-archive\r\n\r\n" ) tail = f"\r\n--{boundary}--\r\n" with open(path, "rb") as f: req = urllib.request.Request( f"{base}/releases/{release_id}/assets?name={name}", method="POST", headers={"Authorization": f"token {token}", "Content-Type": f"multipart/form-data; boundary={boundary}"}) req.data = head.encode() + f.read() + tail.encode() with urllib.request.urlopen(req, timeout=300) as resp: return json.loads(resp.read().decode()) def _delivered_size(base: str, token: str, url: str) -> int: """Taille RÉELLEMENT délivrée par le lien de téléchargement — le listing de l'API a déjà menti pendant deux uploads rapprochés (v1.2.10).""" r = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) with urllib.request.urlopen(r, timeout=300) as resp: return len(resp.read()) def ensure_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict: """Attache un APK à une release avec VÉRIFICATION PAR TÉLÉCHARGEMENT. Le ?name= du POST, le listing et le PATCH de renommage se sont avérés non fiables sur des uploads rapprochés (v1.2.6 : nom générique ; v1.2.10 : APK release disparu, deux assets délivrant le mauvais fichier). Séquence : upload → vérif listing (nom + taille) → vérif TÉLÉCHARGEMENT (octets délivrés = taille du fichier local) → sinon DELETE + re-upload (×2) → échec bruyant. Retourne l'asset vérifié. """ expected = os.path.getsize(path) for attempt in range(2): uploaded = _upload_asset(base, token, release_id, name, path) release = request(f"{base}/releases/{release_id}", token=token) current = next((a for a in release["assets"] if a["id"] == uploaded["id"]), None) if current is not None: name_ok = current["name"] == name size_ok = abs(current["size"] - expected) < 1000 delivered_ok = _delivered_size(base, token, current["browser_download_url"]) == expected if name_ok and size_ok and delivered_ok: return current print(f" asset {name} incorrect (nom_ok={name_ok} taille_ok={size_ok} " f"délivré={delivered_ok}) → DELETE + re-upload (tentative {attempt + 1}/2)") request(f"{base}/releases/{release_id}/assets/{uploaded['id']}", "DELETE", token=token) sys.exit(f"✗ ÉCHEC : {name} pas correctement attaché après retry — " f"la release est incomplète, publier manuellement via l'interface web.") def changelog_section(version: str) -> str: """Extrait la section « ## [X.Y.Z] » de la CHANGELOG (version SANS le « v»).""" num = version.removeprefix("v") text = open(CHANGELOG, encoding="utf-8").read() m = re.search(rf"## \[{re.escape(num)}\][^\n]*\n(.*?)(?=\n## \[|\Z)", text, re.S) if not m: sys.exit(f"Section [{num}] introuvable dans {CHANGELOG}") return m.group(1).strip() def main(): args = sys.argv[1:] # Premier argument = instance s'il n'est pas un tag (un tag commence par « v ») instance = "cloudyfy" if args and not args[0].startswith("v"): instance = args.pop(0) if instance not in INSTANCES: sys.exit(f"Instance inconnue : {instance} (attendu : {', '.join(INSTANCES)})") if not args: sys.exit(f"Usage: gitea-release.py [{instance}] vX.Y.Z [app.apk]") tag = args[0] apk_path = args[1] if len(args) > 1 else None conf = INSTANCES[instance] base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}" token = gitea_token(conf["host"]) body = changelog_section(tag) # Récupère la release du tag (crée-la si absente) try: release = request(f"{base}/releases/tags/{tag}", token=token) release_id = release["id"] created = False except urllib.error.HTTPError as e: if e.code != 404: raise release = request(f"{base}/releases", "POST", token=token, data={ "tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False, }) release_id = release["id"] created = True # Corps = section CHANGELOG (PATCH si la release existait déjà) if not created: request(f"{base}/releases/{release_id}", "PATCH", token=token, data={"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False}) # APK en pièce jointe (vérifié par téléchargement) if apk_path: asset_name = f"HormoneTrack-{tag}.apk" for asset in release.get("assets", []): if asset["name"] == asset_name: request(f"{base}/releases/{release_id}/assets/{asset['id']}", "DELETE", token=token) asset = ensure_asset(base, token, release_id, asset_name, apk_path) link = asset.get("browser_download_url", "?") else: link = "—" action = "créée" if created else "mise à jour" print(f"[{instance}] Release {tag} {action} (id {release_id}) — " f"corps {len(body)} caractères, APK : {link}") if __name__ == "__main__": main()