Add an update_channel setting (stable|experimental) that selects which git-tag
stream the self-updater tracks on the single linear master line:
stable -> 'v*' promoted, soaked releases (default; unchanged glob)
experimental -> 'exp-v*' every release batch (opt-in testers)
A channel is only a tag glob — no branches, no divergence — so every ff-only
guarantee (#2653/#2846/#3140) is preserved. Channel governs the WebUI repo only;
the Agent repo keeps its historical branch fall-through.
Correctness (advisor-reviewed, Codex + Fable):
- describe uses --match <glob> so a commit tagged both v* and exp-v* resolves
to the channel-correct tag
- stable never falls through to origin/master when HEAD contains the latest
stable tag (the firehose-suppression that makes channels work)
- apply_force_update refuses to reset --hard onto an ancestor ref (rewind guard)
- update cache + in-progress guard keyed by (channel, include_agent)
- channel display badge is a SEPARATE field; WEBUI_VERSION stays channel-neutral
(asset cache-busting / SW cache / stale-client skew all do exact-string equality)
Settings UX: channel dropdown with risk-forward copy + the ff-only asymmetry
helper ('switching back to Stable keeps your current version until Stable catches
up'), channel chip on the version badge, i18n keys, re-check on switch.
Tests: new tests/test_update_channels.py (12 real-git-fixture tests) + existing
update suite updated for the channel-aware signatures.
210 lines
9.8 KiB
Python
210 lines
9.8 KiB
Python
"""End-to-end tests for update channels (stable vs experimental).
|
|
|
|
These use REAL git repositories in tmp_path (not mocked _run_git) so they
|
|
exercise the actual git commands the channel logic depends on — the tag globs,
|
|
`describe --match`, and the ancestor/descendant merge-base checks. This is the
|
|
durable regression proof for the two-tag-channel design:
|
|
|
|
stable -> 'v*' promoted, soaked releases (default)
|
|
experimental -> 'exp-v*' every batch (opt-in testers)
|
|
|
|
Both tag families live on the SAME linear master line; a channel is only a tag
|
|
glob. The critical property under test: a STABLE user with an unpromoted
|
|
`exp-v*` tag ahead of them reports up-to-date, NOT the master firehose.
|
|
"""
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import api.updates as updates
|
|
|
|
|
|
def _git(repo, *args):
|
|
subprocess.run(
|
|
['git', *args], cwd=str(repo), check=True,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def channel_repo(tmp_path):
|
|
"""A linear repo: 5 batch commits, each tagged exp-v0.52.N; stable promoted
|
|
at batch 2 (v0.52.2) and batch 5 (v0.52.5). HEAD ends on batch 5."""
|
|
repo = tmp_path / 'repo'
|
|
repo.mkdir()
|
|
_git(repo, 'init', '-q')
|
|
_git(repo, 'config', 'user.email', 't@t.co')
|
|
_git(repo, 'config', 'user.name', 'Test')
|
|
_git(repo, 'remote', 'add', 'origin', 'https://github.com/nesquena/hermes-webui.git')
|
|
for i in range(1, 6):
|
|
_git(repo, 'commit', '-q', '--allow-empty', '-m', f'batch{i}')
|
|
_git(repo, 'tag', f'exp-v0.52.{i}')
|
|
# Promote batch 2 and batch 5 to stable (same commit, same version number).
|
|
_git(repo, 'tag', 'v0.52.2', 'HEAD~3')
|
|
_git(repo, 'tag', 'v0.52.5', 'HEAD')
|
|
return repo
|
|
|
|
|
|
# ── Tag-glob separation ──────────────────────────────────────────────────────
|
|
|
|
def test_stable_glob_excludes_exp_tags(channel_repo):
|
|
tags = updates._release_tags(channel_repo, 'stable')
|
|
assert tags == ['v0.52.5', 'v0.52.2']
|
|
assert all(not t.startswith('exp-') for t in tags)
|
|
|
|
|
|
def test_experimental_glob_lists_only_exp_tags(channel_repo):
|
|
tags = updates._release_tags(channel_repo, 'experimental')
|
|
assert tags == [f'exp-v0.52.{i}' for i in (5, 4, 3, 2, 1)]
|
|
|
|
|
|
def test_default_channel_is_stable(channel_repo):
|
|
# No explicit channel == stable glob.
|
|
assert updates._release_tags(channel_repo) == updates._release_tags(channel_repo, 'stable')
|
|
|
|
|
|
def test_unknown_channel_falls_back_to_stable(channel_repo):
|
|
assert updates._normalize_channel('bogus') == 'stable'
|
|
assert updates._normalize_channel(None) == 'stable'
|
|
assert updates._release_tags(channel_repo, 'bogus') == updates._release_tags(channel_repo, 'stable')
|
|
|
|
|
|
# ── current_release_tag is channel-scoped (Codex CORE #1) ────────────────────
|
|
|
|
def test_current_release_tag_matches_channel(channel_repo):
|
|
# HEAD is tagged BOTH v0.52.5 and exp-v0.52.5. describe must pick the
|
|
# channel-matched tag, not whichever git prefers lexically.
|
|
assert updates._current_release_tag(channel_repo, 'stable') == 'v0.52.5'
|
|
assert updates._current_release_tag(channel_repo, 'experimental') == 'exp-v0.52.5'
|
|
|
|
|
|
# ── The core property: stable never sees the firehose (Codex CORE #2) ────────
|
|
|
|
def test_stable_user_behind_a_promoted_tag_sees_the_update(channel_repo):
|
|
"""Stable user sitting on v0.52.2 must be offered v0.52.5 (a real gap)."""
|
|
_git(channel_repo, 'checkout', '-q', 'v0.52.2')
|
|
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
|
|
assert info is not None
|
|
assert info['behind'] == 1
|
|
assert info['current_version'] == 'v0.52.2'
|
|
assert info['latest_version'] == 'v0.52.5'
|
|
assert updates._select_apply_compare_ref(channel_repo, 'stable', 'webui') == 'v0.52.5'
|
|
|
|
|
|
def test_stable_user_on_latest_stable_with_unpromoted_exp_ahead_is_up_to_date(channel_repo):
|
|
"""THE key guarantee: a stable webui user on v0.52.5 with an unpromoted
|
|
exp-v0.52.6 ahead of them reports UP-TO-DATE, never the master firehose."""
|
|
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'batch6')
|
|
_git(channel_repo, 'tag', 'exp-v0.52.6') # experimental-only, NOT promoted
|
|
_git(channel_repo, 'checkout', '-q', 'v0.52.5')
|
|
|
|
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
|
|
assert info is not None
|
|
assert info['behind'] == 0, 'stable must be up-to-date, not offered the firehose'
|
|
# Apply side agrees: it resolves to the current stable tag (a no-op ff),
|
|
# NEVER to origin/master. Both "current tag" and None mean up-to-date; the
|
|
# firehose ref (origin/*) must never appear.
|
|
apply_ref = updates._select_apply_compare_ref(channel_repo, 'stable', 'webui')
|
|
assert apply_ref in ('v0.52.5', None)
|
|
assert apply_ref is None or not apply_ref.startswith('origin/')
|
|
|
|
|
|
def test_stable_user_PAST_latest_stable_on_untagged_commit_is_up_to_date(channel_repo):
|
|
"""A stable webui user whose HEAD moved PAST the latest stable tag onto an
|
|
unpromoted (untagged-on-stable) commit must report up-to-date, NOT fall
|
|
through to origin/master. This is the exact firehose-suppression path."""
|
|
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past-stable')
|
|
_git(channel_repo, 'tag', 'exp-v0.52.6') # exp-only; no stable tag here
|
|
# HEAD is now one commit PAST v0.52.5 with no stable tag on it.
|
|
info = updates._check_repo_release(channel_repo, 'webui', 'stable')
|
|
assert info is not None
|
|
assert info['behind'] == 0, 'stable past-tag must be up-to-date, not firehose'
|
|
assert updates._select_apply_compare_ref(channel_repo, 'stable', 'webui') is None
|
|
|
|
|
|
def test_experimental_user_sees_unpromoted_exp_commit(channel_repo):
|
|
"""Same HEAD (v0.52.5), experimental channel, sees exp-v0.52.6 as behind=1."""
|
|
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'batch6')
|
|
_git(channel_repo, 'tag', 'exp-v0.52.6')
|
|
_git(channel_repo, 'checkout', '-q', 'v0.52.5')
|
|
|
|
info = updates._check_repo_release(channel_repo, 'webui', 'experimental')
|
|
assert info is not None
|
|
assert info['behind'] == 1
|
|
assert info['current_version'] == 'exp-v0.52.5'
|
|
assert info['latest_version'] == 'exp-v0.52.6'
|
|
|
|
|
|
# ── Agent repo keeps historical fall-through (channel is webui-only) ─────────
|
|
|
|
def test_agent_repo_falls_through_to_branch_even_on_stable(channel_repo):
|
|
"""The agent repo legitimately tracks master past its tags — the stable
|
|
channel's fall-through suppression is webui-only, so the agent still
|
|
branch-compares when HEAD is past the latest tag."""
|
|
# Add commits past the latest stable tag WITHOUT promoting them, and set an
|
|
# upstream so the branch path is reachable.
|
|
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past1')
|
|
_git(channel_repo, 'commit', '-q', '--allow-empty', '-m', 'past2')
|
|
# name='agent' → suppression OFF → past-tag HEAD returns None (fall through).
|
|
result = updates._check_repo_release(channel_repo, 'agent', 'stable')
|
|
assert result is None, 'agent must fall through to branch check when past its tag'
|
|
|
|
|
|
# ── Force-update rewind guard (Codex CORE #3) ────────────────────────────────
|
|
|
|
def test_force_update_refuses_rewind_when_ref_is_ancestor(channel_repo, monkeypatch):
|
|
"""The rewind guard: apply_force_update must refuse to reset --hard onto a
|
|
ref that is a strict ANCESTOR of HEAD (a downgrade). HEAD is on v0.52.5;
|
|
we force to a ref resolving to the older v0.52.2 (an ancestor)."""
|
|
monkeypatch.setattr(updates, 'REPO_ROOT', channel_repo)
|
|
monkeypatch.setattr(
|
|
updates, '_restart_blocker_snapshot',
|
|
lambda: {'restart_blocked': False, 'active_streams': 0, 'active_runs': 0},
|
|
)
|
|
# Force the compare ref to the older stable tag (a strict ancestor of HEAD).
|
|
monkeypatch.setattr(
|
|
updates, '_select_apply_compare_ref',
|
|
lambda path, channel='stable', target=None: 'v0.52.2',
|
|
)
|
|
real_run_git = updates._run_git
|
|
|
|
def no_fetch(args, cwd, timeout=10):
|
|
if args[:2] == ['fetch', 'origin']:
|
|
return '', True
|
|
return real_run_git(args, cwd, timeout=timeout)
|
|
|
|
monkeypatch.setattr(updates, '_run_git', no_fetch)
|
|
result = updates.apply_force_update('webui', channel='stable')
|
|
assert result.get('refused_rewind') is True, result
|
|
assert result['ok'] is False
|
|
# HEAD must not have moved (no rewind actually happened).
|
|
head, ok = updates._run_git(['rev-parse', 'HEAD'], channel_repo)
|
|
v525, _ = updates._run_git(['rev-parse', 'v0.52.5'], channel_repo)
|
|
assert head == v525, 'force-update must not have rewound HEAD'
|
|
|
|
|
|
# ── Cache is keyed by channel (Codex SILENT #5) ──────────────────────────────
|
|
|
|
def test_update_cache_scoped_by_channel(channel_repo, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_check_repo(path, name, channel='stable'):
|
|
calls.append((name, channel))
|
|
return {'name': name, 'behind': 0, 'channel': channel}
|
|
|
|
monkeypatch.setattr(updates, 'REPO_ROOT', channel_repo)
|
|
monkeypatch.setattr(updates, '_AGENT_DIR', channel_repo)
|
|
monkeypatch.setattr(updates, '_check_repo', fake_check_repo)
|
|
monkeypatch.setitem(updates._update_cache, 'checked_at', 0)
|
|
monkeypatch.setitem(updates._update_cache, 'channel', 'stable')
|
|
|
|
stable = updates.check_for_updates(force=True, include_agent=False, channel='stable')
|
|
# A non-forced call on a DIFFERENT channel must not serve the stable cache.
|
|
experimental = updates.check_for_updates(force=False, include_agent=False, channel='experimental')
|
|
assert stable['channel'] == 'stable'
|
|
assert experimental['channel'] == 'experimental'
|
|
# Both channels triggered a real check (no stale cross-channel cache hit).
|
|
assert ('webui', 'stable') in calls
|
|
assert ('webui', 'experimental') in calls
|