From 5fc6f6695553100e7205860a2b1aa66c2ca936b9 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Fri, 31 Jul 2026 15:28:11 +0200 Subject: [PATCH] feat: fait reposer le deploiement sur des versions taguees --- .env.example | 9 ++ .gitea/workflows/docker-publish.yml | 47 ++++++++- Makefile | 14 ++- README.md | 76 +++++++++++++- docker-compose.yml | 7 +- scripts/release.py | 155 ++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 scripts/release.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f3f9f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Configuration du déploiement — copier en .env (non versionné) : +# cp .env.example .env + +# Version de l'image déployée, telle que publiée par la CI à partir d'un tag +# git. Un tag v0.2.0 publie les images 0.2.0, 0.2 et 0 : épingler 0.2.0 fige +# le déploiement à l'octet près, 0.2 laisse entrer les correctifs de la série. +# +# Monter de version ou revenir en arrière = changer ce numéro puis `make docker`. +PLESNA_VERSION=0.1.0 diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index e939df9..2e28c4f 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -13,8 +13,47 @@ env: NAMESPACE: ${{ secrets.REGISTRY_NAMESPACE }} jobs: + # Une image publiee est une image deployable : rien ne part au registre sans + # que la suite soit passee. Les tests golden se sautent d'eux-memes ici (le + # corpus PDF n'est pas versionne), le reste de la suite tourne. + test: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install backend dependencies + run: uv sync + + - name: Lint + run: uv run ruff check . + + - name: Backend tests + run: uv run pytest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Frontend tests + working-directory: frontend + run: | + npm ci + npm test + build: name: Build App Image + needs: [test] runs-on: ubuntu-latest steps: - name: Checkout code @@ -78,14 +117,16 @@ jobs: name: Build Summary runs-on: ubuntu-latest needs: [build] - if: always() + # Pas de `always()` : un resume qui annonce un succes apres un build rate + # est pire que pas de resume du tout. steps: - name: Build summary run: | echo "## 🐳 Docker Image Built Successfully" echo "" echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance" - echo "- Tags: latest, ${{ gitea.ref_name }}" + echo "- Ref: ${{ gitea.ref_name }}" echo "" echo "### 🚀 Deployment" - echo "docker compose up -d" + echo "Epingler la version dans .env (PLESNA_VERSION), puis :" + echo "docker compose pull && docker compose up -d" diff --git a/Makefile b/Makefile index 731745f..dafa006 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev_back dev_front dev docker docker-build +.PHONY: install dev_back dev_front dev test docker docker-build release # Installe les dependances backend + frontend (a refaire par worktree) install: @@ -17,6 +17,12 @@ dev_front: dev: $(MAKE) dev_back & $(MAKE) dev_front & wait +# Ce que la CI verifie avant de publier une image (cf. .gitea/workflows) +test: + uv run ruff check . + uv run pytest + cd frontend && npm test + # Tire l'image publiee (registre Gitea) et lance via podman-compose docker: podman-compose pull @@ -25,3 +31,9 @@ docker: # Construit l'image en local (test avant publication) docker-build: podman build -f backend.Dockerfile -t plesna-gerance:local . + +# Pose une version : aligne les fichiers, commite, tague (sans pousser). +# Usage : make release VERSION=0.2.0 +release: + @test -n "$(VERSION)" || { echo "Usage : make release VERSION=0.2.0"; exit 1; } + uv run python scripts/release.py $(VERSION) diff --git a/README.md b/README.md index a9f580a..f19d835 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,80 @@ make dev Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backend. +## Tests + +```bash +make test # ruff check + pytest + vitest, soit exactement ce que la CI verifie +``` + +Les tests de non-regression des parseurs comparent l'extraction de PDF reels a +des references figees. PDF (`data/`) et references (`tests/golden/`) contiennent +des donnees personnelles et ne sont pas versionnes : ces tests se **sautent** +d'eux-memes la ou le corpus est absent, CI comprise. Les lancer pour de vrai +suppose donc un corpus local (cf. `tests/test_parsers_golden.py`). + +## Versions et cycle de vie + +La production heberge des donnees reelles : elle suit des **versions**, pas la +pointe de `main`. + +### Ce qu'est une version + +Un tag git `vMAJEUR.MINEUR.CORRECTIF` (semver), pose sur `main`. Le meme numero +est ecrit dans quatre fichiers, que l'outillage tient d'accord entre eux : +`pyproject.toml`, `src/plesna_gerance/__init__.py`, `frontend/package.json` et +`packaging/installer.iss` (version affichee par l'installeur Windows). + +Quand incrementer quoi : + +| Segment | Quand | Exemple | +| --- | --- | --- | +| CORRECTIF (`0.1.1`) | correction sans changement d'usage | un montant mal lu | +| MINEUR (`0.2.0`) | nouvelle fonctionnalite, donnees existantes intactes | une nouvelle page | +| MAJEUR (`1.0.0`) | rupture : migration de base ou changement d'usage a annoncer | refonte du referentiel | + +### Publier une version + +```bash +make test # ce que la CI verifiera de toute facon +make release VERSION=0.2.0 # aligne les 4 fichiers, commite, pose le tag +git push origin main v0.2.0 # <- c'est ce push qui publie +``` + +`make release` **ne pousse pas** : le tag reste local tant qu'on ne l'a pas +envoye, ce qui laisse le temps de relire le commit de version. Le script +(`scripts/release.py`) refuse d'avancer hors de `main`, sur un arbre sale, ou +si le tag existe deja — un tag publie ne se reecrit pas. + +### Ce que declenche le push d'un tag + +- **`.gitea/workflows/docker-publish.yml`** : lance d'abord les tests + (`ruff`, `pytest`, `vitest`), et seulement s'ils passent construit et publie + l'image sous **trois** tags — `0.2.0`, `0.2` et `0`. Epingler `0.2.0` fige le + deploiement a l'octet pres ; `0.2` laisse entrer les correctifs de la serie. +- **`.github/workflows/build-windows.yml`** : construit l'executable et + l'installeur Windows (necessite un runner `windows-latest`). + +Un push sur `main` **sans tag** publie `latest` et `main` : utile pour essayer +la pointe, jamais pour la production. + +### Deployer une version + +La version deployee est epinglee dans `.env`, lu par `docker-compose.yml` : + +```bash +cp .env.example .env # une seule fois +# editer PLESNA_VERSION=0.2.0 +make docker # pull + up -d +``` + +Revenir en arriere, c'est la meme manoeuvre : remettre le numero precedent dans +`.env` et relancer `make docker`. Les donnees vivent dans le volume nomme +`plesna-data`, independamment de l'image — un retour arriere d'image ne les +touche pas. En revanche une version qui a **migre le schema** de la base ne se +defait pas en changeant le numero : sauvegarder le volume avant une montee de +version majeure. + ## Production (Docker) Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par @@ -42,7 +116,7 @@ le backend FastAPI (API + interface web sur le meme port). Pas de nginx. L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`) sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire** -directement (pas de build local) : +directement (pas de build local), a la version epinglee dans `.env` : ```bash make docker # podman-compose pull && podman-compose up -d diff --git a/docker-compose.yml b/docker-compose.yml index a659742..ac79b11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,10 @@ services: app: - # Image publiée par la CI Gitea (pas de build local). - image: git.opytex.org/lafrite/plesna-gerance:latest + # Image publiée par la CI Gitea (pas de build local). La version déployée + # est épinglée dans .env (voir .env.example) : en production on suit un + # numéro de version, pas la pointe de main. Sans .env, on retombe sur + # `latest`, qui suit main et n'a donc pas de garantie de stabilité. + image: git.opytex.org/lafrite/plesna-gerance:${PLESNA_VERSION:-latest} ports: # hôte:conteneur — l'app (API + interface web) écoute sur 8000 - "8080:8000" diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..e1a918f --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,155 @@ +"""Pose une version : aligne les fichiers, commite, tague. + +Le numéro de version est écrit à quatre endroits qui doivent rester d'accord +(paquet Python, module, frontend, installeur Windows). Les tenir à jour à la +main, c'est publier tôt ou tard un tag `v0.3.0` sur un code qui se déclare +`0.1.0`. Ce script fait la mise à jour d'un bloc et refuse d'avancer au moindre +doute plutôt que de produire une version à moitié cohérente. + + uv run python scripts/release.py 0.2.0 + +Il s'arrête avant le `git push` : le tag reste local tant qu'il n'est pas +poussé, et c'est le push qui déclenche la CI (images Docker + build Windows). +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent + +# Un fichier, le motif qui y porte la version, et le remplacement. Le motif doit +# capturer la version dans le groupe 1 pour que l'on puisse relire l'ancienne +# valeur, et matcher **exactement une fois** (vérifié plus bas). +PORTEURS_DE_VERSION = [ + ("pyproject.toml", re.compile(r'^version = "([^"]+)"$', re.M), 'version = "{v}"'), + ( + "src/plesna_gerance/__init__.py", + re.compile(r'^__version__ = "([^"]+)"$', re.M), + '__version__ = "{v}"', + ), + ( + "frontend/package.json", + re.compile(r'^ "version": "([^"]+)",$', re.M), + ' "version": "{v}",', + ), + ( + "packaging/installer.iss", + re.compile(r'^#define AppVersion "([^"]+)"$', re.M), + '#define AppVersion "{v}"', + ), +] + +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") + + +class Refus(Exception): + """Condition non remplie : on s'arrête sans rien modifier.""" + + +def git(*args: str) -> str: + """Lance une commande git dans le dépôt et renvoie sa sortie.""" + resultat = subprocess.run( + ["git", "-C", str(RACINE), *args], + capture_output=True, + text=True, + ) + if resultat.returncode != 0: + raise Refus(f"git {' '.join(args)} a échoué :\n{resultat.stderr.strip()}") + return resultat.stdout.strip() + + +def verifier_le_depot(version: str, autoriser_hors_main: bool) -> None: + """Refuse de poser une version depuis un état de dépôt douteux.""" + branche = git("rev-parse", "--abbrev-ref", "HEAD") + if branche != "main" and not autoriser_hors_main: + raise Refus( + f"branche courante « {branche} » : une version se pose sur main.\n" + "Fusionner d'abord, ou forcer avec --autoriser-hors-main." + ) + + if git("status", "--porcelain"): + raise Refus( + "l'arbre de travail n'est pas propre : commiter ou remiser d'abord.\n" + "Une version doit correspondre à un état de code identifiable." + ) + + if git("tag", "--list", f"v{version}"): + raise Refus( + f"le tag v{version} existe déjà. Choisir un numéro supérieur " + "(un tag publié ne se réécrit pas)." + ) + + +def appliquer_la_version(version: str) -> list[str]: + """Écrit la version dans les fichiers porteurs. Renvoie ceux qui ont changé.""" + modifies = [] + for chemin_relatif, motif, remplacement in PORTEURS_DE_VERSION: + chemin = RACINE / chemin_relatif + contenu = chemin.read_text(encoding="utf-8") + + occurrences = motif.findall(contenu) + if len(occurrences) != 1: + raise Refus( + f"{chemin_relatif} : {len(occurrences)} ligne(s) de version trouvée(s), " + "une seule attendue. Le fichier a changé de forme : " + "corriger le motif dans scripts/release.py." + ) + + if occurrences[0] == version: + continue + + chemin.write_text( + motif.sub(remplacement.format(v=version), contenu), encoding="utf-8" + ) + modifies.append(chemin_relatif) + + return modifies + + +def main() -> int: + parseur = argparse.ArgumentParser(description=__doc__) + parseur.add_argument("version", help="numéro de version, sans le v (ex. 0.2.0)") + parseur.add_argument( + "--autoriser-hors-main", + action="store_true", + help="pose la version depuis une autre branche (rattrapage)", + ) + arguments = parseur.parse_args() + + version = arguments.version.lstrip("v") + if not SEMVER.match(version): + print( + f"Version « {arguments.version} » invalide : attendu MAJEUR.MINEUR.CORRECTIF " + "(ex. 0.2.0).", + file=sys.stderr, + ) + return 1 + + try: + verifier_le_depot(version, arguments.autoriser_hors_main) + modifies = appliquer_la_version(version) + + if modifies: + git("add", *modifies) + git("commit", "-m", f"chore: passe en version {version}") + print(f"Version écrite dans : {', '.join(modifies)}") + else: + print(f"Les fichiers déclarent déjà {version} : aucun commit de version.") + + git("tag", "-a", f"v{version}", "-m", f"Version {version}") + except Refus as refus: + print(f"Publication interrompue : {refus}", file=sys.stderr) + return 1 + + print(f"Tag v{version} posé sur {git('rev-parse', '--short', 'HEAD')}.") + print("\nRien n'est publié tant que le tag n'est pas poussé :") + print(f" git push origin main v{version}") + print("\nLe push déclenche la CI : images Docker taguées puis build Windows.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())