From 4b351c751a3be1eaef0cedff432ed8f72f78c95d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:37:03 -0700 Subject: [PATCH] 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. --- hermes_cli/source_releases.py | 17 +++++---- .../test_source_channel_integration.py | 27 ++++++++++---- tests/hermes_cli/test_source_check.py | 36 +++++++++---------- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/hermes_cli/source_releases.py b/hermes_cli/source_releases.py index 57831e20b7..d9927e102a 100644 --- a/hermes_cli/source_releases.py +++ b/hermes_cli/source_releases.py @@ -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") diff --git a/tests/hermes_cli/test_source_channel_integration.py b/tests/hermes_cli/test_source_channel_integration.py index 67091bf2de..38bcef8eb9 100644 --- a/tests/hermes_cli/test_source_channel_integration.py +++ b/tests/hermes_cli/test_source_channel_integration.py @@ -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): diff --git a/tests/hermes_cli/test_source_check.py b/tests/hermes_cli/test_source_check.py index f94b8b80b8..6a757b33f3 100644 --- a/tests/hermes_cli/test_source_check.py +++ b/tests/hermes_cli/test_source_check.py @@ -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