fix(update): the main channel follows the git branch without reading an R2 record

Every `hermes update` on the main channel first fetched
hermes-assets.nousresearch.com/releases/channels/main.json. That record has
never been published; a 404 fell through to the git branch. Some regional
WAFs answer that same request 403 instead, which raised and stopped the
update outright (#128295; patched around in #128323 and #128305).

main IS the git branch, and the only thing its record could ever carry is a
retirement notice nobody has published. resolve_source_target now returns
the main branch target directly, the way stable reads GitHub releases
directly. Preview channels (e.g. pm-preview) keep their R2 records.
This commit is contained in:
Teknium
2026-10-10 20:37:03 -07:00
parent 87da183a8a
commit 4b351c751a
3 changed files with 46 additions and 34 deletions

View File

@@ -80,7 +80,8 @@ def _resolve_channel(name: str, repository: str):
def resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None,
forward_only: bool = False) -> SourceTarget:
"""Resolve every subscription, including default labels, through R2.
"""Resolve a subscription: stable from GitHub releases, main from the git branch,
preview channels (e.g. ``pm-preview``) through their R2 records.
``forward_only`` (an unchosen default subscription) pins a checkout that already
contains the release to its own HEAD, so the update is a no-op instead of a downgrade.
@@ -122,20 +123,18 @@ def _head_containing(git_cmd, cwd, commit: str, repository: str) -> str | None:
def _resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=None) -> SourceTarget:
from hermes_cli.release_channels import ChannelNotFound, validate_name
from hermes_cli.release_channels import validate_name
validate_name(channel)
repository = repository or source_repository(git_cmd, cwd)
if channel == "stable":
return _resolve_stable(repository, git_cmd, cwd)
try:
resolved = _resolve_channel(channel, repository)
except ChannelNotFound:
if channel != "main":
raise
# main IS the source branch; its record can only add a retirement.
# Until one is published, a checkout keeps following the branch via git.
if channel == "main":
# main IS the git branch. No R2 record is read: none was ever published, and a
# network that answers it with 403 instead of 404 (regional WAFs) must not stop
# an install from following the branch it is on.
return SourceTarget(channel, channel, repository, branch="main")
resolved = _resolve_channel(channel, repository)
terminal = resolved.terminal
if terminal["repository"].lower() != repository.lower():
raise ValueError("Channel repository does not match this source installation")

View File

