feat(updates): stable/experimental release channels

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.
This commit is contained in:
t committed 2026-07-08 19:25:27 +00:00
1 parent 16272dd646
commit 2cf43527a3
8 files changed
+589 -80

No files matched your search

+2
View File
@@ -8183,6 +8183,7 @@ _SETTINGS_DEFAULTS = {
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
"update_channel": "stable", # stable | experimental — which release stream to track (stable = soaked/promoted; experimental = every batch)
"ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
"tts_enabled": False,
@@ -8463,6 +8464,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
_SETTINGS_ENUM_VALUES = {
"send_key": {"enter", "ctrl+enter", "shift+enter"},
"sidebar_density": {"compact", "detailed"},
"update_channel": {"stable", "experimental"},
"font_size": {"small", "default", "large", "xlarge"},
"auto_title_refresh_every": {"0", "5", "10", "20"},
"default_message_mode": {"queue", "interrupt", "steer"},
+12 -1
View File
@@ -11628,6 +11628,16 @@ def handle_get(handler, parsed) -> bool:
settings["agent_version"] = AGENT_VERSION
except Exception:
pass
# Channel-scoped display badge — SEPARATE from webui_version (which is
# load-bearing for asset cache-busting / SW cache / skew detection and
# must stay channel-neutral). update_channel_version is display-only.
try:
from api.updates import channel_version_badge, _read_update_channel
channel = _read_update_channel()
settings["update_channel"] = channel
settings["update_channel_version"] = channel_version_badge(channel)
except Exception:
pass
return j(handler, settings)
if parsed.path == "/api/transcribe/capability":
@@ -13072,9 +13082,10 @@ def handle_post(handler, parsed) -> bool:
return j(handler, {"disabled": True})
include_agent_updates = not bool(settings.get("ignore_agent_updates"))
force = bool(body.get("force", False))
channel = settings.get("update_channel")
from api.updates import check_for_updates
return j(handler, check_for_updates(force=force, include_agent=include_agent_updates))
return j(handler, check_for_updates(force=force, include_agent=include_agent_updates, channel=channel))
if parsed.path == "/api/extensions/toggle":
from api.extensions import ExtensionToggleError, set_extension_user_enabled
+273 -44
View File
@@ -35,7 +35,7 @@ try:
except ImportError:
_AGENT_DIR = None
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'}
_SUMMARY_CACHE_MAX = 16
_summary_cache: OrderedDict = OrderedDict()
_cache_lock = threading.Lock()
@@ -648,17 +648,102 @@ def _detect_default_branch(path):
return 'master'
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)
# ── Release channels ─────────────────────────────────────────────────────────
# The self-updater tracks ONE of several release channels, selected in Settings
# (``update_channel``). A channel is nothing more than *which glob of tags the
# updater reads* on the single linear master line — no branches, no divergence,
# so every hard-won ff-only guarantee (#2653/#2846/#3140) is preserved.
#
# stable -> 'v*' promoted, soaked releases (the default). Same glob
# the updater has always used — every existing
# v0.51.N tag matches, so legacy installs and the
# full existing test suite keep working unchanged.
# experimental -> 'exp-v*' every release batch, tagged for testers who opt in.
#
# ``exp-v*`` deliberately does NOT match ``v*`` (exp tags start with 'e', not
# 'v'): the two channels never leak into each other's tag list, and a legacy
# install running the historical 'v*' glob never matches an exp tag, so it
# auto-lands on the stable stream with zero action.
DEFAULT_UPDATE_CHANNEL = 'stable'
_CHANNEL_TAG_GLOBS = {
'stable': 'v*',
'experimental': 'exp-v*',
}
def _normalize_channel(channel) -> str:
"""Return a known channel name, defaulting to stable for anything unknown."""
if isinstance(channel, str) and channel in _CHANNEL_TAG_GLOBS:
return channel
return DEFAULT_UPDATE_CHANNEL
def _channel_tag_glob(channel) -> str:
"""Return the ``git tag --list`` glob for the given channel."""
return _CHANNEL_TAG_GLOBS[_normalize_channel(channel)]
def _read_update_channel() -> str:
"""Read the configured update channel from settings (stable fallback).
Read lazily at request time — never baked at import — so a channel switch in
Settings takes effect on the next update check without a process restart.
"""
try:
from api.config import load_settings
return _normalize_channel(load_settings().get('update_channel'))
except Exception:
return DEFAULT_UPDATE_CHANNEL
def channel_version_badge(channel=None) -> str:
"""Return a channel-scoped version string for the Settings display badge ONLY.
This is DELIBERATELY separate from ``WEBUI_VERSION``. ``WEBUI_VERSION`` is
load-bearing in exact-string-equality systems — asset cache-busting URLs, the
service-worker CACHE_NAME, the models-cache stamp, and the stale-client skew
banner — so it must stay channel-neutral and stable for the process lifetime.
Making it channel-dependent would falsely trip "hard refresh" banners and
spurious cache rebuilds on every channel flip. This helper is read at request
time purely to render ``WebUI: v0.52.47 · Experimental`` in Settings.
Returns the channel-matched ``git describe`` (e.g. ``v0.52.47`` on stable,
``exp-v0.52.51`` on experimental), or falls back to ``WEBUI_VERSION`` when no
channel tag is reachable (fresh clone, Docker image without channel tags).
"""
if channel is None:
channel = _read_update_channel()
channel = _normalize_channel(channel)
out, ok = _run_git(
['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)],
REPO_ROOT,
)
if ok and out:
return out + _dirty_suffix(REPO_ROOT)
return WEBUI_VERSION
def _release_tags(path, channel=DEFAULT_UPDATE_CHANNEL):
"""Return the channel's release tags newest-first, in version-sort order."""
glob = _channel_tag_glob(channel)
out, ok = _run_git(['tag', '--list', glob, '--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)
def _current_release_tag(path, channel=DEFAULT_UPDATE_CHANNEL):
"""Return the latest channel release tag reachable from HEAD, if one exists.
MUST filter by the channel glob (``--match``): a commit tagged BOTH
``v0.52.0`` and ``exp-v0.52.0`` describes as ``exp-v0.52.0`` (git prefers the
lexically-later tag), so an unfiltered ``describe`` would make stable-channel
math resolve to the experimental tag and fall through to the branch firehose.
"""
out, ok = _run_git(
['describe', '--tags', '--abbrev=0', '--match', _channel_tag_glob(channel)],
path,
)
return out if ok and out else None
@@ -671,18 +756,25 @@ def _release_gap(tags, current, latest):
return 1
def _head_is_past_latest_tag(path, current_tag):
"""Return True when HEAD has moved past the latest reachable release tag.
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.
`git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``)
when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix
when HEAD has moved 608 commits past it. Used by both the update check and
the update apply path so they agree on which ref to advance to — see #2653
(check side) and #2846 (apply side).
`git describe --tags --always --match <glob>` returns the bare tag name
(e.g. ``v2026.5.16``) when HEAD is exactly on the tag, and a
``v2026.5.16-608-g1d22b9c2`` suffix when HEAD has moved 608 commits past it.
Used by both the update check and the update apply path so they agree on
which ref to advance to — see #2653 (check side) and #2846 (apply side).
The ``--match`` filter is mandatory: without it, a HEAD sitting on a commit
that carries the other channel's tag would describe against that tag and
give a wrong past/at answer for THIS channel.
"""
if not current_tag:
return False
full_desc, ok = _run_git(['describe', '--tags', '--always'], path)
full_desc, ok = _run_git(
['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)],
path,
)
return bool(ok and full_desc and full_desc != current_tag)
@@ -708,7 +800,7 @@ def _can_fast_forward_to(path, ref):
return bool(ok)
def _select_apply_compare_ref(path):
def _select_apply_compare_ref(path, channel=DEFAULT_UPDATE_CHANNEL, target=None):
"""Return the same remote ref family that the update check reports.
The update banner prefers published release tags when they exist. Applying
@@ -722,11 +814,24 @@ def _select_apply_compare_ref(path):
decision — otherwise we run `git pull --ff-only <latest-tag>` against a
checkout that's already past the tag, no-op, restart, and the banner
re-appears with the same N commits available. See #2846.
CHANNEL SEMANTICS (webui only): the stable/experimental channels govern the
WebUI repo. For ``target == 'webui'`` on the ``stable`` channel, stable tags
are a *promoted subset* of master, so a stable install whose HEAD already
contains the latest stable tag but sits behind master's tip must NOT fall
through to the branch comparison (that would advance it to ``origin/master``
— the full experimental firehose, defeating the channel). We return ``None``
so the caller reports "no update". Every other case — the experimental
channel, and the AGENT repo (which is a separate project that legitimately
tracks master past its tags) — keeps the historical branch fallthrough
unchanged. This mirrors ``_check_repo_release``.
"""
tags = _release_tags(path)
channel = _normalize_channel(channel)
suppress_stable_fallthrough = (channel == 'stable' and target == 'webui')
tags = _release_tags(path, channel)
if tags:
latest_tag = tags[0]
current_tag = _current_release_tag(path)
current_tag = _current_release_tag(path, channel)
behind = _release_gap(tags, current_tag, latest_tag)
# Mirror the check side exactly: fall through to the branch comparison
# whenever the checkout has already moved past the release tag that the
@@ -735,13 +840,18 @@ def _select_apply_compare_ref(path):
# have behind > 0 after fetching a newer tag that HEAD already contains
# (#3140). In both cases applying the tag would no-op, move backwards,
# or fail fast-forward; branch comparison is the truthful update path.
# Short-circuit `or` preserves the original minimal git-call pattern.
if (
behind == 0 and _head_is_past_latest_tag(path, current_tag)
) or (
behind > 0 and _head_contains_ref(path, latest_tag)
) or (
behind > 0 and not _can_fast_forward_to(path, latest_tag)
(behind == 0 and _head_is_past_latest_tag(path, current_tag, channel))
or (behind > 0 and _head_contains_ref(path, latest_tag))
or (behind > 0 and not _can_fast_forward_to(path, latest_tag))
):
# WebUI stable: "HEAD past/contains the latest stable tag" means
# up-to-date on the promoted subset — NOT a signal to advance to
# master. Return None so the caller reports no update.
if suppress_stable_fallthrough:
return None
# Experimental / agent: preserve the historical branch fallthrough.
pass
else:
return latest_tag
@@ -754,14 +864,39 @@ def _select_apply_compare_ref(path):
return f'origin/{branch}'
def _check_repo_release(path, name):
"""Check if a git repo is behind its latest published release tag."""
tags = _release_tags(path)
def _channel_up_to_date_info(path, name, channel, current_tag):
"""Return an 'up to date' payload for a channel that must NOT branch-compare.
Used by the stable channel: stable tags are a promoted subset of master, so
when HEAD already contains the latest stable tag we report up-to-date
(behind == 0) rather than falling through to the branch comparison, which
would advance the user onto the experimental firehose.
"""
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)
return {
'name': name,
'behind': 0,
'current_sha': current_tag,
'latest_sha': current_tag,
'branch': current_tag,
'repo_url': remote_url,
'release_based': True,
'current_version': current_tag,
'latest_version': current_tag,
'channel': channel,
}
def _check_repo_release(path, name, channel=DEFAULT_UPDATE_CHANNEL):
"""Check if a git repo is behind its latest published channel release tag."""
channel = _normalize_channel(channel)
tags = _release_tags(path, channel)
if not tags:
return None
latest_tag = tags[0]
current_tag = _current_release_tag(path)
current_tag = _current_release_tag(path, channel)
behind = _release_gap(tags, current_tag, latest_tag)
# If behind == 0 but HEAD has moved past the tag (e.g. the agent repo
@@ -770,7 +905,16 @@ def _check_repo_release(path, name):
# Fall through to _check_repo_branch so the real commit count is reported
# instead. The same predicate is used by _select_apply_compare_ref so the
# check and apply sides cannot drift again. See #2653 (check), #2846 (apply).
if behind == 0 and _head_is_past_latest_tag(path, current_tag):
#
# CHANNEL (webui only): for the WebUI repo on stable, stable tags are a
# promoted SUBSET of master, so "HEAD past the latest stable tag" means
# up-to-date on the promoted subset, NOT a signal to branch-compare against
# origin/master (the firehose). Report up-to-date. The AGENT repo and the
# experimental channel keep the historical fall-through.
suppress_stable_fallthrough = (channel == 'stable' and name == 'webui')
if behind == 0 and _head_is_past_latest_tag(path, current_tag, channel):
if suppress_stable_fallthrough:
return _channel_up_to_date_info(path, name, channel, current_tag)
return None
# Users tracking main can already contain the newest fetched release tag
@@ -779,12 +923,16 @@ def _check_repo_release(path, name):
# Fall through to the branch check so the banner compares against the
# configured upstream instead of advertising a tag that cannot fast-forward.
if behind > 0 and _head_contains_ref(path, latest_tag):
if suppress_stable_fallthrough:
return _channel_up_to_date_info(path, name, channel, current_tag)
return None
# Patch releases can land on a side branch while day-to-day installs track
# main past an older tag. A positive tag-name gap then advertises an update
# that `git pull --ff-only <latest-tag>` cannot reach.
if behind > 0 and not _can_fast_forward_to(path, latest_tag):
if suppress_stable_fallthrough:
return _channel_up_to_date_info(path, name, channel, current_tag)
return None
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
@@ -802,6 +950,7 @@ def _check_repo_release(path, name):
'release_based': True,
'current_version': current_tag,
'latest_version': latest_tag,
'channel': channel,
}
@@ -876,7 +1025,7 @@ def _check_repo_branch(path, name, *, fetch=True):
}
def _check_repo(path, name):
def _check_repo(path, name, channel=DEFAULT_UPDATE_CHANNEL):
"""Check if a git repo is behind its latest release. Returns dict or None.
The returned dict (when not None) always carries a ``dirty: bool`` reflecting
@@ -889,6 +1038,7 @@ def _check_repo(path, name):
with ``no_git: True`` and ``behind: None`` so the frontend can distinguish
"can't check" from "up to date" (issue #4356).
"""
channel = _normalize_channel(channel)
if path is None or not (path / '.git').exists():
return {
'name': name,
@@ -907,7 +1057,7 @@ def _check_repo(path, name):
# See #2756.
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15)
if not fetch_ok:
release_info = _check_repo_release(path, name)
release_info = _check_repo_release(path, name, channel)
message = 'fetch failed'
if fetch_out:
message = f'{message}: {_sanitize_git_diagnostic(fetch_out)}'
@@ -925,7 +1075,7 @@ def _check_repo(path, name):
'dirty': _is_dirty(path),
}
release_info = _check_repo_release(path, name)
release_info = _check_repo_release(path, name, channel)
if release_info is not None:
release_info = dict(release_info)
release_info['dirty'] = _is_dirty(path)
@@ -935,6 +1085,7 @@ def _check_repo(path, name):
if branch_info is not None:
branch_info = dict(branch_info)
branch_info['dirty'] = _is_dirty(path)
branch_info['channel'] = channel
return branch_info
return None
@@ -959,11 +1110,20 @@ def _ignored_agent_update_info() -> dict:
return {'name': 'agent', 'behind': 0, 'ignored': True}
def cached_update_status(*, include_agent=True):
def cached_update_status(*, include_agent=True, channel=None):
"""Return cached update status without performing network or git mutations."""
include_agent = bool(include_agent)
if channel is None:
channel = _read_update_channel()
channel = _normalize_channel(channel)
with _cache_lock:
cached = dict(_update_cache)
# If the cache was populated for a different channel, it is not a valid
# answer for this channel — signal that so callers don't render stale
# cross-channel data as authoritative.
if cached.get('channel') != channel:
cached['channel'] = channel
cached['stale_channel'] = True
if cached.get('include_agent') != include_agent:
cached['include_agent'] = include_agent
if not include_agent:
@@ -972,31 +1132,43 @@ def cached_update_status(*, include_agent=True):
return cached
def check_for_updates(force=False, *, include_agent=True):
def check_for_updates(force=False, *, include_agent=True, channel=None):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
include_agent = bool(include_agent)
if channel is None:
channel = _read_update_channel()
channel = _normalize_channel(channel)
with _cache_lock:
# Cache is only valid when BOTH the channel AND include_agent match —
# a channel switch must not serve the previous channel's answer, and an
# in-progress check for the other channel must not short-circuit this one
# with a stale cross-channel payload (Codex SILENT #5).
cache_matches = (
_update_cache.get('include_agent') == include_agent
and _update_cache.get('channel') == channel
)
if (
not force
and _update_cache.get('include_agent') == include_agent
and cache_matches
and time.time() - _update_cache['checked_at'] < CACHE_TTL
):
return dict(_update_cache)
if _check_in_progress:
return dict(_update_cache) # another thread is already checking
if _check_in_progress and cache_matches:
return dict(_update_cache) # another thread is already checking this channel
_check_in_progress = True
try:
# Run checks outside the lock (network I/O)
webui_info = _check_repo(REPO_ROOT, 'webui')
agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info()
webui_info = _check_repo(REPO_ROOT, 'webui', channel)
agent_info = _check_repo(_AGENT_DIR, 'agent', channel) if include_agent else _ignored_agent_update_info()
with _cache_lock:
_update_cache['webui'] = webui_info
_update_cache['agent'] = agent_info
_update_cache['checked_at'] = time.time()
_update_cache['include_agent'] = include_agent
_update_cache['channel'] = channel
return dict(_update_cache)
finally:
_check_in_progress = False
@@ -1482,7 +1654,7 @@ def _agent_gateway_restart_failure_message(target: str, restart_result: dict) ->
)
def apply_force_update(target: str) -> dict:
def apply_force_update(target: str, channel=None) -> dict:
"""Force-reset the target repo to the latest remote HEAD.
Unlike apply_update() which requires a clean working tree and refuses
@@ -1493,7 +1665,17 @@ def apply_force_update(target: str) -> dict:
Should only be called when apply_update() has already returned a
response with ``conflict: True`` or ``diverged: True`` and the user
has confirmed they want to discard local changes.
CHANNEL SAFETY (rewind guard): ``reset --hard`` is destructive. When the
selected channel resolves to a ref that is an ANCESTOR of HEAD (i.e. the
checkout is already ahead of the channel — e.g. an ex-experimental install
switching back to stable), resetting to it would REWIND code and on-disk
state. We refuse and return a clear message instead of silently downgrading.
A deliberate rollback would be a separate, explicit feature.
"""
if channel is None:
channel = _read_update_channel()
channel = _normalize_channel(channel)
blocker_snapshot = _restart_blocker_snapshot()
if blocker_snapshot.get('restart_blocked'):
return _restart_blocked_response(target, blocker_snapshot)
@@ -1533,8 +1715,39 @@ def apply_force_update(target: str) -> dict:
),
}
compare_ref = _select_apply_compare_ref(path)
compare_ref = _select_apply_compare_ref(path, channel, target)
# Stable channel, already up to date on the promoted subset: nothing to
# force to. Do NOT fall back to origin/master (firehose). See
# _select_apply_compare_ref channel semantics.
if compare_ref is None:
return {
'ok': True,
'message': f'{target} is already up to date on the {channel} channel.',
'target': target,
'up_to_date': True,
'channel': channel,
}
# Rewind guard (Codex CORE #3): refuse to reset --hard onto a ref that
# is an ANCESTOR of HEAD — that would downgrade the checkout. This is the
# switch-back-to-stable-while-ahead case. A ref that is a descendant of
# HEAD (normal update / opt-in to experimental) fast-forwards fine and is
# allowed. Refs on a divergent line (neither ancestor nor descendant) are
# the legitimate force-update case (conflict/diverged recovery) and are
# also allowed — the guard fires ONLY on a pure-ancestor rewind.
if _head_contains_ref(path, compare_ref) and not _can_fast_forward_to(path, compare_ref):
return {
'ok': False,
'message': (
f'{target} is already ahead of the {channel} channel '
f'({compare_ref}); refusing to rewind the checkout. '
'Switching to a slower channel keeps your current version '
'until that channel catches up.'
),
'target': target,
'channel': channel,
'refused_rewind': True,
}
# Discard local modifications and untracked colliders before resetting.
# Do not use -x: ignored build/cache artifacts should survive force update.
_run_git(['checkout', '.'], path)
@@ -1587,8 +1800,11 @@ def apply_force_update(target: str) -> dict:
_apply_lock.release()
def apply_update(target):
def apply_update(target, channel=None):
"""Stash, pull --ff-only, pop for the given target repo."""
if channel is None:
channel = _read_update_channel()
channel = _normalize_channel(channel)
blocker_snapshot = _restart_blocker_snapshot()
if blocker_snapshot.get('restart_blocked'):
return _restart_blocked_response(target, blocker_snapshot)
@@ -1596,7 +1812,7 @@ def apply_update(target):
if not _apply_lock.acquire(blocking=False):
return {'ok': False, 'message': 'Update already in progress'}
try:
return _apply_update_inner(target)
return _apply_update_inner(target, channel)
finally:
_apply_lock.release()
@@ -1636,8 +1852,9 @@ def _restore_stash_after_pull_failure(
)
def _apply_update_inner(target):
def _apply_update_inner(target, channel=DEFAULT_UPDATE_CHANNEL):
"""Inner implementation of apply_update, called under _apply_lock."""
channel = _normalize_channel(channel)
if target == 'webui':
path = REPO_ROOT
elif target == 'agent':
@@ -1666,7 +1883,19 @@ def _apply_update_inner(target):
),
}
compare_ref = _select_apply_compare_ref(path)
compare_ref = _select_apply_compare_ref(path, channel, target)
# On the stable channel a None ref means HEAD already contains the latest
# promoted stable tag (up-to-date on the promoted subset). Do NOT fall back
# to origin/master — that would advance the user onto the experimental
# firehose. Report success/no-op instead. See _select_apply_compare_ref.
if compare_ref is None:
return {
'ok': True,
'message': f'{target} is already up to date on the {channel} channel.',
'target': target,
'up_to_date': True,
'channel': channel,
}
# Check for dirty working tree (ignore untracked files — git stash
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
+4
View File
@@ -842,6 +842,10 @@ const LOCALES = {
settings_label_previous_messaging_sessions: 'Show previous messaging sessions',
settings_label_sync_insights: 'Sync to insights',
settings_label_check_updates: 'Check for updates',
settings_label_update_channel: 'Update channel',
settings_update_channel_stable: 'Stable (recommended)',
settings_update_channel_experimental: 'Experimental',
settings_desc_update_channel: 'Stable gets soaked, proven releases. Experimental gets every release as it lands — faster, but may hit rough edges. Switching back to Stable keeps your current version until Stable catches up.',
settings_label_ignore_agent_updates: 'Ignore Agent updates',
settings_label_whats_new_summary: "Summarize What's New with AI",
settings_label_bot_name: 'Default assistant name',
+8
View File
@@ -1398,6 +1398,14 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label for="settingsUpdateChannel" data-i18n="settings_label_update_channel">Update channel</label>
<select id="settingsUpdateChannel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="stable" data-i18n="settings_update_channel_stable">Stable (recommended)</option>
<option value="experimental" data-i18n="settings_update_channel_experimental">Experimental</option>
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_update_channel">Stable gets soaked, proven releases. Experimental gets every release as it lands — faster, but may hit rough edges. Switching back to Stable keeps your current version until Stable catches up.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsIgnoreAgentUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
+47 -1
View File
@@ -8543,6 +8543,8 @@ function _preferencesPayloadFromUi(){
if(syncCb) payload.sync_to_insights=syncCb.checked;
const updateCb=$('settingsCheckUpdates');
if(updateCb) payload.check_for_updates=updateCb.checked;
const updateChannelSel=$('settingsUpdateChannel');
if(updateChannelSel) payload.update_channel=updateChannelSel.value;
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
if(ignoreAgentUpdatesCb) payload.ignore_agent_updates=ignoreAgentUpdatesCb.checked;
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
@@ -8735,9 +8737,21 @@ async function loadSettingsPanel(){
checkWebUIVersionSkew(settings);
// Populate the version badges from the server — keeps them in sync with git
// tags automatically without any manual release step.
//
// The DISPLAY badge uses update_channel_version (a channel-scoped
// `git describe --match`), which is SEPARATE from settings.webui_version.
// webui_version is load-bearing for asset cache-busting / SW cache / stale-
// client skew detection and must stay channel-neutral — never render it as
// the channel badge. See api/updates.channel_version_badge().
const webuiBadge = $('settings-webui-version-badge');
if(webuiBadge){
webuiBadge.textContent = `WebUI: ${settings.webui_version || 'not detected'}`;
const chanVer = settings.update_channel_version || settings.webui_version || 'not detected';
const chan = settings.update_channel==='experimental' ? 'experimental' : 'stable';
// Only annotate the channel when on experimental — stable is the implicit
// default and needs no extra chrome.
webuiBadge.textContent = chan==='experimental'
? `WebUI: ${chanVer} · Experimental`
: `WebUI: ${chanVer}`;
}
const agentBadge = $('settings-agent-version-badge');
if(agentBadge){
@@ -9128,6 +9142,23 @@ async function loadSettingsPanel(){
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const updateChannelSel=$('settingsUpdateChannel');
if(updateChannelSel){
updateChannelSel.value=settings.update_channel==='experimental'?'experimental':'stable';
updateChannelSel.addEventListener('change',function(){
// Persist the channel, then invalidate the cached update check and
// re-check so the banner reflects the newly-selected channel. Changing
// the channel changes WHAT is offered, never WHAT is installed — the
// update banner still gates the actual apply behind "Update Now".
_schedulePreferencesAutosave();
if(typeof checkUpdatesNow==='function'){
// Small delay so the autosave PUT lands before the forced re-check
// reads the new channel server-side.
setTimeout(function(){try{checkUpdatesNow();}catch(e){}},400);
}
if(typeof _syncUpdateChannelBadge==='function') _syncUpdateChannelBadge(updateChannelSel.value);
},{once:false});
}
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
if(ignoreAgentUpdatesCb){ignoreAgentUpdatesCb.checked=!!settings.ignore_agent_updates;ignoreAgentUpdatesCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
@@ -11703,6 +11734,20 @@ function _applySavedSettingsUi(saved, body, opts){
if(typeof renderSessionList==='function') renderSessionList();
}
// Instant client-side badge feedback when the update channel is toggled, before
// the server round-trip that authoritatively re-renders the badge from
// update_channel_version. Keeps the "· Experimental" suffix in sync immediately.
function _syncUpdateChannelBadge(channel){
try{
const badge=$('settings-webui-version-badge');
if(!badge) return;
let base=badge.textContent||'';
// Strip any existing " · Experimental" suffix, then re-append if needed.
base=base.replace(/\s·\sExperimental\s*$/,'');
badge.textContent = channel==='experimental' ? (base+' · Experimental') : base;
}catch(e){}
}
async function checkUpdatesNow(){
const btn=$('btnCheckUpdatesNow');
const label=$('checkUpdatesLabel');
@@ -12268,6 +12313,7 @@ async function saveSettings(andClose){
body.pinned_sessions_limit=pinnedSessionsLimit;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.update_channel=($('settingsUpdateChannel')||{}).value==='experimental'?'experimental':'stable';
body.ignore_agent_updates=!!($('settingsIgnoreAgentUpdates')||{}).checked;
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
+209
View File
@@ -0,0 +1,209 @@
"""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
+34 -34
View File
@@ -15,7 +15,7 @@ def _fake_git_for_release_fetch_failure(args, cwd, timeout=10):
return 'would clobber existing tag v0.50.294', False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v0.51.106\nv0.51.103', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v0.51.103', True
if args == ['merge-base', '--is-ancestor', 'v0.51.106', 'HEAD']:
return '', False
@@ -251,11 +251,11 @@ def test_check_for_updates_can_skip_agent_repo(tmp_path):
seen = []
def fake_check_repo(path, name):
def fake_check_repo(path, name, channel='stable'):
seen.append(name)
return {'name': name, 'behind': 2 if name == 'webui' else 9}
cache_defaults = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
cache_defaults = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'}
with patch.dict(updates._update_cache, cache_defaults, clear=True), \
patch.object(updates, 'REPO_ROOT', webui_path), \
patch.object(updates, '_AGENT_DIR', agent_path), \
@@ -273,11 +273,11 @@ def test_update_cache_is_scoped_by_agent_inclusion(tmp_path):
(tmp_path / '.git').mkdir()
calls = []
def fake_check_repo(path, name):
def fake_check_repo(path, name, channel='stable'):
calls.append(name)
return {'name': name, 'behind': len(calls)}
with patch.dict(updates._update_cache, {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}, clear=True), \
with patch.dict(updates._update_cache, {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'}, clear=True), \
patch.object(updates, 'REPO_ROOT', tmp_path), \
patch.object(updates, '_AGENT_DIR', tmp_path), \
patch.object(updates, '_check_repo', side_effect=fake_check_repo):
@@ -484,9 +484,9 @@ def test_check_repo_recovers_from_remote_retag(tmp_path):
return '', True
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v0.51.110\nv0.51.109', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v0.51.110', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v0.51.110', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-webui.git', True
@@ -529,10 +529,10 @@ def test_check_repo_release_falls_through_when_head_is_past_tag(tmp_path):
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.16', True
# HEAD is 608 commits past the tag — describe includes a suffix.
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.16-608-g1d22b9c2d', True
raise AssertionError(f'unexpected git args: {args!r}')
@@ -552,10 +552,10 @@ def test_check_repo_release_not_affected_when_head_exactly_on_tag(tmp_path):
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.16', True
# No -N-gSHA suffix: HEAD is exactly on the tag.
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.16', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-agent.git', True
@@ -583,10 +583,10 @@ def test_check_repo_branch_check_runs_for_post_tag_commits(tmp_path):
return '', True
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.16', True
# HEAD is 608 commits past the tag.
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.16-608-g1d22b9c2d', True
# Branch-check path follows: rev-parse upstream, default branch, rev-list.
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
@@ -633,9 +633,9 @@ def test_select_apply_compare_ref_uses_tag_when_head_is_on_tag(tmp_path):
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.16', True
raise AssertionError(f'unexpected git args: {args!r}')
@@ -658,17 +658,17 @@ def test_select_apply_compare_ref_falls_through_when_head_is_past_tag(tmp_path):
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
# HEAD's nearest tag is v2026.5.16; HEAD is 608 commits past it.
return 'v2026.5.16', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.16-608-g1d22b9c2d', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return 'origin/main', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
assert ref == 'origin/main', (
'apply path must advance to the upstream branch when HEAD is past the '
@@ -724,9 +724,9 @@ def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path):
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.16-608-g1d22b9c2d', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return 'origin/main', True
@@ -734,7 +734,7 @@ def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path):
with patch.object(updates, '_run_git', side_effect=fake_git):
check_result = updates._check_repo_release(tmp_path, 'agent')
apply_ref = updates._select_apply_compare_ref(tmp_path)
apply_ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
# Check side falls through (release check returns None → branch check runs)
assert check_result is None, (
@@ -761,7 +761,7 @@ def test_check_repo_release_falls_through_when_head_contains_newer_tag(tmp_path)
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.29.2\nv2026.5.29', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.29', True
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
return '', True
@@ -783,7 +783,7 @@ def test_select_apply_compare_ref_falls_through_when_head_contains_newer_tag(tmp
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.29.2\nv2026.5.29', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.29', True
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
return '', True
@@ -792,7 +792,7 @@ def test_select_apply_compare_ref_falls_through_when_head_contains_newer_tag(tmp
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
assert ref == 'origin/main', (
'Update Now must not target a release tag that HEAD already contains; '
@@ -819,10 +819,10 @@ def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_ex
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
# HEAD's nearest reachable tag (older one)
return 'v2026.5.10', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
# HEAD has 3 commits past v2026.5.10, but it does not contain
# the newer v2026.5.16 release tag.
return 'v2026.5.10-3-gabcdef12', True
@@ -857,9 +857,9 @@ def test_check_repo_release_falls_through_when_latest_tag_is_not_ff_reachable(tm
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.29.2\nv2026.5.29', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.29', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.29-265-g5921d6678', True
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
return '', False
@@ -880,9 +880,9 @@ def test_select_apply_compare_ref_falls_through_when_latest_tag_is_not_ff_reacha
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.29.2\nv2026.5.29', True
if args == ['describe', '--tags', '--abbrev=0']:
if args == ['describe', '--tags', '--abbrev=0', '--match', 'v*']:
return 'v2026.5.29', True
if args == ['describe', '--tags', '--always']:
if args == ['describe', '--tags', '--always', '--match', 'v*']:
return 'v2026.5.29-265-g5921d6678', True
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
return '', False
@@ -893,7 +893,7 @@ def test_select_apply_compare_ref_falls_through_when_latest_tag_is_not_ff_reacha
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
ref = updates._select_apply_compare_ref(tmp_path, 'stable', 'agent')
assert ref == 'origin/main'
@@ -1141,7 +1141,7 @@ def test_apply_clear_lock_with_no_lock_runs_normal_update(tmp_path, monkeypatch)
)
monkeypatch.setattr(
updates, '_select_apply_compare_ref',
lambda path: 'origin/main'
lambda path, channel='stable', target=None: 'origin/main'
)
result = updates.apply_clear_lock('webui')
assert result['ok'] is True, result
@@ -1258,7 +1258,7 @@ def test_apply_update_pull_lock_restores_stash(tmp_path, monkeypatch):
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path)
monkeypatch.setattr(
updates, '_select_apply_compare_ref',
lambda path: 'origin/main'
lambda path, channel='stable', target=None: 'origin/main'
)
result = updates._apply_update_inner('webui')
@@ -1310,7 +1310,7 @@ def test_apply_update_pull_lock_no_stash_when_clean(tmp_path, monkeypatch):
monkeypatch.setattr(updates, 'REPO_ROOT', tmp_path)
monkeypatch.setattr(
updates, '_select_apply_compare_ref',
lambda path: 'origin/main'
lambda path, channel='stable', target=None: 'origin/main'
)
result = updates._apply_update_inner('webui')