#!/usr/bin/env python3 """Publie la release Gitea d'une version WEB (attache l'archive zip du site, VÉRIFIÉE PAR TÉLÉCHARGEMENT) — en UNE SEULE INVOCATION. Portage du script Android `publish-release.py` (dépôt HormoneTrack), adapté au web : - UN SEUL artifact : `HormoneTrack-web-vX.Y.Z.zip` = déploiement statique sans git (index.html + css/ + js/ + assets/ + docs/ + LICENSE/README — le contenu runtime, cf .dockerignore) ; - l'archive est construite PAR `git archive` AU NIVEAU DU TAG : le zip correspond EXACTEMENT à ce qui est taggué (aucune divergence possible working tree ↔ release) ; - le corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md (identique à l'Android — c'est le même parseur, piège #31 : tag « vX » mais CHANGELOG sans v) ; - mêmes gardes que l'Android (leçons #37/#43) : purge des assets avant upload, vérification nom+taille par TÉLÉCHARGEMENT immédiat, re-vérification finale, échec BRUYANT. ⚠️ Note de parité : les APK Android mentionnent le tag du dépôt Android ; ce script ne doit être lancé QUE sur les tags du dépôt web (versions alignées). Usage : python3 scripts/publish-release.py [instance] vX.Y.Z Auth : trousseau macOS (security find-internet-password -s -w), scope write:repository (suffit pour releases + assets). """ import json import os import re import subprocess import sys import urllib.error import urllib.request import uuid # Instances connues — miroir du script Android (mêmes noms courts) 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-web" 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 (leçon Android #37/#43 : on ne fait jamais confiance au listing, on télécharge).""" 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/zip\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: """Corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md. ⚠️ Même piège Android #31 : le tag porte le « v », la CHANGELOG non.""" 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 (le listing API a déjà menti côté Android — on ne le croit pas).""" release = request(f"{base}/releases/{release_id}", token=token) current = next((a for a in release["assets"] if a["id"] == asset_id), None) \ if asset_id else next((a for a in release["assets"] if a["name"] == name), 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 build_zip(tag: str) -> str: """Construit l'archive de déploiement PAR GIT ARCHIVE au niveau du TAG — le zip publié = EXACTEMENT ce que le tag contient (jamais le working tree, qui peut être en avance). Exclusions : outils dev (tests/scripts) + fichiers du conteneur — le même contenu que l'image Docker (cf .dockerignore) ; pour Docker, l'utilisatrice build depuis le dépôt. """ out = f"/tmp/HormoneTrack-web-{tag}.zip" paths = ["index.html", "css", "js", "assets", "docs", "LICENSE", "README.md"] subprocess.run( ["git", "archive", "--format=zip", f"-o{out}", tag] + paths, check=True) print(f" zip construit depuis le tag : {out} ({os.path.getsize(out) // 1024} Ko)") return out def publish_zip(base: str, token: str, release_id: int, tag: str, zip_path: str) -> None: """Purge + upload du zip + vérification IMMÉDIATE par téléchargement, puis re-vérification FINALE — le pattern Android qui marche (les uploads rapprochés se remplacent mutuellement, lesson #43 : tout est fait dans UNE invocation).""" name = f"HormoneTrack-web-{tag}.zip" # 1) Purge de tout asset existant (idempotent : relancer le script est sûr) 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 + vérification immédiate par téléchargement name = f"HormoneTrack-web-{tag}.zip" expected = os.path.getsize(zip_path) uploaded = _upload_asset(base, token, release_id, name, zip_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} Ko)") # 3) Re-vérification FINALE (la race se voyait seulement ici côté Android) if not verify_asset(base, token, release_id, 0, name, expected): sys.exit(f"✗ {name} : re-vérification finale échouée") print(f" ✓ zip vérifié par téléchargement") 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 [cloudyfy|farewell] 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) zip_path = build_zip(tag) # Release : crée ou récupère + PATCH corps (idempotent) 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})") publish_zip(base, token, release_id, tag, zip_path) print(f"[{instance}] Release {tag} complète ✓") if __name__ == "__main__": main()