Track updates by release tags

This commit is contained in:
swftwolfzyq
2026-05-12 22:52:12 +08:00
parent 15d620392f
commit f2e5e49442
4 changed files with 193 additions and 19 deletions
+98 -12
View File
@@ -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
+5 -2
View File
@@ -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=[];
+59 -2
View File
@@ -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
+31 -3
View File
@@ -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'
# ---------------------------------------------------------------------------