scripts/gitea-release.py : régénération complète — ensure_asset vérifie par TÉLÉCHARGEMENT (le listing a menti pendant les uploads rapprochés) ; retry DELETE+re-upload ×2 ; échec bruyant ; docs/code commentés

This commit is contained in:
Siphonight 2026-09-05 23:51:23 +02:00
parent f695d3b99e
commit 7e8a2f6d6f

View File

@ -9,8 +9,8 @@ Usage :
- le corps est extrait de docs/CHANGELOG.md (section « ## [X.Y.Z] », SANS « v » - 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) ; — 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 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 - si un chemin d'APK est fourni, il est attaché avec VÉRIFICATION PAR
est remplacé). TÉLÉCHARGEMENT (cf ensure_asset).
Auth : le token Gitea est lu dans le trousseau macOS Auth : le token Gitea est lu dans le trousseau macOS
(security find-internet-password -s <hôte> -w) — scope requis : (security find-internet-password -s <hôte> -w) — scope requis :
@ -26,6 +26,7 @@ import subprocess
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
import uuid
# Instances connues : clé = nom court, hôte = service du trousseau # Instances connues : clé = nom court, hôte = service du trousseau
INSTANCES = { INSTANCES = {
@ -35,6 +36,8 @@ INSTANCES = {
"host": "gitea.farewell.dev"}, "host": "gitea.farewell.dev"},
} }
# Nom du dépôt (identique sur les deux instances)
REPO_NAME = "HormoneTrack"
CHANGELOG = "docs/CHANGELOG.md" CHANGELOG = "docs/CHANGELOG.md"
@ -52,28 +55,74 @@ def gitea_token(host: str) -> str:
return out 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 = urllib.request.Request(url, method=method)
req.add_header("Authorization", f"token {token}") req.add_header("Authorization", f"token {token}")
body = None if data is not 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:
req.add_header("Content-Type", "application/json") req.add_header("Content-Type", "application/json")
body = json.dumps(data).encode() req.data = json.dumps(data).encode()
if body is not None: with urllib.request.urlopen(req, timeout=120) as resp:
req.data = body
with urllib.request.urlopen(req) as resp:
payload = resp.read().decode() payload = resp.read().decode()
# Les DELETE renvoient 204 sans corps : pas de JSON à parser
return json.loads(payload) if payload.strip() else {} 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: def changelog_section(version: str) -> str:
"""Extrait la section « ## [X.Y.Z] » de la CHANGELOG (version SANS le « v»).""" """Extrait la section « ## [X.Y.Z] » de la CHANGELOG (version SANS le « v»)."""
num = version.removeprefix("v") num = version.removeprefix("v")
@ -84,36 +133,6 @@ def changelog_section(version: str) -> str:
return m.group(1).strip() 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(): def main():
args = sys.argv[1:] args = sys.argv[1:]
# Premier argument = instance s'il n'est pas un tag (un tag commence par « v ») # 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, request(f"{base}/releases/{release_id}", "PATCH", token=token,
data={"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False}) 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: if apk_path:
asset_name = f"HormoneTrack-{tag}.apk" asset_name = f"HormoneTrack-{tag}.apk"
for asset in release.get("assets", []): for asset in release.get("assets", []):
if asset["name"] == asset_name: if asset["name"] == asset_name:
request(f"{base}/releases/{release_id}/assets/{asset['id']}", request(f"{base}/releases/{release_id}/assets/{asset['id']}",
"DELETE", token=token) "DELETE", token=token)
# ⚠️ Renommage défensif + VÉRIFICATION : le ?name= de l'upload et/ou le asset = ensure_asset(base, token, release_id, asset_name, apk_path)
# 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})
link = asset.get("browser_download_url", "?") link = asset.get("browser_download_url", "?")
else: else:
link = "—" link = "—"
@ -175,8 +188,5 @@ def main():
f"corps {len(body)} caractères, APK : {link}") f"corps {len(body)} caractères, APK : {link}")
# Nom du dépôt (identique sur les deux instances)
REPO_NAME = "HormoneTrack"
if __name__ == "__main__": if __name__ == "__main__":
main() main()