Docs : publish-release.py documenté (§16.bis + §14 #43 + CHANGELOG), farewell 12 releases complètes, README dépôts jumeaux
This commit is contained in:
parent
af8d40726a
commit
f5e58364a3
189
scripts/publish-release.py
Executable file
189
scripts/publish-release.py
Executable file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publie les DEUX APK (release + debug) d'un tag sur une instance Gitea avec
|
||||
VÉRIFICATION PAR TÉLÉCHARGEMENT de chaque asset — en UNE SEULE INVOCATION.
|
||||
|
||||
⚠️ Pourquoi ce script existe : les deux invocations rapprochées de
|
||||
gitea-release.py se REMPLACENT mutuellement (le 2e upload écrase le 1er) —
|
||||
confirmé en v1.2.10, v1.3.0, v1.3.1, v1.3.2. Ce script fait tout dans une
|
||||
seule passe : purge, upload release, vérif download, upload debug, vérif
|
||||
download, re-vérification finale des deux.
|
||||
|
||||
Usage :
|
||||
python3 scripts/publish-release.py [instance] vX.Y.Z
|
||||
|
||||
Attend les APK dans /tmp/apks/ :
|
||||
HormoneTrack-vX.Y.Z-release.apk (R8, recommandé)
|
||||
HormoneTrack-vX.Y.Z-debug.apk (non optimisé)
|
||||
|
||||
Corps de release = section CHANGELOG de la version (docs/CHANGELOG.md).
|
||||
Auth : trousseau macOS (security find-internet-password -s <hôte> -w).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# Instances connues : clé = nom court
|
||||
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"},
|
||||
}
|
||||
|
||||
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" security add-internet-password -s {host} -a Siphonight -w <TOKEN> -U")
|
||||
return out
|
||||
|
||||
|
||||
def request(url: str, method: str = "GET", data=None, token: str = "") -> dict:
|
||||
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 _delivered_size(base: str, token: str, url: str) -> int:
|
||||
"""Taille RÉELLEMENT délivrée — le listing API peut mentir (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 _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
|
||||
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 changelog_section(version: str) -> str:
|
||||
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 verify_asset(base: str, token: str, release_id: int, asset_id: int, name: str, expected: int) -> bool:
|
||||
"""Vérifie par TÉLÉCHARGEMENT que l'asset a le bon nom et la bonne taille."""
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
current = next((a for a in release["assets"] if a["id"] == asset_id), None)
|
||||
if current is None or current["name"] != name:
|
||||
return False
|
||||
if abs(current["size"] - expected) > 1000:
|
||||
return False
|
||||
delivered = _delivered_size(base, token, current["browser_download_url"])
|
||||
return delivered == expected
|
||||
|
||||
|
||||
def publish_both_apks(base: str, token: str, release_id: int, tag: str, apks_dir: str) -> None:
|
||||
"""Purge + upload séquentiel des 2 APK + vérification par téléchargement
|
||||
de CHAQUE asset immédiatement, puis re-vérification FINALE des deux.
|
||||
C'est le pattern qui marche à chaque fois (v1.2.6 → v1.3.2)."""
|
||||
apk_names = [
|
||||
(f"HormoneTrack-{tag}-release.apk", f"{apks_dir}/HormoneTrack-{tag}-release.apk"),
|
||||
(f"HormoneTrack-{tag}-debug.apk", f"{apks_dir}/HormoneTrack-{tag}-debug.apk"),
|
||||
]
|
||||
|
||||
# 1) Purge de tout asset existant
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
for a in release["assets"]:
|
||||
urllib.request.urlopen(urllib.request.Request(
|
||||
f"{base}/releases/{release_id}/assets/{a['id']}", method="DELETE",
|
||||
headers={"Authorization": f"token {token}"}), timeout=60)
|
||||
print(f" purge : {a['name']}")
|
||||
|
||||
# 2) Upload séquentiel avec vérification IMMÉDIATE par téléchargement
|
||||
for name, path in apk_names:
|
||||
expected = os.path.getsize(path)
|
||||
uploaded = _upload_asset(base, token, release_id, name, path)
|
||||
if not verify_asset(base, token, release_id, uploaded["id"], name, expected):
|
||||
sys.exit(f"✗ {name} : échec de vérification après upload")
|
||||
print(f" ✓ {name} ({expected // 1024 // 1024} Mo)")
|
||||
|
||||
# 3) Re-vérification FINALE des deux (la race se voyait seulement ici)
|
||||
for name, path in apk_names:
|
||||
expected = os.path.getsize(path)
|
||||
if not verify_asset(base, token, release_id, 0, name, expected):
|
||||
# L'asset peut avoir changé d'id — retrouver par nom
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
a = next(x for x in release["assets"] if x["name"] == name)
|
||||
if not verify_asset(base, token, release_id, a["id"], name, expected):
|
||||
sys.exit(f"✗ {name} : re-vérification finale échouée")
|
||||
print(f" ✓ Les 2 APK vérifiés par téléchargement (release + debug)")
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
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}")
|
||||
if not args:
|
||||
sys.exit(f"Usage: publish-release.py [{instance}] vX.Y.Z")
|
||||
tag = args[0]
|
||||
|
||||
conf = INSTANCES[instance]
|
||||
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
|
||||
token = gitea_token(conf["host"])
|
||||
body = changelog_section(tag)
|
||||
apks_dir = "/tmp/apks"
|
||||
|
||||
# Vérifier que les 2 APK locaux existent
|
||||
for suffix in ["release", "debug"]:
|
||||
p = f"{apks_dir}/HormoneTrack-{tag}-{suffix}.apk"
|
||||
if not os.path.exists(p):
|
||||
sys.exit(f"APK manquant : {p}")
|
||||
|
||||
# Release : crée ou récupère + PATCH corps
|
||||
try:
|
||||
release = request(f"{base}/releases/tags/{tag}", token=token)
|
||||
request(f"{base}/releases/{release['id']}", "PATCH", token=token,
|
||||
data={"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False})
|
||||
release_id = release["id"]
|
||||
print(f"[{instance}] release {tag} existante (id {release_id}) — corps mis à jour")
|
||||
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"]
|
||||
print(f"[{instance}] release {tag} créée (id {release_id})")
|
||||
|
||||
# Les 2 APK
|
||||
publish_both_apks(base, token, release_id, tag, apks_dir)
|
||||
print(f"[{instance}] Release {tag} complète ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user