@@ -134,7 +134,7 @@ def test_retirement_adopts_destination_only_after_success(source, monkeypatch, o
assert len(requests) == 1
@pytest.mark.parametrize("channel", ["preview-not-registered", "stable", "canary", "main"])
@pytest.mark.parametrize("channel", ["preview-not-registered", "stable", "canary"])
def test_missing_channel_cannot_fall_back_to_main(source, monkeypatch, channel):
from hermes_cli import source_check
@@ -151,23 +151,36 @@ def test_missing_channel_cannot_fall_back_to_main(source, monkeypatch, channel):
assert git(source.root, "rev-parse", "HEAD") == before
def test_unpublished_main_record_keeps_following_the_git_branch(source, monkeypatch):
"""main IS the source branch: until R2 publishes its record, a checkout
still updates via git instead of failing on a missing channel object."""
@pytest.mark.parametrize("failure", ["not-found", "forbidden", "timeout"])
def test_main_follows_the_git_branch_without_reading_a_channel_record(source, monkeypatch, failure):
"""main IS the source branch, so its R2 record is never read: a 403 from a regional
WAF or a timeout cannot stop an install following main. Preview channels still read theirs."""
import urllib.error
from hermes_cli import source_check
from hermes_cli.release_channels import ChannelNotFound
set_install_channel("main", source.root)
def unpublished(name, repository):
reads = []
def record(name, repository):
reads.append(name)
if failure == "forbidden":
raise urllib.error.HTTPError(f"https://x/releases/channels/{name}.json", 403, "Forbidden", {}, None)
if failure == "timeout":
raise TimeoutError("timed out")
raise ChannelNotFound(f"Channel object not found: releases/channels/{name}.json")
monkeypatch.setattr(source_releases, "_resolve_channel", unpublished)
monkeypatch.setattr(source_releases, "_resolve_channel", record)
target = source_releases.resolve_source_target("main", ["git"], source.root)
assert target.branch == "main" and target.commit is None
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
assert "error" not in status, status
assert status["targetSha"] == source.commits[2]
with pytest.raises(ChannelNotFound):
assert reads == []
with pytest.raises((ChannelNotFound, urllib.error.HTTPError, TimeoutError)):
source_releases.resolve_source_target("canary", ["git"], source.root)
assert reads == ["canary"]
def test_passive_check_reports_retirement_without_adopting_it(source, monkeypatch):

View File

@@ -135,7 +135,7 @@ def test_target_worktree_owns_admission_and_fork_comparison(installation, monkey
assert status["behind"] == 3
assert status["hermesRoot"] == str(linked)
assert check_for_updates(install_root=root, home=home, cache_path=cache)["supported"] is False
assert requests == [MAIN_CHANNEL, "/repos/fixture/fork/commits/feature%2Fgui",
assert requests == ["/repos/fixture/fork/commits/feature%2Fgui",
f"/repos/fixture/fork/compare/{head}...{target}"]
assert all(not any(arg in {"fetch", "checkout", "reset", "update-ref", "stash"} for arg in cmd) for cmd in commands)
assert git("rev-parse", "HEAD", cwd=linked) == head
@@ -158,7 +158,7 @@ def test_counts_are_honest_without_fetch(installation, tip_kind, compare, expect
assert status["behind"] == expected
assert status["updateAvailable"] is (expected != 0)
if tip_kind != "unknown":
assert requests == [MAIN_CHANNEL, "/repos/fixture/fork/commits/main"]
assert requests == ["/repos/fixture/fork/commits/main"]
def test_cache_force_expiry_and_passive_opt_out(installation, monkeypatch):
@@ -177,26 +177,26 @@ def test_cache_force_expiry_and_passive_opt_out(installation, monkeypatch):
responses[url] = (503, {})
(root / "dirty.txt").write_text("carried work")
assert check()["dirty"] is True
assert requests.count(url) == requests.count(MAIN_CHANNEL) == 1
assert requests.count(url) == 1
assert check(force=True)["error"] == "fetch-failed"
clock[0] += 3599
assert check()["error"] == "fetch-failed"
assert requests.count(url) == requests.count(MAIN_CHANNEL) == 2
assert requests.count(url) == 2
clock[0] += 2
responses[url] = (200, head)
assert check()["behind"] == 0
assert requests.count(url) == requests.count(MAIN_CHANNEL) == 3
assert requests.count(url) == 3
clock[0] += 86401
assert check()["behind"] == 0
assert requests.count(url) == requests.count(MAIN_CHANNEL) == 4
assert requests.count(url) == 4
(home / "config.yaml").write_text("updates: {check: false}")
assert check(passive=True)["behind"] is None
assert check()["behind"] == 0
assert requests.count(url) == requests.count(MAIN_CHANNEL) == 4
assert requests.count(url) == 4
git("commit", "--allow-empty", "-m", "moved")
responses[url] = (200, git("rev-parse", "HEAD"))
assert check()["behind"] == 0
assert requests.count(url) == requests.count(MAIN_CHANNEL) == 5
assert requests.count(url) == 5
def test_explicit_and_current_branch_heal_only_after_confirmed_absence(installation, monkeypatch):
@@ -212,7 +212,7 @@ def test_explicit_and_current_branch_heal_only_after_confirmed_absence(installat
assert failed["branch"] == "deleted"
assert failed["error"] == "fetch-failed"
assert git("branch", "--show-current", cwd=linked) == "feature/gui"
assert requests == [MAIN_CHANNEL]
assert requests == []
@pytest.mark.parametrize("selection", ["explicit", "configured", "current", "detached"])
@@ -285,14 +285,14 @@ def test_channel_failure_never_probes_or_heals_a_branch(installation, name, fail
assert json.loads(branch_file.read_text())["branch"] == "deleted"
def test_unpublished_main_record_follows_the_branch(installation):
"""A 404 for main.json keeps a checkout updating via git: the configured
branch is probed exactly as for a published source-branch channel, and the
Desktop branch setting is left alone."""
def test_main_follows_the_branch_without_reading_a_channel_record(installation):
"""main IS the git branch: no R2 record is requested (a regional WAF can answer
it 403), the configured branch is probed, and the Desktop branch setting is left alone."""
from hermes_cli.source_check import check_for_updates
_root, linked, home, _base, head, responses, requests, _git = installation
responses[MAIN_CHANNEL] = (404, source_channel("main", "fixture/fork"))
# Even a record that would answer is never read.
responses[MAIN_CHANNEL] = (403, {"message": "blocked by WAF"})
branch_path = "/repos/fixture/fork/commits/desktop-choice"
responses[branch_path] = (200, head)
branch_file = home / "desktop-update.json"
@@ -303,7 +303,7 @@ def test_unpublished_main_record_follows_the_branch(installation):
assert status.get("branch") == "desktop-choice", status
assert status.get("targetSha") == head, status
assert status["behind"] == 0
assert requests == [MAIN_CHANNEL, branch_path]
assert requests == [branch_path]
assert json.loads(branch_file.read_text())["branch"] == "desktop-choice"
@@ -379,7 +379,7 @@ def test_never_pushed_branch_keeps_its_pin(installation, pinned_by):
assert json.loads(branch_file.read_text()) == {"branch": "local-work"}
else:
assert not branch_file.exists()
assert requests == [MAIN_CHANNEL]
assert requests == []
@pytest.mark.parametrize("merge", ["fast-forward", "rebase", "unmerged"])
@@ -436,7 +436,7 @@ def test_source_admission_is_stamp_owned_not_path_owned(installation, mechanism)
responses["/repos/fixture/fork/commits/feature%2Fgui"] = (200, head)
status = check_for_updates(install_root=linked, home=home)
assert status["supported"] is (mechanism in ("self", None))
assert requests == ([MAIN_CHANNEL, "/repos/fixture/fork/commits/feature%2Fgui"]
assert requests == (["/repos/fixture/fork/commits/feature%2Fgui"]
if status["supported"] else [])
empty = home / "no-source"
empty.mkdir()
@@ -483,7 +483,7 @@ def test_malformed_optional_changelog_and_cache_do_not_hide_the_update(installat
data["status"] = None
cache.write_text(json.dumps(data))
assert check_for_updates(install_root=root, home=home, cache_path=cache)["behind"] == 2
assert requests == [MAIN_CHANNEL, "/repos/fixture/fork/commits/main",
assert requests == ["/repos/fixture/fork/commits/main",
f"/repos/fixture/fork/compare/{head}...{'a' * 40}"] * 2