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
Instance version and access controls.
- 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"
+ )