Release exp-v0.52.44: Docker/no-git image update notices (#5733, #2729) (#5958)

* fix(#2729): show Docker image update notices

* Release exp-v0.52.44: show Docker/no-git image update notices (#5733, #2729)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
nesquena-hermes
2026-07-11 16:46:13 -07:00
committed by GitHub
parent b7b6b3a4c8
commit 3a8f980e2b
6 changed files with 331 additions and 5 deletions
+2
View File
@@ -5,6 +5,8 @@
### Added
- **Docker / no-git installs now get an update notice.** Container and other non-git WebUI installs previously had no way to see that a newer release existed (the in-app updater only works on git checkouts). The Settings update panel now shows an informational notice when the running image trails the latest published release — e.g. "WebUI (v0.52.30 → v0.52.43): 13 releases available · Manual update required: run `docker pull …`, then recreate the container." It is informational only: the Force-update and Clear-lock actions are disabled/hidden for these installs and the notice can never trigger a git reset. Prerelease tags are ignored so stable installs only see stable releases. Thanks @rodboev. (#5733, #2729)
- **Reverse-proxy SSO via trusted identity headers (opt-in, off by default).** You can now put the WebUI behind a reverse proxy you run (Authelia, oauth2-proxy, corporate SSO, …) that authenticates the user and forwards their identity in a header. Set `HERMES_WEBUI_TRUSTED_AUTH_HEADER` (e.g. `Remote-User`) plus `HERMES_WEBUI_TRUSTED_PROXY_CIDRS` with the **exact** IP(s) of your proxy — the header is honored *only* when the request's un-spoofable socket peer is an allowlisted proxy, so a direct client can't forge it. Optional group→profile mapping (`HERMES_WEBUI_GROUP_PROFILE_MAP`, JSON) and SSO single-logout URL are supported. Fails closed on empty/malformed config; existing password/passkey auth is unchanged when this is not configured. See `.env.example` for the full setup and the security warning. Thanks @rodboev. (#5568, #3351)
- **Deep-link straight to a profile with `?profile=<name>`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682)
+103 -1
View File
@@ -58,6 +58,7 @@ _FETCH_NETWORK_FAILURE_SIGNATURES = (
'tls connection was non-properly terminated',
'ssl certificate problem',
)
_RELEASE_TAG_RE = re.compile(r'^v[0-9][0-9A-Za-z.+-]*$')
# Phrases git emits when its own short-lived index/refs lock files block a
# subsequent operation. Tuned to match only the true "lock file already exists"
# semantics that warrant a lock-conflict response -- v2 deliberately drops the
@@ -272,7 +273,7 @@ def _inventory_locks(path: Path) -> dict:
try:
for entry in sorted(git_dir.rglob('*.lock')):
try:
rel = str(entry.relative_to(git_dir))
rel = entry.relative_to(git_dir).as_posix()
except ValueError:
continue
if rel == 'index.lock':
@@ -787,6 +788,101 @@ def _count_channel_tags_ahead(path, channel=DEFAULT_UPDATE_CHANNEL):
return sum(1 for line in out.splitlines() if line.strip())
def _release_tag_sort_key(tag):
"""Return a version-sort key that keeps release tags newest-first."""
raw = str(tag or '').strip()
if raw.startswith('v'):
raw = raw[1:]
parts = []
for chunk in re.split(r'(\d+)', raw):
if not chunk:
continue
parts.append((0, int(chunk)) if chunk.isdigit() else (1, chunk.lower()))
return tuple(parts)
def _is_stable_release_tag(tag):
"""Return True for stable release tags and False for prerelease tags."""
raw = str(tag or '').strip()
return bool(_RELEASE_TAG_RE.fullmatch(raw) and '-' not in raw[1:])
def _github_release_tags(url='https://api.github.com/repos/nesquena/hermes-webui/tags?per_page=100', *, timeout=3.0):
"""Return GitHub release tags newest-first, including commit SHAs when available."""
request = urllib.request.Request(
url,
headers={
'Accept': 'application/vnd.github+json',
'User-Agent': 'hermes-webui',
},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
payload = json.loads(response.read().decode('utf-8'))
if not isinstance(payload, list):
return []
tags = []
for item in payload:
if not isinstance(item, dict):
continue
name = item.get('name')
if not isinstance(name, str):
continue
name = name.strip()
if not _is_stable_release_tag(name):
continue
commit = item.get('commit')
sha = None
if isinstance(commit, dict):
commit_sha = commit.get('sha')
if isinstance(commit_sha, str):
commit_sha = commit_sha.strip()
if commit_sha:
sha = commit_sha
tags.append({'name': name, 'sha': sha})
return sorted(tags, key=lambda item: _release_tag_sort_key(item['name']), reverse=True)
def _check_webui_published_release_update():
"""Return a manual-update payload when the baked WebUI version trails GitHub tags."""
current_version = str(WEBUI_VERSION or '').strip()
if not _RELEASE_TAG_RE.fullmatch(current_version):
return None
try:
tags = _github_release_tags()
except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError, UnicodeDecodeError, ValueError):
return None
if not tags:
return None
tag_names = [item['name'] for item in tags]
if current_version not in tag_names:
return None
latest = tags[0]
latest_version = latest['name']
behind = _release_gap(tag_names, current_version, latest_version)
if behind <= 0:
return None
current = next((item for item in tags if item['name'] == current_version), None) or {}
current_ref = current.get('sha') or current_version
latest_ref = latest.get('sha') or latest_version
repo_url = 'https://github.com/nesquena/hermes-webui'
return {
'name': 'webui',
'behind': behind,
'current_sha': current_ref,
'latest_sha': latest_ref,
'branch': latest_version,
'repo_url': repo_url,
'release_based': True,
'current_version': current_version,
'latest_version': latest_version,
'compare_url': _build_compare_url(repo_url, current_ref, latest_ref),
'manual_update': True,
}
def _head_is_past_latest_tag(path, current_tag, channel=DEFAULT_UPDATE_CHANNEL):
"""Return True when HEAD has moved past the latest reachable channel tag.
@@ -1112,6 +1208,12 @@ def _check_repo(path, name, channel=DEFAULT_UPDATE_CHANNEL):
"""
channel = _normalize_channel(channel)
if path is None or not (path / '.git').exists():
if name == 'webui':
release_info = _check_webui_published_release_update()
if release_info is not None:
release_info = dict(release_info)
release_info['no_git'] = True
return release_info
return {
'name': name,
'behind': None,
+7 -1
View File
@@ -11905,14 +11905,20 @@ async function checkUpdatesNow(channelOverride){
const agentPart=formatUpdatePart('Agent',data.agent);
if(webuiPart) parts.push(webuiPart);
if(agentPart) parts.push(agentPart);
const manualInstruction=(typeof _formatManualUpdateInstruction==='function')
? _formatManualUpdateInstruction(data.webui)
: (data.webui&&data.webui.no_git&&data.webui.manual_update&&data.webui.behind>0
? 'Manual update required: run docker pull ghcr.io/nesquena/hermes-webui:latest, then recreate the container.'
: null);
// Track non-git targets separately so a mixed deployment (one git
// checkout + one no-git install) never hides the "can't check" state
// behind an up-to-date summary (#4356).
const noGitParts=[];
if(data.webui&&data.webui.no_git) noGitParts.push('WebUI');
if(data.webui&&data.webui.no_git&&!data.webui.manual_update) noGitParts.push('WebUI');
if(data.agent&&data.agent.no_git&&!data.agent.ignored) noGitParts.push('Agent');
if(parts.length){
let txt=t('settings_updates_available').replace('{count}',parts.join(', '));
if(manualInstruction) txt+=' · '+manualInstruction;
if(noGitParts.length) txt+=' · '+t('settings_update_no_git');
if(status){status.textContent=txt;status.style.color='var(--accent)';}
// Also trigger the update banner
+26 -3
View File
@@ -9140,13 +9140,18 @@ async function refreshSession() {
}
// ── Update banner ──
function _formatUpdateTargetStatus(label,info){
if(!info||info.no_git||!(info.behind>0)) return null;
const manualNoGit=!!(info&&info.no_git&&info.manual_update&&info.behind>0);
if(!info||(info.no_git&&!manualNoGit)||!(info.behind>0)) return null;
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 _formatManualUpdateInstruction(info){
if(!(info&&info.no_git&&info.manual_update&&info.behind>0)) return null;
return 'Manual update required: run docker pull ghcr.io/nesquena/hermes-webui:latest, then recreate the container.';
}
function _formatUpdateCheckError(label,info){
if(!info||!info.error) return null;
const detail=String(info.error).replace(/^fetch failed:?\s*/i,'').trim();
@@ -9453,6 +9458,21 @@ function _showUpdateBanner(data){
if(webuiPart) parts.push(webuiPart);
if(agentPart) parts.push(agentPart);
window._updateData=data;
const btnApply=$('btnApplyUpdate');
if(btnApply){
const webuiManual=!!(data&&data.webui&&data.webui.manual_update&&data.webui.behind>0);
const webuiUpdatable=!!(data&&data.webui&&data.webui.behind>0&&!webuiManual);
const agentUpdatable=!!(data&&data.agent&&data.agent.behind>0);
const hasApplyTargets=webuiUpdatable||agentUpdatable;
btnApply.disabled=!hasApplyTargets;
btnApply.style.display=hasApplyTargets?'':'none';
if(webuiManual){
const forceBtn=$('btnForceUpdate');
if(forceBtn){forceBtn.disabled=true;forceBtn.style.display='none';forceBtn.dataset.target='';}
const clearLockBtn=$('btnClearUpdateLock');
if(clearLockBtn){clearLockBtn.disabled=true;clearLockBtn.style.display='none';clearLockBtn.dataset.target='';}
}
}
if(!parts.length){
_renderUpdateWhatsNewLinks(data);
const staleBanner=$('updateBanner');
@@ -9460,7 +9480,10 @@ function _showUpdateBanner(data){
return;
}
const msg=$('updateMsg');
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
if(msg){
const manualInstruction=_formatManualUpdateInstruction(data&&data.webui);
msg.textContent='\u2B06 '+parts.join(', ')+' available'+(manualInstruction?' · '+manualInstruction:'');
}
const banner=$('updateBanner');
if(banner) banner.classList.add('visible');
const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff';
@@ -9511,7 +9534,7 @@ async function applyUpdates(){
if(forceBtnReset){forceBtnReset.style.display='none';forceBtnReset.dataset.target='';}
const targets=[];
if(window._updateData?.agent?.behind>0) targets.push('agent');
if(window._updateData?.webui?.behind>0) targets.push('webui');
if(window._updateData?.webui?.behind>0&&!window._updateData?.webui?.manual_update) targets.push('webui');
if(!targets.length){
const msg=updateText('update_no_target','No update target selected. Refresh update status and retry.');
if(errEl){errEl.textContent=msg;errEl.style.display='block';}
+124
View File
@@ -1776,6 +1776,130 @@ class TestUpdateBannerUx:
assert '_formatUpdateTargetStatus' in fn
assert "formatUpdatePart('WebUI',data.webui)" in fn
assert "formatUpdatePart('Agent',data.agent)" in fn
assert "data.webui&&data.webui.no_git&&!data.webui.manual_update" in fn
def test_manual_webui_no_git_updates_are_bannerable_but_plain_no_git_stays_hidden(self):
src = read('static/ui.js')
format_fn = extract_js_function(src, '_formatUpdateTargetStatus')
instruction_fn = extract_js_function(src, '_formatManualUpdateInstruction')
script = f"""
{format_fn}
{instruction_fn}
const manual=_formatUpdateTargetStatus('WebUI', {{
no_git: true,
manual_update: true,
behind: 1,
release_based: true,
current_version: 'v0.51.833',
latest_version: 'v0.51.913',
}});
if(manual !== 'WebUI (v0.51.833 -> v0.51.913): 1 release') throw new Error('manual webui update must be bannerable: '+manual);
const instruction=_formatManualUpdateInstruction({{ no_git: true, manual_update: true, behind: 1 }});
if(!instruction || instruction.indexOf('docker pull ghcr.io/nesquena/hermes-webui:latest') === -1) throw new Error('manual webui update must include pull guidance: '+instruction);
if(_formatManualUpdateInstruction({{ no_git: true, behind: 1 }}) !== null) throw new Error('plain no-git webui must not show manual guidance');
if(_formatUpdateTargetStatus('WebUI', {{ no_git: true, behind: 1 }}) !== null) throw new Error('plain no-git webui must stay hidden');
""".strip()
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_manual_webui_banner_hides_apply_button(self):
src = read('static/ui.js')
format_fn = extract_js_function(src, '_formatUpdateTargetStatus')
instruction_fn = extract_js_function(src, '_formatManualUpdateInstruction')
show_fn = extract_js_function(src, '_showUpdateBanner')
script = f"""
const state = {{
updateBanner: {{ classList: {{ added: false, add() {{ this.added = true; }}, remove() {{ this.removed = true; }} }} }},
updateMsg: {{ textContent: '' }},
btnApplyUpdate: {{ disabled: false, style: {{ display: '' }} }},
btnForceUpdate: {{ disabled: false, style: {{ display: 'inline-block' }}, dataset: {{ target: 'agent' }} }},
btnClearUpdateLock: {{ disabled: false, style: {{ display: 'inline-block' }}, dataset: {{ target: 'agent' }} }},
updateWhatsNewLinks: {{ style: {{ display: 'none' }}, replaceChildren() {{ this.cleared = true; }} }},
}};
global.window = {{}};
global.$ = (id) => state[id] || null;
global._renderUpdateWhatsNewLinks = () => {{}};
{format_fn}
{instruction_fn}
{show_fn}
_showUpdateBanner({{
webui: {{
no_git: true,
manual_update: true,
behind: 1,
release_based: true,
current_version: 'v0.51.833',
latest_version: 'v0.51.913',
compare_url: 'https://github.com/nesquena/hermes-webui/compare/current-sha...latest-sha',
}},
agent: null,
}});
if(state.updateMsg.textContent.indexOf('WebUI') === -1) throw new Error('manual update must still render banner text');
if(state.updateMsg.textContent.indexOf('docker pull ghcr.io/nesquena/hermes-webui:latest') === -1) throw new Error('manual update must render pull guidance');
if(state.btnApplyUpdate.style.display !== 'none') throw new Error('manual webui update must hide the apply button');
if(state.btnApplyUpdate.disabled !== true) throw new Error('manual webui update must disable the apply button');
if(state.btnForceUpdate.style.display !== 'none') throw new Error('manual webui update must hide the force button');
if(state.btnForceUpdate.disabled !== true) throw new Error('manual webui update must disable the force button');
if(state.btnClearUpdateLock.style.display !== 'none') throw new Error('manual webui update must hide the clear lock button');
if(state.btnClearUpdateLock.disabled !== true) throw new Error('manual webui update must disable the clear lock button');
if(state.updateBanner.classList.added !== true) throw new Error('manual update must show the banner');
""".strip()
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_settings_manual_webui_update_includes_pull_guidance(self):
ui_src = read('static/ui.js')
panels_src = read('static/panels.js')
format_fn = extract_js_function(ui_src, '_formatUpdateTargetStatus')
instruction_fn = extract_js_function(ui_src, '_formatManualUpdateInstruction')
error_fn = extract_js_function(ui_src, '_formatUpdateCheckError')
check_fn = extract_js_function(panels_src, 'checkUpdatesNow')
script = f"""
const state = {{
btnCheckUpdatesNow: {{ disabled: false }},
checkUpdatesLabel: {{ textContent: '' }},
checkUpdatesSpinner: {{ style: {{ display: 'none' }} }},
checkUpdatesStatus: {{ textContent: '', style: {{ color: '' }} }},
}};
let apiData = {{
webui: {{
no_git: true,
manual_update: true,
behind: 1,
release_based: true,
current_version: 'v0.51.833',
latest_version: 'v0.51.913',
}},
agent: null,
}};
function $(id) {{ return state[id] || null; }}
function t(key) {{
const values = {{
settings_checking: 'Checking',
settings_check_now: 'Check now',
settings_updates_available: '{{count}} update(s) available',
settings_update_no_git: 'Cannot check for updates',
settings_up_to_date: 'Up to date',
settings_update_check_failed: 'Check failed',
}};
return values[key] || key;
}}
async function api() {{ return apiData; }}
function _showUpdateBanner() {{}}
{format_fn}
{instruction_fn}
{error_fn}
{check_fn}
(async () => {{
await checkUpdatesNow();
if(state.checkUpdatesStatus.textContent.indexOf('docker pull ghcr.io/nesquena/hermes-webui:latest') === -1) throw new Error('settings manual update must render pull guidance: '+state.checkUpdatesStatus.textContent);
if(state.checkUpdatesStatus.style.color !== 'var(--accent)') throw new Error('manual update should stay in available state');
apiData = {{ webui: {{ no_git: true, behind: 1 }}, agent: null }};
state.checkUpdatesStatus.textContent = '';
await checkUpdatesNow();
if(state.checkUpdatesStatus.textContent.indexOf('docker pull') !== -1) throw new Error('plain no-git must not show manual guidance');
if(state.checkUpdatesStatus.textContent !== 'Cannot check for updates') throw new Error('plain no-git should keep cannot-check status: '+state.checkUpdatesStatus.textContent);
}})().catch(err => {{ console.error(err.message); process.exit(1); }});
""".strip()
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
# ── static/index.html ─────────────────────────────────────────────────────────
+69
View File
@@ -1,4 +1,5 @@
"""Tests for self-update diagnostics (api/updates.py)."""
import json
import os
import time
from unittest.mock import MagicMock, patch
@@ -74,6 +75,74 @@ def test_check_repo_redacts_credentialed_fetch_failure(tmp_path):
assert 'Authentication failed' in info['error']
def test_check_repo_reports_manual_update_for_baked_webui_version(tmp_path, monkeypatch):
"""Docker WebUI installs should banner a manual update when GitHub tags are newer."""
class FakeResponse:
def __init__(self, body):
self._body = body
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return self._body
payload = [
{'name': 'v0.51.833', 'commit': {'sha': 'current-sha'}},
{'name': 'v0.51.914-rc1', 'commit': {'sha': 'prerelease-sha'}},
{'name': 'v0.51.914', 'commit': {'sha': 'stable-sha'}},
]
seen = {}
def fake_urlopen(request, timeout=0):
seen['url'] = request.full_url
seen['timeout'] = timeout
return FakeResponse(json.dumps(payload).encode('utf-8'))
monkeypatch.setattr(updates.urllib.request, 'urlopen', fake_urlopen)
monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.51.833')
info = updates._check_repo(tmp_path, 'webui')
assert info['name'] == 'webui'
assert info['no_git'] is True
assert info['manual_update'] is True
assert info['release_based'] is True
assert info['behind'] == 1
assert info['current_version'] == 'v0.51.833'
assert info['latest_version'] == 'v0.51.914'
assert info['current_sha'] == 'current-sha'
assert info['latest_sha'] == 'stable-sha'
assert info['compare_url'] == (
'https://github.com/nesquena/hermes-webui/compare/current-sha...stable-sha'
)
assert seen['url'] == 'https://api.github.com/repos/nesquena/hermes-webui/tags?per_page=100'
assert seen['timeout'] == 3.0
def test_check_repo_webui_no_git_falls_back_to_old_payload_on_tags_failure(tmp_path, monkeypatch):
"""If GitHub tags cannot be read, the Docker path must stay on can't-check."""
monkeypatch.setattr(updates.urllib.request, 'urlopen', lambda *args, **kwargs: (_ for _ in ()).throw(updates.urllib.error.URLError('boom')))
monkeypatch.setattr(updates, 'WEBUI_VERSION', 'v0.51.833')
info = updates._check_repo(tmp_path, 'webui')
assert info == {'name': 'webui', 'behind': None, 'no_git': True}
def test_check_repo_no_git_agent_stays_cant_check(tmp_path):
"""Agent no-git installs must keep the legacy can't-check response."""
info = updates._check_repo(tmp_path, 'agent')
assert info == {'name': 'agent', 'behind': None, 'no_git': True}
def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path):
"""If release tags cannot be read, behind is unknown rather than zero."""
(tmp_path / '.git').mkdir()