From 8cf6ad54ab407c6fdb24c7cf58c0a32d13e68828 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sat, 5 Sep 2026 23:51:23 +0200 Subject: [PATCH] =?UTF-8?q?scripts/gitea-release.py=20:=20r=C3=A9g=C3=A9n?= =?UTF-8?q?=C3=A9ration=20compl=C3=A8te=20=E2=80=94=20ensure=5Fasset=20v?= =?UTF-8?q?=C3=A9rifie=20par=20T=C3=89L=C3=89CHARGEMENT=20(le=20listing=20?= =?UTF-8?q?a=20menti=20pendant=20les=20uploads=20rapproch=C3=A9s)=20;=20re?= =?UTF-8?q?try=20DELETE+re-upload=20=C3=972=20;=20=C3=A9chec=20bruyant=20;?= =?UTF-8?q?=20docs/code=20comment=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/gitea-release.py | 126 +++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 58 deletions(-) diff --git a/scripts/gitea-release.py b/scripts/gitea-release.py index 78910a6..77085d5 100755 --- a/scripts/gitea-release.py +++ b/scripts/gitea-release.py @@ -9,8 +9,8 @@ Usage : - 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é (l'asset existant du même nom - est remplacé). +- 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 : @@ -26,6 +26,7 @@ import subprocess import sys import urllib.error import urllib.request +import uuid # Instances connues : clé = nom court, hôte = service du trousseau INSTANCES = { @@ -35,6 +36,8 @@ INSTANCES = { "host": "gitea.farewell.dev"}, } +# Nom du dépôt (identique sur les deux instances) +REPO_NAME = "HormoneTrack" CHANGELOG = "docs/CHANGELOG.md" @@ -52,28 +55,74 @@ def gitea_token(host: str) -> str: return out -def request(url: str, method: str = "GET", data=None, token: str = "", files=None): +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}") - body = None - if files is not None: - boundary = "----HormoneTrackBoundary" - body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; " - f"filename=\"{files['name']}\"\r\n" - f"Content-Type: application/vnd.android.package-archive\r\n\r\n").encode() \ - + files["data"] + f"\r\n--{boundary}--\r\n".encode() - req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") - elif data is not None: + if data is not None: req.add_header("Content-Type", "application/json") - body = json.dumps(data).encode() - if body is not None: - req.data = body - with urllib.request.urlopen(req) as resp: + req.data = json.dumps(data).encode() + with urllib.request.urlopen(req, timeout=120) as resp: payload = resp.read().decode() - # Les DELETE renvoient 204 sans corps : pas de JSON à parser 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") @@ -84,36 +133,6 @@ def changelog_section(version: str) -> str: return m.group(1).strip() -def ensure_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict: - """Upload + vérification (nom ET taille) + retry PATCH — échec bruyant. - Le ?name= de l'upload et/ou le PATCH peuvent être ignorés par certaines - instances (v1.2.6/v1.2.10 : asset au nom générique, APK release disparu).""" - import uuid - expected = os.path.getsize(path) - boundary = uuid.uuid4().hex - with open(path, "rb") as f: - data = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; " - f"filename=\"{name}\"\r\n" - f"Content-Type: application/vnd.android.package-archive\r\n\r\n").encode() \ - + f.read() + f"\r\n--{boundary}--\r\n".encode() - 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 = data - uploaded = json.loads(urllib.request.urlopen(req, timeout=300).read().decode()) - - for _ in range(2): - 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 and current["name"] == name and abs(current["size"] - expected) < 1000: - return current - request(f"{base}/releases/{release_id}/assets/{uploaded['id']}", - "PATCH", token=token, data={"name": name}) - sys.exit(f"✗ ÉCHEC : l'asset {name} n'a pas le bon nom/taille après retry — " - f"la release est incomplète, publier manuellement.") - - def main(): args = sys.argv[1:] # Premier argument = instance s'il n'est pas un tag (un tag commence par « v ») @@ -152,20 +171,14 @@ def main(): 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 (remplace l'asset existant du même nom) + # 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) - # ⚠️ Renommage défensif + VÉRIFICATION : le ?name= de l'upload et/ou le - # PATCH peuvent être ignorés par certaines instances (nom générique - # observé en v1.2.6/10) → vérifier nom + taille après upload, retenter - # le PATCH une fois, et ÉCHOUER bruyamment si l'asset ne colle pas. - ensure_asset(base, token, release_id, asset_name, apk_path) - request(f"{base}/releases/{release_id}/assets/{asset['id']}", - "PATCH", token=token, data={"name": asset_name}) + asset = ensure_asset(base, token, release_id, asset_name, apk_path) link = asset.get("browser_download_url", "?") else: link = "—" @@ -175,8 +188,5 @@ def main(): f"corps {len(body)} caractères, APK : {link}") -# Nom du dépôt (identique sur les deux instances) -REPO_NAME = "HormoneTrack" - if __name__ == "__main__": main()