Files
pdf_oralia_vibe/scripts/release.py

183 lines
6.2 KiB
Python

"""Pose une version : aligne les fichiers, commite, tague.
Le numéro de version est écrit dans cinq fichiers qui doivent rester d'accord
(paquet Python, module, frontend, son lock, 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, le remplacement, et le nombre
# d'occurrences attendues. Le motif doit capturer la version dans son dernier
# groupe pour que l'on puisse relire l'ancienne valeur ; le compte est verifie
# a chaque passage, pour que le script s'arrete si un fichier change de forme
# plutot que de laisser filer une version a moitie posee.
PORTEURS_DE_VERSION = [
(
"pyproject.toml",
re.compile(r'^version = "([^"]+)"$', re.M),
'version = "{v}"',
1,
),
(
"src/plesna_gerance/__init__.py",
re.compile(r'^__version__ = "([^"]+)"$', re.M),
'__version__ = "{v}"',
1,
),
(
"frontend/package.json",
re.compile(r'^ "version": "([^"]+)",$', re.M),
' "version": "{v}",',
1,
),
# Le lock porte la version du paquet racine a deux endroits (en-tete et
# packages[""]). Les laisser en arriere fait diverger lock et package.json,
# ce que `npm ci` peut refuser en CI selon la version de npm. On s'ancre sur
# le nom du paquet, qui n'apparait qu'a ces deux endroits.
(
"frontend/package-lock.json",
re.compile(
r'(^(\s*)"name": "plesna-gerance-frontend",\n\s*"version": ")([^"]+)',
re.M,
),
r"\g<1>{v}",
2,
),
(
"packaging/installer.iss",
re.compile(r'^#define AppVersion "([^"]+)"$', re.M),
'#define AppVersion "{v}"',
1,
),
]
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, attendues in PORTEURS_DE_VERSION:
chemin = RACINE / chemin_relatif
contenu = chemin.read_text(encoding="utf-8")
# Le dernier groupe capture la version elle-même, quel que soit le
# nombre de groupes servant à l'ancrage.
trouvees = [
correspondance.groups()[-1] for correspondance in motif.finditer(contenu)
]
if len(trouvees) != attendues:
raise Refus(
f"{chemin_relatif} : {len(trouvees)} version(s) trouvée(s), "
f"{attendues} attendue(s). Le fichier a changé de forme : "
"corriger le motif dans scripts/release.py."
)
if all(trouvee == version for trouvee in trouvees):
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())