diff --git a/backend/main.py b/backend/main.py
index 250891b..fecd6c3 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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(),
}
diff --git a/backend/version.py b/backend/version.py
index 333034b..79b1a3c 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -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__":
diff --git a/frontend/index.html b/frontend/index.html
index 8e1c3e4..5885e9a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4950,7 +4950,7 @@ curl -X POST https://votre-serveur.com/webhook \
ObsiGate
Vos notes Obsidian,
simplement accessibles.
- v2.0.0-dev
+ —
diff --git a/frontend/js/app.js b/frontend/js/app.js
index 8b0998b..52fc020 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -5,7 +5,6 @@
import * as UI from './ui.js';
import * as Utils from './utils.js';
import { initI18n, t } from './i18n.js';
-import { state } from './state.js';
import { initAIFab } from './ai-fab.js';
// Wire up AI toolbar toast (avoids circular import in utils.js)
@@ -117,7 +116,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Show version in header — always visible, fallback before the async fetch
const versionBadge = document.getElementById('version-badge');
if (versionBadge) {
- versionBadge.textContent = window.__OBSIGATE_VERSION || state.APP_VERSION || 'dev';
+ versionBadge.textContent = window.__OBSIGATE_VERSION || '…';
}
fetch('/api/health')
.then(r => r.json())
diff --git a/frontend/js/config.js b/frontend/js/config.js
index ff7c240..6dc8fbb 100644
--- a/frontend/js/config.js
+++ b/frontend/js/config.js
@@ -923,8 +923,7 @@ function loadAbout() {
title: t("about.section_app"),
rows: [
[t("about.name"), "ObsiGate"],
- [t("about.version"), state.APP_VERSION],
- [t("about.api_version"), health.version || "—"],
+ [t("about.version"), health.version || "—"],
[t("about.status"), health.status || "—"],
],
},
@@ -1653,7 +1652,7 @@ function initAboutModal() {
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.classList.contains('active')) overlay.classList.remove('active'); });
function populateAbout() {
- // Live stats from health endpoint
+ // Live stats + version from health endpoint (single source of truth)
fetch('/api/health')
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -1661,6 +1660,11 @@ function initAboutModal() {
var vEl = document.getElementById('about-stat-vaults');
if (fEl) fEl.textContent = d.total_files || '—';
if (vEl) vEl.textContent = d.vaults || '—';
+ // Clean x.y.z version (not hardcoded) + git detail
+ var versionEl = document.getElementById('about-version');
+ if (versionEl && d.version) versionEl.textContent = d.version;
+ var commitEl = document.getElementById('about-commit');
+ if (commitEl) commitEl.textContent = d.git_describe || d.git_commit || '—';
})
.catch(function() {});
diff --git a/frontend/js/state.js b/frontend/js/state.js
index e5d290c..cc55c0d 100644
--- a/frontend/js/state.js
+++ b/frontend/js/state.js
@@ -2,8 +2,6 @@
Use state.xxx to read/write any value. ES module imports are read-only,
so we export a single mutable object instead of individual let bindings. */
export const state = {
- APP_VERSION: "1.5.0",
-
// Core navigation
currentVault: null,
currentPath: null,
diff --git a/scripts/bump_version.sh b/scripts/bump_version.sh
new file mode 100644
index 0000000..8b07174
--- /dev/null
+++ b/scripts/bump_version.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# -----------------------------------------------------------------------------
+# ObsiGate — version bump helper (SemVer MAJOR.MINOR.PATCH)
+#
+# 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 creates and pushes the new tag:
+#
+# 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)
+#
+# Usage:
+# scripts/bump_version.sh [--major|--minor|--patch] [--dry-run] [--push]
+#
+# --dry-run : only print the computed next version, do not tag.
+# --push : also `git push origin` the new tag (default: local only).
+# --major|--minor|--patch : force the increment, overriding commit detection.
+#
+# Examples:
+# scripts/bump_version.sh --dry-run # preview next version
+# scripts/bump_version.sh # tag locally per commits
+# scripts/bump_version.sh --patch --push # force patch + push
+# -----------------------------------------------------------------------------
+set -euo pipefail
+
+cd "$(git rev-parse --show-toplevel)" # run from repo root regardless of cwd
+BRANCH="$(git branch --show-current)"
+
+# ── Parse args ────────────────────────────────────────────────────────────
+FORCE=""; DRY_RUN=0; DO_PUSH=0
+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 ;;
+ *) echo "✗ Argument inconnu: $a"; exit 2 ;;
+ esac
+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."
+ exit 1
+fi
+
+if [ "$DRY_RUN" = "1" ]; then
+ echo "── DRY RUN ─────────────────────────────"
+ echo " Prochain tag : v${NEXT}"
+ echo " (aucun tag créé)"
+ exit 0
+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 "v${NEXT}"
+ echo "✓ Tag poussé : v${NEXT}"
+else
+ echo "ℹ Tag local uniquement. Poussez avec: git push origin v${NEXT}"
+fi
+
+echo "Done."
diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs
index 744db7d..71cab66 100644
--- a/tests/frontend/unit.test.mjs
+++ b/tests/frontend/unit.test.mjs
@@ -28,7 +28,7 @@ function testEscapeHtml() {
// ── Test state object structure ────────────────────────────────────────────
// Replicate state.js structure for testing
const expectedStateKeys = [
- 'APP_VERSION', 'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
+ 'currentVault', 'currentPath', 'allVaults', 'selectedContextVault',
'searchTimeout', 'searchAbortController', 'advancedSearchOffset', 'advancedSearchTotal',
'advancedSearchSort', 'advancedSearchLastQuery', 'suggestAbortController',
'dropdownActiveIndex', 'dropdownItems', 'currentSearchId', 'selectedTags',
@@ -43,7 +43,7 @@ const expectedStateKeys = [
function testStateKeys() {
const state = {
- APP_VERSION: "1.5.0", currentVault: null, currentPath: null, allVaults: [],
+ currentVault: null, currentPath: null, allVaults: [],
selectedContextVault: "all", searchTimeout: null, searchAbortController: null,
advancedSearchOffset: 0, advancedSearchTotal: 0, advancedSearchSort: "relevance",
advancedSearchLastQuery: "", suggestAbortController: null, dropdownActiveIndex: -1,