Files
hermes-agent/tests/scripts/test_release_channels.py
ethernet d786c08e37 refactor(releases): allocate channels in CI; drop resume and local R2 minting
The local --channel command no longer touches R2: it resolves the exact
pushed commit and dispatches the default-branch workflow, whose privileged
allocation step creates the channel and mints the immutable build request.
The disposable allocation path is generalized to cover the unscoped
production preview, gated by a new `channel` workflow input; build legs
consume the same channel-build / channel-request-sha256 job outputs as
before. The anti-tamper gate is now commit_build.admit (maintainer
permission) running inside the allocate step.

Drop the resume path: --resume-channel-build / --request-sha256 and
resume_build() are gone, and allocate_protected no longer recovers a lost
request PUT by sequence. Retrying re-dispatches and mints a fresh sequence
slot; idempotency survives via the deterministic build ID and the existing
immutable-request dedup. Keep the preview allocate `lastAllocation` build-ID
field (concurrent-CAS uniqueness), which is not resume.

channel_public_base now defaults to the documented production origin like
the commit-build path, so a local command names its page without a
hand-set CLOUDFLARE_R2_PUBLIC_URL.

Also drops a stale fork-isolation assertion left behind by the
fork-conditional dispatch removal.
2026-09-15 18:33:15 -04:00

524 lines
30 KiB
Python

