From f5e58364a3d623c9d4302f7f6aea7c3b986b9673 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sun, 6 Sep 2026 10:51:46 +0200 Subject: [PATCH] =?UTF-8?q?Docs=20:=20publish-release.py=20document=C3=A9?= =?UTF-8?q?=20(=C2=A716.bis=20+=20=C2=A714=20#43=20+=20CHANGELOG),=20farew?= =?UTF-8?q?ell=2012=20releases=20compl=C3=A8tes,=20README=20d=C3=A9p=C3=B4?= =?UTF-8?q?ts=20jumeaux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/publish-release.py | 189 +++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100755 scripts/publish-release.py diff --git a/scripts/publish-release.py b/scripts/publish-release.py new file mode 100755 index 0000000..e7c7c8a --- /dev/null +++ b/scripts/publish-release.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Publie les DEUX APK (release + debug) d'un tag sur une instance Gitea avec +VÉRIFICATION PAR TÉLÉCHARGEMENT de chaque asset — en UNE SEULE INVOCATION. + +⚠️ Pourquoi ce script existe : les deux invocations rapprochées de +gitea-release.py se REMPLACENT mutuellement (le 2e upload écrase le 1er) — +confirmé en v1.2.10, v1.3.0, v1.3.1, v1.3.2. Ce script fait tout dans une +seule passe : purge, upload release, vérif download, upload debug, vérif +download, re-vérification finale des deux. + +Usage : + python3 scripts/publish-release.py [instance] vX.Y.Z + +Attend les APK dans /tmp/apks/ : + HormoneTrack-vX.Y.Z-release.apk (R8, recommandé) + HormoneTrack-vX.Y.Z-debug.apk (non optimisé) + +Corps de release = section CHANGELOG de la version (docs/CHANGELOG.md). +Auth : trousseau macOS (security find-internet-password -s -w). +""" +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +import uuid + +# Instances connues : clé = nom court +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"}, +} + +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" security add-internet-password -s {host} -a Siphonight -w -U") + return out + + +def request(url: str, method: str = "GET", data=None, token: str = "") -> dict: + 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 _delivered_size(base: str, token: str, url: str) -> int: + """Taille RÉELLEMENT délivrée — le listing API peut mentir (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 _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict: + 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 changelog_section(version: str) -> str: + 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 verify_asset(base: str, token: str, release_id: int, asset_id: int, name: str, expected: int) -> bool: + """Vérifie par TÉLÉCHARGEMENT que l'asset a le bon nom et la bonne taille.""" + release = request(f"{base}/releases/{release_id}", token=token) + current = next((a for a in release["assets"] if a["id"] == asset_id), None) + if current is None or current["name"] != name: + return False + if abs(current["size"] - expected) > 1000: + return False + delivered = _delivered_size(base, token, current["browser_download_url"]) + return delivered == expected + + +def publish_both_apks(base: str, token: str, release_id: int, tag: str, apks_dir: str) -> None: + """Purge + upload séquentiel des 2 APK + vérification par téléchargement + de CHAQUE asset immédiatement, puis re-vérification FINALE des deux. + C'est le pattern qui marche à chaque fois (v1.2.6 → v1.3.2).""" + apk_names = [ + (f"HormoneTrack-{tag}-release.apk", f"{apks_dir}/HormoneTrack-{tag}-release.apk"), + (f"HormoneTrack-{tag}-debug.apk", f"{apks_dir}/HormoneTrack-{tag}-debug.apk"), + ] + + # 1) Purge de tout asset existant + release = request(f"{base}/releases/{release_id}", token=token) + for a in release["assets"]: + urllib.request.urlopen(urllib.request.Request( + f"{base}/releases/{release_id}/assets/{a['id']}", method="DELETE", + headers={"Authorization": f"token {token}"}), timeout=60) + print(f" purge : {a['name']}") + + # 2) Upload séquentiel avec vérification IMMÉDIATE par téléchargement + for name, path in apk_names: + expected = os.path.getsize(path) + uploaded = _upload_asset(base, token, release_id, name, path) + if not verify_asset(base, token, release_id, uploaded["id"], name, expected): + sys.exit(f"✗ {name} : échec de vérification après upload") + print(f" ✓ {name} ({expected // 1024 // 1024} Mo)") + + # 3) Re-vérification FINALE des deux (la race se voyait seulement ici) + for name, path in apk_names: + expected = os.path.getsize(path) + if not verify_asset(base, token, release_id, 0, name, expected): + # L'asset peut avoir changé d'id — retrouver par nom + release = request(f"{base}/releases/{release_id}", token=token) + a = next(x for x in release["assets"] if x["name"] == name) + if not verify_asset(base, token, release_id, a["id"], name, expected): + sys.exit(f"✗ {name} : re-vérification finale échouée") + print(f" ✓ Les 2 APK vérifiés par téléchargement (release + debug)") + + +def main(): + args = sys.argv[1:] + 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}") + if not args: + sys.exit(f"Usage: publish-release.py [{instance}] vX.Y.Z") + tag = args[0] + + conf = INSTANCES[instance] + base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}" + token = gitea_token(conf["host"]) + body = changelog_section(tag) + apks_dir = "/tmp/apks" + + # Vérifier que les 2 APK locaux existent + for suffix in ["release", "debug"]: + p = f"{apks_dir}/HormoneTrack-{tag}-{suffix}.apk" + if not os.path.exists(p): + sys.exit(f"APK manquant : {p}") + + # Release : crée ou récupère + PATCH corps + try: + release = request(f"{base}/releases/tags/{tag}", token=token) + request(f"{base}/releases/{release['id']}", "PATCH", token=token, + data={"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False}) + release_id = release["id"] + print(f"[{instance}] release {tag} existante (id {release_id}) — corps mis à jour") + 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"] + print(f"[{instance}] release {tag} créée (id {release_id})") + + # Les 2 APK + publish_both_apks(base, token, release_id, tag, apks_dir) + print(f"[{instance}] Release {tag} complète ✓") + + +if __name__ == "__main__": + main()