HormoneTrack/scripts/gitea-release.py

193 lines
8.0 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Publie/met à jour une release Gitea : corps = section CHANGELOG de la version,
APK en pièce jointe (optionnel). Réutilisable à chaque release, multi-instances.
Usage :
python3 scripts/gitea-release.py [instance] vX.Y.Z [chemin/vers/app.apk]
- instance : « cloudyfy » (défaut) ou « farewell » — voir INSTANCES ci-dessous ;
- 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é 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 :
write:repository (suffit pour releases + assets, pas pour créer un repo).
Pour ajouter un token au trousseau :
security add-internet-password -s gitea.farewell.dev -a Siphonight \
-w <TOKEN> -U
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
import uuid
# Instances connues : clé = nom court, hôte = service du trousseau
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"},
}
# Nom du dépôt (identique sur les deux instances)
REPO_NAME = "HormoneTrack"
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" Ajoute-le avec : security add-internet-password -s {host} "
f"-a Siphonight -w <TOKEN> -U"
)
return out
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}")
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 _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")
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 main():
args = sys.argv[1:]
# Premier argument = instance s'il n'est pas un tag (un tag commence par « v »)
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} (attendu : {', '.join(INSTANCES)})")
if not args:
sys.exit(f"Usage: gitea-release.py [{instance}] vX.Y.Z [app.apk]")
tag = args[0]
apk_path = args[1] if len(args) > 1 else None
conf = INSTANCES[instance]
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
token = gitea_token(conf["host"])
body = changelog_section(tag)
# Récupère la release du tag (crée-la si absente)
try:
release = request(f"{base}/releases/tags/{tag}", token=token)
release_id = release["id"]
created = False
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"]
created = True
# Corps = section CHANGELOG (PATCH si la release existait déjà)
if not created:
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 (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)
asset = ensure_asset(base, token, release_id, asset_name, apk_path)
link = asset.get("browser_download_url", "?")
else:
link = "—"
action = "créée" if created else "mise à jour"
print(f"[{instance}] Release {tag} {action} (id {release_id}) — "
f"corps {len(body)} caractères, APK : {link}")
if __name__ == "__main__":
main()