578 lines
21 KiB
Python
578 lines
21 KiB
Python
#!/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())
|