fix(updates): fall through when release tag is not ff-reachable

When agent checkouts track main past an older tag but the newest published
tag is on a divergent side branch, stop advertising tag-based updates and
route check/apply through the upstream branch instead.
This commit is contained in:
Pamnard
2026-05-31 12:50:32 +03:00
committed by nesquena-hermes
parent f17f582fc1
commit 3a5cf6cda6
4 changed files with 77 additions and 0 deletions

View File

@@ -3,6 +3,9 @@
## [Unreleased]
### Fixed
- Agent self-update no longer advertises or applies unreachable release tags when the checkout tracks `main` past an older tag but the newest published tag lives on a divergent side branch (for example `v2026.5.29``v2026.5.29.2`). The update checker and apply path now fall through to the configured upstream branch when `git pull --ff-only <latest-tag>` cannot fast-forward, matching the existing #2653/#3140 release-vs-branch routing.
## [v0.51.185] — 2026-05-31 — Release FE (stage-batchE — clarify-card bug-fix batch: identical-prompt dedup + autofill guard + GBK startup crash)
### Fixed

View File

@@ -466,6 +466,14 @@ def _head_contains_ref(path, ref):
return bool(ok)
def _can_fast_forward_to(path, ref):
"""Return True when ``ref`` is a descendant of HEAD (``git pull --ff-only`` can reach it)."""
if not ref:
return False
_, ok = _run_git(['merge-base', '--is-ancestor', 'HEAD', ref], path)
return bool(ok)
def _select_apply_compare_ref(path):
"""Return the same remote ref family that the update check reports.
@@ -497,6 +505,8 @@ def _select_apply_compare_ref(path):
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)
):
pass
else:
@@ -537,6 +547,12 @@ def _check_repo_release(path, name):
if behind > 0 and _head_contains_ref(path, latest_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):
return None
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)

View File

@@ -224,6 +224,8 @@ class TestUpdateChecker:
return 'v0.51.35\nv0.51.34\nv0.51.33', True
if args[:3] == ['describe', '--tags', '--abbrev=0']:
return 'v0.51.34', True
if args == ['merge-base', '--is-ancestor', 'HEAD', 'v0.51.35']:
return '', True
if args[:2] == ['remote', 'get-url']:
return 'https://github.com/nesquena/hermes-webui.git', True
return '', False

View File

@@ -13,6 +13,8 @@ def _fake_git_for_release_fetch_failure(args, cwd, timeout=10):
return 'v0.51.103', True
if args == ['merge-base', '--is-ancestor', 'v0.51.106', 'HEAD']:
return '', False
if args == ['merge-base', '--is-ancestor', 'HEAD', 'v0.51.106']:
return '', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-webui.git', True
raise AssertionError(f'unexpected git args: {args!r}')
@@ -675,3 +677,57 @@ def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_ex
'origin/<branch>. Regression for Opus-flagged drift in #2855.'
)
def test_check_repo_release_falls_through_when_latest_tag_is_not_ff_reachable(tmp_path):
"""Main-tracking HEAD past an older tag cannot ff to a patch release tag.
Repro: installer puts agent on main at v2026.5.29+N-g..., maintainers cut
v2026.5.29.2 from a side branch. Tag gap is positive, but
``git pull --ff-only v2026.5.29.2`` fails with diverging branches.
The release check must fall through to the upstream branch comparison.
"""
(tmp_path / '.git').mkdir()
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']:
return 'v2026.5.29', True
if args == ['describe', '--tags', '--always']:
return 'v2026.5.29-265-g5921d6678', True
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
return '', False
if args == ['merge-base', '--is-ancestor', 'HEAD', 'v2026.5.29.2']:
return '', False
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
result = updates._check_repo_release(tmp_path, 'agent')
assert result is None
def test_select_apply_compare_ref_falls_through_when_latest_tag_is_not_ff_reachable(tmp_path):
"""Apply path mirrors the ff-unreachable release-tag fall-through."""
(tmp_path / '.git').mkdir()
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']:
return 'v2026.5.29', True
if args == ['describe', '--tags', '--always']:
return 'v2026.5.29-265-g5921d6678', True
if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']:
return '', False
if args == ['merge-base', '--is-ancestor', 'HEAD', 'v2026.5.29.2']:
return '', False
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)
assert ref == 'origin/main'