HormoneTrack/scripts/gitea-release.py

183 lines
7.7 KiB
Python
Executable File

#!/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é (l'asset existant du même nom
est remplacé).
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
# 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"},
}
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 = "", files=None):
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:
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:
payload = resp.read().decode()
# Les DELETE renvoient 204 sans corps : pas de JSON à parser
return json.loads(payload) if payload.strip() else {}
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 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 »)
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 (remplace l'asset existant du même nom)
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})
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}")
# Nom du dépôt (identique sur les deux instances)
REPO_NAME = "HormoneTrack"
if __name__ == "__main__":
main()