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,385 @@
|
||||
"""Tests du versionnage ObsiGate (source unique de vérité : `VERSION`).
|
||||
|
||||
Deux niveaux :
|
||||
|
||||
1. `backend/version.py` — résolution de la version exposée par /api/health.
|
||||
2. `scripts/bump_version.py` — incrément SemVer, détection depuis le message de
|
||||
commit, synchronisation des fichiers dérivés et rotation du CHANGELOG.
|
||||
3. Un test de cohérence globale : la version affichée doit être **la même**
|
||||
partout (VERSION, package.json, desktop Tauri, CHANGELOG, ROADMAP) — c'est le
|
||||
garde-fou contre la dérive qui a motivé BUG-047.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import version as version_module
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# ── scripts/bump_version.py (outil hors package) ───────────────────────────
|
||||
|
||||
|
||||
def _load_bump_tool():
|
||||
"""Charge `scripts/bump_version.py` par chemin (scripts/ n'est pas un package)."""
|
||||
path = REPO_ROOT / "scripts" / "bump_version.py"
|
||||
spec = importlib.util.spec_from_file_location("obsigate_bump_version", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
bump_tool = _load_bump_tool()
|
||||
|
||||
|
||||
# ── backend/version.py ────────────────────────────────────────────────────
|
||||
class TestVersionResolution:
|
||||
def test_clean_base(self):
|
||||
assert version_module._clean_base("v2.3.0") == "2.3.0"
|
||||
assert version_module._clean_base("2.3.0-12-gabc1234") == "2.3.0"
|
||||
assert version_module._clean_base("2.3.0-dev") == "2.3.0"
|
||||
assert version_module._clean_base(" v1.2.3-dirty ") == "1.2.3"
|
||||
assert version_module._clean_base("pas une version") == ""
|
||||
assert version_module._clean_base("") == ""
|
||||
|
||||
def test_root_version_file_wins(self, monkeypatch, tmp_path):
|
||||
root_version = tmp_path / "VERSION"
|
||||
root_version.write_text("2.3.0\n", encoding="utf-8")
|
||||
legacy = tmp_path / "backend" / "VERSION"
|
||||
legacy.parent.mkdir()
|
||||
legacy.write_text("1.9.9\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_VERSION", raising=False)
|
||||
monkeypatch.setattr(version_module, "VERSION_FILE", root_version)
|
||||
monkeypatch.setattr(version_module, "LEGACY_VERSION_FILE", legacy)
|
||||
|
||||
assert version_module.get_version() == "2.3.0"
|
||||
assert version_module.get_version_tuple() == (2, 3, 0)
|
||||
|
||||
def test_env_override_wins(self, monkeypatch, tmp_path):
|
||||
root_version = tmp_path / "VERSION"
|
||||
root_version.write_text("2.3.0\n", encoding="utf-8")
|
||||
monkeypatch.setattr(version_module, "VERSION_FILE", root_version)
|
||||
monkeypatch.setenv("OBSIGATE_VERSION", "v9.9.9-dirty")
|
||||
|
||||
assert version_module.get_version() == "9.9.9"
|
||||
|
||||
def test_falls_back_to_legacy_then_zero(self, monkeypatch, tmp_path):
|
||||
missing = tmp_path / "VERSION"
|
||||
legacy = tmp_path / "backend" / "VERSION"
|
||||
legacy.parent.mkdir()
|
||||
legacy.write_text("1.8.0-35-g314e37c\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_VERSION", raising=False)
|
||||
monkeypatch.setattr(version_module, "VERSION_FILE", missing)
|
||||
monkeypatch.setattr(version_module, "LEGACY_VERSION_FILE", legacy)
|
||||
assert version_module.get_version() == "1.8.0"
|
||||
|
||||
legacy.unlink()
|
||||
monkeypatch.setattr(version_module, "_run_git", lambda args: "")
|
||||
assert version_module.get_version() == "0.0.0"
|
||||
|
||||
def test_invalid_version_file_is_ignored(self, monkeypatch, tmp_path):
|
||||
broken = tmp_path / "VERSION"
|
||||
broken.write_text("dev\n", encoding="utf-8")
|
||||
monkeypatch.delenv("OBSIGATE_VERSION", raising=False)
|
||||
monkeypatch.setattr(version_module, "VERSION_FILE", broken)
|
||||
monkeypatch.setattr(version_module, "LEGACY_VERSION_FILE", tmp_path / "absent")
|
||||
monkeypatch.setattr(version_module, "_run_git", lambda args: "")
|
||||
assert version_module.get_version() == "0.0.0"
|
||||
|
||||
def test_no_dev_suffix_ever_returned(self, monkeypatch, tmp_path):
|
||||
"""Le numéro affiché ne doit jamais contenir -dev / -N-gHASH."""
|
||||
root_version = tmp_path / "VERSION"
|
||||
root_version.write_text("2.3.0-12-gabc1234\n", encoding="utf-8")
|
||||
monkeypatch.delenv("OBSIGATE_VERSION", raising=False)
|
||||
monkeypatch.setattr(version_module, "VERSION_FILE", root_version)
|
||||
assert version_module.get_version() == "2.3.0"
|
||||
|
||||
|
||||
# ── Incrément SemVer ──────────────────────────────────────────────────────
|
||||
class TestBumpMath:
|
||||
def test_major_resets_minor_and_patch(self):
|
||||
assert bump_tool.bump_version((2, 3, 4), "major") == (3, 0, 0)
|
||||
|
||||
def test_minor_resets_patch(self):
|
||||
assert bump_tool.bump_version((2, 3, 4), "minor") == (2, 4, 0)
|
||||
|
||||
def test_patch(self):
|
||||
assert bump_tool.bump_version((2, 3, 4), "patch") == (2, 3, 5)
|
||||
|
||||
def test_unknown_kind_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
bump_tool.bump_version((2, 3, 4), "banana")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("v2.3.0", (2, 3, 0)),
|
||||
("2.3.0-dev", (2, 3, 0)),
|
||||
("2.2.1-35-g314e37c", (2, 2, 1)),
|
||||
("n'importe quoi", None),
|
||||
],
|
||||
)
|
||||
def test_parse_version(self, raw, expected):
|
||||
assert bump_tool.parse_version(raw) == expected
|
||||
|
||||
def test_format_version(self):
|
||||
assert bump_tool.format_version((2, 3, 0)) == "2.3.0"
|
||||
|
||||
|
||||
# ── Détection depuis le message de commit ─────────────────────────────────
|
||||
class TestDetectBumpFromMessage:
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected"),
|
||||
[
|
||||
("feat(editeur): edition inline", "minor"),
|
||||
("feature: x", "minor"),
|
||||
("fix(ai): erreur lisible", "patch"),
|
||||
("perf: rapide", "patch"),
|
||||
("docs: readme", "patch"),
|
||||
("chore: menage", "patch"),
|
||||
("refactor: nettoyage", "patch"),
|
||||
("feat: x\n\nBREAKING CHANGE: api", "major"),
|
||||
("feat(api)!: rupture", "major"),
|
||||
("fix!: rupture", "major"),
|
||||
],
|
||||
)
|
||||
def test_types(self, message, expected):
|
||||
assert bump_tool.detect_bump_from_message(message) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"",
|
||||
" \n",
|
||||
"# commentaire de git seulement\n",
|
||||
"Merge branch 'main' into dev",
|
||||
"Revert \"feat: x\"",
|
||||
"chore(release): v2.3.0",
|
||||
],
|
||||
)
|
||||
def test_no_bump(self, message):
|
||||
assert bump_tool.detect_bump_from_message(message) is None
|
||||
|
||||
def test_commit_message_comment_is_ignored(self):
|
||||
message = "fix: correction\n# Please enter the commit message\n"
|
||||
assert bump_tool.detect_bump_from_message(message) == "patch"
|
||||
|
||||
def test_empty_message_uses_default_kind(self):
|
||||
"""Commit rédigé dans un éditeur : incrément différé, message pas encore saisi."""
|
||||
template = "\n# Please enter the commit message for your changes. Lines starting\n# with '#'\n"
|
||||
assert bump_tool.detect_bump_from_message(template) is None
|
||||
assert bump_tool.detect_bump_from_message(template, default="patch") == "patch"
|
||||
assert bump_tool.detect_bump_from_message("", default="patch") == "patch"
|
||||
# Un vrai message garde la priorité sur le défaut.
|
||||
assert bump_tool.detect_bump_from_message("feat: x", default="patch") == "minor"
|
||||
|
||||
|
||||
# ── Synchronisation des fichiers dérivés ──────────────────────────────────
|
||||
@pytest.fixture
|
||||
def fake_repo(tmp_path: Path) -> Path:
|
||||
"""Dépôt minimal avec tous les fichiers dérivés du versionnage."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "desktop").mkdir()
|
||||
(tmp_path / "scripts").mkdir()
|
||||
|
||||
(tmp_path / "VERSION").write_text("2.2.1\n", encoding="utf-8")
|
||||
(tmp_path / "package.json").write_text(
|
||||
json.dumps({"name": "obsigate", "version": "1.0.0", "type": "commonjs"}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "desktop" / "tauri.conf.json").write_text(
|
||||
json.dumps({"productName": "ObsiGate", "version": "2.0.0"}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "desktop" / "Cargo.toml").write_text(
|
||||
'[package]\nname = "obsigate-desktop"\nversion = "2.0.0"\nedition = "2021"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "desktop" / "Cargo.lock").write_text(
|
||||
'[[package]]\nname = "autre"\nversion = "1.0.0"\n\n'
|
||||
'[[package]]\nname = "obsigate-desktop"\nversion = "2.0.0"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "docs" / "ROADMAP.md").write_text(
|
||||
"# ObsiGate — Roadmap\n\n"
|
||||
"> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-15\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "CHANGELOG.md").write_text(
|
||||
"# Changelog — ObsiGate\n\n"
|
||||
"> **En cours de développement** : les changements non publiés sont dans la section\n"
|
||||
"> [Unreleased](#unreleased). La dernière version publiée est **2.2.1**.\n\n"
|
||||
"---\n\n"
|
||||
"## [Unreleased]\n\n"
|
||||
"### Corrigé\n\n"
|
||||
"- **BUG-047 — version** : correctif.\n\n"
|
||||
"---\n\n"
|
||||
"## [2.2.1] — 2026-09-11\n\n"
|
||||
"### Corrigé\n\n"
|
||||
"- ancien\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestSyncDerivedFiles:
|
||||
def test_apply_version_writes_everything(self, fake_repo):
|
||||
touched = bump_tool.apply_version(fake_repo, "2.3.0", when=date(2026, 9, 16))
|
||||
|
||||
assert (fake_repo / "VERSION").read_text(encoding="utf-8").strip() == "2.3.0"
|
||||
assert json.loads((fake_repo / "package.json").read_text(encoding="utf-8"))["version"] == "2.3.0"
|
||||
assert (
|
||||
json.loads((fake_repo / "desktop" / "tauri.conf.json").read_text(encoding="utf-8"))[
|
||||
"version"
|
||||
]
|
||||
== "2.3.0"
|
||||
)
|
||||
cargo = (fake_repo / "desktop" / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "2.3.0"' in cargo
|
||||
lock = (fake_repo / "desktop" / "Cargo.lock").read_text(encoding="utf-8")
|
||||
assert 'name = "autre"\nversion = "1.0.0"' in lock # bloc non touché
|
||||
assert re.search(r'name = "obsigate-desktop"\nversion = "2\.3\.0"', lock)
|
||||
assert "2.3.0" in (fake_repo / "docs" / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
assert "-dev" not in (fake_repo / "docs" / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
|
||||
assert set(touched) == {
|
||||
"VERSION",
|
||||
"package.json",
|
||||
"desktop/tauri.conf.json",
|
||||
"desktop/Cargo.toml",
|
||||
"desktop/Cargo.lock",
|
||||
"docs/ROADMAP.md",
|
||||
"CHANGELOG.md",
|
||||
}
|
||||
|
||||
def test_changelog_rotation(self, fake_repo):
|
||||
bump_tool.rotate_changelog(fake_repo, "2.3.0", when=date(2026, 9, 16))
|
||||
text = (fake_repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
|
||||
# La nouvelle section vide est en tête, la version livrée juste après.
|
||||
assert re.search(
|
||||
r"## \[Unreleased\]\n\n---\n\n## \[2\.3\.0\] — 2026-09-16\n", text
|
||||
), text
|
||||
# Le contenu précédemment « Unreleased » appartient désormais à 2.3.0.
|
||||
assert text.index("## [2.3.0] — 2026-09-16") < text.index("- **BUG-047 — version**")
|
||||
# La phrase d'en-tête suit la version livrée.
|
||||
assert "La dernière version livrée est **2.3.0**" in text
|
||||
assert "La dernière version publiée est **2.2.1**" not in text
|
||||
|
||||
def test_changelog_rotation_is_idempotent(self, fake_repo):
|
||||
bump_tool.rotate_changelog(fake_repo, "2.3.0", when=date(2026, 9, 16))
|
||||
once = (fake_repo / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert bump_tool.rotate_changelog(fake_repo, "2.3.0", when=date(2026, 9, 16)) is False
|
||||
assert (fake_repo / "CHANGELOG.md").read_text(encoding="utf-8") == once
|
||||
|
||||
def test_apply_version_is_idempotent(self, fake_repo):
|
||||
bump_tool.apply_version(fake_repo, "2.3.0", when=date(2026, 9, 16))
|
||||
assert bump_tool.apply_version(fake_repo, "2.3.0", when=date(2026, 9, 16)) == []
|
||||
|
||||
def test_crlf_is_preserved(self, fake_repo):
|
||||
path = fake_repo / "VERSION"
|
||||
path.write_bytes(b"2.2.1\r\n")
|
||||
bump_tool.write_preserving_newlines(path, "2.4.0\n")
|
||||
assert path.read_bytes() == b"2.4.0\r\n"
|
||||
|
||||
def test_readmes_badges_and_footers(self, fake_repo):
|
||||
(fake_repo / "README.md").write_text(
|
||||
"[]()\n"
|
||||
"...\n"
|
||||
"See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v1.7.0).\n"
|
||||
"*Project: ObsiGate | Version: 1.7.0 | Last updated: May 2026*\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(fake_repo / "README.fr.md").write_text(
|
||||
"[]()\n"
|
||||
"Consultez le [CHANGELOG.md](./CHANGELOG.md) (v1.0.0 → v2.0.0-dev).\n"
|
||||
"*Projet : ObsiGate | Version : 2.0.0-dev | Dernière mise à jour : Juin 2026*\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
changed = bump_tool.sync_readmes(fake_repo, "2.3.0")
|
||||
|
||||
assert sorted(changed) == ["README.fr.md", "README.md"]
|
||||
readme = (fake_repo / "README.md").read_text(encoding="utf-8")
|
||||
assert "badge/Version-2.3.0-blue.svg" in readme
|
||||
assert "| Version: 2.3.0 |" in readme
|
||||
assert "(v1.0.0 → v2.3.0)" in readme
|
||||
readme_fr = (fake_repo / "README.fr.md").read_text(encoding="utf-8")
|
||||
assert "badge/Version-2.3.0-blue.svg" in readme_fr
|
||||
assert "| Version : 2.3.0 |" in readme_fr
|
||||
assert "(v1.0.0 → v2.3.0)" in readme_fr
|
||||
# Idempotent
|
||||
assert bump_tool.sync_readmes(fake_repo, "2.3.0") == []
|
||||
|
||||
|
||||
# ── Cohérence globale du dépôt (garde-fou anti-dérive) ────────────────────
|
||||
class TestRepoVersionAlignment:
|
||||
def _repo_version(self) -> str:
|
||||
raw = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
assert re.fullmatch(r"\d+\.\d+\.\d+", raw), f"VERSION invalide : {raw!r}"
|
||||
return raw
|
||||
|
||||
def test_version_file_is_semver(self):
|
||||
assert self._repo_version()
|
||||
|
||||
def test_package_json_matches(self):
|
||||
data = json.loads((REPO_ROOT / "package.json").read_text(encoding="utf-8"))
|
||||
assert data["version"] == self._repo_version()
|
||||
|
||||
def test_desktop_files_match(self):
|
||||
version = self._repo_version()
|
||||
conf = json.loads((REPO_ROOT / "desktop" / "tauri.conf.json").read_text(encoding="utf-8"))
|
||||
assert conf["version"] == version
|
||||
cargo = (REPO_ROOT / "desktop" / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert re.search(rf'^version = "{re.escape(version)}"$', cargo, re.M), cargo[:200]
|
||||
lock = (REPO_ROOT / "desktop" / "Cargo.lock").read_text(encoding="utf-8")
|
||||
assert re.search(rf'name = "obsigate-desktop"\r?\nversion = "{re.escape(version)}"', lock)
|
||||
|
||||
def test_changelog_has_a_section_for_the_version(self):
|
||||
version = self._repo_version()
|
||||
text = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert re.search(rf"(?m)^## \[{re.escape(version)}\]", text), (
|
||||
"CHANGELOG.md doit contenir une section pour la version livrée"
|
||||
)
|
||||
|
||||
def test_roadmap_header_matches(self):
|
||||
version = self._repo_version()
|
||||
text = (REPO_ROOT / "docs" / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
assert re.search(rf"> \*\*Version :\*\* {re.escape(version)} ", text)
|
||||
|
||||
def test_no_hardcoded_version_left_in_build_pipeline(self):
|
||||
"""Dockerfile / compose / CI ne doivent plus coder de numéro de version."""
|
||||
dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8")
|
||||
assert "COPY VERSION ./VERSION" in dockerfile
|
||||
assert not re.search(r"ARG VERSION=\d+\.\d+\.\d+", dockerfile)
|
||||
|
||||
compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
assert not re.search(r"VERSION:\s*\$\{VERSION:-[\d.]+\}", compose)
|
||||
|
||||
ci = (REPO_ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||
assert "git describe --tags --dirty" not in ci
|
||||
|
||||
def test_hooks_are_versioned_and_documented(self):
|
||||
assert (REPO_ROOT / ".githooks" / "prepare-commit-msg").exists()
|
||||
assert (REPO_ROOT / ".githooks" / "post-commit").exists()
|
||||
assert (REPO_ROOT / "scripts" / "install-hooks.sh").exists()
|
||||
|
||||
# post-commit rattache les fichiers au commit (amend) puis tague.
|
||||
post = (REPO_ROOT / ".githooks" / "post-commit").read_text(encoding="utf-8")
|
||||
assert "--amend" in post
|
||||
assert "git tag -a" in post
|
||||
assert "rebase-merge" in post # garde : jamais d'amend en rebase/merge
|
||||
|
||||
# prepare-commit-msg incrémente et laisse la liste des fichiers au post-commit.
|
||||
prep = (REPO_ROOT / ".githooks" / "prepare-commit-msg").read_text(encoding="utf-8")
|
||||
assert "--from-message-file" in prep
|
||||
assert "obsigate-version-files" in prep
|
||||
Reference in New Issue
Block a user