Conteneurisation (site 100 % statique — l'image ne détient AUCUNE donnée, vie privée identique) : - Dockerfile : nginxinc/nginx-unprivileged:alpine (uid 101, port 8080, pas de root), healthcheck wget intégré, labels OCI - nginx.conf : no-cache systématique (cohérence du jeu de fichiers à chaque mise à jour d'image, coût nul : ~600 Ko + gzip sur l'asset PK), gzip, en-têtes de sécurité, deny des dotfiles - .dockerignore : runtime uniquement (docs/ inclus — dialog changelog) ; local-test-data exclu en filet de sécurité - docker-compose.yml : read_only + tmpfs (/var/cache/nginx, /run, /tmp — les temporaires de nginx-unprivileged, constat au premier test réel) + no-new-privileges Nouveaux tests avant release : - scripts/container-test.sh : build + run DURCI (config compose) + healthcheck healthy + endpoints 200 + headers (no-cache/nosniff/DENY/ no-referrer) + gzip réel + MIME strict des modules ES + fichiers cachés non servis + image propre (pas de node_modules/npm) + docs/ embarqué + E2E playwright complet CONTRE LE CONTENEUR (HRT_E2E_BASE) - scripts/e2e.mjs : mode externe HRT_E2E_BASE (pilote un site déjà déployé — conteneur inclus, serveur local non démarré) - scripts/check.sh --release : supplée les 9 vérifications (version ↔ changelog ↔ tag ↔ arbre propre + test conteneur si daemon) Runtime Docker local installé pour le CI-like (colima 2 CPU / 2 Go) : premier build + run réel = 2 problèmes testés et corrigés (tmpfs /tmp, key case header_json curl). CONTAINER TEST OK en configuration durcie.
206 lines
8.8 KiB
Python
206 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Publie la release Gitea d'une version WEB (attache l'archive zip du site,
|
|
VÉRIFIÉE PAR TÉLÉCHARGEMENT) — en UNE SEULE INVOCATION.
|
|
|
|
Portage du script Android `publish-release.py` (dépôt HormoneTrack),
|
|
adapté au web :
|
|
- UN SEUL artifact : `HormoneTrack-web-vX.Y.Z.zip` = déploiement
|
|
statique sans git (index.html + css/ + js/ + assets/ + docs/ +
|
|
LICENSE/README — le contenu runtime, cf .dockerignore) ;
|
|
- l'archive est construite PAR `git archive` AU NIVEAU DU TAG : le zip
|
|
correspond EXACTEMENT à ce qui est taggué (aucune divergence possible
|
|
working tree ↔ release) ;
|
|
- le corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md
|
|
(identique à l'Android — c'est le même parseur, piège #31 : tag « vX »
|
|
mais CHANGELOG sans v) ;
|
|
- mêmes gardes que l'Android (leçons #37/#43) : purge des assets avant
|
|
upload, vérification nom+taille par TÉLÉCHARGEMENT immédiat,
|
|
re-vérification finale, échec BRUYANT.
|
|
|
|
⚠️ Note de parité : les APK Android mentionnent le tag du dépôt Android ; ce
|
|
script ne doit être lancé QUE sur les tags du dépôt web (versions alignées).
|
|
|
|
Usage :
|
|
python3 scripts/publish-release.py [instance] vX.Y.Z
|
|
|
|
Auth : trousseau macOS (security find-internet-password -s <hôte> -w),
|
|
scope write:repository (suffit pour releases + assets).
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
# Instances connues — miroir du script Android (mêmes noms courts)
|
|
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-web"
|
|
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 (leçon Android
|
|
#37/#43 : on ne fait jamais confiance au listing, on télécharge)."""
|
|
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/zip\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:
|
|
"""Corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md.
|
|
⚠️ Même piège Android #31 : le tag porte le « v », la CHANGELOG non."""
|
|
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
|
|
(le listing API a déjà menti côté Android — on ne le croit pas)."""
|
|
release = request(f"{base}/releases/{release_id}", token=token)
|
|
current = next((a for a in release["assets"] if a["id"] == asset_id), None) \
|
|
if asset_id else next((a for a in release["assets"] if a["name"] == name), 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 build_zip(tag: str) -> str:
|
|
"""Construit l'archive de déploiement PAR GIT ARCHIVE au niveau du TAG —
|
|
le zip publié = EXACTEMENT ce que le tag contient (jamais le working
|
|
tree, qui peut être en avance). Exclusions : outils dev (tests/scripts)
|
|
+ fichiers du conteneur — le même contenu que l'image Docker (cf
|
|
.dockerignore) ; pour Docker, l'utilisatrice build depuis le dépôt.
|
|
"""
|
|
out = f"/tmp/HormoneTrack-web-{tag}.zip"
|
|
paths = ["index.html", "css", "js", "assets", "docs", "LICENSE", "README.md"]
|
|
subprocess.run(
|
|
["git", "archive", "--format=zip", f"-o{out}", tag] + paths, check=True)
|
|
print(f" zip construit depuis le tag : {out} ({os.path.getsize(out) // 1024} Ko)")
|
|
return out
|
|
|
|
|
|
def publish_zip(base: str, token: str, release_id: int, tag: str, zip_path: str) -> None:
|
|
"""Purge + upload du zip + vérification IMMÉDIATE par téléchargement,
|
|
puis re-vérification FINALE — le pattern Android qui marche
|
|
(les uploads rapprochés se remplacent mutuellement, lesson #43 :
|
|
tout est fait dans UNE invocation)."""
|
|
name = f"HormoneTrack-web-{tag}.zip"
|
|
|
|
# 1) Purge de tout asset existant (idempotent : relancer le script est sûr)
|
|
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 + vérification immédiate par téléchargement
|
|
name = f"HormoneTrack-web-{tag}.zip"
|
|
expected = os.path.getsize(zip_path)
|
|
uploaded = _upload_asset(base, token, release_id, name, zip_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} Ko)")
|
|
|
|
# 3) Re-vérification FINALE (la race se voyait seulement ici côté Android)
|
|
if not verify_asset(base, token, release_id, 0, name, expected):
|
|
sys.exit(f"✗ {name} : re-vérification finale échouée")
|
|
print(f" ✓ zip vérifié par téléchargement")
|
|
|
|
|
|
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 [cloudyfy|farewell] 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)
|
|
zip_path = build_zip(tag)
|
|
|
|
# Release : crée ou récupère + PATCH corps (idempotent)
|
|
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})")
|
|
|
|
publish_zip(base, token, release_id, tag, zip_path)
|
|
print(f"[{instance}] Release {tag} complète ✓")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|