Passive checks (`hermes --version`, the banner, the Desktop/dashboard update check) now make one channel-read attempt again. Offline usually surfaces as DNS EAI_AGAIN or ENETUNREACH, which pm.network.is_transient classes as transient, so wrapping every read in retry_network added 7 s of backoff to the synchronous version line, stretched a hung CDN from 30 s to ~127 s, and logged a WARNING to stderr on every retry. `release_channels.retrying_reads()` opts a block in; update_cmd wraps the channel resolution of `hermes update` in it. Tests: keep the Retry-After retry test (now scoped to the update path) and replace the budget-exhaustion test with one pinning that passive reads and 404s make exactly one attempt.
332 lines
17 KiB
Python
332 lines
17 KiB
Python
"""Public channel resolution is strict, pinned, and independent of name registries."""
|
|
from copy import deepcopy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import importlib.util
|
|
|
|
import pytest
|
|
|
|
# The HTTP fixture is shared without making tests/ an importable package.
|
|
_spec = importlib.util.spec_from_file_location("channel_http_fixture", Path(__file__).parents[1] / "scripts/test_release_channels.py")
|
|
_fixture = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_fixture)
|
|
object_server, publisher = _fixture.object_server, _fixture.publisher
|
|
|
|
|
|
def seed_manifest(pub, request, objects):
|
|
from hermes_cli.release_channels import build_prefix, canonical_json
|
|
key = build_prefix(request["buildId"]) + "build.json"
|
|
manifest = {"schema": 1, "receiverProtocol": 1, "request": request, "packages": [{"platform": "darwin", "arch": "arm64", "variant": "bundled",
|
|
"artifact": {"key": build_prefix(request["buildId"]) + "darwin/package.zip", "sha256": "f" * 64, "size": 123},
|
|
"version": request["version"], "identity": request["identity"]["appId"], "teamId": "ABCDEFGHIJ",
|
|
"feed": {"key": build_prefix(request["buildId"]) + "darwin/stable-mac.yml", "channel": "stable"}}]}
|
|
body = canonical_json(manifest)
|
|
objects[key] = body
|
|
head = {"buildId": request["buildId"], "sequence": request["sequence"], "manifestKey": key, "sha256": hashlib.sha256(body).hexdigest()}
|
|
record_key = f"releases/channels/{request['channel']}.json"
|
|
record = json.loads(objects[record_key])
|
|
record["head"] = head
|
|
objects[record_key] = canonical_json(record)
|
|
return manifest, record
|
|
|
|
|
|
def test_resolve_verifies_exact_manifest_and_preserves_retirement_constraints():
|
|
from hermes_cli.release_channels import ChannelReader, ChannelError, canonical_json
|
|
with object_server() as (url, objects, headers, requests, faults):
|
|
pub = publisher(url)
|
|
for name in ("old-preview", "next-preview"):
|
|
pub.create(name)
|
|
request = pub.allocate("next-preview", "a" * 40, "1.2.3")
|
|
request.update(version="1.2.3", windowsVersion="1.2.3.0", releaseTag="v1.2.3")
|
|
manifest, record = seed_manifest(pub, request, objects)
|
|
record["policy"] = "stable-release"
|
|
objects["releases/channels/next-preview.json"] = canonical_json(record)
|
|
reader = ChannelReader(url + "/bucket", repository="example/hermes-agent")
|
|
assert reader.resolve("next-preview").manifest == manifest
|
|
old = pub._read("old-preview")[0]
|
|
old.update(state="retired", destination="next-preview", minimumVersion="1.0.0",
|
|
destinationHead=record["head"], receiverProtocol=1,
|
|
receiver={"kind": "discontinued"}, lastHead=None)
|
|
objects["releases/channels/old-preview.json"] = canonical_json(old)
|
|
resolved = reader.resolve("old-preview")
|
|
assert resolved.requested == old and resolved.terminal == record
|
|
assert resolved.requested["minimumVersion"] == "1.0.0"
|
|
# Retirement pins the qualified destination, not an unqualified later head.
|
|
newer = dict(request, buildId="f" * 32, sequence=2, sourceVersion="1.3.0", version="1.3.0", windowsVersion="1.3.0.0", releaseTag="v1.3.0")
|
|
record["nextSequence"] = 3
|
|
objects["releases/channels/next-preview.json"] = canonical_json(record)
|
|
seed_manifest(pub, newer, objects)
|
|
after_advance = reader.resolve("old-preview")
|
|
assert after_advance.manifest == manifest
|
|
assert after_advance.terminal["head"]["buildId"] == newer["buildId"]
|
|
objects[record["head"]["manifestKey"]] += b" "
|
|
with pytest.raises(ChannelError, match="SHA256"):
|
|
reader.resolve("old-preview")
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["X", "../x", "two--parts", "a/b", "a%2fb", "a_1", "con", "nul", "x-", "x" * 33, " a", "a\n"])
|
|
def test_names_are_validated_without_normalizing(name):
|
|
from hermes_cli.release_channels import ChannelError, validate_name
|
|
with pytest.raises(ChannelError):
|
|
validate_name(name)
|
|
|
|
|
|
def test_update_reads_retry_transient_http_and_honor_retry_after(monkeypatch):
|
|
from email.message import Message
|
|
from urllib.error import HTTPError
|
|
from hermes_cli.release_channels import ChannelReader, retrying_reads
|
|
|
|
url = "http://127.0.0.1:12345/releases/fixture.json"
|
|
headers = Message()
|
|
headers["Retry-After"] = "7"
|
|
attempts = []
|
|
waits = []
|
|
|
|
class Response:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def geturl(self):
|
|
return url
|
|
|
|
def read(self, _limit):
|
|
return b"fixture"
|
|
|
|
def opener(request, timeout):
|
|
assert timeout == 30
|
|
attempts.append(request.full_url)
|
|
if len(attempts) == 1:
|
|
raise HTTPError(url, 503, "unavailable", headers, None)
|
|
return Response()
|
|
|
|
monkeypatch.setattr("pm.network.time.sleep", waits.append)
|
|
reader = ChannelReader("http://127.0.0.1:12345", opener=opener)
|
|
with retrying_reads():
|
|
assert reader.read_bytes("releases/fixture.json") == b"fixture"
|
|
assert attempts == [url, url]
|
|
assert waits == [7.0]
|
|
|
|
|
|
def test_passive_reads_and_missing_objects_make_one_attempt(monkeypatch):
|
|
"""Offline looks transient (ENETUNREACH); a passive check must not back off on it."""
|
|
import errno
|
|
from email.message import Message
|
|
from urllib.error import HTTPError, URLError
|
|
from hermes_cli.release_channels import ChannelError, ChannelNotFound, ChannelReader, retrying_reads
|
|
|
|
waits = []
|
|
monkeypatch.setattr("pm.network.time.sleep", waits.append)
|
|
|
|
def reader(fault, calls):
|
|
def opener(request, timeout):
|
|
calls.append(request.full_url)
|
|
raise fault
|
|
return ChannelReader("https://releases.example", opener=opener)
|
|
|
|
calls = []
|
|
offline = URLError(OSError(errno.ENETUNREACH, "Network is unreachable"))
|
|
with pytest.raises(ChannelError, match="unavailable"):
|
|
reader(offline, calls).read_bytes("releases/channels/main.json")
|
|
assert len(calls) == 1
|
|
|
|
calls.clear()
|
|
missing = HTTPError("https://releases.example/x", 404, "missing", Message(), None)
|
|
with retrying_reads(), pytest.raises(ChannelNotFound):
|
|
reader(missing, calls).read_bytes("releases/channels/stable.json")
|
|
assert len(calls) == 1
|
|
assert waits == []
|
|
|
|
|
|
def test_reader_rejects_cycles_identity_substitution_and_cross_authority():
|
|
from hermes_cli.release_channels import ChannelReader, ChannelError, canonical_json
|
|
with object_server() as (url, objects, headers, requests, faults):
|
|
pub = publisher(url)
|
|
pub.create("alpha")
|
|
request = pub.allocate("alpha", "a" * 40, "1.2.3")
|
|
manifest, record = seed_manifest(pub, request, objects)
|
|
reader = ChannelReader(url + "/bucket", repository="example/hermes-agent")
|
|
substituted = deepcopy(manifest)
|
|
substituted["request"]["identity"]["appId"] = "ai.other.identity"
|
|
raw = canonical_json(substituted)
|
|
objects[record["head"]["manifestKey"]] = raw
|
|
record["head"]["sha256"] = hashlib.sha256(raw).hexdigest()
|
|
objects["releases/channels/alpha.json"] = canonical_json(record)
|
|
with pytest.raises(ChannelError, match="identity"):
|
|
reader.resolve("alpha")
|
|
record["repository"] = "other/hermes-agent"
|
|
objects["releases/channels/alpha.json"] = canonical_json(record)
|
|
with pytest.raises(ChannelError, match="authority"):
|
|
reader.resolve("alpha")
|
|
record["repository"] = "example/hermes-agent"
|
|
record.update(state="retired", destination="alpha", minimumVersion="1.0.0", lastHead=record["head"],
|
|
destinationHead=record["head"], receiverProtocol=1,
|
|
receiver={"kind": "discontinued"})
|
|
objects["releases/channels/alpha.json"] = canonical_json(record)
|
|
with pytest.raises(ChannelError, match="cycle"):
|
|
reader.resolve("alpha")
|
|
|
|
|
|
def test_legacy_bootstrap_uses_real_archive_keys_and_source_main_has_no_bundle():
|
|
from hermes_cli.release_channels import canonical_json, ChannelError
|
|
with object_server() as (url, objects, headers, requests, faults):
|
|
pub = publisher(url, verify_build=lambda request, manifest: True)
|
|
preview = pub.create("temporary")
|
|
request = pub.allocate("temporary", "a" * 40, "2.0.0")
|
|
manifest, _ = seed_manifest(pub, request, objects)
|
|
for name, policy, tag in [("stable", "stable-release", "v2.0.0"), ("canary", "canary-release", "v2.1.0+canary.20260913T000100Z")]:
|
|
legacy = deepcopy(manifest)
|
|
legacy["request"].update(channel=name, buildId=("a" if name == "stable" else "b") * 32,
|
|
releaseTag=tag, version=tag[1:], windowsVersion="2.0.0.0" if name == "stable" else "2.1.0.10")
|
|
legacy["packages"][0].update(version=tag[1:])
|
|
legacy["packages"][0]["artifact"]["key"] = f"releases/tag/{tag}/actual.zip"
|
|
legacy["packages"][0]["feed"]["key"] = f"releases/tag/{tag}/stable-mac.yml"
|
|
record = dict(preview, name=name, policy=policy, nextSequence=2,
|
|
head={"buildId": legacy["request"]["buildId"], "sequence": 1,
|
|
"manifestKey": "releases/channel-builds/" + legacy["request"]["buildId"] + "/build.json",
|
|
"sha256": hashlib.sha256(canonical_json(legacy)).hexdigest()})
|
|
pub.bootstrap(record, legacy, publish=True)
|
|
assert pub.reader.resolve(name).manifest == legacy
|
|
assert pub.request(legacy["request"]["buildId"]) == legacy["request"]
|
|
from hermes_cli.release_channels import validate_request
|
|
invalid_versions = ["2.1.0.65536", "65536.1.0.0", "2.1.0.-1", "2.1.0.1.0", "2.1.0.x"]
|
|
if policy == "stable-release":
|
|
invalid_versions.append("2.0.0.10")
|
|
for windows_version in invalid_versions:
|
|
with pytest.raises(ChannelError, match="Windows version"):
|
|
validate_request(dict(legacy["request"], windowsVersion=windows_version), policy=policy)
|
|
with pytest.raises(ChannelError, match="Protected"):
|
|
pub.promote(legacy["request"]["buildId"])
|
|
with pytest.raises(ChannelError, match="Protected"):
|
|
pub.allocate(name, "a" * 40, "2.0.0")
|
|
|
|
|
|
@pytest.mark.parametrize("sequence", [1, 65535, 65536, 2**32 - 1])
|
|
def test_sequence_versions_remain_monotonic_at_native_rollover(sequence):
|
|
from hermes_cli.release_channels import package_versions
|
|
version, windows = package_versions(sequence)
|
|
assert version == f"0.0.{sequence}"
|
|
quad = tuple(map(int, windows.split(".")))
|
|
assert quad[1] * 65536 + quad[2] == sequence and max(quad) <= 65535
|
|
if sequence > 1:
|
|
assert quad > tuple(map(int, package_versions(sequence - 1)[1].split(".")))
|
|
|
|
|
|
@pytest.mark.parametrize("sequence", [0, -1, True, 2**32, "1"])
|
|
def test_sequence_exhaustion_never_wraps(sequence):
|
|
from hermes_cli.release_channels import package_versions, ChannelError
|
|
with pytest.raises(ChannelError):
|
|
package_versions(sequence)
|
|
|
|
|
|
def test_malformed_record_and_unqualified_retirement_never_resolve():
|
|
from hermes_cli.release_channels import ChannelError, canonical_json
|
|
with object_server() as (url, objects, headers, requests, faults):
|
|
pub = publisher(url)
|
|
pub.create("preview")
|
|
record = pub._read("preview")[0]
|
|
malformed = dict(record)
|
|
del malformed["head"]
|
|
objects["releases/channels/preview.json"] = canonical_json(malformed)
|
|
with pytest.raises(ChannelError):
|
|
pub.reader.resolve("preview")
|
|
objects["releases/channels/preview.json"] = canonical_json(record)
|
|
pub.create("destination")
|
|
request = pub.allocate("destination", "a" * 40, "1.0.0")
|
|
request.update(version="1.0.0", windowsVersion="1.0.0.0", releaseTag="v1.0.0")
|
|
_, target = seed_manifest(pub, request, objects)
|
|
target["policy"] = "stable-release"
|
|
objects["releases/channels/destination.json"] = canonical_json(target)
|
|
record.update(state="retired", destination="destination", minimumVersion="2.0.0", lastHead=None,
|
|
destinationHead=target["head"], receiverProtocol=1,
|
|
receiver={"kind": "discontinued"})
|
|
objects["releases/channels/preview.json"] = canonical_json(record)
|
|
with pytest.raises(ChannelError, match="minimum version"):
|
|
pub.reader.resolve("preview")
|
|
|
|
|
|
@pytest.mark.parametrize("field,value", [("policy", []), ("state", {}), ("revision", True), ("head", []), ("identity", None)])
|
|
def test_malformed_wire_types_are_channel_errors(field, value):
|
|
from hermes_cli.release_channels import validate_record, ChannelError
|
|
from scripts.releases.channels import preview_identity
|
|
record = {"schema": 1, "name": "arbitrary", "repository": "example/hermes-agent", "policy": "preview",
|
|
"state": "active", "revision": 1, "nextSequence": 1, "head": None,
|
|
"identity": preview_identity("arbitrary", "a" * 16)}
|
|
record[field] = value
|
|
with pytest.raises(ChannelError):
|
|
validate_record(record)
|
|
|
|
|
|
def stable_request(version="1.2.3", archive_ref=None):
|
|
from scripts.releases.channels import preview_identity
|
|
request = {"schema": 1, "buildId": "a" * 32, "channel": "stable", "sequence": 1,
|
|
"repository": "example/hermes-agent", "commit": "b" * 40,
|
|
"sourceVersion": version, "version": version, "windowsVersion": version + ".0",
|
|
"releaseTag": "v" + version, "identity": preview_identity("stable", "a" * 16),
|
|
"bundleEnv": {}, "publicBase": "https://releases.example"}
|
|
if archive_ref is not None:
|
|
request["archiveRef"] = archive_ref
|
|
return request
|
|
|
|
|
|
def stable_manifest(request, archive_prefix):
|
|
from hermes_cli.release_channels import build_prefix
|
|
manifest = {"schema": 1, "receiverProtocol": 1, "request": request, "packages": [
|
|
{"platform": "darwin", "arch": "arm64", "variant": "bundled",
|
|
"identity": request["identity"]["appId"], "version": request["version"], "teamId": "ABCDEFGHIJ",
|
|
"artifact": {"key": archive_prefix + "Hermes.dmg", "sha256": "d" * 64, "size": 100},
|
|
"feed": {"key": archive_prefix + "stable-mac.yml", "channel": "stable"}}]}
|
|
record = {"schema": 1, "name": request["channel"], "repository": request["repository"],
|
|
"policy": "stable-release", "state": "active", "revision": 1, "nextSequence": 2,
|
|
"identity": request["identity"],
|
|
"head": {"buildId": request["buildId"], "sequence": request["sequence"],
|
|
"manifestKey": build_prefix(request["buildId"]) + "build.json", "sha256": "a" * 64}}
|
|
return manifest, record
|
|
|
|
|
|
def test_archive_ref_names_the_protected_archive_prefix():
|
|
from hermes_cli.release_channels import validate_manifest, validate_request
|
|
request = stable_request(archive_ref="rc.2-v1.2.3")
|
|
validate_request(request, policy="stable-release")
|
|
manifest, record = stable_manifest(request, "releases/tag/rc.2-v1.2.3/")
|
|
assert validate_manifest(manifest, record, request["publicBase"]) == manifest
|
|
|
|
|
|
def test_stable_manifest_without_archive_ref_fails_closed_outside_the_tag_prefix():
|
|
from hermes_cli.release_channels import validate_manifest, ChannelError
|
|
request = stable_request()
|
|
manifest, record = stable_manifest(request, "releases/tag/rc.2-v1.2.3/")
|
|
with pytest.raises(ChannelError, match="namespace"):
|
|
validate_manifest(manifest, record, request["publicBase"])
|
|
|
|
|
|
def test_archive_ref_equal_to_the_release_tag_keeps_the_tag_prefix():
|
|
from hermes_cli.release_channels import validate_manifest, validate_request
|
|
request = stable_request(archive_ref="v1.2.3")
|
|
validate_request(request, policy="stable-release")
|
|
manifest, record = stable_manifest(request, "releases/tag/v1.2.3/")
|
|
assert validate_manifest(manifest, record, request["publicBase"]) == manifest
|
|
|
|
|
|
def test_archive_ref_must_name_the_release_version():
|
|
from hermes_cli.release_channels import validate_request, ChannelError
|
|
with pytest.raises(ChannelError, match="(?i)archive ref"):
|
|
validate_request(stable_request(archive_ref="rc.2-v1.2.4"), policy="stable-release")
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["rc.1-v0.21.5", "rc.12-v1.0.0"])
|
|
def test_attempt_ref_shapes_are_attempt_refs(ref):
|
|
from scripts.releases.versioning import parse_attempt_ref
|
|
assert parse_attempt_ref(ref) is not None
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["v0.21.5-rc", "v0.21.5-rc.1", "rc.01-v0.21.5", "rc.0-v0.21.5",
|
|
"rc.1-v2026.9.21", "v0.21.5", "abandoned-rc.1-v0.21.5"])
|
|
def test_non_attempt_ref_shapes_are_rejected_as_archive_refs(ref):
|
|
from hermes_cli.release_channels import validate_request, ChannelError
|
|
with pytest.raises(ChannelError, match="(?i)archive ref"):
|
|
validate_request(stable_request(archive_ref=ref), policy="stable-release")
|