fix(version): BUG-047 la version suit chaque livraison (source unique VERSION + bump SemVer automatique)
CI / lint (push) Successful in 1m27s
CI / security (push) Successful in 57s
CI / test (push) Successful in 3m11s
CI / build (push) Successful in 54s
CI / e2e (push) Successful in 10m59s

VERSION (racine) devient la source unique de verite MAJEUR.MINEUR.CORRECTIF, incrementee a chaque commit par le hook prepare-commit-msg (BREAKING -> majeur, feat -> mineur, sinon correctif) ; post-commit rattache les fichiers derives au commit et cree le tag vX.Y.Z, publie au push (push.followTags). Tous les derives sont resynchronises : package.json, desktop Tauri, README.md/README.fr.md, docs/ROADMAP.md, CHANGELOG.md. Backend, image Docker et desktop lisent le meme fichier (plus de numero code en dur). Outils : scripts/bump_version.py, scripts/install-hooks.sh. Tests : tests/test_version.py (46, garde-fou de coherence globale).
This commit is contained in:
2026-09-16 11:02:47 -04:00
parent 3261917d62
commit d98c0c2f33
24 changed files with 1423 additions and 249 deletions
+576
View File
@@ -0,0 +1,576 @@
#!/usr/bin/env python3
"""ObsiGate — outil de versionnage SemVer (source unique de vérité : `VERSION`).
Le fichier `VERSION` (racine du dépôt) contient la version **livrée** au format
`MAJEUR.MINEUR.CORRECTIF`. C'est la seule source de vérité : tout le reste en
découle (backend `backend/version.py`, image Docker, desktop Tauri, `package.json`,
`CHANGELOG.md`, `docs/ROADMAP.md`).
Incrément (Conventional Commits) :
`!:` ou `BREAKING CHANGE:` -> MAJEUR x.0.0
`feat:` -> MINEUR x.y.0
tout le reste (fix, perf…) -> CORRECTIF x.y.z
Usage :
scripts/bump_version.py # bump auto (commits depuis le dernier tag)
scripts/bump_version.py --from-message-file .git/COMMIT_EDITMSG --stage
scripts/bump_version.py --minor --dry-run
scripts/bump_version.py --set 3.0.0
scripts/bump_version.py --print-version
scripts/bump_version.py --major --commit --tag --push
Options :
--bump {auto,major,minor,patch} incrément explicite
--major | --minor | --patch raccourcis de --bump
--set X.Y.Z fixe la version (sans incrément)
--from-message-file PATH détecte l'incrément depuis un message de commit
--message TEXT détecte l'incrément depuis un message donné
--dry-run affiche sans rien écrire
--sync / --no-files synchroniser (ou non) les fichiers dérivés
--changelog / --no-changelog rotation de CHANGELOG.md (ou non)
--stage `git add` des fichiers modifiés (utilisé par le hook)
--commit crée un commit `chore(release): vX.Y.Z`
--tag crée le tag annoté `vX.Y.Z` s'il n'existe pas
--push pousse la branche et les tags (implique --tag)
--print-version affiche la version courante et sort
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = "VERSION" # source unique de vérité
LEGACY_VERSION_FILES = ("backend/VERSION",) # anciens emplacements (repli)
PACKAGE_JSON = "package.json"
ROADMAP = "docs/ROADMAP.md"
CHANGELOG = "CHANGELOG.md"
DESKTOP_CONF = "desktop/tauri.conf.json"
DESKTOP_CARGO = "desktop/Cargo.toml"
DESKTOP_LOCK = "desktop/Cargo.lock"
BUMP_KINDS = ("major", "minor", "patch")
# ── Version ────────────────────────────────────────────────────────────────
def parse_version(raw: str) -> tuple[int, int, int] | None:
"""Extrait (majeur, mineur, correctif) d'une chaîne quelconque.
Accepte `2.3.0`, `v2.3.0`, `2.3.0-dev`, `2.2.1-35-g314e37c`.
Retourne ``None`` si aucun triplet numérique n'est présent.
"""
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(raw or ""))
if not match:
return None
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def format_version(version: tuple[int, int, int]) -> str:
"""Rend `MAJEUR.MINEUR.CORRECTIF`."""
return f"{version[0]}.{version[1]}.{version[2]}"
def bump_version(version: tuple[int, int, int], kind: str) -> tuple[int, int, int]:
"""Applique un incrément SemVer à ``version``."""
major, minor, patch = version
if kind == "major":
return (major + 1, 0, 0)
if kind == "minor":
return (major, minor + 1, 0)
if kind == "patch":
return (major, minor, patch + 1)
raise ValueError(f"incrément inconnu : {kind!r}")
# ── Git ────────────────────────────────────────────────────────────────────
def git(*args: str, root: Path = REPO_ROOT) -> str | None:
"""Exécute git dans ``root`` ; retourne stdout (strippé) ou ``None``."""
try:
result = subprocess.run(
["git", *args],
cwd=str(root),
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
if result.returncode != 0:
return None
return result.stdout.strip()
def last_tag_version(root: Path = REPO_ROOT) -> str | None:
"""Version (sans `v`) du dernier tag de release, ou ``None``."""
tag = git("describe", "--tags", "--abbrev=0", root=root)
if not tag:
return None
parsed = parse_version(tag)
return format_version(parsed) if parsed else None
def read_version(root: Path = REPO_ROOT) -> str:
"""Version courante : `VERSION` → `backend/VERSION` → dernier tag → `0.0.0`."""
for rel in (VERSION_FILE, *LEGACY_VERSION_FILES):
path = root / rel
if path.exists():
parsed = parse_version(path.read_text(encoding="utf-8"))
if parsed:
return format_version(parsed)
tag = last_tag_version(root)
return tag or "0.0.0"
def head_version(root: Path = REPO_ROOT) -> str | None:
"""Version contenue dans `VERSION` au commit HEAD, ou ``None``."""
raw = git("show", f"HEAD:{VERSION_FILE}", root=root)
if not raw:
return None
parsed = parse_version(raw)
return format_version(parsed) if parsed else None
# ── Détection de l'incrément ───────────────────────────────────────────────
def detect_bump_from_message(message: str, default: str | None = None) -> str | None:
"""Type d'incrément déduit d'un message de commit.
Retourne ``None`` quand le commit ne doit **pas** déclencher de version
(merge, revert, commit de release, message vide) — sauf si ``default`` est
fourni : il s'applique alors à un message vide (commit rédigé dans un
éditeur, où le hook s'exécute avant la saisie du message).
"""
text = (message or "").strip()
if not text:
return default
lines = [line for line in text.splitlines() if not line.lstrip().startswith("#")]
if not lines or not "\n".join(lines).strip():
return default
subject = lines[0].strip()
if re.match(r"^(Merge|Revert)\b", subject):
return None
if re.match(r"^chore\(release\)", subject):
return None
if "BREAKING CHANGE" in text or re.match(r"^[a-zA-Z][a-zA-Z0-9-]*(\([^)]*\))?!:", subject):
return "major"
if re.match(r"^(feat|feature)(\([^)]*\))?:", subject):
return "minor"
return "patch"
def detect_bump_from_commits(root: Path = REPO_ROOT) -> str | None:
"""Incrément déduit des commits depuis le dernier tag (ou des 20 derniers)."""
tag = git("describe", "--tags", "--abbrev=0", root=root)
rev_range = f"{tag}..HEAD" if tag else "HEAD~20..HEAD"
log = git("log", "--format=%s%n%b", rev_range, root=root)
if log is None:
log = git("log", "-1", "--format=%s%n%b", root=root) or ""
if not log.strip():
log = git("log", "-1", "--format=%s%n%b", root=root) or ""
subjects = [line for line in log.splitlines() if line.strip()]
if not subjects:
return None
if "BREAKING CHANGE" in log or any(
re.match(r"^[a-zA-Z][a-zA-Z0-9-]*(\([^)]*\))?!:", line) for line in subjects
):
return "major"
if any(re.match(r"^(feat|feature)(\([^)]*\))?:", line) for line in subjects):
return "minor"
return "patch"
# ── Écriture de fichiers (préserve les fins de ligne) ──────────────────────
def write_preserving_newlines(path: Path, text: str) -> None:
"""Écrit ``text`` en conservant le style de fin de ligne du fichier."""
original = path.read_bytes() if path.exists() else b""
crlf = b"\r\n" in original
data = text.replace("\r\n", "\n")
if crlf:
data = data.replace("\n", "\r\n")
path.write_bytes(data.encode("utf-8"))
def sync_package_json(root: Path, version: str) -> bool:
"""Aligne `package.json` (`version`)."""
path = root / PACKAGE_JSON
if not path.exists():
return False
original = path.read_text(encoding="utf-8")
data = json.loads(original)
if data.get("version") == version:
return False
data["version"] = version
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
write_preserving_newlines(path, text)
return True
def sync_desktop(root: Path, version: str) -> list[str]:
"""Aligne les fichiers desktop Tauri ; retourne les fichiers réellement modifiés."""
changed: list[str] = []
conf = root / DESKTOP_CONF
if conf.exists():
text = conf.read_text(encoding="utf-8")
new_text, count = re.subn(
r'("version"\s*:\s*")[^"]*(")',
lambda m: f"{m.group(1)}{version}{m.group(2)}",
text,
count=1,
)
if count and new_text != text:
write_preserving_newlines(conf, new_text)
changed.append(DESKTOP_CONF)
cargo = root / DESKTOP_CARGO
if cargo.exists():
text = cargo.read_text(encoding="utf-8")
new_text, count = re.subn(
r'(?m)^version\s*=\s*"[^"]*"',
f'version = "{version}"',
text,
count=1,
)
if count and new_text != text:
write_preserving_newlines(cargo, new_text)
changed.append(DESKTOP_CARGO)
lock = root / DESKTOP_LOCK
if lock.exists():
text = lock.read_text(encoding="utf-8")
new_text, count = re.subn(
r'(?m)^(name = "obsigate-desktop"\r?\nversion = ")[^"]*(")',
lambda m: f"{m.group(1)}{version}{m.group(2)}",
text,
count=1,
)
if count and new_text != text:
write_preserving_newlines(lock, new_text)
changed.append(DESKTOP_LOCK)
return changed
def sync_readmes(root: Path, version: str) -> list[str]:
"""Aligne les badges et pieds de page des README (FR/EN)."""
changed: list[str] = []
patterns = (
(r"(badge/Version-)[^)]*(-blue)", rf"\g<1>{version}\g<2>"),
(r"(\| Version\s?:\s?)[\d.]+(?:-dev)?", rf"\g<1>{version}"),
(r"\(v1\.0\.0 → v[\d.]+(?:-dev)?\)", f"(v1.0.0 → v{version})"),
)
for rel in ("README.md", "README.fr.md"):
path = root / rel
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
new_text = text
for pattern, replacement in patterns:
new_text = re.sub(pattern, replacement, new_text)
if new_text != text:
write_preserving_newlines(path, new_text)
changed.append(rel)
return changed
def sync_roadmap(root: Path, version: str, when: date | None = None) -> bool:
"""Aligne l'en-tête de `docs/ROADMAP.md` (version + date de mise à jour)."""
path = root / ROADMAP
if not path.exists():
return False
text = path.read_text(encoding="utf-8")
today = (when or date.today()).isoformat()
new_text, count = re.subn(
r"(> \*\*Version :\*\* )\S+( \| \*\*Dernière mise à jour :\*\* )\S+",
lambda m: f"{m.group(1)}{version}{m.group(2)}{today}",
text,
count=1,
)
if not count:
return False
if new_text == text:
return False
write_preserving_newlines(path, new_text)
return True
def rotate_changelog(root: Path, version: str, when: date | None = None) -> bool:
"""Publie `[Unreleased]` en `[X.Y.Z] — date` et ouvre une nouvelle section vide.
Idempotent : si la version est déjà présente dans le fichier, rien n'est fait.
"""
path = root / CHANGELOG
if not path.exists():
return False
text = path.read_text(encoding="utf-8")
if re.search(rf"(?m)^## \[{re.escape(version)}\]", text):
return False
heading = re.search(r"(?m)^## \[Unreleased\][^\n]*\n", text)
if not heading:
return False
today = (when or date.today()).isoformat()
replacement = (
"## [Unreleased]\n"
"\n"
"---\n"
"\n"
f"## [{version}] — {today}\n"
)
text = text[: heading.start()] + replacement + text[heading.end() :]
# Phrase d'en-tête : dernière version livrée
text = re.sub(
r"(?s)> \*\*En cours de développement\*\* : les changements non publiés sont dans la section\n"
r"> \[Unreleased\]\(#unreleased\)\. La dernière version publiée est \*\*[\d.]+\*\*\.",
"> **En cours de développement** : les changements à venir sont listés dans la section\n"
"> [Unreleased](#unreleased). La dernière version livrée est "
f"**{version}**.",
text,
count=1,
)
text = re.sub(
r"(La dernière version (?:publiée|livrée) est \*\*)[\d.]+(\*\*)",
rf"\g<1>{version}\g<2>",
text,
count=1,
)
write_preserving_newlines(path, text)
return True
def apply_version(
root: Path,
version: str,
*,
sync: bool = True,
changelog: bool = True,
when: date | None = None,
) -> list[str]:
"""Écrit `VERSION` et synchronise tous les fichiers dérivés.
Retourne la liste (relative au dépôt) des fichiers modifiés.
"""
touched: list[str] = []
version_path = root / VERSION_FILE
previous = read_version(root)
if previous != version or not version_path.exists():
write_preserving_newlines(version_path, version + "\n")
touched.append(VERSION_FILE)
if sync:
if sync_package_json(root, version):
touched.append(PACKAGE_JSON)
touched.extend(sync_desktop(root, version))
touched.extend(sync_readmes(root, version))
if sync_roadmap(root, version, when):
touched.append(ROADMAP)
if changelog and rotate_changelog(root, version, when):
touched.append(CHANGELOG)
return touched
def stage(root: Path, files: list[str]) -> None:
"""`git add` des fichiers modifiés (les fichiers absents sont ignorés)."""
existing = [rel for rel in dict.fromkeys(files) if (root / rel).exists()]
if existing:
git("add", "--", *existing, root=root)
# ── CLI ────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="bump_version.py",
description="ObsiGate — version SemVer (source unique de vérité : VERSION).",
)
parser.add_argument(
"--bump",
choices=("auto", *BUMP_KINDS),
default="auto",
help="incrément à appliquer (défaut : auto = commits depuis le dernier tag)",
)
parser.add_argument("--major", action="store_true", help="raccourci --bump major")
parser.add_argument("--minor", action="store_true", help="raccourci --bump minor")
parser.add_argument("--patch", action="store_true", help="raccourci --bump patch")
parser.add_argument("--set", metavar="X.Y.Z", help="fixe la version sans incrément")
parser.add_argument(
"--from-message-file",
metavar="PATH",
help="détecte l'incrément depuis un fichier de message de commit",
)
parser.add_argument("--message", metavar="TEXT", help="détecte l'incrément depuis un message")
parser.add_argument(
"--default-kind",
choices=BUMP_KINDS,
help="incrément appliqué quand le message est vide (commit via éditeur)",
)
parser.add_argument("--dry-run", action="store_true", help="affiche sans écrire")
parser.add_argument(
"--sync",
dest="sync",
action="store_true",
default=True,
help="synchroniser les fichiers dérivés (défaut)",
)
parser.add_argument(
"--no-files",
"--no-sync",
dest="sync",
action="store_false",
help="ne pas toucher aux fichiers dérivés",
)
parser.add_argument(
"--changelog",
dest="changelog",
action="store_true",
default=True,
help="rotation de CHANGELOG.md (défaut)",
)
parser.add_argument(
"--no-changelog",
dest="changelog",
action="store_false",
help="ne pas toucher à CHANGELOG.md",
)
parser.add_argument("--stage", action="store_true", help="`git add` des fichiers modifiés")
parser.add_argument(
"--files-out",
metavar="PATH",
help="écrit la liste des fichiers modifiés (un par ligne) — utilisée par les hooks git",
)
parser.add_argument("--commit", action="store_true", help="commit `chore(release): vX.Y.Z`")
parser.add_argument("--tag", action="store_true", help="créer le tag annoté vX.Y.Z")
parser.add_argument("--push", action="store_true", help="pousser la branche et les tags")
parser.add_argument(
"--print-version", action="store_true", help="afficher la version courante"
)
return parser
def resolve_bump(args: argparse.Namespace, root: Path) -> str | None:
"""Détermine l'incrément à appliquer (``None`` = aucun bump)."""
for flag, kind in (("major", "major"), ("minor", "minor"), ("patch", "patch")):
if getattr(args, flag):
return kind
if args.bump != "auto":
return args.bump
if args.from_message_file:
message = Path(args.from_message_file).read_text(encoding="utf-8", errors="replace")
return detect_bump_from_message(message, default=args.default_kind)
if args.message:
return detect_bump_from_message(args.message, default=args.default_kind)
return detect_bump_from_commits(root)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
root = REPO_ROOT
if args.print_version:
print(read_version(root))
return 0
current = read_version(root)
# Version déjà fixée explicitement
if args.set:
parsed = parse_version(args.set)
if not parsed:
print(f"✗ Version invalide : {args.set!r} (attendu MAJEUR.MINEUR.CORRECTIF)", file=sys.stderr)
return 1
next_version = format_version(parsed)
kind = "set"
else:
# Un incrément est-il déjà préparé dans l'arbre de travail (commit avorté,
# version posée à la main, premier passage avant tout commit) ?
committed = head_version(root)
last_tag = last_tag_version(root)
prepared = (committed is not None and current != committed) or (
committed is None and last_tag is not None and current != last_tag
)
if prepared:
kind = "none"
next_version = current
else:
kind = resolve_bump(args, root)
if kind is None:
print("Version inchangée (commit sans bump : merge, revert ou release).")
return 3
next_version = format_version(bump_version(parse_version(current) or (0, 0, 0), kind))
print(f"ObsiGate version : {current} → {next_version} ({kind})")
if args.dry_run:
print("── DRY RUN ─────────────────────────────")
for rel in (
VERSION_FILE,
PACKAGE_JSON,
DESKTOP_CONF,
DESKTOP_CARGO,
DESKTOP_LOCK,
ROADMAP,
CHANGELOG,
"README.md",
"README.fr.md",
):
if (root / rel).exists():
print(f" à synchroniser : {rel}")
print(" (aucune modification, aucun tag créé)")
return 0
if kind == "none" and (root / VERSION_FILE).exists():
# Rien à incrémenter : on resynchronise seulement les satellites.
touched = apply_version(root, current, sync=args.sync, changelog=False)
else:
touched = apply_version(root, next_version, sync=args.sync, changelog=args.changelog)
if touched:
print(" fichiers mis à jour : " + ", ".join(dict.fromkeys(touched)))
if args.files_out:
list_path = Path(args.files_out)
if not list_path.is_absolute():
list_path = root / list_path
list_path.write_text(
"".join(f"{rel}\n" for rel in dict.fromkeys(touched)), encoding="utf-8"
)
if args.stage and touched:
stage(root, touched)
if args.commit:
stage(root, touched or [VERSION_FILE])
git("commit", "-m", f"chore(release): v{next_version}", root=root)
print(f"✓ Commit de release créé : v{next_version}")
if args.tag or args.push:
tag = f"v{next_version}"
if git("rev-parse", "-q", "--verify", f"refs/tags/{tag}", root=root) is None:
if git("tag", "-a", tag, "-m", f"ObsiGate {tag}", root=root) is None:
print(f"✗ Impossible de créer le tag {tag}", file=sys.stderr)
return 1
print(f"✓ Tag créé : {tag}")
else:
print(f"ℹ Tag {tag} déjà présent.")
if args.push:
branch = git("rev-parse", "--abbrev-ref", "HEAD", root=root) or "main"
git("push", "origin", branch, "--follow-tags", root=root)
print(f"✓ Branche {branch} et tags poussés.")
return 0
if __name__ == "__main__":
sys.exit(main())