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 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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user