mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-20 22:51:07 +00:00
* 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:
@@ -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
@@ -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
@@ -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
@@ -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';}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user