feat(version): version x.y.z propre et cohérente partout + bump script
CI / lint (push) Failing after 21s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 27s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

Corrige l'incoherence d'affichage (page principale 0.0.0-dev vs a propos
v2.0.0-dev) et unifie la gestion de version sur une seule source de verite.

Backend:
- version.py: get_version() retourne toujours le tag x.y.z propre du dernier
  tag (plus de 0.0.0-dev / -N-gHASH dans l'UI). Ajout get_git_describe() et
  get_git_commit() pour le detail.
- main.py: /api/health expose git_describe + git_commit (nouveaux champs).

Frontend:
- modale A propos (populateAbout): version + commit lus depuis /api/health,
  suppression du hardcode v2.0.0-dev dans index.html.
- section config A propos (loadAbout): lit health.version, plus de state.
  APP_VERSION obsolete.
- badge header: fetch /api/health (fallback neutre '...').
- state.js: suppression de APP_VERSION (1.5.0) devenu mort; imports nettoyes.

Script:
- scripts/bump_version.sh: incrementation SemVer selon les commits
  (breaking->major, feat->minor, sinon patch), creer/pousser le tag vX.Y.Z.

Tests: 507 passed, frontend validators OK.
This commit is contained in:
2026-09-07 22:13:54 -04:00
parent 01453bce5f
commit c5b92eabe1
8 changed files with 189 additions and 40 deletions
+6 -2
View File
@@ -287,9 +287,11 @@ class ReloadResponse(BaseModel):
class HealthResponse(BaseModel):
"""Application health status."""
status: str = Field(description="Health status ('ok' or 'error')")
version: str = Field(description="Application version")
version: str = Field(description="Application version (x.y.z — latest release tag)")
vaults: int = Field(description="Number of configured vaults")
total_files: int = Field(description="Total indexed files across all vaults")
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
class DirectoryCreateRequest(BaseModel):
@@ -645,7 +647,7 @@ async def lifespan(app: FastAPI):
_search_executor = None
from backend.version import get_version
from backend.version import get_version, get_git_describe, get_git_commit
app = FastAPI(title="ObsiGate", version=get_version(), lifespan=lifespan)
@@ -1029,6 +1031,8 @@ async def api_health():
"version": app.version,
"vaults": len(index),
"total_files": total_files,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
}
+64 -28
View File
@@ -1,13 +1,15 @@
"""
Version management for ObsiGate.
Uses git describe to generate a SemVer-compatible version string.
Format: MAJOR.MINOR.PATCH[-commits-since-tag-gHASH]
The canonical version is the numeric SemVer of the LATEST release tag
(MAJOR.MINOR.PATCH). get_version() always returns a clean "x.y.z" string so
the UI never shows "-dev", "0.0.0-dev" or a "-N-gHASH" suffix — the same
number appears in the header badge, the About modal and the API health.
Examples:
v1.7.0 (exact tag)
v1.7.0-3-gabc1234 (3 commits after v1.7.0)
v1.7.0-dev (fallback when Git unavailable)
tag v2.0.0 -> "2.0.0"
HEAD 31 commits after -> "2.0.0" (release version, no dev clutter)
no git / no VERSION -> "0.0.0"
"""
from __future__ import annotations
@@ -15,13 +17,14 @@ import subprocess
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root
_VERSION_FILE = Path(__file__).resolve().parent / "VERSION"
def get_version() -> str:
"""Get the current version from Git tags."""
def _run_git(args: list[str]) -> str:
"""Run a git command in the repo root; return stdout (stripped) or ''."""
try:
result = subprocess.run(
["git", "describe", "--tags", "--dirty=-dirty"],
["git", *args],
cwd=str(_ROOT),
capture_output=True,
text=True,
@@ -29,35 +32,68 @@ def get_version() -> str:
check=False,
)
if result.returncode == 0:
tag = result.stdout.strip()
# git describe returns v1.7.0 or v1.7.0-3-gabc1234
# Remove leading 'v' if present
tag = tag.removeprefix("v")
return tag
return result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
pass
return ""
# Fallback: try reading from a VERSION file next to this module
version_file = Path(__file__).parent / "VERSION"
if version_file.exists():
return version_file.read_text().strip()
return "0.0.0-dev"
def _clean_base(raw: str) -> str:
"""Reduce a version string to its numeric MAJOR.MINOR.PATCH base.
Handles "v2.0.0", "2.0.0-31-gabc1234", "2.0.0-dev", "0.0.0-dev".
Returns "" if no numeric triplet can be parsed.
"""
s = (raw or "").strip().lstrip("vV")
base = s.split("-")[0] # strip -N-gHASH / -dev / -dirty suffixes
parts = base.split(".")
nums = []
for p in parts[:3]:
if not p.isdigit():
break
nums.append(str(int(p)))
if len(nums) != 3:
return ""
return ".".join(nums)
def get_git_describe() -> str:
"""Full `git describe` string (e.g. "2.0.0-31-gabc1234") or '' if no git."""
return _run_git(["describe", "--tags", "--dirty=-dirty"]).lstrip("v")
def get_git_commit() -> str:
"""Short HEAD commit hash (e.g. "abc1234") or '' if unavailable."""
return _run_git(["rev-parse", "--short", "HEAD"])
def get_version() -> str:
"""Return the clean release version x.y.z (latest tag) — never a -suffix.
Priority: latest git tag -> backend/VERSION file -> "0.0.0".
"""
# 1) Latest tag from git (works even with commits beyond the tag)
tag = _run_git(["describe", "--tags", "--abbrev=0"])
base = _clean_base(tag)
if base:
return base
# 2) backend/VERSION file (baked at build time by build.sh / CI / Docker)
if _VERSION_FILE.exists():
base = _clean_base(_VERSION_FILE.read_text(encoding="utf-8"))
if base:
return base
# 3) Nothing available
return "0.0.0"
def get_version_tuple() -> tuple[int, int, int]:
"""Return (major, minor, patch) tuple for programmatic use."""
raw = get_version()
# Strip -suffix for parsing
base = raw.split("-")[0]
try:
parts = base.split(".")
major = int(parts[0]) if len(parts) > 0 else 0
minor = int(parts[1]) if len(parts) > 1 else 0
patch = int(parts[2]) if len(parts) > 2 else 0
return (major, minor, patch)
except (ValueError, IndexError):
return (0, 0, 0)
base = _clean_base(raw) or "0.0.0"
major, minor, patch = (int(p) for p in base.split("."))
return (major, minor, patch)
if __name__ == "__main__":