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,