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())
+20 -151
View File
@@ -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" "$@"
+29
View File
@@ -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."