"""Exercise publisher/reader through the real signed HTTP transport."""
from __future__ import annotations
from contextlib import contextmanager
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import socket
from threading import Lock, Thread
from urllib.parse import parse_qs, urlsplit
import xml.sax.saxutils
import pytest
@contextmanager
def object_server():
objects, headers, requests = {}, {}, []
lock = Lock()
faults = {"lose_put": False, "stale_public": None, "conflict": None, "bad_pagination": False}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
parsed = urlsplit(self.path)
query = parse_qs(parsed.query)
with lock:
requests.append(("GET", self.path))
if "list-type" in query:
prefix = query.get("prefix", [""])[0]
keys = sorted(k for k in objects if k.startswith(prefix))
start = int(query.get("continuation-token", ["0"])[0])
page = keys[start:start + 2]
more = start + 2 < len(keys)
body = ("<ListBucketResult>" + "".join(
f"<Contents><Key>{xml.sax.saxutils.escape(k)}</Key></Contents>" for k in page)
+ f"<IsTruncated>{str(more).lower()}</IsTruncated>"
+ (f"<NextContinuationToken>{start + 2}</NextContinuationToken>" if more else "")
+ "</ListBucketResult>").encode()
if faults["bad_pagination"]:
body = b"<ListBucketResult><IsTruncated>true</IsTruncated></ListBucketResult>"
etag = None
else:
key = parsed.path.removeprefix("/bucket/")
body = objects.get(key)
if not self.headers.get("Authorization") and faults["stale_public"] is not None:
body = faults["stale_public"]
etag = '"' + hashlib.sha256(body).hexdigest() + '"' if body is not None else None
self.send_response(200 if body is not None else 404)
if etag:
self.send_header("ETag", etag)
self.end_headers()
if body is not None:
self.wfile.write(body)
def do_PUT(self):
body = self.rfile.read(int(self.headers["Content-Length"]))
key = self.path.removeprefix("/bucket/")
with lock:
requests.append(("PUT", key))
if faults["conflict"]:
conflict = faults["conflict"]
faults["conflict"] = None
conflict(objects, key)
old = objects.get(key)
etag = '"' + hashlib.sha256(old).hexdigest() + '"' if old is not None else None
conflict = ((self.headers.get("If-None-Match") == "*" and old is not None)
or (self.headers.get("If-Match") is not None and self.headers["If-Match"] != etag))
if conflict:
self.send_response(412)
self.end_headers()
return
objects[key] = body
headers[key] = dict(self.headers)
if faults["lose_put"]:
faults["lose_put"] = False
self.connection.shutdown(socket.SHUT_RDWR)
self.connection.close()
return
self.send_response(200)
self.end_headers()
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", objects, headers, requests, faults
finally:
server.shutdown()
server.server_close()
thread.join()
def publisher(url, **kwargs):
from scripts.releases.channels import ChannelPublisher, R2ChannelStore
store = R2ChannelStore({"access_key_id": "fixture", "secret_key": "fixture"}, url, "bucket")
return ChannelPublisher(store, "example/hermes-agent", url + "/bucket",
authorize=kwargs.pop("authorize", lambda action, record: None), **kwargs)
def test_unknown_channel_created_over_http_retains_identity_and_immutable_requests():
from hermes_cli.release_channels import ChannelReader, ChannelNotFound
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url)
reader = ChannelReader(url + "/bucket", repository="example/hermes-agent")
with pytest.raises(ChannelNotFound):
reader.resolve("not-registered-in-code")
record = pub.create("not-registered-in-code")
assert reader.resolve(record["name"]).manifest is None
one = pub.allocate(record["name"], "a" * 40, "1.2.3", {"FEATURE": "one"})
two = pub.allocate(record["name"], "a" * 40, "1.2.3", {"FEATURE": "two"})
assert one["identity"] == two["identity"] == record["identity"]
assert one["buildId"] != two["buildId"]
assert one["sequence"] < two["sequence"]
assert pub.request(one["buildId"]) == one
assert reader.resolve(record["name"]).terminal["nextSequence"] == two["sequence"] + 1
assert headers[f"releases/channels/{record['name']}.json"]["Cache-Control"] == "no-store"
assert "immutable" in headers[f"releases/channel-builds/{one['buildId']}/request.json"]["Cache-Control"]
def put_build(objects, request):
from hermes_cli.release_channels import build_prefix, canonical_json
prefix = build_prefix(request["buildId"])
data = b"fixture native artifact"
objects[prefix + "darwin/package.zip"] = data
manifest = {"schema": 1, "receiverProtocol": 1, "request": request, "packages": [{"platform": "darwin", "arch": "arm64", "variant": "bundled",
"artifact": {"key": prefix + "darwin/package.zip", "sha256": hashlib.sha256(data).hexdigest(), "size": len(data)},
"version": request["version"], "identity": request["identity"]["appId"], "teamId": "ABCDEFGHIJ",
"feed": {"key": prefix + "darwin/stable-mac.yml", "channel": "stable"}}]}
objects[prefix + "build.json"] = canonical_json(manifest)
return manifest
def test_concurrent_allocations_reverse_completion_retirement_and_readback():
from concurrent.futures import ThreadPoolExecutor
from hermes_cli.release_channels import ChannelError, canonical_json
from scripts.releases.channels import PublicVisibilityError
with object_server() as (url, objects, headers, requests, faults):
with ThreadPoolExecutor(max_workers=2) as pool:
pub = publisher(url, verify_build=lambda request, manifest: True)
created = list(pool.map(pub.create, ["race-preview"] * 2))
assert created[0]["identity"] == created[1]["identity"]
with ThreadPoolExecutor(max_workers=2) as pool:
allocated = list(pool.map(lambda _: pub.allocate("race-preview", "a" * 40, "1.0.0"), range(2)))
one, two = sorted(allocated, key=lambda r: r["sequence"])
assert one["sequence"] != two["sequence"], "concurrent allocations must be distinct"
for request in (one, two):
put_build(objects, request)
pub.promote(two["buildId"])
with pytest.raises(ChannelError, match="newer|stale"):
pub.promote(one["buildId"])
assert pub.reader.resolve("race-preview").manifest["request"] == two
# The protected destination is seeded from accepted existing metadata, not a preview masquerade.
pub.create("destination")
target = pub._read("destination")[0]
target["policy"] = "stable-release"
stable = dict(two, channel="destination", identity=target["identity"], releaseTag="v2.0.0", version="2.0.0", windowsVersion="2.0.0.0", sourceVersion="2.0.0", buildId="e" * 32)
stable_manifest = put_build(objects, stable)
raw = canonical_json(stable_manifest)
target.update(nextSequence=stable["sequence"] + 1, head={"buildId": stable["buildId"], "sequence": stable["sequence"], "manifestKey": "releases/channel-builds/" + stable["buildId"] + "/build.json", "sha256": hashlib.sha256(raw).hexdigest()})
objects["releases/channels/destination.json"] = canonical_json(target)
unsupported = dict(stable_manifest)
del unsupported["receiverProtocol"]
objects[target["head"]["manifestKey"]] = canonical_json(unsupported)
old_target = {**target, "head": {**target["head"], "sha256": hashlib.sha256(canonical_json(unsupported)).hexdigest()}}
objects["releases/channels/destination.json"] = canonical_json(old_target)
with pytest.raises(ChannelError, match="receiver support"):
pub.retire("race-preview", "destination", "2.0.0")
assert pub._read("race-preview")[0]["state"] == "active"
objects[target["head"]["manifestKey"]] = raw
objects["releases/channels/destination.json"] = canonical_json(target)
retired = pub.retire("race-preview", "destination", "2.0.0")
assert retired["destinationHead"] == target["head"]
assert retired["lastHead"]["buildId"] == two["buildId"]
with pytest.raises(ChannelError, match="Retired"):
pub.promote(two["buildId"])
with pytest.raises(ChannelError, match="Retired"):
pub.create("race-preview")
pub.create("visibility")
faults["lose_put"] = True
lost = pub.allocate("visibility", "b" * 40, "1.0.0")
assert pub.request(lost["buildId"]) == lost
faults["stale_public"] = b"{}"
with pytest.raises(PublicVisibilityError, match="Committed"):
pub.allocate("visibility", "b" * 40, "1.0.0")
assert json.loads(objects["releases/channels/visibility.json"])["nextSequence"] > lost["sequence"] + 1
def test_list_bootstrap_protected_roles_and_qualification_gate():
from hermes_cli.release_channels import ChannelError
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url)
main = {"schema": 1, "name": "main", "repository": "example/hermes-agent", "policy": "source-branch", "state": "active", "revision": 1, "nextSequence": 1, "identity": None, "head": None, "delivery": {"kind": "source-branch", "branch": "main"}}
assert pub.bootstrap(main) == main and not objects
pub.bootstrap(main, publish=True)
for name in ("first", "second", "third"):
pub.create(name)
assert {r["name"] for r in pub.list()} == {"main", "first", "second", "third"}
assert any("continuation-token" in path for method, path in requests)
faults["bad_pagination"] = True
with pytest.raises(ChannelError, match="pagination"):
pub.list()
faults["bad_pagination"] = False
assert pub.reader.resolve("main").manifest is None
with pytest.raises(ChannelError, match="Protected"):
pub.allocate("main", "a" * 40, "1.0.0")
request = pub.allocate("first", "a" * 40, "1.0.0")
put_build(objects, request)
with pytest.raises(ChannelError, match="qualification"):
pub.promote(request["buildId"])
assert pub.reader.resolve("first").terminal["head"] is None
def test_retirement_race_requires_a_new_explicit_attempt():
from hermes_cli.release_channels import ChannelError, canonical_json
from scripts.releases.channels import ChannelConflict
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url, verify_build=lambda request, manifest: True)
pub.create("preview")
first = pub.allocate("preview", "a" * 40, "1.0.0")
second = pub.allocate("preview", "b" * 40, "1.0.0")
put_build(objects, first)
put_build(objects, second)
pub.promote(first["buildId"])
pub.create("stable")
target = pub._read("stable")[0]
target["policy"] = "stable-release"
stable = dict(first, channel="stable", identity=target["identity"], releaseTag="v2.0.0", version="2.0.0", windowsVersion="2.0.0.0", sourceVersion="2.0.0", buildId="f" * 32)
manifest = put_build(objects, stable)
target.update(nextSequence=2, head={"buildId": stable["buildId"], "sequence": 1, "manifestKey": "releases/channel-builds/" + stable["buildId"] + "/build.json", "sha256": hashlib.sha256(canonical_json(manifest)).hexdigest()})
objects["releases/channels/stable.json"] = canonical_json(target)
def race(store, object_key):
record = json.loads(store[object_key])
record["revision"] += 1
record["head"] = {"buildId": second["buildId"], "sequence": second["sequence"], "manifestKey": "releases/channel-builds/" + second["buildId"] + "/build.json", "sha256": hashlib.sha256(store["releases/channel-builds/" + second["buildId"] + "/build.json"]).hexdigest()}
store[object_key] = canonical_json(record)
faults["conflict"] = race
with pytest.raises(ChannelConflict):
pub.retire("preview", "stable", "2.0.0")
assert pub.reader.resolve("preview").manifest["request"] == second
with pytest.raises(ChannelError, match="cycle"):
pub.retire("preview", "preview", "2.0.0")
assert pub.retire("preview", "stable", "2.0.0")["lastHead"]["buildId"] == second["buildId"]
def test_retire_derives_receiver_kind_from_channel_identity_match():
"""The pinned kind is derived from identity comparison, never caller-asserted."""
from hermes_cli.release_channels import canonical_json
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url, verify_build=lambda request, manifest: True)
for name in ("mainline-preview", "suffixed-preview", "stable"):
pub.create(name)
# A mainline-like prerelease shares the destination stable identity.
preview = pub._read("mainline-preview")[0]
first = pub.allocate("mainline-preview", "a" * 40, "1.0.0")
put_build(objects, first)
pub.promote(first["buildId"])
target = pub._read("stable")[0]
target["policy"] = "stable-release"
target["identity"] = preview["identity"]
stable = dict(first, channel="stable", identity=target["identity"], releaseTag="v2.0.0", version="2.0.0", windowsVersion="2.0.0.0", sourceVersion="2.0.0", buildId="e" * 32)
manifest = put_build(objects, stable)
target.update(nextSequence=stable["sequence"] + 1, head={"buildId": stable["buildId"], "sequence": stable["sequence"], "manifestKey": "releases/channel-builds/" + stable["buildId"] + "/build.json", "sha256": hashlib.sha256(canonical_json(manifest)).hexdigest()})
objects["releases/channels/stable.json"] = canonical_json(target)
in_place = pub.retire("mainline-preview", "stable", "2.0.0")
assert in_place["receiver"] == {"kind": "in-place"}
assert pub.reader.resolve("mainline-preview").requested["receiver"] == {"kind": "in-place"}
# A suffixed channel identity can never match stable's.
second = pub.allocate("suffixed-preview", "b" * 40, "1.0.0")
put_build(objects, second)
pub.promote(second["buildId"])
discontinued = pub.retire("suffixed-preview", "stable", "2.0.0")
assert discontinued["receiver"] == {"kind": "discontinued"}
assert pub.reader.resolve("suffixed-preview").requested["receiver"] == {"kind": "discontinued"}
def test_mutable_read_loss_recovery_never_clones_another_allocation():
from scripts.releases.channels import ChannelConflict
from hermes_cli.release_channels import canonical_json
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url)
pub.create("nonce-check")
initial = pub._read("nonce-check")[0]
def compete(store, key):
winner = dict(initial, revision=2, nextSequence=2,
lastAllocation={"buildId": "b" * 32, "sequence": 1})
store[key] = canonical_json(winner)
faults["conflict"] = compete
request = pub.allocate("nonce-check", "a" * 40, "1.0.0")
assert request["sequence"] == 2
key = "releases/channel-builds/" + request["buildId"] + "/request.json"
with pytest.raises(ChannelConflict):
pub.store.put(key, canonical_json(dict(request, commit="b" * 40)))
assert pub.request(request["buildId"]) == request
def test_protected_releases_bootstrap_retry_and_refuse_late_or_ungated_promotion():
from hermes_cli.release_channels import ChannelError, canonical_json
from scripts.releases.channels import preview_identity
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url, verify_build=lambda request, manifest: True)
accepted = True
gate = lambda request: accepted
identity = preview_identity("official", "1" * 16)
def allocate(version, commit):
return pub.allocate_protected(
"official", commit, version, release_tag="v" + version,
version=version, windows_version=version + ".0", identity=identity,
policy="stable-release", release_gate=gate)
# A failed request PUT is not recovered by sequence: retrying the same
# release adopts a fresh sequence gap, while the deterministic build ID
# keeps the immutable request idempotent across retries.
original_write = pub._write
def lose_request(key, value, etag=None):
if key.endswith("request.json"):
raise OSError("request upload interrupted")
return original_write(key, value, etag)
pub._write = lose_request
with pytest.raises(OSError):
allocate("1.0.0", "a" * 40)
pub._write = original_write
allocate("0.5.0", "e" * 40)
first = allocate("1.0.0", "a" * 40)
assert allocate("1.0.0", "a" * 40) == first
assert len(first["buildId"]) == 32
assert pub.reader.resolve("official").manifest is None
put_build(objects, first)
accepted = False
with pytest.raises(ChannelError, match="release gate"):
pub.promote_protected(first["buildId"], policy="stable-release", release_gate=gate)
assert pub.reader.resolve("official").manifest is None
accepted = True
pub.promote_protected(first["buildId"], policy="stable-release", release_gate=gate)
second = allocate("2.0.0", "b" * 40)
late = allocate("1.5.0", "c" * 40)
for request in (second, late):
put_build(objects, request)
pub.promote_protected(second["buildId"], policy="stable-release", release_gate=gate)
assert pub.promote_protected(second["buildId"], policy="stable-release", release_gate=gate)["head"]["buildId"] == second["buildId"]
with pytest.raises(ChannelError, match="version|newer|stale"):
pub.promote_protected(late["buildId"], policy="stable-release", release_gate=gate)
with pytest.raises(ChannelError, match="Protected"):
pub.promote(second["buildId"])
assert pub.reader.resolve("official").manifest["request"] == second
# A competing allocation must not turn a protected promotion into an overwrite.
def contend(store, key):
record = json.loads(store[key])
record["revision"] += 1
record["nextSequence"] += 1
store[key] = canonical_json(record)
third = allocate("3.0.0", "d" * 40)
put_build(objects, third)
faults["conflict"] = contend
pub.promote_protected(third["buildId"], policy="stable-release", release_gate=gate)
assert pub.reader.resolve("official").manifest["request"] == third
def test_accepted_release_receipts_feed_the_protected_head_without_rebuilding(tmp_path, monkeypatch):
from scripts.releases import channel_releases
from hermes_cli.release_channels import ChannelError, canonical_json
from scripts.releases.channels import preview_identity
from scripts.releases.handoff import receipt_name
from copy import deepcopy
import zipfile
identity = preview_identity("released", "2" * 16)
tag, commit = "v2.0.0", "d" * 40
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url)
base = pub.public_base
prefix = f"releases/tag/{tag}/"
for platform in ("darwin", "win32"):
for arch in ("arm64", "x64"):
native = "macos" if platform == "darwin" else "windows"
version = "2.0.0" if platform == "darwin" else "2.0.0.0"
metadata = {"platform": native, "arch": arch, "tag": tag, "commit": commit,
"version": version, "identity": identity["appId" if platform == "darwin" else "msixAppIdWithOrg"]}
files = {}
if platform == "darwin":
metadata["teamId"] = "ABCDEFGHIJ"
for suffix in ("zip", "dmg", "zip.blockmap", "dmg.blockmap"):
name = f"{identity['artifactNamePascal']}-2.0.0-mac-{arch}.{suffix}"
files[name] = (name + " fixture bytes").encode()
metadata["filename"] = f"{identity['artifactNamePascal']}-2.0.0-mac-{arch}.zip"
else:
metadata.update(publisher="CN=Fixture", applicationId=identity["appNamePascal"])
files[f"{identity['artifactNamePascal']}-2.0.0-win-{arch}.msix"] = b"fixture msix"
files[f"metadata-{native}-{arch}.json"] = canonical_json(metadata)
rows = []
for name, body in files.items():
objects[prefix + name] = body
(tmp_path / name).write_bytes(body)
rows.append({"path": name, "size": len(body), "sha256": hashlib.sha256(body).hexdigest()})
receipt = {"schema": 1, "tag": tag, "commit": commit, "name": f"{platform}-{arch}", "files": rows}
(tmp_path / receipt_name(receipt["name"])).write_bytes(canonical_json(receipt))
objects[prefix + receipt_name(receipt["name"])] = canonical_json(receipt)
bundle_name = f"{identity['artifactNamePascal']}-2.0.0.0-win.msixbundle"
with zipfile.ZipFile(tmp_path / bundle_name, "w") as archive:
archive.writestr("AppxMetadata/AppxBundleManifest.xml", f'<Bundle><Identity Name="{identity["msixAppIdWithOrg"]}" Publisher="CN=Fixture" Version="2.0.0.0"/><Packages><Package Type="application" Architecture="arm64"/><Package Type="application" Architecture="x64"/></Packages></Bundle>')
body = (tmp_path / bundle_name).read_bytes()
objects[prefix + bundle_name] = body
receipt = {"schema": 1, "tag": tag, "commit": commit, "name": "windows-universal", "files": [{"path": bundle_name, "size": len(body), "sha256": hashlib.sha256(body).hexdigest()}]}
(tmp_path / receipt_name(receipt["name"])).write_bytes(canonical_json(receipt))
objects[prefix + receipt_name(receipt["name"])] = canonical_json(receipt)
# An R2 policy record chooses this name, not the legacy default selector.
record = {"schema": 1, "name": "released", "repository": pub.repository, "policy": "stable-release", "state": "active", "revision": 1, "nextSequence": 1, "head": None, "identity": identity}
objects["releases/channels/released.json"] = canonical_json(record)
assert channel_releases.select_channel(pub, "stable-release") == "released"
native = channel_releases.read_native_receipts(tmp_path, tag, commit)
request = pub.allocate_protected("released", commit, "2.0.0", release_tag=tag, version="2.0.0", windows_version="2.0.0.0", identity=identity, policy="stable-release", release_gate=lambda request: True)
from scripts.bundles.channel_artifacts import assemble
manifest, feeds = assemble(request, native, tmp_path, artifact_prefix=prefix)
assert {p["arch"] for p in manifest["packages"]} == {"arm64", "x64"}
assert all(p["artifact"]["key"].startswith(prefix) for p in manifest["packages"])
assert all(f.is_file() for f in feeds)
accepted = {"packages": []}
for row in manifest["packages"]:
accepted["packages"].append({"platform": "macos" if row["platform"] == "darwin" else "windows", "arch": row["arch"], "identity": row["identity"], "version": row["version"], "artifact": {"url": base + "/" + row["artifact"]["key"], "sha256": row["artifact"]["sha256"]}, **{k: row[k] for k in ("teamId", "publisher") if k in row}})
if row["platform"] == "win32":
accepted["packages"][-1]["applicationId"] = identity["appNamePascal"]
channel_releases.match_accepted_packages(manifest, accepted)
wrong = deepcopy(accepted)
wrong["packages"][0]["artifact"]["sha256"] = "0" * 64
with pytest.raises(ChannelError, match="accepted"):
channel_releases.match_accepted_packages(manifest, wrong)
with pytest.raises(ChannelError, match="every native"):
channel_releases.match_accepted_packages(dict(manifest, packages=manifest["packages"][:1]), accepted)
# Exercise the real controller, HTTP receipt downloader, immutable feeds,
# manifest and final CAS; only GitHub admission and generated product facts
# are fixture inputs (no native signature acceptance is claimed here).
from scripts.releases import r2, commit_build
monkeypatch.setattr(channel_releases, "admit_transaction", lambda policy, env: (tag, commit))
monkeypatch.setattr(channel_releases, "accepted_stable", lambda *args: accepted)
monkeypatch.setattr(channel_releases, "product_identity", lambda tag: dict(identity))
monkeypatch.setattr(commit_build, "version_at", lambda *args: "2.0.0")
monkeypatch.setattr(r2, "credentials", lambda: (pub.store.creds, url, "bucket"))
monkeypatch.setattr(r2, "public_base_url", lambda: base)
def put(**kwargs):
pub.store.put(kwargs["key"], __import__("pathlib").Path(kwargs["file"]).read_bytes())
monkeypatch.setattr(r2, "put", put)
result = channel_releases.publish_release("stable-release", {"GITHUB_REPOSITORY": pub.repository}, tmp_path / "downloaded")
assert result["name"] == "released"
assert pub.reader.resolve("released").manifest == manifest
before = dict(objects)
assert channel_releases.publish_release("stable-release", {"GITHUB_REPOSITORY": pub.repository}, tmp_path / "retry") == result
assert objects == before
(tmp_path / bundle_name).write_bytes(b"corrupt")
with pytest.raises(ChannelError, match="receipt"):
channel_releases.read_native_receipts(tmp_path, tag, commit)
def test_protected_transaction_refuses_custom_workflow_failed_gate_and_unpublished_release():
from scripts.releases import channel_releases
from hermes_cli.release_channels import ChannelError
tag, commit = "v2.0.0", "a" * 40
env = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REPOSITORY": "example/hermes-agent", "GITHUB_SHA": commit,
"GITHUB_REF": "refs/tags/" + tag, "RELEASE_TAG": tag,
"GITHUB_WORKFLOW_REF": "example/hermes-agent/.github/workflows/stable-release.yml@refs/tags/" + tag,
"RELEASE_NEEDS": json.dumps({key: {"result": "success"} for key in channel_releases.STABLE_NEEDS})}
published = {"tagName": tag, "isDraft": False, "isPrerelease": False}
def run(command):
if command[:3] == ["gh", "release", "view"]:
return json.dumps(published)
if command[:2] == ["git", "ls-remote"]:
return commit + "\trefs/tags/" + tag
if command[:2] == ["git", "rev-parse"]:
return commit
return ""
assert channel_releases.admit_transaction("stable-release", env, run=run) == (tag, commit)
published["isDraft"] = True
with pytest.raises(ChannelError, match="published"):
channel_releases.admit_transaction("stable-release", env, run=run)
published["isDraft"] = False
with pytest.raises(ChannelError, match="workflow"):
channel_releases.admit_transaction("stable-release", dict(env, GITHUB_WORKFLOW_REF="custom.yml"), run=run)
with pytest.raises(ValueError, match="blocked"):
channel_releases.admit_transaction("stable-release", dict(env, RELEASE_NEEDS="{}"), run=run)
from hermes_cli.release_channels import canonical_json
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url)
# Exercise HTTPS authority validation through the loopback transport.
pub.public_base = "https://releases.example"
candidate = {"schema": 2, "tag": tag, "commit": commit,
"smoke_results": {job: {"result": "success"} for job in channel_releases.stable.SMOKE_JOBS},
"packages": []}
for platform in ("macos", "windows"):
for arch in ("arm64", "x64"):
candidate["packages"].append({"platform": platform, "arch": arch, "tag": tag, "commit": commit,
"version": "2.0.0" if platform == "macos" else "2.0.0.0", "identity": "fixture.identity",
"teamId": "ABCDEFGHIJ", "publisher": "CN=Fixture", "applicationId": "Fixture",
"artifact": {"url": f"{pub.public_base}/releases/tag/{tag}/fixture-{arch}." + ("zip" if platform == "macos" else "msixbundle"), "sha256": "d" * 64}})
raw = canonical_json(candidate)
key = f"releases/tag/{tag}/release-candidates.json"
objects[key] = raw
candidate_env = {"CANDIDATE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), "CANDIDATE_MANIFEST_URL": pub.public_base + "/" + key}
with pytest.raises(ChannelError, match="transaction"):
channel_releases.accepted_stable(pub, candidate_env, tag, commit)
objects["releases/stable/release-candidates.json"] = raw
assert channel_releases.accepted_stable(pub, candidate_env, tag, commit) == candidate
faults["stale_public"] = b"{}"
with pytest.raises(ChannelError):
channel_releases.accepted_stable(pub, candidate_env, tag, commit)
def test_request_inputs_are_rejected_before_allocating():
from hermes_cli.release_channels import ChannelError
with object_server() as (url, objects, headers, requests, faults):
pub = publisher(url)
pub.create("validation")
before = dict(objects)
with pytest.raises(ValueError):
pub.allocate("validation", "a" * 40, "1.0.0", [])
assert objects == before
with pytest.raises(ChannelError):
pub.allocate("validation", "not-a-sha", "1.0.0")
assert objects == before