diff --git a/api/updates.py b/api/updates.py index e3e025c2..746a7243 100644 --- a/api/updates.py +++ b/api/updates.py @@ -1,8 +1,8 @@ """ Hermes Web UI -- Self-update checker. -Checks if the webui and hermes-agent git repos are behind their upstream -branches. Results are cached server-side (30-min TTL) so git fetch runs +Checks if the webui and hermes-agent git repos are behind their latest +release tags. Results are cached server-side (30-min TTL) so git fetch runs at most twice per hour regardless of client count. Skips repos that are not git checkouts (e.g. Docker baked images where @@ -79,6 +79,24 @@ def _run_git(args, cwd, timeout=10): return f'git failed to start: {exc}', False +def _dirty_suffix(path: Path, timeout=1) -> str: + """Return a best-effort ``-dirty`` suffix without blocking version display.""" + out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) + if ok: + return "" + # diff-index exits 1 with no output for a dirty tree. Timeouts and real git + # failures include a diagnostic; skip the suffix so the base version remains. + return "-dirty" if not out else "" + + +def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None: + """Return a fast git version string for a checkout, if available.""" + out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout) + if not (ok and out): + return None + return out + _dirty_suffix(path, timeout=dirty_timeout) + + def _detect_webui_version() -> str: """Detect the running WebUI version from git or a baked-in fallback file. @@ -94,8 +112,8 @@ def _detect_webui_version() -> str: """ # 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: + out = _describe_git_version(REPO_ROOT) + if out: return out # Docker / baked-image fallback: api/_version.py written by CI at build time. @@ -138,8 +156,8 @@ def _detect_agent_version() -> str: # Symmetric with _detect_webui_version() above — `--dirty` flags a # locally-modified checkout so operators can see when their agent has # uncommitted changes vs a clean tag. Per Opus advisor on stage-293. - out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], _AGENT_DIR, timeout=3) - if ok and out: + out = _describe_git_version(Path(_AGENT_DIR)) + if out: return out return 'not detected' @@ -194,15 +212,65 @@ def _detect_default_branch(path): return 'master' -def _check_repo(path, name): - """Check if a git repo is behind its upstream. Returns dict or None.""" - if path is None or not (path / '.git').exists(): +def _release_tags(path): + """Return release tags newest-first, using the repo's version-sort order.""" + out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path) + if not (ok and out): + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + +def _current_release_tag(path): + """Return the latest release tag reachable from HEAD, if one exists.""" + out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path) + return out if ok and out else None + + +def _release_gap(tags, current, latest): + """Count release tags between current and latest in a newest-first list.""" + if not latest or current == latest: + return 0 + if current in tags: + return tags.index(current) + return 1 + + +def _check_repo_release(path, name): + """Check if a git repo is behind its latest published release tag.""" + tags = _release_tags(path) + if not tags: return None + latest_tag = tags[0] + current_tag = _current_release_tag(path) + behind = _release_gap(tags, current_tag, latest_tag) + + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) + remote_url = _normalize_remote_url(remote_url) + + return { + 'name': name, + 'behind': behind, + # GitHub compare URLs accept tag names, and tag-to-tag links are the + # clearest "what changed in this release?" view for operators. + 'current_sha': current_tag, + 'latest_sha': latest_tag, + 'branch': latest_tag, + 'repo_url': remote_url, + 'release_based': True, + 'current_version': current_tag, + 'latest_version': latest_tag, + } + + +def _check_repo_branch(path, name, *, fetch=True): + """Fallback: check if a git repo is behind its upstream branch.""" + # Fetch latest from origin (network call, cached by TTL) - _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) - if not fetch_ok: - return {'name': name, 'behind': 0, 'error': 'fetch failed'} + if fetch: + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} # Use the current branch's upstream tracking branch, not the repo default. # This avoids false "N updates behind" alerts when the user is on a feature @@ -265,6 +333,24 @@ def _check_repo(path, name): } +def _check_repo(path, name): + """Check if a git repo is behind its latest release. Returns dict or None.""" + if path is None or not (path / '.git').exists(): + return None + + # Fetch tags first so update prompts track published releases, not every + # development commit that lands on master/main after the latest release. + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} + + release_info = _check_repo_release(path, name) + if release_info is not None: + return release_info + + return _check_repo_branch(path, name, fetch=False) + + def check_for_updates(force=False): """Return cached update status for webui and agent repos.""" global _check_in_progress diff --git a/static/ui.js b/static/ui.js index 28f94817..b3a11e5f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3773,8 +3773,11 @@ async function refreshSession() { // ── Update banner ── function _formatUpdateTargetStatus(label,info){ if(!info||!(info.behind>0)) return null; - const branch=info.branch?` (${info.branch})`:''; - return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`; + const release=(info.release_based&&info.latest_version) + ?` (${info.current_version||'unknown'} -> ${info.latest_version})` + :(info.branch?` (${info.branch})`:''); + const noun=info.release_based?'release':'update'; + return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`; } function _showUpdateBanner(data){ const parts=[]; diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 5eaee61e..3a55796c 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -104,6 +104,61 @@ class TestUpdateChecker: assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui' + def test_release_check_ignores_post_release_branch_commits(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:3] == ['tag', '--list', 'v*']: + return 'v2026.5.7\nv2026.4.30', True + if args[:3] == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.7', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/NousResearch/hermes-agent.git', True + if args[:2] == ['rev-parse', '--abbrev-ref']: + return 'origin/main', True + if args[:2] == ['rev-list', '--count']: + return '16', True + if args[0] == 'merge-base': + return '3800972dd', True + return '', False + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'agent') + + assert result['release_based'] is True + assert result['current_version'] == 'v2026.5.7' + assert result['latest_version'] == 'v2026.5.7' + assert result['behind'] == 0 + + def test_release_check_counts_release_gap(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:3] == ['tag', '--list', 'v*']: + return 'v0.51.35\nv0.51.34\nv0.51.33', True + if args[:3] == ['describe', '--tags', '--abbrev=0']: + return 'v0.51.34', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/nesquena/hermes-webui.git', True + return '', False + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'webui') + + assert result['release_based'] is True + assert result['current_version'] == 'v0.51.34' + assert result['latest_version'] == 'v0.51.35' + assert result['behind'] == 1 + assert result['branch'] == 'v0.51.35' + class TestConflictError: """#813 — conflict error must include flag + recovery command.""" @@ -475,10 +530,12 @@ class TestUiJsUpdateBanner: class TestUpdateBannerUx: - def test_update_banner_includes_repo_branch_labels(self): + def test_update_banner_includes_release_labels(self): src = read('static/ui.js') assert 'function _formatUpdateTargetStatus' in src - assert 'info.branch' in src + assert 'info.release_based' in src + assert 'info.current_version' in src + assert 'info.latest_version' in src assert "_formatUpdateTargetStatus('WebUI',data.webui)" in src assert "_formatUpdateTargetStatus('Agent',data.agent)" in src diff --git a/tests/test_version_badge.py b/tests/test_version_badge.py index 79fb8953..be53d5b5 100644 --- a/tests/test_version_badge.py +++ b/tests/test_version_badge.py @@ -87,8 +87,8 @@ class TestDetectWebUIVersion: ) assert result == 'unknown' - def test_git_uses_correct_describe_flags(self, tmp_path): - """git describe is called with --tags --always --dirty.""" + def test_git_uses_fast_describe_flags(self, tmp_path): + """git describe avoids --dirty so WSL /mnt checkouts do not stall.""" called_args = [] def capture(args, cwd, timeout=10): @@ -99,7 +99,35 @@ class TestDetectWebUIVersion: 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] + assert '--dirty' not in called_args[0] + + def test_dirty_check_appends_suffix_when_fast(self, tmp_path): + """A dirty worktree still gets a suffix when the cheap probe returns quickly.""" + calls = [] + + def fake_run_git(args, cwd, timeout=10): + calls.append((args, timeout)) + if args[:3] == ['describe', '--tags', '--always']: + return ('v0.50.123', True) + if args[:2] == ['diff-index', '--quiet']: + return ('', False) + return ('unexpected', False) + + result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path) + assert result == 'v0.50.123-dirty' + assert calls[1][0][:2] == ['diff-index', '--quiet'] + + def test_dirty_check_timeout_does_not_hide_base_version(self, tmp_path): + """If dirty detection times out, keep the base version instead of unknown.""" + def fake_run_git(args, cwd, timeout=10): + if args[:3] == ['describe', '--tags', '--always']: + return ('v0.50.123', True) + if args[:2] == ['diff-index', '--quiet']: + return ('git diff-index --quiet HEAD -- timed out after 1s', False) + return ('unexpected', False) + + result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path) + assert result == 'v0.50.123' # ---------------------------------------------------------------------------