fix(version): BUG-047 la version suit chaque livraison (source unique VERSION + bump SemVer automatique)
CI / lint (push) Successful in 1m27s
CI / security (push) Successful in 57s
CI / test (push) Successful in 3m11s
CI / build (push) Successful in 54s
CI / e2e (push) Successful in 10m59s

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:
2026-09-16 11:02:47 -04:00
parent 3261917d62
commit d98c0c2f33
24 changed files with 1423 additions and 249 deletions
+4 -4
View File
@@ -126,10 +126,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Generate VERSION file
run: |
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
echo "$VERSION" > backend/VERSION
- name: Version livrée
# VERSION (racine du dépôt) est copié dans l'image par le Dockerfile :
# plus aucun numéro généré ni codé en dur dans le pipeline.
run: echo "Version livree = $(cat VERSION)"
- name: Configure DNS (workaround flaky 127.0.0.11 resolver)
# GitHub Actions runners occasionally fail to resolve auth.docker.io via
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# -----------------------------------------------------------------------------
# ObsiGate — rattache l'incrément de version au commit qui vient d'être créé,
# puis crée le tag `vX.Y.Z` de la version livrée.
#
# Deux situations :
# 1. `prepare-commit-msg` a incrémenté ./VERSION et mis à jour les fichiers
# dérivés (package.json, desktop, READMEs, ROADMAP, CHANGELOG) : on les
# rattache ici au commit via `--amend` (le commit n'est pas encore poussé) ;
# 2. le message venait d'un éditeur : l'incrément a été différé, il est
# calculé ici à partir du message final (`COMMIT_EDITMSG`).
#
# Résultat : la version livrée, le contenu du commit et le tag correspondent
# toujours. Le tag est publié avec la branche à chaque push (`push.followTags`,
# posé par scripts/install-hooks.sh).
# -----------------------------------------------------------------------------
set -eu
[ "${SKIP_VERSION_BUMP:-}" = "" ] || exit 0
[ "${OBSIGATE_VERSION_AMENDING:-}" = "" ] || exit 0 # garde anti-récursion
root="$(git rev-parse --show-toplevel)"
version_file="$root/VERSION"
[ -f "$version_file" ] || exit 0
# Opérations en cours (rebase, merge, cherry-pick…) : ne jamais amender ici.
for state in rebase-merge rebase-apply CHERRY_PICK_HEAD REVERT_HEAD MERGE_HEAD BISECT_LOG; do
if [ -e "$(git rev-parse --git-path "$state")" ]; then
exit 0
fi
done
list="$(git rev-parse --git-path obsigate-version-files)"
defer="$(git rev-parse --git-path obsigate-version-defer)"
# Cas 2 : l'incrément avait été différé (commit rédigé dans un éditeur).
if [ -f "$defer" ]; then
rm -f "$defer"
rm -f "$list"
tool="$root/scripts/bump_version.py"
py=""
for cand in python3 python; do
if command -v "$cand" >/dev/null 2>&1; then py="$cand"; break; fi
done
if [ -n "$py" ] && [ -f "$tool" ]; then
"$py" "$tool" --from-message-file "$(git rev-parse --git-path COMMIT_EDITMSG)" \
--files-out "$list" || true
fi
fi
# Rattachement des fichiers synchronisés au commit qui vient d'être créé.
if [ -s "$list" ]; then
# shellcheck disable=SC2046
git add -- $(tr -d '\r' < "$list")
OBSIGATE_VERSION_AMENDING=1 \
git commit --amend --no-edit --no-verify --quiet
rm -f "$list"
fi
ver="$(tr -d ' \t\r\n' < "$version_file")"
[ -n "$ver" ] || exit 0
if git rev-parse -q --verify "refs/tags/v$ver" >/dev/null 2>&1; then
exit 0
fi
git tag -a "v$ver" -m "ObsiGate v$ver"
echo "ObsiGate version : tag v$ver créé (poussé avec la branche)."
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
# -----------------------------------------------------------------------------
# ObsiGate — incrément automatique de la version livrée (SemVer) à chaque commit.
#
# Source unique de vérité : ./VERSION (MAJEUR.MINEUR.CORRECTIF)
#
# `!:` / `BREAKING CHANGE:` -> MAJEUR (x.0.0)
# `feat:` -> MINEUR (x.y.0)
# tout le reste (fix, perf…) -> CORRECTIF (x.y.z)
#
# Aucun incrément pour : merge, squash, revert, `chore(release)`, amend.
# Commit rédigé dans un éditeur (message pas encore saisi) : l'incrément est
# différé à `post-commit`, qui dispose du message final.
#
# `scripts/bump_version.py` incrémente VERSION et resynchronise les fichiers
# dérivés (package.json, desktop, READMEs, ROADMAP, CHANGELOG) ; le `git add` est
# fait ici, par le hook lui-même. La liste des fichiers est laissée à
# `.githooks/post-commit`, qui rattache l'incrément au commit (git fige l'arbre
# avant `prepare-commit-msg` : un `git add` à cet instant ne serait pris en
# compte qu'au commit suivant).
#
# Neutraliser ponctuellement : SKIP_VERSION_BUMP=1 git commit ...
# -----------------------------------------------------------------------------
set -eu
[ "${SKIP_VERSION_BUMP:-}" = "" ] || exit 0
msg_file="${1:-}"
source_kind="${2:-message}"
case "$source_kind" in
merge|squash|commit) exit 0 ;; # merge / squash / amend (-c, -C, --amend)
esac
[ -n "$msg_file" ] && [ -f "$msg_file" ] || exit 0
root="$(git rev-parse --show-toplevel)"
tool="$root/scripts/bump_version.py"
[ -f "$tool" ] || exit 0
py=""
for cand in python3 python; do
if command -v "$cand" >/dev/null 2>&1; then py="$cand"; break; fi
done
if [ -z "$py" ]; then
echo "⚠ version : aucun interpréteur Python trouvé — version non incrémentée." >&2
exit 0
fi
list="$(git rev-parse --git-path obsigate-version-files)"
defer="$(git rev-parse --git-path obsigate-version-defer)"
# Message pas encore rédigé (ouverture d'un éditeur) : on diffère l'incrément.
if ! grep -qvE '^[[:space:]]*(#|$)' "$msg_file"; then
: > "$defer"
exit 0
fi
rm -f "$defer" # marqueur d'un commit précédent avorté
out=""
if out="$("$py" "$tool" --from-message-file "$msg_file" --files-out "$list" 2>&1)"; then
printf '%s\n' "$out"
else
status=$?
if [ "$status" = "3" ]; then
rm -f "$list"
exit 0 # 3 = version inchangée (revert, release…) : pas une erreur
fi
echo "✗ version : échec de l'incrément automatique de ./VERSION" >&2
printf '%s\n' "$out" >&2
echo " → corriger, ou passer outre avec : SKIP_VERSION_BUMP=1 git commit ..." >&2
exit 1
fi
# Ajout des fichiers synchronisés (chemins explicites).
if [ -s "$list" ]; then
# shellcheck disable=SC2046
git add -- $(tr -d '\r' < "$list")
fi
+33 -5
View File
@@ -5,13 +5,17 @@ Toutes les modifications notables d'ObsiGate sont documentées dans ce fichier.
Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements non publiés sont dans la section
> [Unreleased](#unreleased). La dernière version publiée est **2.2.1**.
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.3.0**.
---
## [Unreleased]
---
## [2.3.0] — 2026-09-16
### Ajouté
- **#89 Drag & Drop complet de fichiers/dossiers & intégration Assistant IA** — solution
@@ -87,9 +91,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
lecture (bandeau, métadonnées, tags et contenu). L'overlay reste monté mais neutralisé
(transparent, sans capture des clics, `z-index` conservé) car le ruban d'édition mobile y est
ancré. Retour à la lecture par ✓ (sauvegarde), ✕, Échap ou l'ouverture d'un autre fichier —
un garde-fou dans `renderFile()` libère proprement la session (destruction CodeMirror/Yjs,
retrait de l'iframe Forge) ; l'overlay n'est conservé qu'en repli, quand la cible n'est pas le
document affiché (ex. fichier créé depuis la palette). Conséquence directe : l'éditeur ne
trois garde-fous libèrent proprement la session (destruction CodeMirror/Yjs, retrait de
l'iframe Forge) avant qu'un rendu concurrent n'efface son DOM : `renderFile()`, l'activation
d'onglet (`TabManager.activate`, `PaneTabManager.activate`, qui vident la zone avant de
rendre) et l'événement SSE `index_updated` (un fichier modifié hors de l'app recharge le
**tampon de l'éditeur** au lieu de re-rendre la vue lecture). L'overlay n'est conservé qu'en
repli, quand la cible n'est pas le document affiché (ex. fichier créé depuis la palette).
Conséquence directe : l'éditeur ne
recouvre plus le panneau de l'assistant IA, qui peut donc mettre le document à jour **sous
les yeux de l'utilisateur** — `app_context.editing` annonce au modèle le document en cours
d'édition (et sa surface), et toute écriture d'outil (`edit_file`, `append_to_file`,
@@ -101,6 +109,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Tests : `tests/frontend/editor-inline.test.mjs` (19), `tests/test_bookslm.py::TestGeneralPrompt` (3 nouveaux).
### Corrigé
- **BUG-047 — La version affichée ne suivait pas les livraisons** : la version
provenait du **dernier tag git**, or aucun tag n'était créé lors des livraisons
→ l'application restait bloquée sur `2.2.1` alors que 66 commits avaient été
livrés (et les numéros codés en dur divergeaient : `package.json` 1.0.0,
desktop Tauri 2.0.0, `Dockerfile` 2.2.1, README 1.7.0). Le fichier **`VERSION`
(racine du dépôt) devient la source unique de vérité** (`MAJEUR.MINEUR.CORRECTIF`),
incrémenté **automatiquement à chaque commit** par le hook versionné
`.githooks/prepare-commit-msg` (`!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon
CORRECTIF) ; `.githooks/post-commit` crée le tag `vX.Y.Z` et `push.followTags`
le publie à chaque push. Tous les dérivés sont resynchronisés dans le même
commit : `package.json`, desktop Tauri (`tauri.conf.json`, `Cargo.toml`,
`Cargo.lock`), `docs/ROADMAP.md`, `README.md`/`README.fr.md` et rotation de
`CHANGELOG.md` (`[Unreleased]` → `[X.Y.Z] — date`). Le backend
(`backend/version.py`), l'image Docker (`COPY VERSION`) et le desktop Tauri
lisent ce même fichier : plus aucun numéro codé en dur dans le pipeline.
Outils : `scripts/bump_version.py` (+ `scripts/bump_version.sh`),
`scripts/install-hooks.sh`. Tests : `tests/test_version.py`, dont un garde-fou
de cohérence globale (VERSION ↔ package.json ↔ desktop ↔ CHANGELOG ↔ ROADMAP ↔
README). Vérifié : `curl /api/health` sur l'instance de test → `2.3.0`.
- **BUG-045 — Double barre de défilement dans l'éditeur « Editer »** : sur les
documents longs, `#editor-body` (qui gardait `overflow:auto`) et le scroller
CodeMirror débordaient simultanément — la barre d'outils IA empilée sous un
+4 -7
View File
@@ -15,7 +15,6 @@ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.11-slim
LABEL maintainer="Bruno Beloeil" \
version="1.4.0" \
description="ObsiGate — lightweight web interface for Obsidian vaults"
WORKDIR /app
@@ -33,12 +32,10 @@ RUN apt-get update \
COPY backend/ ./backend/
COPY frontend/ ./frontend/
# Bake version: build.sh/CI pre-generate backend/VERSION; backend/VERSION est
# exclu du .dockerignore pour que le build utilise TOUJOURS l'ARG ci-dessous
# (jamais un fichier stale "1.8.0-...-dirty" du contexte build qui afficherait
# une vieille version au lieu du tag courant).
ARG VERSION=2.2.1
RUN test -f backend/VERSION || echo "$VERSION" > backend/VERSION
# Version livrée : `VERSION` (racine du dépôt) est la source unique de vérité —
# copié dans l'image, jamais un numéro codé en dur. `backend/version.py` le lit
# et /api/health l'expose (header + boîte À propos).
COPY VERSION ./VERSION
# Create non-root user for security + data directory for auth persistence
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.0.0--dev-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.3.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.0.0-dev).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.3.0).
---
*Projet : ObsiGate | Version : 2.0.0-dev | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.3.0 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-1.7.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.3.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v1.7.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.3.0).
---
*Project: ObsiGate | Version: 1.7.0 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.3.0 | Last updated: May 2026*
+1
View File
@@ -0,0 +1 @@
2.3.0
+49 -19
View File
@@ -1,23 +1,34 @@
"""
Version management for ObsiGate.
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.
Source unique de vérité : le fichier `VERSION` à la racine du dépôt, au format
`MAJEUR.MINEUR.CORRECTIF` (incrémenté à chaque livraison par
`scripts/bump_version.py`, hook git `commit-msg`). get_version() retourne
toujours une chaîne propre "x.y.z" — jamais "-dev", "0.0.0-dev" ou "-N-gHASH" :
le même numéro s'affiche dans le badge d'en-tête, la boîte À propos et /api/health.
Examples:
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"
Les sources sont consultées dans cet ordre :
1. variable d'environnement OBSIGATE_VERSION (surcharge explicite / tests) ;
2. `VERSION` à la racine du dépôt (source unique, copiée dans l'image Docker) ;
3. `backend/VERSION` (ancien emplacement, encore produit par certains builds) ;
4. dernier tag git (`git describe --tags --abbrev=0`) ;
5. "0.0.0".
Exemples :
VERSION = 2.3.0 -> "2.3.0"
tag v2.3.0, 4 commits -> "2.3.0"
aucun VERSION / git -> "0.0.0"
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root
_VERSION_FILE = Path(__file__).resolve().parent / "VERSION"
_ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate
VERSION_FILE = _ROOT / "VERSION" # source unique de vérité
LEGACY_VERSION_FILE = Path(__file__).resolve().parent / "VERSION" # backend/VERSION
_ENV_VAR = "OBSIGATE_VERSION"
def _run_git(args: list[str]) -> str:
@@ -57,6 +68,16 @@ def _clean_base(raw: str) -> str:
return ".".join(nums)
def _read_version_file(path: Path) -> str:
"""Clean base of a VERSION file, or '' when absent/unreadable/invalid."""
try:
if not path.exists():
return ""
return _clean_base(path.read_text(encoding="utf-8", errors="replace"))
except OSError:
return ""
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")
@@ -68,23 +89,32 @@ def get_git_commit() -> str:
def get_version() -> str:
"""Return the clean release version x.y.z (latest tag) — never a -suffix.
"""Return the clean release version x.y.z — never a -suffix.
Priority: latest git tag -> backend/VERSION file -> "0.0.0".
Priority: OBSIGATE_VERSION -> ./VERSION -> backend/VERSION -> latest git tag
-> "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)
# 1) Explicit override (docker-compose, tests, builds hors dépôt)
override = _clean_base(os.environ.get(_ENV_VAR, ""))
if override:
return override
# 2) Source unique de vérité : VERSION à la racine du dépôt
base = _read_version_file(VERSION_FILE)
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"))
# 3) Ancien emplacement (backend/VERSION, baké par certains builds)
base = _read_version_file(LEGACY_VERSION_FILE)
if base:
return base
# 3) Nothing available
# 4) Dernier tag git
base = _clean_base(_run_git(["describe", "--tags", "--abbrev=0"]))
if base:
return base
# 5) Rien d'exploitable
return "0.0.0"
+4 -4
View File
@@ -193,10 +193,10 @@ else
info "Construction de l'image Docker (avec cache)..."
fi
# Generate VERSION file from git before Docker build
VERSION=$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")
echo "$VERSION" > backend/VERSION
# Export so docker compose substitutes ${VERSION} in the build arg
# Version livrée : le fichier VERSION (racine du dépôt) est la source unique de
# vérité ; le Dockerfile le copie dans l'image. Aucun numéro codé en dur ici.
VERSION=$(cat VERSION 2>/dev/null | tr -d '[:space:]' | sed 's/^v//' || true)
[ -n "$VERSION" ] || VERSION="0.0.0"
export VERSION
info "Version: $VERSION"
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.0.0"
version = "2.3.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.0.0"
version = "2.3.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+14 -3
View File
@@ -1,12 +1,22 @@
fn main() {
// Get version from git describe (same as backend/version.py)
let version = std::process::Command::new("git")
// Version livrée : `VERSION` (racine du dépôt) est la source unique de
// vérité (même fichier que backend/version.py et l'image Docker).
// Repli : `git describe` quand le fichier n'est pas dans le contexte de build.
let version = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../VERSION"),
)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
std::process::Command::new("git")
.args(["describe", "--tags", "--dirty=-dirty"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().trim_start_matches('v').to_string())
.unwrap_or_else(|| "0.0.0-dev".to_string());
.unwrap_or_else(|| "0.0.0".to_string())
});
// Extract short hash for display
let short_hash = std::process::Command::new("git")
@@ -23,6 +33,7 @@ fn main() {
println!("cargo:rustc-env=GIT_VERSION={}", version);
println!("cargo:rustc-env=GIT_HASH={}", short_hash);
println!("cargo:rustc-env=SEMVER={}", semver_base);
println!("cargo:rerun-if-changed=../VERSION");
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/heads/main");
println!("cargo:rerun-if-changed=.git/refs/tags");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.0.0",
"version": "2.3.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
-6
View File
@@ -11,12 +11,6 @@ services:
obsigate:
build:
context: .
args:
# VERSION est injecte par build.sh/CI via `git describe`.
# En `docker compose build` direct (sans build.sh), on retombe sur le
# DERNIER TAG (v2.2.1) et non 0.0.0-dev, pour ne jamais afficher "0.0.0"
# dans le header / la boîte À propos. Packagé par backend/VERSION.
VERSION: ${VERSION:-2.2.1}
image: obsigate:latest
container_name: obsigate
user: "1000:1000"
+24 -6
View File
@@ -17,8 +17,9 @@ la checklist du §5 n'est pas entièrement verte, **CI compris**.
| Fichier | Rôle | Quand le mettre à jour |
|---|---|---|
| [`VERSION`](../VERSION) (racine) | **Version livrée** — source unique de vérité (SemVer `MAJEUR.MINEUR.CORRECTIF`) | Automatiquement à chaque commit (hook `.githooks/prepare-commit-msg`) |
| [`docs/ROADMAP.md`](./ROADMAP.md) | Travail **à venir** (🔵 En cours + ⚪ Backlog) + index du complété | Au début (statut) et à la fin (index) |
| [`CHANGELOG.md`](../CHANGELOG.md) | Historique officiel par version (Keep a Changelog) | À chaque livraison, dans `[Unreleased]` |
| [`CHANGELOG.md`](../CHANGELOG.md) | Historique officiel par version (Keep a Changelog) | À chaque livraison : écrire dans `[Unreleased]` (publié en `[X.Y.Z] — date` par le bump) |
| [`docs/features/<slug>.md`](./features/) | **Conception / spec détaillée** d'une grosse feature | Quand l'item est livré |
| [`docs/archive/COMPLETED_v1-v2.md`](./archive/COMPLETED_v1-v2.md) | Détail des items courts livrés | Quand l'item est livré |
| [`docs/ISSUES_TODOLIST.md`](./ISSUES_TODOLIST.md) | Registre des **bugs / TODO** | À chaque bug (statut + correctif/commit) |
@@ -52,8 +53,11 @@ la checklist du §5 n'est pas entièrement verte, **CI compris**.
4. **Vérifier en local** — exécuter les commandes du §6.
5. **Documenter** — CHANGELOG `[Unreleased]`, Roadmap / ISSUES, fiche feature ou archive, guide
utilisateur + i18n FR/EN, README si besoin, OpenAPI si API.
6. **Commit** — message conventionnel (`feat:`, `fix:`…) référençant `#NN` / `BUG-NNN`.
7. **Push** puis **vérifier le CI vert** (jobs `lint`, `test`, `security`, `build`, `e2e`).
6. **Commit** — message conventionnel (`feat:`, `fix:`…) référençant `#NN` / `BUG-NNN` : le
préfixe détermine l'incrément SemVer de `VERSION` (MAJEUR / MINEUR / CORRECTIF), appliqué
automatiquement par le hook `.githooks/prepare-commit-msg`.
7. **Push** puis **vérifier le CI vert** (jobs `lint`, `test`, `security`, `build`, `e2e`) ; le
tag `vX.Y.Z` de la version livrée est publié avec la branche (`push.followTags`).
8. **Clôturer** — statut `🟢 corrigé` / index `✅` posé par l'IA ; l'utilisateur valide (`✅ vérifié`).
---
@@ -74,6 +78,7 @@ la checklist du §5 n'est pas entièrement verte, **CI compris**.
- [ ] E2E Playwright si flow UI critique touché
### Documentation
- [ ] `VERSION` incrémenté et dérivés synchronisés (automatique via le hook ; `tests/test_version.py` vert)
- [ ] `CHANGELOG.md` → `[Unreleased]` (section Ajouté / Modifié / Corrigé)
- [ ] `docs/ROADMAP.md` → statut mis à jour + ligne dans l'index « Complété »
- [ ] Fiche `docs/features/<slug>.md` **ou** `docs/archive/` si l'item est livré
@@ -113,9 +118,22 @@ npx playwright test
## 7. Versionnement & release
- **SemVer** `MAJOR.MINOR.PATCH` ; tags Git `vX.Y.Z` ; source de vérité = `CHANGELOG.md`.
- Lors d'une publication : déplacer `[Unreleased]` vers `[X.Y.Z] — date`, tagger, builder et
publier (procédure détaillée dans [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md)).
- **Source unique de vérité : le fichier [`VERSION`](../VERSION)** (racine du dépôt), au format
`MAJEUR.MINEUR.CORRECTIF` (SemVer). Il est incrémenté **automatiquement à chaque commit** par
le hook versionné `.githooks/prepare-commit-msg` — `!:` / `BREAKING CHANGE` → **MAJEUR**, `feat` →
**MINEUR**, tout le reste → **CORRECTIF** — puis tagué `vX.Y.Z` par `.githooks/post-commit`
(tag publié au push grâce à `push.followTags`). Installation une fois par clone :
`scripts/install-hooks.sh`.
- Le même commit resynchronise les dérivés : `package.json`, desktop Tauri (`tauri.conf.json`,
`Cargo.toml`, `Cargo.lock`), `README.md`/`README.fr.md`, `docs/ROADMAP.md`, et publie la
section `[Unreleased]` du `CHANGELOG.md` en `[X.Y.Z] — date`.
- Le backend (`backend/version.py`), l'image Docker (`COPY VERSION`) et le desktop Tauri lisent
ce fichier : la version affichée par l'UI (`/api/health` → header, boîte À propos) suit donc
chaque livraison.
- **Garde-fou** : `tests/test_version.py::TestRepoVersionAlignment` échoue dès qu'un dérivé
diverge de `VERSION` (numéro codé en dur, CHANGELOG sans section, README non resynchronisé).
- Bump manuel si besoin : `scripts/bump_version.py --dry-run|--minor|--set X.Y.Z|--push`.
Commit sans incrément (cas exceptionnel) : `SKIP_VERSION_BUMP=1 git commit …`.
- **Ne jamais** réécrire une version déjà publiée dans le CHANGELOG.
---
+51 -24
View File
@@ -164,40 +164,67 @@ Document produit (URLs construites vers les assets de la release) :
---
## 3. Commit, Push & Tagging Git
## 3. Version, Commit & Push
Une fois les modifications testées et validées :
### Source unique de vérité : `VERSION`
1. **Commit et Push du code source** :
```bash
git add .
git commit -m "feat: préparation release v2.0.0"
git push origin main
```
*Ceci déclenchera la vérification CI standard sur Gitea (lint, tests unitaires, build Docker).*
Le fichier **`VERSION`** à la racine du dépôt contient la version livrée au format
`MAJEUR.MINEUR.CORRECTIF`. C'est la **seule** source : elle est incrémentée à chaque
commit et tout le reste en découle.
2. **Création du Tag de Version** :
```bash
git tag -a v2.0.0 -m "Release v2.0.0"
git push origin v2.0.0
```
| Dérivé | Contenu |
|---|---|
| `backend/version.py` | lit `VERSION` (priorité : `OBSIGATE_VERSION` > `./VERSION` > `backend/VERSION` > tag git) et l'expose via `/api/health` |
| `Dockerfile` | `COPY VERSION ./VERSION` — plus aucun numéro codé en dur dans l'image |
| `build.sh` / CI | affichent la version lue dans `VERSION` |
| `desktop/build.rs` | injecte `VERSION` dans `GIT_VERSION` (repli `git describe`) |
| `package.json`, `desktop/tauri.conf.json`, `desktop/Cargo.{toml,lock}`, `README.md`, `README.fr.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | resynchronisés automatiquement à chaque incrément |
### Bump automatisé (recommandé)
### Incrément automatique (hooks git versionnés)
`scripts/bump_version.sh` calcule la prochaine version depuis les commits
(Conventional Commits), **synchronise la version desktop**
(`tauri.conf.json`, `Cargo.toml`, `Cargo.lock`), crée un commit de release puis
le tag :
`scripts/install-hooks.sh` (une seule fois par clone) pose `core.hooksPath=.githooks`
et `push.followTags=true`. Ensuite, chaque commit incrémente la version selon son
message (Conventional Commits) :
| Message de commit | Incrément |
|---|---|
| `!:` ou `BREAKING CHANGE:` | MAJEUR — `x.0.0` |
| `feat:` / `feat(scope):` | MINEUR — `x.y.0` |
| `fix:`, `perf:`, `docs:`, … | CORRECTIF — `x.y.z` |
`.githooks/prepare-commit-msg` incrémente `VERSION`, resynchronise les fichiers dérivés
et publie la section `[Unreleased]` du CHANGELOG en `[X.Y.Z] — date`. `.githooks/post-commit`
rattache ces fichiers au commit qui vient d'être créé (git fige l'arbre avant
`prepare-commit-msg` : un `git add` à cet instant ne serait repris qu'au commit suivant —
d'où un `--amend` immédiat, le commit n'étant pas encore poussé) puis crée le tag `vX.Y.Z`,
publié automatiquement au push (`push.followTags`). Aucun incrément pour un merge, un
revert, un `chore(release)` ou un `--amend`.
Livraison type :
```bash
scripts/bump_version.sh --dry-run # prévisualiser la prochaine version
scripts/bump_version.sh # bump + commit + tag (local)
scripts/bump_version.sh --push # idem + push branche & tag
git add <chemins explicites> # pas de `git add -A` (test_vault = brouillon utilisateur)
git commit -m "fix(ai): BUG-047 …" # → VERSION incrémentée + tag vX.Y.Z créé
git push origin main # → branche + tag publiés
```
Contournement ponctuel (commit sans incrément) : `SKIP_VERSION_BUMP=1 git commit …`.
### Bump manuel / outillage
```bash
scripts/bump_version.py --print-version # version courante
scripts/bump_version.py --dry-run # prochaine version, sans rien écrire
scripts/bump_version.py --minor # incrément forcé + resynchronisation
scripts/bump_version.py --set 3.0.0 # version imposée
scripts/bump_version.py --major --commit --tag --push
scripts/bump_version.sh … # même outil (wrapper historique)
```
> La version desktop doit être synchronisée avec le tag pour que l'updater Tauri
> détecte les mises à jour (`latest.json` reprend la version de
> `tauri.conf.json`). `--no-files` conserve l'ancien comportement (tag seul).
> détecte les mises à jour (`latest.json` reprend la version de `tauri.conf.json`) :
> l'incrément automatique s'en charge.
---
+3 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-15
- **Dernière mise à jour** : 2026-09-16
---
@@ -156,6 +156,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-044* | [🟡 IMPORTANT] Capacités des modèles IA erronées : aucun modèle Mistral n'est détecté « Vision capable » (et des modèles texte sont annoncés « Embeddings ») | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/model_capabilities.py`, `backend/provider_capabilities.py`, `backend/main.py`, `backend/ai_routes.py` | `curl -H "Authorization: Bearer $MISTRAL_API_KEY" https://api.mistral.ai/v1/models` puis `GET /api/ai/model-capabilities?provider=mistral&model=mistral-small-latest` | Nouveau calque **déclaratif** (`backend/provider_capabilities.py`) : les capacités publiées par le fournisseur (Mistral `capabilities`, OpenRouter `architecture`) sont lues et mises en cache par `GET /api/config/ai-models` ; elles **priment** sur la table statique, qui ne comble plus que les drapeaux non déclarés (et sert de repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings`. Tests : `tests/test_provider_capabilities.py`, `tests/test_model_capabilities.py::TestMistralFamilies`, `tests/test_ai_models.py::TestDeclaredCapabilities` | Le panneau de modèle par défaut et la bulle ⓘ n'affichaient **aucun** Mistral vision alors que l'API en déclare 28 ; seul le motif `pixtral` (retiré de l'API) matchait. Effet secondaire corrigé : `mistral-large-latest` / `codestral-latest` étaient annoncés « Embeddings » (défaut fournisseur). La porte vision de l'assistant (`bookslm_routes.py`) accepte désormais les images avec un modèle Mistral vision |
| *BUG-045* | [🟡 IMPORTANT] Éditeur « Editer » : deux barres de défilement superposées sur les documents longs | 🟢 corrigé | P1 | 📱 frontend | Éditeur | `frontend/style.css` | Ouvrir un fichier long (ex. IT/Docker Guide.md), cliquer Editer, mesurer `#editor-body` et `.cm-scroller` | `.editor-body-cm` gardait `overflow:auto` et un `.cm-editor{height:100%}` sous la rangée barre d'outils IA → le corps (toolbar+éditeur) ET le scroller CodeMirror débordaient simultanément. L'override global legacy `.cm-scroller{min-height:100%;overflow-y:auto!important}` aggravait. Passé en flex column : corps `overflow:hidden`, toolbar `flex:0 0 auto`, `.cm-editor` `flex:1 1 auto; height:auto`, seul le scroller défile ; override legacy retiré. Test : `tests/frontend/editor-inline.test.mjs` (+1) ; vérifié Playwright sur l'instance de test (un seul conteneur scrollable). |
| *BUG-046* | [🔴 BLOQUANT] Assistant IA : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant | 🟢 corrigé | P0 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py` | Mode agent : demander d'ajouter du texte au document ouvert puis cliquer « Appliquer » | Trois causes : (1) continuation de confirmation avec `payload:null` → second « Appliquer » sans `message` → 422 ; (2) `new Error(detail)` sur un `detail` tableau d'objets FastAPI → « [object Object] » ; (3) prompt documents/directory sans nom de vault → le modèle inventait `"vault":"test"` → échec silencieux de l'outil. Nouveau `_responseError()` (aplatit tableau/objet), payload porté à la continuation, bloc « Ces documents appartiennent au vault « X » » + consigne outils d'écriture (`build_system_prompt(vault_name=...)`). `SW_VERSION` v20. Tests : `tests/test_bookslm.py::test_vault_name_guidance`, `tests/frontend/ai.test.mjs` (+2). |
| *BUG-047* | [🔴 BLOQUANT] La version affichée par l'application ne suit pas les livraisons : 66 commits livrés depuis v2.2.1 et l'UI/API restent bloquées sur `2.2.1` (et les numéros codés en dur divergent : `package.json` 1.0.0, desktop Tauri 2.0.0, `Dockerfile` 2.2.1, README 1.7.0) | 🟢 corrigé | P0 | ⚙️ build + 📄 docs | IA | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `tests/test_version.py` (nouveau) | `git tag -l \| tail -1` puis `python scripts/bump_version.py --print-version` ; `curl -s http://localhost:2020/api/health \| jq .version` | Le numéro provenait du **dernier tag git** et aucun tag n'était créé aux livraisons (`bump_version.sh` jamais appelé) → version figée, plus quatre numéros codés en dur ailleurs. Corrigé : **`VERSION` (racine) = source unique de vérité**, incrémentée automatiquement à chaque commit par le hook versionné `prepare-commit-msg` (SemVer : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF), tag `vX.Y.Z` créé par `post-commit` et publié au push (`push.followTags`) ; `bump_version.py` resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et fait la rotation du CHANGELOG dans le même commit ; backend, image Docker (`COPY VERSION`) et desktop lisent ce fichier. Contournement ponctuel : `SKIP_VERSION_BUMP=1`. | Garde-fou : `tests/test_version.py::TestRepoVersionAlignment` échoue dès qu'un dérivé diverge de `VERSION`. Vérifié : pytest complet vert, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test. |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -199,6 +200,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-14 | BUG-043, #82 | Correction | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-provider-picker.md` | La liste des fournisseurs de la barre latérale de l'assistant suit désormais la configuration du projet : nouveau `refreshAIPickers()` (`ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (host conservé dans la barre de l'assistant, montage par `replaceChildren`), appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; slot préservé même sans fournisseur configuré (le premier fournisseur ajouté s'y monte) ; sélection persistée d'un fournisseur non configuré purgée de `obsigate_ai_picker` (retour au défaut, modèle effacé). Vérifié : tests frontend 66/66 (IA) + 9 suites JSDOM vertes, validate-imports 36 modules, pytest 963 passed / 6 skipped, ruff OK, et vérification en navigateur (Playwright) sur l'instance de test : ajout de `nvidia` → fournisseur visible sans rechargement, retrait → disparition + sélection réinitialisée (`{"provider":null,"model":null}`), un seul montage du picker dans son host. CI Gitea verte (lint, test, security, build, e2e). |
| 2026-09-15 | BUG-044 | Correction | `backend/provider_capabilities.py` (nouveau), `backend/model_capabilities.py`, `backend/main.py`, `backend/ai_routes.py`, `tests/test_provider_capabilities.py` (nouveau), `tests/test_model_capabilities.py`, `tests/test_ai_models.py`, `docs/features/ai-provider-picker.md`, `CHANGELOG.md` | BUG-044 : les capacités des modèles IA sont désormais lues chez le fournisseur quand il les publie (Mistral `capabilities`, OpenRouter `architecture`, détection par forme du payload), mises en cache par `GET /api/config/ai-models` (aucune requête supplémentaire) et prioritaires sur la table statique qui ne comble plus que les drapeaux non déclarés (repli hors ligne / sans clé). Table statique corrigée : familles vision Mistral (`ministral`, `magistral`, `mistral-small`, `mistral-medium`, `mistral-vibe-cli`, `labs-leanstral`), `mistral-ocr` = vision sans chat, défaut fournisseur Mistral sans `embeddings` (mistral-large / codestral n'étaient plus des « embedders »). Vérifié : diagnostic live avant/après sur l'API Mistral (28 modèles vision déclarés, **0** détectés avant → **28** après, 0 écart dans les deux sens), pytest 1007 passed / 6 skipped, ruff 0 (backend), mypy 0 (68 fichiers), tests frontend (unit 9/9, IA 66/66, validate-imports 37 modules), et vérification sur l'instance de test reconstruite (`obsigate-test`, http://localhost:2020) via les deux endpoints du panneau : `mistral-small/medium-latest`, `ministral-8b-latest`, `magistral-small-latest` → `chat,vision` ; `mistral-ocr-latest` → `vision` seul ; `mistral-large-latest`/`codestral-latest` → `chat` ; `mistral-embed` → `embeddings` seul ; `deepseek-chat` (fournisseur muet) inchangé. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-045, BUG-046 | Correction | `frontend/style.css`, `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/sw.js`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md` | BUG-045 : fin de la double barre de défilement de l'éditeur « Editer » — `#editor-body` en flex column `overflow:hidden`, `.cm-editor` `flex:1 1 auto`, seul le scroller CodeMirror défile ; suppression de l'override global `.cm-scroller` (min-height/overflow-y !important). BUG-046 : « Échec de l'action : [object Object] » à l'application d'un ajout de texte au document courant — (1) `_responseError()` aplatit le `detail` 422 (tableau d'objets FastAPI) en message lisible, (2) la continuation de confirmation hérite du payload d'origine (le second « Appliquer » ne POSTe plus sans `message`), (3) le prompt documents/dossier nomme le vault et enjoint les outils d'écriture d'utiliser ce nom exact (le modèle inventait `"vault":"test"` → échec silencieux). `SW_VERSION` v20. Vérifié : pytest 1038 passed / 6 skipped, ruff 0 (backend), tests frontend IA 84/84, editor-inline 25/25, validate-imports 38 modules ; reproduction et validation Playwright sur l'instance de test (un seul conteneur scrollable après correctif). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-16 | BUG-047 | Correction + build | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `scripts/bump_version.sh`, `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `package.json`, `README.md`, `README.fr.md`, `docs/ROADMAP.md`, `docs/DELIVERY_WORKFLOW.md`, `docs/DEVELOPMENT_AND_RELEASES.md`, `tests/test_version.py` (nouveau) | **Version alignée de bout en bout.** Cause : la version affichée venait du dernier **tag git** et aucun tag n'était créé aux livraisons → 66 commits livrés mais UI/API figées sur `2.2.1` ; quatre autres numéros codés en dur dérivaient (`package.json` 1.0.0, desktop 2.0.0, `Dockerfile` 2.2.1, README 1.7.0). Nouveau modèle : **`VERSION` (racine) = source unique de vérité** (`MAJEUR.MINEUR.CORRECTIF`), incrémentée **automatiquement au commit** par le hook versionné `prepare-commit-msg` (SemVer depuis le message : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF ; aucun bump pour merge/revert/`chore(release)`/amend) qui resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et publie la section `[Unreleased]` du CHANGELOG en `[X.Y.Z] — date` ; `post-commit` rattache ces fichiers au commit qui vient d'être créé (amend immédiat, le commit n'étant pas encore poussé) puis crée le tag `vX.Y.Z`, publié au push (`push.followTags`). `bump_version.py` (avec `--dry-run`, `--set`, `--major|--minor|--patch`, `--print-version`) reste utilisable à la main ; `scripts/install-hooks.sh` installe les hooks. Côté build : Docker `COPY VERSION` (plus d'`ARG VERSION` ni d'env compose codés en dur), `build.sh` et CI lisent `VERSION`, `desktop/build.rs` aussi. Vérifié : `tests/test_version.py` (46 tests, dont le garde-fou `TestRepoVersionAlignment` : VERSION ↔ package.json ↔ desktop ↔ CHANGELOG ↔ ROADMAP ↔ READMEs ↔ pipeline sans numéro codé en dur), pytest complet, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test reconstruite. | 🟢 corrigé (en attente vérif utilisateur) |
---
+61 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-15
> **Version :** 2.3.0 | **Dernière mise à jour :** 2026-09-16
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -94,6 +94,63 @@
- [ ] Transverse : `tenacity` (backoff), cache SQLite des résultats web avec TTL, secrets via Infisical
- [ ] Chaque outil : libellé `labels.py` + clés i18n `ai.step.*` FR/EN + tests (httpx mocké)
### 94. Assistant IA — Bouton de soumission arrondi et icône renouvelée
- **Effort :** 0,5 jour | **Impact :** 🟢 | **Zone :** frontend (`style.css`, `bookslm.js`)
- **Description :** remplacer le bouton de soumission du panneau Assistant IA par un bouton rond
et changer l'icône avion (`✈`) par une icône plus moderne (par ex. flèche vers le haut ou
icône séduisante).
- **Sous-tâches :**
- [ ] Remplacer l'icône avion par une icône alternatif (flèche, paper plane stylisée, etc.)
- [ ] Rendre le bouton de soumission circulaire (taille fixe, border-radius 50%)
- [ ] Ajuster le positionnement (aligné en bas à droite de la zone de saisie)
- [ ] i18n FR/EN + tests frontend
### 95. Assistant IA — Historique permanent des conversations (persistance côté backend)
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** backend + frontend
- **Description :** rendre la gestion de l'historique des conversations avec l'assistant IA
permanente en persistant les échanges côté backend (et non uniquement en session/navigateur).
L'historique doit survivre aux rechargements de page et être consultable à tout moment.
- **Sous-tâches :**
- [ ] Persister les conversations dans un stockage backend (SQLite ou JSON chiffré)
- [ ] API CRUD pour les conversations (liste, récupération, suppression)
- [ ] Synchroniser l'historique côté frontend au chargement du panneau IA
- [ ] Nettoyage automatique de l'historique (rétention configurable, purge des anciennes)
- [ ] Tests unitaires backend + tests frontend
### 96. Assistant IA — Accès rapide à l'historique depuis le panneau de navigation
- **Effort :** 1 jour | **Impact :** 🟡 | **Zone :** frontend (`nav.js`, `bookslm.js`)
- **Description :** ajouter un icône dédié dans le panneau de navigation gauche (à côté de
Vaultes, Tags, Récent, Sauvegarde) permettant d'ouvrir directement la liste des
conversations de l'historique de l'assistant IA.
- **Sous-tâches :**
- [ ] Ajouter l'icône « Historique IA » dans la sidebar de navigation
- [ ] Au clic : afficher un panneau latéral avec la liste chronologique des conversations
- [ ] Sélection d'une conversation → ouverture dans le panneau Assistant IA
- [ ] Indicateur visuel (badge) si de nouvelles conversations sont présentes
- [ ] i18n FR/EN + tests frontend
### 97. Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills, etc.)
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** frontend (`bookslm.js`, `style.css`)
- **Description :** remplacer le bouton « Attach an image » par un bouton « + » qui ouvre
un panneau d'extensions contenant diverses actions : téléverser des fichiers, Deep Research,
ajouter des contextes, ajouter des skills, recherche sur Internet, intégration Canva, et
d'autres options à venir. Ce panneau est extensible et modulaire.
- **Sous-tâches :**
- [ ] Remplacer le bouton « Attach an image » par un bouton « + » (icône universelle)
- [ ] Panneau overlay / dropdown listant les options disponibles
- [ ] Téléverser des fichiers (drag & drop + sélecteur)
- [ ] Deep Research (lancement d'une recherche approfondie via les outils IA)
- [ ] Ajouter contextes (fichiers, répertoires, URL)
- [ ] Ajouter skills (catalogue de skills disponibles)
- [ ] Recherche sur Internet (accès direct à `web_search`)
- [ ] Intégration Canva (ou outil externe similaire)
- [ ] Architecture modulaire : chaque option est un plugin/extensible facilement
- [ ] i18n FR/EN + tests frontend
---
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
@@ -151,6 +208,7 @@
| # | Domaine / fonctionnalité | Version | Détails |
|---|---|---|---|
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
| 1–8 | Fondations (FastAPI, TF-IDF + stemming, watchdog, SPA, sécurité, path traversal, gzip, PWA) | 1.0.0→1.4.0 | [archive](./archive/COMPLETED_v1-v2.md) |
@@ -200,8 +258,9 @@
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours |
| ⚪ P2 restant | #94–97 Assistant IA — améliorations UI (soumission, historique, navigation, panneau +) | ~6-7 jours |
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours |
| **Total restant** | **7 items + finitions** | **~31-47 jours** |
| **Total restant** | **11 items + finitions** | **~37-54 jours** |
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "1.0.0",
"version": "2.3.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+576
View File
@@ -0,0 +1,576 @@
#!/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:
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())
+20 -151
View File
@@ -1,165 +1,34 @@
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# ObsiGate — version bump helper (SemVer MAJOR.MINOR.PATCH)
# ObsiGate — version bump helper (SemVer MAJEUR.MINEUR.CORRECTIF)
#
# 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:
# 1. synchronise la version desktop (tauri.conf.json, Cargo.toml, Cargo.lock) ;
# 2. commit ce bump (`chore(release): vX.Y.Z`) ;
# 3. crée le tag annoté (et pousse branche + tag avec --push).
# Wrapper historique : toute la logique vit dans `scripts/bump_version.py`
# (source unique de vérité : `./VERSION`, incrément inspiré des
# Conventional Commits).
#
# 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)
# breaking (`!:` / BREAKING CHANGE) -> MAJEUR (x.0.0)
# `feat` -> MINEUR (x.y.0)
# tout le reste (fix, perf, …) -> CORRECTIF (x.y.z)
#
# Usage:
# scripts/bump_version.sh [--major|--minor|--patch] [--dry-run] [--push] [--no-files]
# Usage :
# scripts/bump_version.sh --dry-run # prévisualiser
# scripts/bump_version.sh # bump + synchro des fichiers
# scripts/bump_version.sh --minor --commit --tag --push
# scripts/bump_version.sh --print-version
#
# --dry-run : affiche la prochaine version sans rien modifier ni tagger.
# --push : pousse la branche courante ET le nouveau tag vers origin.
# --no-files : ne met pas à jour les fichiers desktop (tag seul).
# --major|--minor|--patch : force l'incrément, ignore la détection.
#
# Examples:
# scripts/bump_version.sh --dry-run # preview next version
# scripts/bump_version.sh # bump + commit + tag (local)
# scripts/bump_version.sh --push # idem + push branche & tag
# scripts/bump_version.sh --patch --push # force patch + push
# Voir `scripts/bump_version.py --help` pour la liste complète des options.
# -----------------------------------------------------------------------------
set -euo pipefail
cd "$(git rev-parse --show-toplevel)" # run from repo root regardless of cwd
BRANCH="$(git branch --show-current)"
root="$(git rev-parse --show-toplevel)"
# ── Parse args ────────────────────────────────────────────────────────────
FORCE=""; DRY_RUN=0; DO_PUSH=0; DO_FILES=1
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 ;;
--no-files) DO_FILES=0 ;;
*) echo "✗ Argument inconnu: $a"; exit 2 ;;
esac
py=""
for cand in python3 python; do
if command -v "$cand" >/dev/null 2>&1; then py="$cand"; break; fi
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."
if [ -z "$py" ]; then
echo "✗ Aucun interpréteur Python trouvé (python3 / python)." >&2
exit 1
fi
# ── Fichiers desktop à synchroniser ───────────────────────────────────────
DESKTOP_CONF="desktop/tauri.conf.json"
DESKTOP_CARGO="desktop/Cargo.toml"
DESKTOP_LOCK="desktop/Cargo.lock"
# Met à jour la version dans les fichiers desktop (in-place, portable GNU sed).
sync_desktop_version() {
local ver="$1"
if [ -f "$DESKTOP_CONF" ]; then
sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*")[^"]*(")/\1'"$ver"'\2/' "$DESKTOP_CONF"
fi
if [ -f "$DESKTOP_CARGO" ]; then
# Première ligne `version = "..."` = version du [package].
sed -i -E '0,/^version = "[^"]*"/s//version = "'"$ver"'"/' "$DESKTOP_CARGO"
fi
if [ -f "$DESKTOP_LOCK" ]; then
# Bloc `name = "obsigate-desktop"` -> ligne `version` suivante.
sed -i -E '/^name = "obsigate-desktop"$/{n;s/^version = "[^"]*"/version = "'"$ver"'"/;}' "$DESKTOP_LOCK"
fi
}
if [ "$DRY_RUN" = "1" ]; then
echo "── DRY RUN ─────────────────────────────"
echo " Prochain tag : v${NEXT}"
if [ "$DO_FILES" = "1" ]; then
echo " Fichiers desktop à synchroniser :"
echo " - $DESKTOP_CONF"
echo " - $DESKTOP_CARGO"
echo " - $DESKTOP_LOCK (obsigate-desktop)"
fi
echo " (aucune modification, aucun tag créé)"
exit 0
fi
# ── Sync + commit des versions desktop ────────────────────────────────────
if [ "$DO_FILES" = "1" ]; then
echo "─ Synchronisation de la version desktop → ${NEXT}"
sync_desktop_version "$NEXT"
CHANGED_FILES=()
for f in "$DESKTOP_CONF" "$DESKTOP_CARGO" "$DESKTOP_LOCK"; do
[ -f "$f" ] && CHANGED_FILES+=("$f")
done
if git diff --quiet -- "${CHANGED_FILES[@]}"; then
echo "ℹ Aucun changement de version desktop à committer."
else
git add -- "${CHANGED_FILES[@]}"
git commit -m "chore(release): v${NEXT}" \
-m "Synchronise la version desktop (tauri.conf.json, Cargo.toml, Cargo.lock)."
echo "✓ Commit de release créé : v${NEXT}"
fi
else
echo "ℹ --no-files : fichiers desktop inchangés."
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 "$BRANCH"
git push origin "v${NEXT}"
echo "✓ Branche '$BRANCH' et tag v${NEXT} poussés."
else
echo "ℹ Tag local uniquement. Poussez avec:"
echo " git push origin $BRANCH && git push origin v${NEXT}"
fi
echo "Done."
exec "$py" "$root/scripts/bump_version.py" "$@"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# ObsiGate — installe les hooks git versionnés (.githooks).
#
# 1. `core.hooksPath = .githooks` -> hooks partagés par tout le monde (versionnés)
# 2. `push.followTags = true` -> le tag de version créé au commit est publié
# automatiquement à chaque `git push`
#
# À exécuter une fois par clone : scripts/install-hooks.sh
# -----------------------------------------------------------------------------
set -euo pipefail
root="$(git rev-parse --show-toplevel)"
cd "$root"
if [ ! -d .githooks ]; then
echo "✗ Dossier .githooks introuvable." >&2
exit 1
fi
git config core.hooksPath .githooks
git config push.followTags true
chmod +x .githooks/* 2>/dev/null || true
echo "✓ Hooks installés :"
echo " core.hooksPath = $(git config --get core.hooksPath)"
echo " push.followTags = $(git config --get push.followTags)"
echo " → La version (./VERSION) est incrémentée à chaque commit et taguée"
echo " (vX.Y.Z), puis publiée avec la branche à chaque push."
+385
View File
@@ -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(
"[![Version](https://img.shields.io/badge/Version-1.7.0-blue.svg)]()\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(
"[![Version](https://img.shields.io/badge/Version-2.0.0--dev-blue.svg)]()\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