From 38e215e8f82bfc56c9375e61b8b3919de7a90ef2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Apr 2026 20:36:53 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20dynamic=20version=20badge=20=E2=80=94=20?= =?UTF-8?q?read=20from=20git=20tag,=20never=20hardcoded=20(#790)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: dynamic version badge — read from git tag, never hardcoded The settings panel showed v0.50.87 and the HTTP Server: header said HermesWebUI/0.50.38 — both hardcoded strings that drift further behind with every release because there was no mechanism to keep them in sync. Changes: - api/updates.py: add _run_git() (moved before _detect_webui_version), _detect_webui_version(), and WEBUI_VERSION module constant resolved once at import time via 'git describe --tags --always --dirty'. Fallback chain: git → api/_version.py → 'unknown'. - api/routes.py: inject webui_version into GET /api/settings response so the frontend can read it without a separate API call. - static/panels.js: loadSettingsPanel() populates .settings-version-badge from settings.webui_version — one line after the existing api() call. - static/index.html: replace stale hardcoded 'v0.50.87' with '—' placeholder; JS overwrites it as soon as the settings panel opens. - server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with 'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically. - Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py so Docker images (where .git is excluded) still show the correct tag. - .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }} to the Docker build step on tag pushes. - .gitignore: exclude api/_version.py (generated by Docker/CI, never committed). No manual 'update the version badge' step is required going forward. Tagging is sufficient — the badge and HTTP header update automatically. Tests: 18 new tests in tests/test_version_badge.py covering the full resolution chain, /api/settings injection, HTML placeholder, JS wiring, and server.py import. 1596 tests pass total. * fix: address review feedback on PR #790 - api/updates.py: replace exec() with regex parse for api/_version.py (no supply-chain risk from build artifact; exec unnecessary for one assignment) - api/updates.py: cap git describe timeout at 3s (was 10s — import-time stall on NFS/.git would block server startup unnecessarily) - server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix) - server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than 'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none) - CHANGELOG.md: add v0.50.124 entry for this user-visible change - tests: rename exec-error test to reflect regex behaviour; add tests for removeprefix usage and unknown-version header guard (1598 tests total) --------- Co-authored-by: nesquena-hermes --- .github/workflows/release.yml | 1 + .gitignore | 3 + CHANGELOG.md | 6 +- Dockerfile | 7 + api/routes.py | 7 + api/updates.py | 42 +++++ server.py | 4 +- static/index.html | 2 +- static/panels.js | 4 + tests/test_version_badge.py | 296 ++++++++++++++++++++++++++++++++++ 10 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 tests/test_version_badge.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c31119..c0f65df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,5 +52,6 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: HERMES_VERSION=${{ github.ref_name }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 20373fa..75a7b67 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ copilot-instructions.md screenshot-*.png full-UI.png +# Version file written by Docker/CI build — generated, never committed +api/_version.py + # OS files .DS_Store Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md index b726188..a2aeb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Hermes Web UI -- Changelog -## [v0.50.123] — 2026-04-21 +## [v0.50.124] — 2026-04-21 + +### Fixed +- **Settings version badge now shows the real running version** — the badge in the Settings → System panel was hardcoded to `v0.50.87` (36 releases behind) and the HTTP `Server:` header said `HermesWebUI/0.50.38` (85 behind). Both are now resolved dynamically at server startup from `git describe --tags --always --dirty`. Docker images (where `.git` is excluded) receive the correct tag via a build-time `ARG HERMES_VERSION` written to `api/_version.py`. No manual "update the badge" step is needed going forward — tagging is sufficient. Version file parsing uses regex instead of `exec()` for supply-chain safety. (#790) + ### Fixed - **Default model change surfaced stale value after model-list TTL cache landed** — `set_hermes_default_model()` now explicitly invalidates `_available_models_cache` after `reload_config()`. The 60s TTL cache introduced in v0.50.121 (#780) only invalidates on config-file mtime change, but `reload_config()` resyncs `_cfg_mtime` before `get_available_models()` runs — so the mtime check never fires and the POST response (plus downstream reads within the TTL window) returned the previous model until the cache expired. Root cause of the `test_default_model_updates_hermes_config` CI flake as well. (#788) diff --git a/Dockerfile b/Dockerfile index 0b7feda..06fb2ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -78,6 +78,13 @@ USER hermeswebuitoo COPY . /apptoo +# Bake the git version tag into the image so the settings badge works even +# when .git is not present (it is excluded by .dockerignore). +# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always) +# Local builds that omit the arg get "unknown" as the fallback. +ARG HERMES_VERSION=unknown +RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py + # Default to binding all interfaces (required for container networking) ENV HERMES_WEBUI_HOST=0.0.0.0 ENV HERMES_WEBUI_PORT=8787 diff --git a/api/routes.py b/api/routes.py index 9e81fe4..5be4784 100644 --- a/api/routes.py +++ b/api/routes.py @@ -548,6 +548,13 @@ def handle_get(handler, parsed) -> bool: settings = load_settings() # Never expose the stored password hash to clients settings.pop("password_hash", None) + # Inject the running version so the UI badge stays in sync with git tags + # without any manual release step. + try: + from api.updates import WEBUI_VERSION + settings["webui_version"] = WEBUI_VERSION + except Exception: + pass return j(handler, settings) if parsed.path == "/api/onboarding/status": diff --git a/api/updates.py b/api/updates.py index 17f4cad..4bd9661 100644 --- a/api/updates.py +++ b/api/updates.py @@ -53,6 +53,48 @@ def _run_git(args, cwd, timeout=10): return f'git failed to start: {exc}', False +def _detect_webui_version() -> str: + """Detect the running WebUI version from git or a baked-in fallback file. + + Resolution order: + 1. ``git describe --tags --always --dirty`` — works in any git checkout. + Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a + post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``), + or a bare SHA when no tags exist (shallow clones, fresh forks). + 2. ``api/_version.py`` — a fallback written by the Docker / CI release + workflow when ``.git`` is not present in the image. Expected to define + ``__version__ = 'vX.Y.Z'``. + 3. ``'unknown'`` — last resort; displayed as-is in the settings badge. + """ + # Timeout capped at 3s: git describe on a healthy local repo is <50ms; + # a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable. + out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3) + if ok and out: + return out + + # Docker / baked-image fallback: api/_version.py written by CI at build time. + # Parse with regex rather than exec() — the file holds exactly one assignment + # and regex is sufficient; exec() on a build artifact is an unnecessary surface. + version_file = REPO_ROOT / 'api' / '_version.py' + if version_file.exists(): + try: + import re as _re + m = _re.search( + r"""__version__\s*=\s*['"]([^'"]+)['"]""", + version_file.read_text(encoding='utf-8'), + ) + if m: + return m.group(1) + except Exception: + pass + + return 'unknown' + + +# Resolved once at import time — tags cannot change without a process restart. +WEBUI_VERSION: str = _detect_webui_version() + + def _split_remote_ref(ref): """Split 'origin/branch-name' into ('origin', 'branch-name'). diff --git a/server.py b/server.py index 1a14ef8..5d8c4b1 100644 --- a/server.py +++ b/server.py @@ -18,6 +18,7 @@ from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j from api.routes import handle_get, handle_post from api.startup import auto_install_agent_deps, fix_credential_permissions +from api.updates import WEBUI_VERSION class QuietHTTPServer(ThreadingHTTPServer): @@ -44,7 +45,8 @@ class QuietHTTPServer(ThreadingHTTPServer): class Handler(BaseHTTPRequestHandler): timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion - server_version = 'HermesWebUI/0.50.38' + _ver_suffix = WEBUI_VERSION.removeprefix('v') + server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI' def log_message(self, fmt, *args): pass # suppress default Apache-style log def log_request(self, code: str='-', size: str='-') -> None: diff --git a/static/index.html b/static/index.html index bc9876a..6b0fb1d 100644 --- a/static/index.html +++ b/static/index.html @@ -596,7 +596,7 @@
System
- v0.50.87 +
diff --git a/static/panels.js b/static/panels.js index 0560889..514e257 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1199,6 +1199,10 @@ function _markSettingsDirty(){ async function loadSettingsPanel(){ try{ const settings=await api('/api/settings'); + // Populate the version badge from the server — keeps it in sync with git + // tags automatically without any manual release step. + const vbadge=document.querySelector('.settings-version-badge'); + if(vbadge && settings.webui_version) vbadge.textContent=settings.webui_version; // Hydrate appearance controls first so a slow /api/models request // cannot overwrite an in-progress theme/skin selection. const themeSel=$('settingsTheme'); diff --git a/tests/test_version_badge.py b/tests/test_version_badge.py new file mode 100644 index 0000000..88eec13 --- /dev/null +++ b/tests/test_version_badge.py @@ -0,0 +1,296 @@ +""" +Tests for the dynamic version badge (issue: stale hardcoded version strings). + +Covers: + 1. api/updates.py: _detect_webui_version() resolution chain + 2. api/updates.py: WEBUI_VERSION module constant is set and non-empty + 3. api/routes.py: GET /api/settings includes webui_version key + 4. static/index.html: hardcoded stale badge is gone + 5. static/panels.js: loadSettingsPanel() populates badge from settings + 6. server.py: server_version is not the old hardcoded string +""" +import importlib +import sys +import types +from pathlib import Path +from unittest.mock import patch, MagicMock + +REPO_ROOT = Path(__file__).parent.parent + + +# --------------------------------------------------------------------------- +# 1. _detect_webui_version — resolution chain +# --------------------------------------------------------------------------- + +class TestDetectWebUIVersion: + + def _fresh_detect(self, mock_run_git=None, version_file_content=None, tmp_path=None): + """Call _detect_webui_version() with controlled dependencies.""" + import api.updates as upd + + fake_root = tmp_path or Path('/nonexistent-path') + + if version_file_content is not None: + vf = tmp_path / 'api' / '_version.py' + vf.parent.mkdir(parents=True, exist_ok=True) + vf.write_text(version_file_content, encoding='utf-8') + + def _run_git_side_effect(args, cwd, timeout=10): + if mock_run_git is not None: + return mock_run_git(args, cwd, timeout) + return ('', False) + + with patch.object(upd, '_run_git', side_effect=_run_git_side_effect), \ + patch.object(upd, 'REPO_ROOT', fake_root): + return upd._detect_webui_version() + + def test_git_success_returns_tag(self, tmp_path): + """When git describe succeeds, returns the tag string directly.""" + result = self._fresh_detect( + mock_run_git=lambda args, cwd, timeout: ('v0.50.123', True), + tmp_path=tmp_path, + ) + assert result == 'v0.50.123' + + def test_git_between_tags_returns_descriptor(self, tmp_path): + """Between releases, git describe returns a post-tag descriptor — pass it through.""" + result = self._fresh_detect( + mock_run_git=lambda args, cwd, timeout: ('v0.50.123-3-ge91325d', True), + tmp_path=tmp_path, + ) + assert result == 'v0.50.123-3-ge91325d' + + def test_git_failure_falls_back_to_version_file(self, tmp_path): + """When git fails (Docker image), falls back to api/_version.py.""" + result = self._fresh_detect( + mock_run_git=lambda args, cwd, timeout: ('', False), + version_file_content="__version__ = 'v0.50.100'\n", + tmp_path=tmp_path, + ) + assert result == 'v0.50.100' + + def test_git_failure_no_version_file_returns_unknown(self, tmp_path): + """When git fails and no _version.py exists, returns 'unknown'.""" + result = self._fresh_detect( + mock_run_git=lambda args, cwd, timeout: ('', False), + tmp_path=tmp_path, + ) + assert result == 'unknown' + + def test_version_file_malformed_returns_unknown(self, tmp_path): + """Malformed _version.py (no recognisable __version__ assignment) returns 'unknown'.""" + result = self._fresh_detect( + mock_run_git=lambda args, cwd, timeout: ('', False), + version_file_content="this is not valid python !!! ~~~\n", + tmp_path=tmp_path, + ) + assert result == 'unknown' + + def test_git_uses_correct_describe_flags(self, tmp_path): + """git describe is called with --tags --always --dirty.""" + called_args = [] + + def capture(args, cwd, timeout=10): + called_args.append(args) + return ('v0.50.123', True) + + self._fresh_detect(mock_run_git=capture, tmp_path=tmp_path) + assert called_args, 'git was never called' + assert '--tags' in called_args[0] + assert '--always' in called_args[0] + assert '--dirty' in called_args[0] + + +# --------------------------------------------------------------------------- +# 2. WEBUI_VERSION module constant +# --------------------------------------------------------------------------- + +class TestWebUIVersionConstant: + + def test_webui_version_is_set(self): + """WEBUI_VERSION is a non-empty string exported from api.updates.""" + import api.updates as upd + assert hasattr(upd, 'WEBUI_VERSION'), 'WEBUI_VERSION not exported from api.updates' + assert isinstance(upd.WEBUI_VERSION, str) + assert upd.WEBUI_VERSION, 'WEBUI_VERSION must not be empty string' + + def test_webui_version_is_not_old_hardcoded(self): + """WEBUI_VERSION must not be the old stale value from server.py.""" + import api.updates as upd + # These were the two stale hardcoded strings before this fix + assert upd.WEBUI_VERSION not in ('0.50.38', 'HermesWebUI/0.50.38'), ( + 'WEBUI_VERSION still holds the old hardcoded server.py value' + ) + + +# --------------------------------------------------------------------------- +# 3. GET /api/settings includes webui_version +# --------------------------------------------------------------------------- + +class TestSettingsEndpointVersion: + + def test_api_settings_includes_webui_version(self): + """GET /api/settings response dict must include webui_version key.""" + import api.routes as routes + import api.updates as upd + + # Patch load_settings to return a minimal dict (no disk I/O) + minimal_settings = {'send_key': 'enter', 'theme': 'dark'} + + handler = MagicMock() + from urllib.parse import urlparse + parsed = urlparse('/api/settings') + + captured = {} + + def fake_j(h, data, status=200): + captured['data'] = data + + with patch('api.routes.load_settings', return_value=dict(minimal_settings)), \ + patch('api.routes.j', side_effect=fake_j): + routes.handle_get(handler, parsed) + + assert 'webui_version' in captured.get('data', {}), ( + '/api/settings response must contain webui_version key' + ) + assert captured['data']['webui_version'] == upd.WEBUI_VERSION + + def test_api_settings_webui_version_not_empty(self): + """webui_version in /api/settings must be a non-empty string.""" + import api.routes as routes + + handler = MagicMock() + from urllib.parse import urlparse + parsed = urlparse('/api/settings') + + captured = {} + + def fake_j(h, data, status=200): + captured['data'] = data + + with patch('api.routes.load_settings', return_value={}), \ + patch('api.routes.j', side_effect=fake_j): + routes.handle_get(handler, parsed) + + version = captured.get('data', {}).get('webui_version', '') + assert version, 'webui_version in /api/settings must not be empty' + + def test_api_settings_no_password_hash(self): + """password_hash must still be stripped even with version injection.""" + import api.routes as routes + + handler = MagicMock() + from urllib.parse import urlparse + parsed = urlparse('/api/settings') + + captured = {} + + def fake_j(h, data, status=200): + captured['data'] = data + + with patch('api.routes.load_settings', return_value={'password_hash': 'secret123'}), \ + patch('api.routes.j', side_effect=fake_j): + routes.handle_get(handler, parsed) + + assert 'password_hash' not in captured.get('data', {}), ( + 'password_hash must still be stripped from /api/settings' + ) + + +# --------------------------------------------------------------------------- +# 4. static/index.html — no stale hardcoded badge +# --------------------------------------------------------------------------- + +class TestIndexHTMLBadge: + + def _read_html(self): + return (REPO_ROOT / 'static' / 'index.html').read_text(encoding='utf-8') + + def test_old_stale_version_removed_from_html(self): + """The old hardcoded v0.50.87 badge must not appear in index.html.""" + html = self._read_html() + assert 'v0.50.87' not in html, ( + 'Stale hardcoded version v0.50.87 still present in index.html. ' + 'The badge should be a neutral placeholder; JS populates it at runtime.' + ) + + def test_badge_element_still_present(self): + """settings-version-badge span must still be in the DOM (JS needs the target).""" + html = self._read_html() + assert 'settings-version-badge' in html, ( + 'settings-version-badge span missing from index.html — JS cannot populate it' + ) + + +# --------------------------------------------------------------------------- +# 5. static/panels.js — badge population from settings +# --------------------------------------------------------------------------- + +class TestPanelsJSVersionBadge: + + def _read_js(self): + return (REPO_ROOT / 'static' / 'panels.js').read_text(encoding='utf-8') + + def test_panels_js_reads_webui_version(self): + """loadSettingsPanel must reference settings.webui_version to populate the badge.""" + src = self._read_js() + assert 'webui_version' in src, ( + 'panels.js loadSettingsPanel() must read settings.webui_version ' + 'to populate the badge dynamically' + ) + + def test_panels_js_targets_version_badge(self): + """panels.js must target the .settings-version-badge element.""" + src = self._read_js() + assert 'settings-version-badge' in src, ( + 'panels.js must query .settings-version-badge to update the badge text' + ) + + +# --------------------------------------------------------------------------- +# 6. server.py — server_version not the old hardcoded string +# --------------------------------------------------------------------------- + +class TestServerVersionHeader: + + def test_server_version_not_old_hardcoded(self): + """server.py Handler.server_version must not be the stale hardcoded value.""" + src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8') + assert 'HermesWebUI/0.50.38' not in src, ( + 'server.py still contains the old hardcoded server_version string. ' + 'It should use WEBUI_VERSION from api.updates.' + ) + + def test_server_version_uses_webui_version(self): + """server.py must reference WEBUI_VERSION when setting server_version.""" + src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8') + assert 'WEBUI_VERSION' in src, ( + 'server.py must import and use WEBUI_VERSION from api.updates ' + 'to keep the HTTP Server: header in sync with git tags' + ) + + def test_server_py_imports_webui_version(self): + """server.py must import WEBUI_VERSION from api.updates.""" + src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8') + assert 'from api.updates import WEBUI_VERSION' in src, ( + 'server.py must import WEBUI_VERSION from api.updates' + ) + + def test_server_version_no_slash_when_unknown(self): + """When WEBUI_VERSION is 'unknown', server_version must be bare 'HermesWebUI' with no slash.""" + src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8') + # The guard must be present so log aggregators don't see 'HermesWebUI/unknown' + assert "'unknown'" in src or '"unknown"' in src, ( + "server.py must guard against emitting 'HermesWebUI/unknown' as the server header" + ) + + def test_server_version_uses_removeprefix_not_lstrip(self): + """server.py must use str.removeprefix() to strip 'v', not lstrip() which strips chars.""" + src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8') + assert 'lstrip' not in src, ( + "server.py must use removeprefix('v') not lstrip('v') — lstrip strips characters, " + "not a prefix, and would incorrectly mangle strings like 'vvv0.50.124'" + ) + assert 'removeprefix' in src, ( + "server.py must use removeprefix('v') to strip the leading 'v' from the version tag" + )