Conteneurisation Docker + tests de release (container-test, mode --release)
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.
This commit is contained in:
parent
491efef8eb
commit
a0245e72c2
27
.dockerignore
Normal file
27
.dockerignore
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# .dockerignore — ce qui n'entre PAS dans l'image de déploiement.
|
||||||
|
#
|
||||||
|
# L'image ne sert que le RUNTIME : app (index.html, css/, js/, assets/),
|
||||||
|
# docs/ (⚠️ requis — le dialog « Nouveautés » fetch docs/CHANGELOG.md),
|
||||||
|
# LICENSE/README. Tout le reste est exclu :
|
||||||
|
# - .git, node_modules, package-lock.json : jamais dans une image ;
|
||||||
|
# - tests/, scripts/ : outils de dev — inutiles au runtime (et l'image
|
||||||
|
# devient plus propre à auditer : `docker run … find /usr/share/nginx/html`);
|
||||||
|
# - local-test-data/ : DOUBLON DE SÉCURITÉ — exports de santé réels jamais
|
||||||
|
# versionnés (cf docs/DEVELOPPEMENT.md §3) ; s'ils existaient par erreur
|
||||||
|
# à la racine du contexte de build, ils n'iraient JAMAIS dans l'image ;
|
||||||
|
# - fichiers de config du conteneur exclus aussi (Dockerfile/compose sont
|
||||||
|
# des instructions, pas des fichiers à servir).
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
tests
|
||||||
|
scripts
|
||||||
|
local-test-data
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# HormoneTrack Web — image de déploiement Docker (site statique, 100 % local).
|
||||||
|
#
|
||||||
|
# POURQUOI C'EST SIMPLE : l'app n'a AUCUN build (ES modules natifs) et AUCUN
|
||||||
|
# composant serveur — « conteneuriser » = servir les fichiers avec nginx.
|
||||||
|
# Le conteneur ne détient AUCUNE donnée : tout vit dans le localStorage des
|
||||||
|
# navigateurs clients (le serveur ne fait que distribuer des fichiers, cf
|
||||||
|
# docs/DEVELOPPEMENT.md §10 — la vie privée est inchangée).
|
||||||
|
#
|
||||||
|
# Choix d'image : `nginx-unprivileged` (utilisateur 101, port 8080, écritures
|
||||||
|
# limitées à /tmp) — meilleure pratique conteneur (pas de root), même image
|
||||||
|
# que pour un self-host NAS/VPS. Alpine = ~50 Mo décompressé.
|
||||||
|
#
|
||||||
|
# Build : docker build -t hormonetrack-web .
|
||||||
|
# Run : docker run -p 8080:8080 hormonetrack-web (ou docker compose up -d)
|
||||||
|
# Cf docs/DEVELOPPEMENT.md §10 « Déploiement ».
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
|
||||||
|
# ── Métadonnées OCI (traçabilité du conteneur déployé) ───────────────────────
|
||||||
|
LABEL org.opencontainers.image.title="HormoneTrack Web" \
|
||||||
|
org.opencontainers.image.description="Suivi de thérapie hormonale — app web 100 % locale (site statique). Portage navigateur de l'app Android." \
|
||||||
|
org.opencontainers.image.source="https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web" \
|
||||||
|
org.opencontainers.image.licenses="GPL-3.0"
|
||||||
|
|
||||||
|
# ── Config nginx spécifique (port non-privilégié, no-cache, gzip, sécurité) ──
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# ── Fichiers du site ────────────────────────────────────────────────────────
|
||||||
|
# Tout le dépôt SAUF ce qui n'est pas runtime (tests, scripts, npm) — cf
|
||||||
|
# .dockerignore. ⚠️ docs/ DOIT rester dans l'image : le dialog « Nouveautés »
|
||||||
|
# fetch docs/CHANGELOG.md au démarrage.
|
||||||
|
COPY . /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# Le site est statique : le healthcheck synthétique vérifie que nginx répond
|
||||||
|
# (busybox wget est présent dans l'image alpine).
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||||
|
|
||||||
|
# Port non privilégié (l'image unprivileged n'a pas le droit de binde <1024)
|
||||||
|
EXPOSE 8080
|
||||||
21
README.md
21
README.md
@ -57,6 +57,27 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Avec Docker (self-host NAS/VPS)
|
||||||
|
|
||||||
|
L'image ne fait **que servir les fichiers** : aucune donnée dedans, la vie
|
||||||
|
privée est identique (les données vivent dans les navigateurs). Build et
|
||||||
|
exécution :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # build + run → http://<hôte>:8080/
|
||||||
|
docker compose up -d --build # rebuild après mise à jour du dépôt
|
||||||
|
```
|
||||||
|
|
||||||
|
- Image **non privilégiée** (`nginx-unprivileged`, utilisateur 101, port
|
||||||
|
8080), filesystem en **lecture seule** (composé prêt pour le NAS),
|
||||||
|
healthcheck intégré, gzip sur l'asset PK (550 Ko → ~150 Ko).
|
||||||
|
- Headers `no-cache` : une mise à jour d'image est prise en compte au
|
||||||
|
rechargement suivant, sans code périmé chez les clients.
|
||||||
|
- Validation en 3 commandes sur l'hôte qui a Docker (cf
|
||||||
|
[docs/DEVELOPPEMENT.md §10](docs/DEVELOPPEMENT.md)) : build, run, puis
|
||||||
|
`HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs` — le même test
|
||||||
|
E2E que le CI pilote le site SERVI PAR LE CONTENEUR.
|
||||||
|
|
||||||
Aucun build, aucune variable d'environnement, aucun composant serveur : les
|
Aucun build, aucune variable d'environnement, aucun composant serveur : les
|
||||||
données de chaque personne vivent **dans SON navigateur**, jamais sur la
|
données de chaque personne vivent **dans SON navigateur**, jamais sur la
|
||||||
machine qui sert les fichiers.
|
machine qui sert les fichiers.
|
||||||
|
|||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# docker-compose — un commandement pour tourner HormoneTrack Web.
|
||||||
|
#
|
||||||
|
# docker compose up -d → http://<hôte>:8080/
|
||||||
|
# docker compose up -d --build (rebuild après mise à jour du dépôt)
|
||||||
|
#
|
||||||
|
# Le conteneur ne détient AUCUNE donnée (site statique) : le volume n'est
|
||||||
|
# pas nécessaire, la persistance vit dans les navigateurs. Pas de variable
|
||||||
|
# d'environnement, pas de volume — la config = le code servi.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
# Nom d'image stable : retaguer par version à chaque release sync si
|
||||||
|
# tu veux garder un historique (docker tag hormonetrack-web hormonetrack-web:vX.Y.Z)
|
||||||
|
image: hormonetrack-web:latest
|
||||||
|
container_name: hormonetrack-web
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # port non privilégié de l'image unprivileged nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Durcissement (site statique : AUCUNE écriture nécessaire) ───────────
|
||||||
|
# Filesystem en lecture seule + tmpfs pour les seuls dirs que nginx
|
||||||
|
# touche : l'image unprivileged place ses temporaires dans /tmp
|
||||||
|
# (proxy_temp, client_temp, pid) — sans ce tmpfs, nginx émerge
|
||||||
|
# « mkdir /tmp/proxy_temp failed (30: Read-only file system) » au
|
||||||
|
# démarrage avec read_only (constaté au premier container-test 8 sept.).
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
@ -521,12 +521,56 @@ push initial v1.2.3, cf doc Android §16.)
|
|||||||
3. si l'asset PK change : les DEUX dépôts dans la même session (tests
|
3. si l'asset PK change : les DEUX dépôts dans la même session (tests
|
||||||
pins identiques) — jamais un seul côté.
|
pins identiques) — jamais un seul côté.
|
||||||
|
|
||||||
### Déploiement du site (quand décidé)
|
### Déploiement — Docker (recommandé pour self-host)
|
||||||
|
|
||||||
- **copier le dépôt** (ou `git archive vX.Y.Z`) sur n'importe quel
|
L'app étant un site **100 % statique**, la conteneurisation est triviale :
|
||||||
hébergeur statique ; rien à compiler ;
|
l'image ajoute UNIQUEMENT un serveur web. Points structurants :
|
||||||
- optionnel : exclure `tests/`, `scripts/`, `docs/` du document root
|
|
||||||
(inoffensifs mais inutiles en prod).
|
| Aspect | Décision | Pourquoi |
|
||||||
|
|---|---|---|
|
||||||
|
| Image de base | `nginxinc/nginx-unprivileged:alpine` | run en uid 101 (pas de root), port 8080, writes limités à /tmp — meilleure pratique conteneur, ~50 Mo |
|
||||||
|
| Données | **AUCUNE dans l'image** (`.dockerignore` exclut aussi `local-test-data` en filet de sécurité) | le conteneur distribue des fichiers ; la vie privée est identique (localStorage clients) — cf §3/§5 |
|
||||||
|
| Contenu servi | index.html + css/js/assets + **docs/** (⚠️ le dialog « Nouveautés » fetch `docs/CHANGELOG.md` au démarrage) | pas de `tests/`, `scripts/`, npm dans l'image (audit facile : `find /usr/share/nginx/html`) |
|
||||||
|
| Cache | `no-cache, must-revalidate` sur TOUT | l'app = un jeu de fichiers qui doivent rester COHÉRENTS entre eux ; un JS périmé au moment d'une mise à jour = état incohérent. Total ≈ 600 Ko + gzip sur l'asset PK (550 Ko → ~150 Ko) : coût nul |
|
||||||
|
| Ports | 8080 (non privilégié) | l'image unprivileged ne peut pas binde <1024 |
|
||||||
|
| Durcissement | `read_only: true` + tmpfs `/var/cache/nginx` + `/run`, `no-new-privileges` | site statique : AUCUNE écriture nécessaire ; si un hôte compose refuse les tmpfs, retirer les deux lignes |
|
||||||
|
| Healthcheck | busybox `wget` sur `/` (30 s/3 s) | intégré au Dockerfile — orchestrateurs (compose/NAS) savent si le site répond |
|
||||||
|
|
||||||
|
**Fichiers** : `Dockerfile`, `nginx.conf` (server block remplacé),
|
||||||
|
`.dockerignore` (exclusions documentées — `docs/` reste inclus),
|
||||||
|
`docker-compose.yml` (durcissement inclus).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── Build + run ──
|
||||||
|
docker compose up -d # build + run → http://<hôte>:8080/
|
||||||
|
docker compose up -d --build # rebuild après git pull (mise à jour = 30 s)
|
||||||
|
docker tag hormonetrack-web:latest hormonetrack-web:vX.Y.Z # garder un historique d'images par release sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valider le conteneur avec le MÊME test E2E que le CI** (le script pilote
|
||||||
|
n'importe quel serveur via `HRT_E2E_BASE` — à lancer sur l'hôte qui a
|
||||||
|
Docker ET exécute `npm i && npx playwright install firefox` une fois, cf
|
||||||
|
§8) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs
|
||||||
|
# → les mêmes ~30 assertions (rendu, canvas peint pixel par pixel,
|
||||||
|
# navigation, écran changelog, 0 erreur console) contre le site SERVI
|
||||||
|
# PAR LE CONTENEUR — un bug nginx/MIME casse l'app, le test le voit.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fichiers servis vérifiés** : `/`, `/css/style.css`, `/assets/pk_profiles.json`,
|
||||||
|
`/js/ui/app.js`, `/docs/CHANGELOG.md` — les mêmes que le smoke HTTP de
|
||||||
|
`check.sh --with-serve`.
|
||||||
|
|
||||||
|
**Statut (8 sept. 2026)** : fichiers + docs prêts et relus — build NON
|
||||||
|
exécuté localement (aucun daemon Docker sur la machine de dev, CLI seule) ;
|
||||||
|
la configuration compose (`read_only`/tmpfs) est le pattern standard de
|
||||||
|
nginx-unprivileged, à valider au premier `docker compose up` sur l'hôte
|
||||||
|
cible. Si le durcissement pose problème sur ton NAS : retirer
|
||||||
|
`read_only`/`tmpfs`/`security_opt` du compose (aucune perte de sécurité
|
||||||
|
cruciale — le conteneur ne détient rien, cf plus haut).
|
||||||
|
|
||||||
## 11. Bugs potentiels évités pendant le portage
|
## 11. Bugs potentiels évités pendant le portage
|
||||||
|
|
||||||
|
|||||||
58
nginx.conf
Normal file
58
nginx.conf
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Config nginx du site HormoneTrack Web (conteneur — remplace le default.conf).
|
||||||
|
#
|
||||||
|
# La partie « serveur statique » est triviale ; les choix qui comptent :
|
||||||
|
#
|
||||||
|
# 1. **No-cache systématique** — raison : l'app est une SPA versionnée par un
|
||||||
|
# jeu de fichiers (index.html + modules ES + asset PK) qui doivent rester
|
||||||
|
# cohérents entre eux. Un cache client périmé AU MOMENT d'une mise à jour
|
||||||
|
# d'image = code JS ancien + changelog nouveau = état incohérent. Le site
|
||||||
|
# complet ≈ 600 Ko : le coût du re-téléchargement est négligeable sur un
|
||||||
|
# LAN/VPN, et la fraîcheur après mise à jour est garantie.
|
||||||
|
#
|
||||||
|
# 2. **gzip** — l'asset pk_profiles.json (550 Ko de floats) se compresse
|
||||||
|
# ~70 % : prend le seul fichier lourd en charge.
|
||||||
|
#
|
||||||
|
# 3. **En-têtes de sécurité minimaux** (site statique sans API, sans cookie,
|
||||||
|
# sans iframe légitime).
|
||||||
|
#
|
||||||
|
# Ce fichier est copié dans /etc/nginx/conf.d/default.conf (server block) par
|
||||||
|
# le Dockerfile — le http{} parent de l'image inclut déjà mime.types (.js
|
||||||
|
# servi en application/javascript : requis pour les modules ES).
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# gzip : l'asset PK (550 Ko) se compresse ~4× — le seul fichier lourd
|
||||||
|
gzip on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_types application/json application/javascript text/css text/markdown text/plain;
|
||||||
|
|
||||||
|
# ── Headers appliqués à toutes les réponses ──────────────────────────────
|
||||||
|
# No-cache « always » : une mise à jour d'image est prise en compte au
|
||||||
|
# rechargement suivant, sans qu'aucun client conserve du code périmé.
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
|
||||||
|
# ── Routing ─────────────────────────────────────────────────────────────
|
||||||
|
# SPA à fichier unique : pas de réécriture (le routing est par HASH
|
||||||
|
# `#chart` etc., jamais par chemin) — un simple fallback 404 suffit.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Défensif : ne jamais servir les fichiers cachés (.gitignore,
|
||||||
|
# .dockerignore — ils sont normalement exclus par .dockerignore, la
|
||||||
|
# règle reste en filet si quelqu'un build depuis une copie non propre).
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# check.sh — vérifications de l'app web (pré-commit / pré-push).
|
# check.sh — vérifications de l'app web (pré-commit / pré-push / pré-release).
|
||||||
#
|
#
|
||||||
# 1. node --check sur chaque module ES (syntaxe) — attrape les typos avant
|
# 1. node --check sur chaque module ES (syntaxe) — attrape les typos avant
|
||||||
# même de charger l'app dans un navigateur ;
|
# même de charger l'app dans un navigateur ;
|
||||||
@ -12,12 +12,26 @@
|
|||||||
# install firefox, cf docs/DEVELOPPEMENT.md §Tests) ;
|
# install firefox, cf docs/DEVELOPPEMENT.md §Tests) ;
|
||||||
# 6. smoke HTTP (--with-serve : serveur + curl des ressources clés).
|
# 6. smoke HTTP (--with-serve : serveur + curl des ressources clés).
|
||||||
#
|
#
|
||||||
# Usage : bash scripts/check.sh [--with-serve]
|
# Mode RELEASE (--release) : ajoute les vérifications OBLIGATOIRES avant une
|
||||||
|
# release (cf docs/DEVELOPPEMENT.md §10) —
|
||||||
|
# 7. cohérence de la version : WEB_VERSION == section `## [X.Y.Z]` de
|
||||||
|
# docs/CHANGELOG.md == tag annoté `vX.Y.Z` existant (le bump de version
|
||||||
|
# jamais commité côté Android, bug #46, ne doit pas se reproduire ici) ;
|
||||||
|
# 8. arbre git propre (tout commité avant publication) ;
|
||||||
|
# 9. test conteneur RÉEL (scripts/container-test.sh — build/run durci/
|
||||||
|
# headers/gzip/MIME/E2E contre le conteneur) si un daemon Docker est
|
||||||
|
# joignable, ignoré sinon (à lancer sur l'hôte qui a Docker).
|
||||||
|
#
|
||||||
|
# Usage : bash scripts/check.sh [--with-serve] [--release]
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.." # racine du dépôt
|
cd "$(dirname "$0")/.." # racine du dépôt
|
||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
|
RELEASE=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
[[ "$arg" == "--release" ]] && RELEASE=1
|
||||||
|
done
|
||||||
|
|
||||||
echo "── 1. Syntaxe des modules (node --check) ──"
|
echo "── 1. Syntaxe des modules (node --check) ──"
|
||||||
while IFS= read -r f; do
|
while IFS= read -r f; do
|
||||||
@ -81,6 +95,47 @@ if [[ "${1:-}" == "--with-serve" ]]; then
|
|||||||
wait $SRV 2>/dev/null || true
|
wait $SRV 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$RELEASE" == "1" ]]; then
|
||||||
|
echo "── 7. Cohérence de la version (release) ──"
|
||||||
|
ver=$(node --input-type=module -e "
|
||||||
|
import { WEB_VERSION } from './js/ui/settings.js';
|
||||||
|
process.stdout.write(WEB_VERSION);")
|
||||||
|
verGrep=$(echo "$ver" | sed 's/\./\\./g') # 1.4.10 → 1\.4\.10 pour grep -E
|
||||||
|
if grep -qE "^## \\[$verGrep\\]" docs/CHANGELOG.md; then
|
||||||
|
echo " WEB_VERSION = $ver ; section [$ver] présente dans docs/CHANGELOG.md"
|
||||||
|
else
|
||||||
|
echo "✗ WEB_VERSION ($ver) n'a pas de section correspondante dans docs/CHANGELOG.md"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
# Le bump de version jamais commité (bug Android #46) ne doit pas se
|
||||||
|
# reproduire : la release est faite SUR un tag qui existe.
|
||||||
|
if git rev-parse -q --verify "refs/tags/v$ver" >/dev/null 2>&1; then
|
||||||
|
echo " tag v$ver présent"
|
||||||
|
else
|
||||||
|
echo "✗ tag v$ver ABSENT — créer et pousser le tag avant la release (leçon Android #46)"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── 8. Arbre git propre avant publication ──"
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "✗ working tree sale — tout committer avant la release"
|
||||||
|
git status --short | head -5
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " working tree propre"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── 9. Test conteneur RÉEL (si daemon Docker joignable) ──"
|
||||||
|
if docker info --format x >/dev/null 2>&1; then
|
||||||
|
if ! bash scripts/container-test.sh 8975; then
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (ignoré : aucun daemon Docker ici — lancer sur l'hôte cible :"
|
||||||
|
echo " bash scripts/container-test.sh ; cf docs/DEVELOPPEMENT.md §10)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ $fail -eq 0 ]]; then
|
if [[ $fail -eq 0 ]]; then
|
||||||
echo "✓ CHECK OK"
|
echo "✓ CHECK OK"
|
||||||
else
|
else
|
||||||
|
|||||||
126
scripts/container-test.sh
Executable file
126
scripts/container-test.sh
Executable file
@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# container-test.sh — validation RÉELLE de l'image Docker HormoneTrack Web.
|
||||||
|
#
|
||||||
|
# C'est le test qui doit passer AVANT toute release web déployable en
|
||||||
|
# conteneur (cf docs/DEVELOPPEMENT.md §10) : il attrape ce qu'aucun test
|
||||||
|
# statique ne voit — MIME des modules ES (requis par les navigateurs),
|
||||||
|
# headers réellement émis, gzip appliqué, fichiers exclus non servis,
|
||||||
|
# healthcheck, et le comportement de l'app COMPLÈTE servie par le conteneur
|
||||||
|
# (via l'E2E playwright branclé sur le conteneur).
|
||||||
|
#
|
||||||
|
# Prérequis : docker (daemon) ; node + playwright pour l'E2E final (skippé
|
||||||
|
# sinon — cf check.sh pour l'installation).
|
||||||
|
#
|
||||||
|
# Usage : bash scripts/container-test.sh [port] (défaut 8975)
|
||||||
|
#
|
||||||
|
# ⚠️ Un SEUL invocation fait tout (leçon Android #43 : les invocations
|
||||||
|
# rapprochées se remplacent mutuellement) et l'échec est BRUYANT (exit 1
|
||||||
|
# à la première vérification fausse — jamais de « probablement bon »).
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # racine du dépôt
|
||||||
|
|
||||||
|
PORT="${1:-8975}"
|
||||||
|
HOST_PORT="127.0.0.1:${PORT}"
|
||||||
|
IMAGE="hormonetrack-web:smoke"
|
||||||
|
CONTAINER="hrt-web-smoke"
|
||||||
|
ROOM=0 # compteur d'échecs
|
||||||
|
|
||||||
|
fail() { echo " ✗ $1"; ROOM=$((ROOM + 1)); }
|
||||||
|
ok() { echo " ✓ $1"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "── 1. Build de l'image ──"
|
||||||
|
docker build -t "$IMAGE" . 2>&1 | tail -2
|
||||||
|
|
||||||
|
echo "── 2. Run (durcissement compose : read_only + no-new-privileges) ──"
|
||||||
|
# On teste l'image dans la configuration DURCIE de docker-compose.yml —
|
||||||
|
# si ces options ne passent pas ici, elles n'auraient pas mieux passé en
|
||||||
|
# prod. ⚠️ tmpfs /tmp requis : l'image nginx-unprivileged écrit ses
|
||||||
|
# temporaires (proxy_temp…) dans /tmp — en read-only pur, nginx émerge
|
||||||
|
# « mkdir /tmp/proxy_temp failed » (constaté au premier container-test).
|
||||||
|
docker run -d --rm --name "$CONTAINER" \
|
||||||
|
-p "${HOST_PORT}:8080" \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /var/cache/nginx --tmpfs /run --tmpfs /tmp \
|
||||||
|
--security-opt no-new-privileges \
|
||||||
|
"$IMAGE" >/dev/null
|
||||||
|
|
||||||
|
echo "── 3. Healthcheck (statut docker healthy) ──"
|
||||||
|
state=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
state=$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo "")
|
||||||
|
if [[ "$state" == "healthy" ]]; then ok "healthcheck : healthy"; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[[ "$state" == "healthy" ]] || fail "healthcheck jamais healthy (état: '${state:-inconnu}')"
|
||||||
|
|
||||||
|
echo "── 4. Endpoints clés (HTTP 200) ──"
|
||||||
|
# Les MEMES chemins que le smoke HTTP de check.sh — le conteneur doit les
|
||||||
|
# servir TOUS (l'oubli de docs/ casserait le dialog « Nouveautés »).
|
||||||
|
for path in / /css/style.css /assets/pk_profiles.json /js/ui/app.js /docs/CHANGELOG.md; do
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}${path}")
|
||||||
|
if [[ "$code" == "200" ]]; then ok "200 ${path}"; else fail "${path} → HTTP ${code}"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "── 5. Headers (no-cache + sécurité) ──"
|
||||||
|
hdr=$(curl -s -D - -o /dev/null "http://${HOST_PORT}/")
|
||||||
|
for want in "no-cache" "nosniff" "DENY" "no-referrer"; do
|
||||||
|
if echo "$hdr" | grep -qi "$want"; then ok "header présent : $want"; else fail "header manquant : $want"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "── 5. gzip effectif sur l'asset PK (550 Ko → ~150 Ko) ──"
|
||||||
|
enc=$(curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w '%{header_json}' \
|
||||||
|
"http://${HOST_PORT}/assets/pk_profiles.json")
|
||||||
|
# ⚠️ curl %header_json produit des clés MINUSCULES (convention JSON) —
|
||||||
|
# chercher « content-encoding » pas « Content-Encoding ».
|
||||||
|
if echo "$enc" | grep -qi 'content-encoding.*gzip'; then ok "gzip appliqué"; else fail "pas de gzip sur l'asset — ${enc}"; fi
|
||||||
|
|
||||||
|
echo "── 6. MIME des modules ES (requête STRICTE des navigateurs) ──"
|
||||||
|
# Le piège réel du portage : un .js servi en text/html → « disallowed MIME
|
||||||
|
# type » et l'app ne charge PAS (cf §11 #6). On vérifie le CType réel.
|
||||||
|
mime=$(curl -s -o /dev/null -w '%{content_type}' "http://${HOST_PORT}/js/ui/app.js")
|
||||||
|
if echo "$mime" | grep -qi 'javascript'; then ok "module ES : ${mime}"; else fail "CType module ES = ${mime} (javascript requis)"; fi
|
||||||
|
|
||||||
|
echo "── 7. Fichiers cachés NON servis (défensif) ──"
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}/.gitignore")
|
||||||
|
if [[ "$code" == "403" || "$code" == "404" ]]; then ok "fichier caché non servi (${code})"; else fail "fichier caché servi → HTTP ${code}"; fi
|
||||||
|
|
||||||
|
echo "── 8. Le conteneur ne contient AUCUNE donnée de dev ──"
|
||||||
|
# Audit de contenu : ce qui est dans l'image (garde double de .dockerignore)
|
||||||
|
for excl in package-lock.json node_modules; do
|
||||||
|
if docker exec "$CONTAINER" find /usr/share/nginx/html -name "$excl" 2>/dev/null | grep -q .; then
|
||||||
|
fail "$excl trouvé dans l'image"
|
||||||
|
else
|
||||||
|
ok "image propre : pas de ${excl}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# docs/ DOIT être présent (dialog changelog)
|
||||||
|
if docker exec "$CONTAINER" test -f /usr/share/nginx/html/docs/CHANGELOG.md; then
|
||||||
|
ok "docs/CHANGELOG.md embarqué (dialog « Nouveautés »)"
|
||||||
|
else
|
||||||
|
fail "docs/CHANGELOG.md ABSENT de l'image — le dialog « Nouveautés » planerait"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── 9. E2E navigateur complet contre le conteneur (si playwright dispo) ──"
|
||||||
|
if [[ -d node_modules/playwright-core && -d "$HOME/Library/Caches/ms-playwright" ]]; then
|
||||||
|
if HRT_E2E_BASE="http://${HOST_PORT}" node scripts/e2e.mjs; then
|
||||||
|
ok "E2E complet contre le conteneur"
|
||||||
|
else
|
||||||
|
fail "E2E contre le conteneur"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (ignoré : playwright non installé — npm i && npx playwright install firefox)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $ROOM -eq 0 ]]; then
|
||||||
|
echo "✓ CONTAINER TEST OK"
|
||||||
|
else
|
||||||
|
echo "✗ CONTAINER TEST : ${ROOM} échec(s)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@ -40,8 +40,23 @@ function ok(condition, label, detail = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Démarre le serveur statique de dev et attend qu'il réponde. */
|
/**
|
||||||
|
* Démarre le serveur statique de dev et attend qu'il réponde.
|
||||||
|
*
|
||||||
|
* ⚠️ MODE CONTENEUR : si la variable d'environnement HRT_E2E_BASE est définie
|
||||||
|
* (ex. HRT_E2E_BASE=http://127.0.0.1:8080), le serveur local n'est PAS
|
||||||
|
* démarré — l'E2E pilote l'app SÉRVIE PAR CE BASE. C'est ce qui permet de
|
||||||
|
* valider l'image Docker avec ce même test (cf docs/DEVELOPPEMENT.md §10
|
||||||
|
* « Valider le conteneur ») : build + run sur l'hôte Docker, puis
|
||||||
|
* `HRT_E2E_BASE=http://… node scripts/e2e.mjs`.
|
||||||
|
*
|
||||||
|
* @returns {process|null} le process serveur (à tuer à la fin), null en mode externe
|
||||||
|
*/
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
if (process.env.HRT_E2E_BASE) {
|
||||||
|
console.log(`serveur EXTERNE (HRT_E2E_BASE) : ${process.env.HRT_E2E_BASE}`);
|
||||||
|
return null; // rien à démarrer, rien à tuer
|
||||||
|
}
|
||||||
const proc = spawn('python3', ['scripts/serve.py', '--port', String(PORT)], {
|
const proc = spawn('python3', ['scripts/serve.py', '--port', String(PORT)], {
|
||||||
cwd: WEB_ROOT,
|
cwd: WEB_ROOT,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
@ -60,7 +75,10 @@ async function startServer() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
mkdirSync(SHOTS, { recursive: true });
|
mkdirSync(SHOTS, { recursive: true });
|
||||||
const server = await startServer();
|
const server = await startServer();
|
||||||
console.log(`serveur : ${BASE}`);
|
// BASE peut avoir été surchargée par HRT_E2E_BASE (mode conteneur) — les
|
||||||
|
// navigations ci-dessous ciblent quel que soit le serveur qui répond.
|
||||||
|
const base = process.env.HRT_E2E_BASE || BASE;
|
||||||
|
console.log(`cible E2E : ${base}`);
|
||||||
|
|
||||||
const browser = await firefox.launch({
|
const browser = await firefox.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
@ -90,7 +108,7 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
// ── 1. Accueil avec données de démo ─────────────────────────────────────
|
// ── 1. Accueil avec données de démo ─────────────────────────────────────
|
||||||
console.log('── Accueil (?demo=1) ──');
|
console.log('── Accueil (?demo=1) ──');
|
||||||
await page.goto(`${BASE}/?demo=1`, { waitUntil: 'load' });
|
await page.goto(`${base}/?demo=1`, { waitUntil: 'load' });
|
||||||
await page.waitForSelector('body[data-ready="1"]', { timeout: 15000 });
|
await page.waitForSelector('body[data-ready="1"]', { timeout: 15000 });
|
||||||
await page.waitForSelector('.card', { timeout: 15000 });
|
await page.waitForSelector('.card', { timeout: 15000 });
|
||||||
const bodyText = await page.textContent('main.content');
|
const bodyText = await page.textContent('main.content');
|
||||||
@ -126,7 +144,7 @@ async function main() {
|
|||||||
|
|
||||||
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
|
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
|
||||||
console.log('── Graphiques (#chart) ──');
|
console.log('── Graphiques (#chart) ──');
|
||||||
await page.goto(`${BASE}/#chart`, { waitUntil: 'load' });
|
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
|
||||||
await page.waitForSelector('canvas.chart', { timeout: 15000 });
|
await page.waitForSelector('canvas.chart', { timeout: 15000 });
|
||||||
await page.waitForTimeout(600); // requestAnimationFrame du dessin
|
await page.waitForTimeout(600); // requestAnimationFrame du dessin
|
||||||
const canvasInfo = await page.evaluate(() => {
|
const canvasInfo = await page.evaluate(() => {
|
||||||
@ -151,7 +169,7 @@ async function main() {
|
|||||||
|
|
||||||
// ── 3. Navigation : les 5 onglets + settings ────────────────────────────
|
// ── 3. Navigation : les 5 onglets + settings ────────────────────────────
|
||||||
console.log('── Navigation ──');
|
console.log('── Navigation ──');
|
||||||
await page.goto(`${BASE}/#doses`, { waitUntil: 'load' });
|
await page.goto(`${base}/#doses`, { waitUntil: 'load' });
|
||||||
await page.waitForSelector('body[data-ready="1"]');
|
await page.waitForSelector('body[data-ready="1"]');
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
let text = await page.textContent('main.content');
|
let text = await page.textContent('main.content');
|
||||||
@ -159,18 +177,18 @@ async function main() {
|
|||||||
ok(text.includes('EEn — démo'), 'Doses : la dose de démo est listée');
|
ok(text.includes('EEn — démo'), 'Doses : la dose de démo est listée');
|
||||||
ok(text.includes('7.0 j depuis la précédente'), 'Doses : intervalle entre doses');
|
ok(text.includes('7.0 j depuis la précédente'), 'Doses : intervalle entre doses');
|
||||||
|
|
||||||
await page.goto(`${BASE}/#labs`, { waitUntil: 'load' });
|
await page.goto(`${base}/#labs`, { waitUntil: 'load' });
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
text = await page.textContent('main.content');
|
text = await page.textContent('main.content');
|
||||||
ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)');
|
ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)');
|
||||||
|
|
||||||
await page.goto(`${BASE}/#treatments`, { waitUntil: 'load' });
|
await page.goto(`${base}/#treatments`, { waitUntil: 'load' });
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
text = await page.textContent('main.content');
|
text = await page.textContent('main.content');
|
||||||
ok(text.includes('EEn — démo'), 'Traitements : carte du traitement démo');
|
ok(text.includes('EEn — démo'), 'Traitements : carte du traitement démo');
|
||||||
ok(text.includes('Transfem Science'), 'Traitements : chip modèle (modelLabelKey)');
|
ok(text.includes('Transfem Science'), 'Traitements : chip modèle (modelLabelKey)');
|
||||||
|
|
||||||
await page.goto(`${BASE}/#settings`, { waitUntil: 'load' });
|
await page.goto(`${base}/#settings`, { waitUntil: 'load' });
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
text = await page.textContent('main.content');
|
text = await page.textContent('main.content');
|
||||||
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
||||||
@ -185,7 +203,8 @@ async function main() {
|
|||||||
consoleErrors.slice(0, 3).join(' | '));
|
consoleErrors.slice(0, 3).join(' | '));
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
server.kill();
|
// Mode conteneur : pas de serveur local démarré → rien à tuer (cf startServer)
|
||||||
|
server?.kill();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failures === 0) {
|
if (failures === 0) {
|
||||||
|
|||||||
205
scripts/publish-release.py
Normal file
205
scripts/publish-release.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
#!/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()
|
||||||
Loading…
x
Reference in New Issue
Block a user