fix(update): stable is the latest published GitHub release, never an R2 record

Publishing a vX.Y.Z GitHub release is the whole promotion: the source
updater's stable channel now reads only GitHub's latest published release
(non-draft, non-prerelease, strict vX.Y.Z) and verifies the tag on origin.
It no longer asks R2 for releases/channels/stable.json first, and it
ignores the R2 release-candidates pointer, which the stable pipeline writes
before the GitHub draft is published.
This commit is contained in:
Teknium committed 2026-10-09 19:46:19 -07:00
1 parent 6a92a2aa49
commit 158cd52a6b
7 files changed
+102 -121

No files matched your search

+19 -13
View File
@@ -119,17 +119,11 @@ def _resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=N
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 == "stable":
# Until R2 publishes a stable record, stable is the latest published
# GitHub release (non-draft, non-prerelease, strict vX.Y.Z, origin
# tag verified). Never a branch tip or an arbitrary tag.
tag, commit = resolve_source_release("stable", git_cmd, cwd, repository=repository)
if commit is None:
raise ValueError("No published stable release could be verified") from None
return SourceTarget(channel, channel, repository, commit=commit, version=str(tag).removeprefix("v"))
if channel != "main":
raise
# main IS the source branch; its record can only add a retirement.
@@ -159,6 +153,17 @@ def _resolve_source_target(channel: str, git_cmd=None, cwd=None, *, repository=N
version=request["sourceVersion"], build_id=request["buildId"])
def _resolve_stable(repository: str, git_cmd=None, cwd=None) -> SourceTarget:
"""Stable IS the repository's latest published GitHub release: the tag a release
publishes (non-draft, non-prerelease, strict vX.Y.Z), verified against origin.
No R2 record or pointer is consulted; publishing the release is the only promotion.
"""
tag, commit = resolve_source_release("stable", git_cmd, cwd, repository=repository, pointer=False)
if commit is None:
raise ValueError("No published stable release could be verified")
return SourceTarget("stable", "stable", repository, commit=commit, version=str(tag).removeprefix("v"))
def _refuse_retirement_downgrade(request: dict, terminal: dict, git_cmd, cwd) -> None:
"""Qualification of preview data is not permission to roll back newer source."""
from pathlib import Path
@@ -289,12 +294,13 @@ def _release_pointer(channel: str) -> tuple[str | None, str | None]:
return page.tags[0], None
def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=None) -> tuple[str | None, str | None]:
def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=None,
pointer: bool = True) -> tuple[str | None, str | None]:
"""Read published stable/canary release metadata (not channel discovery).
Runtime check/apply go through ``resolve_source_target``; only an unpublished
R2 ``stable`` record falls back here.
Channel pointers outrank GitHub's release listing. A malformed pointer,
The source updater's ``stable`` reads it with ``pointer=False``: GitHub's latest
published release only. With ``pointer`` the R2 channel pointers outrank GitHub's
release listing. A malformed pointer,
draft, or tag/commit mismatch is not permission to select a different build.
``git_cmd`` resolves the selected tag on origin; ZIP callers omit it and
resolve the same tag through GitHub's commit endpoint.
@@ -305,7 +311,7 @@ def resolve_source_release(channel: str, git_cmd=None, cwd=None, *, repository=N
repository = repository or source_repository(git_cmd, cwd)
base = f"https://api.github.com/repos/{repository}"
tag, pinned_sha = (_release_pointer(channel)
if repository.lower() == OFFICIAL_REPOSITORY.lower() else (None, None))
if pointer and repository.lower() == OFFICIAL_REPOSITORY.lower() else (None, None))
if tag is None:
release = _published_fallback(channel, base)
tag = release["tag_name"]
+14 -20
View File
@@ -246,26 +246,20 @@ def _identity(name: str, token: str) -> dict:
"windowsExecutableName": pascal, "msixAppIdWithOrg": f"NousResearch.{pascal}"}
def stable_objects(commit: str, *, version: str = "2099.1.1", build_id: str = "c" * 32,
repository: str = REPOSITORY) -> dict[str, bytes]:
"""A published stable release pinned at ``commit``: the channel record and its build manifest."""
identity = _identity("stable", "5" * 16)
prefix = f"releases/channel-builds/{build_id}/"
request = {"schema": 1, "buildId": build_id, "channel": "stable", "sequence": 1,
"repository": repository, "commit": commit, "sourceVersion": version, "version": version,
"windowsVersion": version + ".0", "releaseTag": "v" + version, "identity": identity,
"bundleEnv": {}, "publicBase": f"https://{ASSETS}"}
manifest = {"schema": 1, "receiverProtocol": 1, "request": request, "packages": [
{"platform": "darwin", "arch": "arm64", "variant": "bundled", "identity": identity["appId"],
"version": version, "teamId": "ABCDEFGHIJ",
"artifact": {"key": prefix + "Hermes.dmg", "sha256": "d" * 64, "size": 100},
"feed": {"key": prefix + "stable-mac.yml", "channel": "stable"}}]}
body = canonical(manifest)
record = {"schema": 1, "name": "stable", "repository": repository, "policy": "stable-release",
"state": "active", "revision": 1, "nextSequence": 2, "identity": identity,
"head": {"buildId": build_id, "sequence": 1, "manifestKey": prefix + "build.json",
"sha256": hashlib.sha256(body).hexdigest()}}
return {"/releases/channels/stable.json": canonical(record), f"/{prefix}build.json": body}
GITHUB_API = "api.github.com"
def stable_release(inst: Installed, commit: str, *, tag: str = "v99.1.1", draft: bool = False,
prerelease: bool = False, api_commit: str | None = None) -> dict[str, bytes]:
"""A published GitHub release ``tag`` at ``commit``: the tag on origin plus the API objects.
``api_commit`` makes GitHub report a different commit than the tag on origin points at.
"""
I.git("tag", "-f", tag, commit, cwd=inst.origin)
release = {"tag_name": tag, "draft": draft, "prerelease": prerelease}
base = f"/repos/{REPOSITORY}"
return {f"{base}/releases/latest": canonical(release), f"{base}/releases/tags/{tag}": canonical(release),
f"{base}/commits/{tag}": canonical({"sha": api_commit or commit})}
# ---------------------------------------------------------------------------
@@ -1,21 +1,20 @@
"""Release-channel records: the updater follows the stable record exactly, or refuses.
"""Stable releases: the updater follows the latest published GitHub release exactly, or refuses.
A source install subscribed to ``stable`` resolves its target from the channel record at
``https://hermes-assets.nousresearch.com/releases/channels/stable.json`` and the build manifest
it names (digest-pinned). The edge serves those objects (a fake of the R2 bucket, behind the
TLS-inspecting proxy that is the namespace's only egress) and the git server behind the same
proxy serves the source. In every cell origin/main has moved past what stable pins, so "the
updater silently followed main" is visible as a HEAD that landed on the main tip.
A source install subscribed to ``stable`` resolves its target from GitHub's latest published
release (``api.github.com/repos/<repo>/releases/latest``: non-draft, non-prerelease, strict
``vX.Y.Z``) and verifies the tag on origin points at the commit GitHub reports. No R2 channel
record is read. The edge serves the API (behind the TLS-inspecting proxy that is the
namespace's only egress) and the git server behind the same proxy serves the source. In every
cell origin/main has moved past the release, so "the updater silently followed main" is
visible as a HEAD that landed on the main tip.
Classes: the record is valid (land exactly on its commit), unavailable (404 / 503 / tunnel cut),
malformed (not JSON, manifest digest mismatch, another repository's record), or names a commit
the forge does not have. #124309 is the missing live record; this suite pins that the updater
then refuses stable with a clear message instead of moving to main.
Classes: the release is valid (land exactly on its commit), unavailable (404 / 503 / tunnel cut),
unacceptable (draft, prerelease, not JSON), or its commit disagrees with the tag on origin.
In each failure the updater refuses stable with a clear message instead of moving to main.
"""
from __future__ import annotations
import json
import shutil
import pytest
@@ -35,7 +34,7 @@ pytestmark = [
pytest.mark.skipif(I.real_uv() is None, reason="uv required"),
]
STABLE = "/releases/channels/stable.json"
LATEST = f"/repos/{S.REPOSITORY}/releases/latest"
@pytest.fixture(scope="module")
@@ -47,30 +46,32 @@ def inst(tmp_path_factory):
def _release_and_main_tip(inst: S.Installed, tag: str) -> tuple[str, str]:
"""A stable candidate commit on top of the install, then a newer main tip on top of it."""
"""A release commit on top of the install, then a newer main tip on top of it."""
release = inst.publish(f"{tag}-release")
tip = I.publish_commit(inst.origin, inst.root, f"e2e network: {tag} main tip",
{f"E2E_NETWORK_{tag}_TIP.txt": "unreleased\n"})
return release, tip
def _update(inst: S.Installed, assets: N.App | None = None, **edge_kw) -> S.Result:
edge = inst.edge(assets=assets, **edge_kw)
def _update(inst: S.Installed, api: N.App | None = None, **edge_kw) -> S.Result:
edge = inst.edge(extra={S.GITHUB_API: api or N.static_app({})}, **edge_kw)
try:
return inst.hermes("update", "--yes", edge=edge, timeout=600)
finally:
edge.close()
def test_valid_stable_record_lands_exactly_on_its_commit(inst):
def test_published_release_lands_exactly_on_its_commit_without_reading_r2(inst):
release, tip = _release_and_main_tip(inst, "valid")
r = _update(inst, N.static_app(S.stable_objects(release)))
assert r.rc == 0, "update to a valid stable record failed\n" + r.report(inst)
r = _update(inst, N.static_app(S.stable_release(inst, release)))
assert r.rc == 0, "update to the published stable release failed\n" + r.report(inst)
head = inst.head()
assert head != tip, "stable subscriber was moved to the unreleased main tip\n" + r.report(inst)
assert head == release, f"stable update landed on {head}, not the released {release}\n" + r.report(inst)
reads = [h.path for h in r.edge.proxy.requests(S.ASSETS)] if r.edge else []
assert STABLE in reads, f"the stable record was not read through the proxy: {reads}\n" + r.report(inst)
reads = [h.path for h in r.edge.proxy.requests(S.GITHUB_API)] if r.edge else []
assert LATEST in reads, f"the latest release was not read through the proxy: {reads}\n" + r.report(inst)
r2 = [h.path for h in r.edge.proxy.requests(S.ASSETS)] if r.edge else []
assert not any("/releases/" in path for path in r2), f"stable read R2 release objects: {r2}\n" + r.report(inst)
def _refused(inst: S.Installed, r: S.Result, before: dict, what: str) -> None:
@@ -80,46 +81,31 @@ def _refused(inst: S.Installed, r: S.Result, before: dict, what: str) -> None:
UNAVAILABLE = {
"not-published-404": dict(assets=N.static_app({})),
"outage-503": dict(assets=N.static_app({}, always={STABLE: N.Response(503, b"down\n")})),
"tunnel-cut": dict(eof_hosts=[S.ASSETS]),
"no-release-404": dict(api=N.static_app({})),
"outage-503": dict(api=N.static_app({}, always={LATEST: N.Response(503, b"down\n")})),
"tunnel-cut": dict(eof_hosts=[S.GITHUB_API]),
}
@pytest.mark.parametrize("fault", sorted(UNAVAILABLE))
def test_unavailable_stable_record_refuses_and_never_moves_to_main(inst, fault):
def test_unavailable_release_refuses_and_never_moves_to_main(inst, fault):
_release_and_main_tip(inst, f"unavailable-{fault}")
before = inst.state()
r = _update(inst, **UNAVAILABLE[fault])
_refused(inst, r, before, f"stable record {fault}")
_refused(inst, r, before, f"stable release {fault}")
def _malformed(release: str) -> dict[str, dict[str, bytes]]:
good = S.stable_objects(release)
manifest_key = next(k for k in good if k != STABLE)
tampered = dict(good)
manifest = json.loads(good[manifest_key])
manifest["request"]["commit"] = "f" * 40 # a different build than the record's digest pins
tampered[manifest_key] = S.canonical(manifest)
return {
"not-json": {STABLE: b"<html>502 Bad Gateway</html>\n"},
"manifest-digest-mismatch": tampered,
"other-repository": S.stable_objects(release, repository="someone-else/hermes-agent"),
}
def _unacceptable(inst: S.Installed, release: str, fault: str) -> dict[str, bytes]:
if fault == "not-json":
return {LATEST: b"<html>502 Bad Gateway</html>\n"}
if fault == "origin-tag-mismatch":
return S.stable_release(inst, release, api_commit="f" * 40)
return S.stable_release(inst, release, **{fault: True})
@pytest.mark.parametrize("fault", ["manifest-digest-mismatch", "not-json", "other-repository"])
def test_malformed_stable_record_refuses(inst, fault):
release, _ = _release_and_main_tip(inst, f"malformed-{fault}")
@pytest.mark.parametrize("fault", ["draft", "not-json", "origin-tag-mismatch", "prerelease"])
def test_unacceptable_release_refuses(inst, fault):
release, _ = _release_and_main_tip(inst, f"unacceptable-{fault}")
before = inst.state()
r = _update(inst, N.static_app(_malformed(release)[fault]))
_refused(inst, r, before, f"stable record {fault}")
def test_stable_record_naming_a_commit_the_forge_does_not_have(inst):
"""The record was published for a build whose commit never reached the forge (or was
force-pushed away): the fetch of that exact commit fails and nothing may change."""
_release_and_main_tip(inst, "unpublished-commit")
before = inst.state()
r = _update(inst, N.static_app(S.stable_objects("0123456789abcdef0123456789abcdef01234567")))
S.assert_nothing_changed(inst, before, r, "stable record naming an unpublished commit")
r = _update(inst, N.static_app(_unacceptable(inst, release, fault)))
_refused(inst, r, before, f"stable release {fault}")
+3
View File
@@ -97,6 +97,9 @@ def _source_channels_resolve_locally(request, monkeypatch):
return ChannelResolution(record, record, None)
monkeypatch.setattr(source_releases, "_resolve_channel", resolve)
# stable reads GitHub releases directly; keep it off the network the same way.
monkeypatch.setattr(source_releases, "_resolve_stable", lambda repository, *_: source_releases.SourceTarget(
"stable", "stable", repository, branch="stable"))
@pytest.fixture(autouse=True)
@@ -332,6 +332,9 @@ def test_offline_retirement_uses_qualified_build_before_current_stable(
update_cmd._cmd_update_impl(args, False)
assert git(source.root, "rev-parse", "HEAD") == source.commits[1]
assert saved(source)["channel"] == "stable"
# The adopted stable subscription follows the published GitHub release, not R2.
monkeypatch.setattr(source_releases, "_resolve_stable", lambda repository, *_: source_releases.SourceTarget(
"stable", "stable", repository, commit=source.commits[2], version="1.2.4"))
status = source_check.check_for_updates(install_root=source.root, home=source.home, force=True)
assert status["targetSha"] == source.commits[2], status
update_cmd._cmd_update_impl(args, False)
@@ -13,6 +13,9 @@ from hermes_cli import main, update_cmd
from hermes_cli.source_releases import resolve_source_release
from hermes_cli.update_channel import set_install_channel
# The fixture serves release metadata over loopback; stable's real GitHub path is the subject.
pytestmark = pytest.mark.real_release_channels
def git(root, *args):
return subprocess.run(
@@ -62,6 +65,9 @@ def releases(tmp_path, monkeypatch, request):
responses[f"/repos/NousResearch/hermes-agent/commits/{tag}"] = {
"sha": commits[1 if channel == "stable" else 2],
}
responses["/repos/NousResearch/hermes-agent/releases/latest"] = {
"tag_name": tags["stable"], "draft": False, "prerelease": False,
}
responses["/releases/stable/release-candidates.json"] = {
"tag": tags["stable"], "commit": commits[1],
}
@@ -253,23 +259,20 @@ def _track_official_origin(releases):
git(releases.root, "config", f"url.{releases.origin.as_uri()}.insteadOf", official)
def test_unpublished_stable_record_resolves_the_published_github_release(releases):
"""Until R2 carries a stable record, stable is the latest published GitHub release."""
def test_stable_is_the_latest_published_github_release_without_r2(releases):
"""Publishing a GitHub release is the only promotion stable needs: no R2 record or pointer."""
from hermes_cli import source_releases
from hermes_cli.release_channels import ChannelNotFound
_track_official_origin(releases)
releases.responses["/repos/NousResearch/hermes-agent/releases/latest"] = {
"tag_name": releases.tags["stable"], "draft": False, "prerelease": False}
def no_r2(name, repository):
raise AssertionError("stable must not read an R2 channel record")
def unpublished(name, repository):
raise ChannelNotFound(f"Channel object not found: releases/channels/{name}.json")
source_releases._resolve_channel = unpublished
source_releases._resolve_channel = no_r2
# A pointer naming a different build must not outrank the published release.
releases.responses["/releases/stable/release-candidates.json"] = {
"tag": "v99.0.0", "commit": releases.commits[3]}
target = source_releases.resolve_source_target("stable", ["git"], releases.root)
assert (target.commit, target.version) == (releases.commits[1], "1.2.3")
with pytest.raises(ChannelNotFound):
source_releases.resolve_source_target("canary", ["git"], releases.root)
assert not any(path.startswith("/releases/") for path in releases.requests)
@pytest.mark.parametrize("start,expected", [(0, 1), (3, 3)], ids=["behind-release", "newer-than-release"])
+10 -24
View File
@@ -88,6 +88,14 @@ def update_tree(tmp_path, monkeypatch):
args=args, resumed=resumed, requests=requests, plans=plans)
def _publish_stable(monkeypatch, t):
"""stable = the latest published GitHub release; its resolver pins t.wanted (v1.1.0)."""
from hermes_cli import source_releases
monkeypatch.setattr(source_releases, '_resolve_stable', lambda repository, *_: source_releases.SourceTarget(
'stable', 'stable', repository, commit=t.wanted, version='1.1.0'))
@pytest.mark.parametrize('case', ['main', 'explicit', 'missing', 'no-move', 'wrong-branch',
'fork-no-upstream', 'fork-upstream', 'fork-upstream-push-ok',
'fork-upstream-wrong-branch', 'fork-upstream-reverted',
@@ -205,20 +213,8 @@ def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree,
"""A stable update is pinned to the channel's exact commit: no tag lookup on
origin, the stale local ``v1.1.0`` never moves, and an explicit --branch
bypasses the channel."""
from hermes_cli import source_releases
from hermes_cli.release_channels import ChannelResolution
t = update_tree
# The stable channel is an R2 record whose published build pins t.wanted
# (the documented reader seam; see test_source_channel_integration).
record = {"schema": 1, "name": "stable", "repository": "NousResearch/hermes-agent",
"policy": "stable-release", "state": "active", "identity": {}, "nextSequence": 2,
"head": {"buildId": "build-fixture", "sequence": 1}}
manifest = {"schema": 1, "request": {"buildId": "build-fixture", "channel": "stable", "sequence": 1,
"repository": "NousResearch/hermes-agent", "commit": t.wanted, "sourceVersion": "1.1.0",
"version": "0.0.1", "identity": {}, "bundleEnv": {}}, "packages": []}
monkeypatch.setattr(source_releases, '_resolve_channel',
lambda name, repository: ChannelResolution(record, record, manifest))
_publish_stable(monkeypatch, t)
expected = t.wanted
if server in {'at-release', 'ahead-release'}:
git(t.clone, 'fetch', '--no-tags', 'origin', t.wanted)
@@ -279,23 +275,13 @@ def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree,
@pytest.mark.platforms('windows')
@pytest.mark.parametrize('transport', ['gitless', 'no-git', 'git-error', 'dirty'])
def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree, monkeypatch, tmp_path, transport):
from hermes_cli import source_releases
from hermes_cli.release_channels import ChannelResolution
t = update_tree
monkeypatch.setattr(cli_main, '_pause_windows_gateways_for_update',
lambda: {"resume_needed": True})
archive = tmp_path / 'source.zip'
git(t.origin, 'archive', '--format=zip', '--prefix=hermes-agent-source/', f'--output={archive}', t.wanted)
archive_bytes = archive.read_bytes()
record = {"schema": 1, "name": "stable", "repository": "NousResearch/hermes-agent",
"policy": "stable-release", "state": "active", "identity": {}, "nextSequence": 2,
"head": {"buildId": "build-fixture", "sequence": 1}}
manifest = {"schema": 1, "request": {"buildId": "build-fixture", "channel": "stable", "sequence": 1,
"repository": "NousResearch/hermes-agent", "commit": t.wanted, "sourceVersion": "1.1.0",
"version": "0.0.1", "identity": {}, "bundleEnv": {}}, "packages": []}
monkeypatch.setattr(source_releases, '_resolve_channel',
lambda name, repository: ChannelResolution(record, record, manifest))
_publish_stable(monkeypatch, t)
routes = {
f'/NousResearch/hermes-agent/archive/{t.wanted}.zip': archive_bytes,
}