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:
parent
3f1378f6ba
commit
48a1cc7c2f
@ -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 <hôte> -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user