193 lines
8.0 KiB
Python
Executable File
193 lines
8.0 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é 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()
|