mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 19:50:15 +00:00
Track updates by release tags
This commit is contained in:
+98
-12
@@ -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
@@ -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=[];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user