183 lines
6.2 KiB
Python
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())
|