fix(version): BUG-047 la version suit chaque livraison (source unique VERSION + bump SemVer automatique)
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:
@@ -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())
|
||||
+20
-151
@@ -1,165 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# ObsiGate — version bump helper (SemVer MAJOR.MINOR.PATCH)
|
||||
# ObsiGate — version bump helper (SemVer MAJEUR.MINEUR.CORRECTIF)
|
||||
#
|
||||
# The canonical version is the latest release tag. This tool inspects the
|
||||
# commits since that tag and decides the next version from their type
|
||||
# (Conventional Commits), then:
|
||||
# 1. synchronise la version desktop (tauri.conf.json, Cargo.toml, Cargo.lock) ;
|
||||
# 2. commit ce bump (`chore(release): vX.Y.Z`) ;
|
||||
# 3. crée le tag annoté (et pousse branche + tag avec --push).
|
||||
# Wrapper historique : toute la logique vit dans `scripts/bump_version.py`
|
||||
# (source unique de vérité : `./VERSION`, incrément inspiré des
|
||||
# Conventional Commits).
|
||||
#
|
||||
# breaking change / `!:` / "BREAKING CHANGE" -> MAJOR bump (x.0.0)
|
||||
# feat / feature -> MINOR bump (x.y.0)
|
||||
# anything else (fix, perf, refactor, ...) -> PATCH bump (x.y.z)
|
||||
# breaking (`!:` / BREAKING CHANGE) -> MAJEUR (x.0.0)
|
||||
# `feat` -> MINEUR (x.y.0)
|
||||
# tout le reste (fix, perf, …) -> CORRECTIF (x.y.z)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/bump_version.sh [--major|--minor|--patch] [--dry-run] [--push] [--no-files]
|
||||
# Usage :
|
||||
# scripts/bump_version.sh --dry-run # prévisualiser
|
||||
# scripts/bump_version.sh # bump + synchro des fichiers
|
||||
# scripts/bump_version.sh --minor --commit --tag --push
|
||||
# scripts/bump_version.sh --print-version
|
||||
#
|
||||
# --dry-run : affiche la prochaine version sans rien modifier ni tagger.
|
||||
# --push : pousse la branche courante ET le nouveau tag vers origin.
|
||||
# --no-files : ne met pas à jour les fichiers desktop (tag seul).
|
||||
# --major|--minor|--patch : force l'incrément, ignore la détection.
|
||||
#
|
||||
# Examples:
|
||||
# scripts/bump_version.sh --dry-run # preview next version
|
||||
# scripts/bump_version.sh # bump + commit + tag (local)
|
||||
# scripts/bump_version.sh --push # idem + push branche & tag
|
||||
# scripts/bump_version.sh --patch --push # force patch + push
|
||||
# Voir `scripts/bump_version.py --help` pour la liste complète des options.
|
||||
# -----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)" # run from repo root regardless of cwd
|
||||
BRANCH="$(git branch --show-current)"
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────────────
|
||||
FORCE=""; DRY_RUN=0; DO_PUSH=0; DO_FILES=1
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--major) FORCE=major ;;
|
||||
--minor) FORCE=minor ;;
|
||||
--patch) FORCE=patch ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--push) DO_PUSH=1 ;;
|
||||
--no-files) DO_FILES=0 ;;
|
||||
*) echo "✗ Argument inconnu: $a"; exit 2 ;;
|
||||
esac
|
||||
py=""
|
||||
for cand in python3 python; do
|
||||
if command -v "$cand" >/dev/null 2>&1; then py="$cand"; break; fi
|
||||
done
|
||||
|
||||
# ── Current version = latest tag base ─────────────────────────────────────
|
||||
CURRENT="$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")"
|
||||
CURRENT="${CURRENT#v}"
|
||||
IFS='.' read -r MAJ MIN PAT <<< "$CURRENT"
|
||||
MAJ="${MAJ:-0}"; MIN="${MIN:-0}"; PAT="${PAT:-0}"
|
||||
|
||||
echo "Version actuelle : $CURRENT (branche: $BRANCH)"
|
||||
|
||||
# ── Detect bump type from commits since last tag ──────────────────────────
|
||||
if [ -z "$FORCE" ]; then
|
||||
# Use HEAD~1..HEAD when no tag exists yet (all commits "since" nothing).
|
||||
RANGE="${CURRENT#0.0.0}" # if 0.0.0 fallback there may be no tag at all
|
||||
if git rev-parse "v${CURRENT}" >/dev/null 2>&1; then
|
||||
RANGE="v${CURRENT}..HEAD"
|
||||
else
|
||||
RANGE="HEAD~10..HEAD"
|
||||
echo "⚠ Aucun tag v${CURRENT} trouvé — analyse des 10 derniers commits."
|
||||
fi
|
||||
LOG="$(git log --format='%s%n%b' "$RANGE" 2>/dev/null || true)"
|
||||
|
||||
BUMP="patch" # default
|
||||
if echo "$LOG" | grep -qE '^[a-zA-Z]+\([^)]*\)!:|\!:\s|BREAKING CHANGE:'; then
|
||||
BUMP="major"
|
||||
elif echo "$LOG" | grep -qiE '^(feat|feature)(\(|:|\s)'; then
|
||||
BUMP="minor"
|
||||
fi
|
||||
# If nothing meaningful in range, keep patch
|
||||
echo "Détection (${RANGE}) : changement ${BUMP}"
|
||||
else
|
||||
BUMP="$FORCE"
|
||||
echo "Incrément forcé : ${BUMP}"
|
||||
fi
|
||||
|
||||
# ── Compute next version ──────────────────────────────────────────────────
|
||||
case "$BUMP" in
|
||||
major) MAJ=$((MAJ + 1)); MIN=0; PAT=0 ;;
|
||||
minor) MIN=$((MIN + 1)); PAT=0 ;;
|
||||
*) PAT=$((PAT + 1)) ;;
|
||||
esac
|
||||
NEXT="${MAJ}.${MIN}.${PAT}"
|
||||
|
||||
# ── Guard: tag already exists? ────────────────────────────────────────────
|
||||
if git rev-parse "v${NEXT}" >/dev/null 2>&1; then
|
||||
echo "✗ Le tag v${NEXT} existe déjà. Corrigez l'incrément ou retirez le tag."
|
||||
if [ -z "$py" ]; then
|
||||
echo "✗ Aucun interpréteur Python trouvé (python3 / python)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Fichiers desktop à synchroniser ───────────────────────────────────────
|
||||
DESKTOP_CONF="desktop/tauri.conf.json"
|
||||
DESKTOP_CARGO="desktop/Cargo.toml"
|
||||
DESKTOP_LOCK="desktop/Cargo.lock"
|
||||
|
||||
# Met à jour la version dans les fichiers desktop (in-place, portable GNU sed).
|
||||
sync_desktop_version() {
|
||||
local ver="$1"
|
||||
if [ -f "$DESKTOP_CONF" ]; then
|
||||
sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*")[^"]*(")/\1'"$ver"'\2/' "$DESKTOP_CONF"
|
||||
fi
|
||||
if [ -f "$DESKTOP_CARGO" ]; then
|
||||
# Première ligne `version = "..."` = version du [package].
|
||||
sed -i -E '0,/^version = "[^"]*"/s//version = "'"$ver"'"/' "$DESKTOP_CARGO"
|
||||
fi
|
||||
if [ -f "$DESKTOP_LOCK" ]; then
|
||||
# Bloc `name = "obsigate-desktop"` -> ligne `version` suivante.
|
||||
sed -i -E '/^name = "obsigate-desktop"$/{n;s/^version = "[^"]*"/version = "'"$ver"'"/;}' "$DESKTOP_LOCK"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "── DRY RUN ─────────────────────────────"
|
||||
echo " Prochain tag : v${NEXT}"
|
||||
if [ "$DO_FILES" = "1" ]; then
|
||||
echo " Fichiers desktop à synchroniser :"
|
||||
echo " - $DESKTOP_CONF"
|
||||
echo " - $DESKTOP_CARGO"
|
||||
echo " - $DESKTOP_LOCK (obsigate-desktop)"
|
||||
fi
|
||||
echo " (aucune modification, aucun tag créé)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Sync + commit des versions desktop ────────────────────────────────────
|
||||
if [ "$DO_FILES" = "1" ]; then
|
||||
echo "─ Synchronisation de la version desktop → ${NEXT}"
|
||||
sync_desktop_version "$NEXT"
|
||||
|
||||
CHANGED_FILES=()
|
||||
for f in "$DESKTOP_CONF" "$DESKTOP_CARGO" "$DESKTOP_LOCK"; do
|
||||
[ -f "$f" ] && CHANGED_FILES+=("$f")
|
||||
done
|
||||
|
||||
if git diff --quiet -- "${CHANGED_FILES[@]}"; then
|
||||
echo "ℹ Aucun changement de version desktop à committer."
|
||||
else
|
||||
git add -- "${CHANGED_FILES[@]}"
|
||||
git commit -m "chore(release): v${NEXT}" \
|
||||
-m "Synchronise la version desktop (tauri.conf.json, Cargo.toml, Cargo.lock)."
|
||||
echo "✓ Commit de release créé : v${NEXT}"
|
||||
fi
|
||||
else
|
||||
echo "ℹ --no-files : fichiers desktop inchangés."
|
||||
fi
|
||||
|
||||
# ── Create tag ────────────────────────────────────────────────────────────
|
||||
git tag -a "v${NEXT}" -m "Release v${NEXT} (${BUMP} bump depuis v${CURRENT})"
|
||||
echo "✓ Tag créé localement : v${NEXT}"
|
||||
|
||||
if [ "$DO_PUSH" = "1" ]; then
|
||||
git push origin "$BRANCH"
|
||||
git push origin "v${NEXT}"
|
||||
echo "✓ Branche '$BRANCH' et tag v${NEXT} poussés."
|
||||
else
|
||||
echo "ℹ Tag local uniquement. Poussez avec:"
|
||||
echo " git push origin $BRANCH && git push origin v${NEXT}"
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
exec "$py" "$root/scripts/bump_version.py" "$@"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# ObsiGate — installe les hooks git versionnés (.githooks).
|
||||
#
|
||||
# 1. `core.hooksPath = .githooks` -> hooks partagés par tout le monde (versionnés)
|
||||
# 2. `push.followTags = true` -> le tag de version créé au commit est publié
|
||||
# automatiquement à chaque `git push`
|
||||
#
|
||||
# À exécuter une fois par clone : scripts/install-hooks.sh
|
||||
# -----------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
root="$(git rev-parse --show-toplevel)"
|
||||
cd "$root"
|
||||
|
||||
if [ ! -d .githooks ]; then
|
||||
echo "✗ Dossier .githooks introuvable." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config core.hooksPath .githooks
|
||||
git config push.followTags true
|
||||
chmod +x .githooks/* 2>/dev/null || true
|
||||
|
||||
echo "✓ Hooks installés :"
|
||||
echo " core.hooksPath = $(git config --get core.hooksPath)"
|
||||
echo " push.followTags = $(git config --get push.followTags)"
|
||||
echo " → La version (./VERSION) est incrémentée à chaque commit et taguée"
|
||||
echo " (vX.Y.Z), puis publiée avec la branche à chaque push."
|
||||
Reference in New Issue
Block a user