#!/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: sys.stdout.reconfigure(encoding="utf-8", errors="replace") 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())