`release.py release` gains two flags. They can be used together. --skip-bundles ships only the claim, the GitHub release, the final tag and the Docker image. No desktop, Termux or PM bundle job runs. The final tag records candidateManifestSha256: null. Publication moves only the Docker stable/latest aliases. The R2 stable head, feeds, APT, the downloads page, the signed-package baseline and the Store stay on the previous bundle release. --skip-tests builds, signs and publishes every artifact and runs no test job: source CI, Nix, PM bundle check, Termux, Windows live, install/update E2E, bootstrap identity, native smokes, upgrade acceptance, tests/docker and the in-build vitest step. The candidate manifest records each smoke as skipped, never as passed. The flags live in the claim message (skipBundles, skipTests), next to autopublish. They are not workflow inputs, so a rerun cannot change them. admit emits them, and every job condition and gate reads them. stable.validate_claim and stable.validate_final are now the one shape check for stable.py and the sequencer. The gates stay strict. SKIPPED_BY in stable.py maps each job to the flags that remove it. `gate` requires those jobs to report skipped and every other gated job to report success. A job that ran although a flag removes it blocks the release. A release that skipped bundles never moves the R2 stable head. Two readers depended on that head: - The next version was derived from it, so the next cut would reuse the version. It now takes the newer of the R2 head and the newest published non-prerelease GitHub release with a vX.Y.Z tag. Bare v* tags do not count, because those refs are not protected yet. - The sequencer used it to decide which published releases still need their publication pass, so a bundle-less release would re-advance every 15 minutes. The head is now the newer of the R2 head and the published release whose final tag binds the Docker stable alias digest. `release` also refuses a cut when its next version already has a final tag. That closes the window between the final tag and the public release, where the published identity still names the old version. Tests: 42 release test files, 546 passed. Three tests fail on this Windows host, and they fail the same way on a clean HEAD worktree: - test_stable_release_graph::test_docker_recovery_refuses_to_replace_a_divergent_version_tag - test_release_artifacts::test_windows_metadata_is_read_from_package_and_stale_stamp_is_rejected - test_tag_builds_summary::test_admitted_failure_publishes_tag_info_without_promoting_channel[True] Not verified: no real Stable Release dispatch ran with either flag, and actionlint is not installed on this host. The workflow changes are checked by the graph tests and by running the phase-result step script.
614 lines
35 KiB
Python
614 lines
35 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", {"HERMES_GUEST_ONBOARDING": "1"})
|
|
two = pub.allocate(record["name"], "a" * 40, "1.2.3", {"HERMES_GUEST_ONBOARDING": "0"})
|
|
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
|
|
monkeypatch.setattr(channel_releases, "admit_transaction",
|
|
lambda policy, env, **_kwargs: (tag, commit))
|
|
monkeypatch.setattr(channel_releases.stable, "final_context",
|
|
lambda env: (tag, commit, {"claim_epoch": 1_787_965_323,
|
|
"skip_bundles": False, "skip_tests": False}))
|
|
monkeypatch.setattr(channel_releases, "accepted_stable", lambda *args, **kwargs: accepted)
|
|
promotion_attempts = [0]
|
|
def promote_stable_feeds(*args):
|
|
promotion_attempts[0] += 1
|
|
if promotion_attempts[0] == 1:
|
|
raise ChannelError("fixture feed failure")
|
|
monkeypatch.setattr(channel_releases, "promote_stable_feeds", promote_stable_feeds)
|
|
monkeypatch.setattr(channel_releases, "product_identity", lambda tag: dict(identity))
|
|
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)
|
|
with pytest.raises(ChannelError, match="fixture feed failure"):
|
|
channel_releases.publish_release("stable-release", {"GITHUB_REPOSITORY": pub.repository}, tmp_path / "failed")
|
|
assert pub._read("released")[0]["head"] is None
|
|
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
|
|
assert manifest["request"]["sourceVersion"] == "2.0.0"
|
|
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_and_unpublished_release(monkeypatch):
|
|
from scripts.releases import channel_releases
|
|
from hermes_cli.release_channels import ChannelError
|
|
attempt, tag, commit = "rc.1-v2.0.0", "v2.0.0", "a" * 40
|
|
env = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_NAME": "workflow_dispatch",
|
|
"GITHUB_REPOSITORY": "example/hermes-agent", "RELEASE_TAG": attempt,
|
|
"RELEASE_COMMIT": commit, "RELEASE_CLAIM_TAG": attempt,
|
|
"RELEASE_CLAIM_OBJECT": "b" * 40,
|
|
"GITHUB_WORKFLOW_REF": "example/hermes-agent/.github/workflows/stable-release.yml@refs/tags/" + attempt}
|
|
published = [True]
|
|
|
|
def final_context(_env, run):
|
|
if not published[0]:
|
|
raise ValueError("Stable channel requires the published final release")
|
|
return tag, commit, {}
|
|
|
|
monkeypatch.setattr(channel_releases.stable, "final_context", final_context)
|
|
|
|
def run(command):
|
|
if command[-1] == ".default_branch":
|
|
return "main"
|
|
return ""
|
|
|
|
assert channel_releases.admit_transaction("stable-release", env, run=run) == (attempt, commit)
|
|
published[0] = False
|
|
with pytest.raises(ChannelError, match="published"):
|
|
channel_releases.admit_transaction("stable-release", env, run=run)
|
|
published[0] = True
|
|
with pytest.raises(ChannelError, match="controller"):
|
|
channel_releases.admit_transaction("stable-release", dict(env, GITHUB_WORKFLOW_REF="custom.yml"), run=run)
|
|
with pytest.raises(ChannelError, match="protected release tag"):
|
|
channel_releases.admit_transaction(
|
|
"stable-release", dict(env, RELEASE_TAG=tag + "-rc"), run=run)
|
|
|
|
|
|
def test_stable_admission_requires_an_attempt_ref_release_tag(monkeypatch):
|
|
from scripts.releases import channel_releases
|
|
from hermes_cli.release_channels import ChannelError
|
|
attempt, commit = "rc.2-v1.2.3", "c" * 40
|
|
env = {"GITHUB_ACTIONS": "true", "GITHUB_EVENT_NAME": "workflow_dispatch",
|
|
"GITHUB_REPOSITORY": "example/hermes-agent", "RELEASE_TAG": attempt,
|
|
"RELEASE_COMMIT": commit, "RELEASE_CLAIM_TAG": attempt,
|
|
"RELEASE_CLAIM_OBJECT": "b" * 40,
|
|
"GITHUB_WORKFLOW_REF": "example/hermes-agent/.github/workflows/stable-release.yml@refs/tags/" + attempt}
|
|
monkeypatch.setattr(channel_releases.stable, "final_context",
|
|
lambda _env, run: ("v1.2.3", commit, {}))
|
|
|
|
def run(command):
|
|
if command[-1] == ".default_branch":
|
|
return "main"
|
|
return ""
|
|
|
|
assert channel_releases.admit_transaction("stable-release", env, run=run) == (attempt, commit)
|
|
# The v-tag the final receipt binds is derived from the attempt ref, so the
|
|
# candidate manifest custody is checked against the claim's own version.
|
|
seen = {}
|
|
def final_context_checked(patched_env, run):
|
|
seen["RELEASE_TAG"] = patched_env["RELEASE_TAG"]
|
|
return "v1.2.3", commit, {}
|
|
monkeypatch.setattr(channel_releases.stable, "final_context", final_context_checked)
|
|
channel_releases.admit_transaction("stable-release", env, run=run)
|
|
assert seen["RELEASE_TAG"] == "v1.2.3"
|
|
with pytest.raises(ChannelError, match="protected release tag"):
|
|
channel_releases.admit_transaction("stable-release", dict(env, RELEASE_TAG="v1.2.3"), run=run)
|
|
|
|
|
|
def test_accepted_stable_reads_the_release_archive_by_tag(monkeypatch):
|
|
from scripts.releases import channel_releases
|
|
from hermes_cli.release_channels import ChannelError, canonical_json
|
|
tag, commit = "v2.0.0", "c" * 40
|
|
attempt = "rc.1-v2.0.0"
|
|
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"
|
|
release_epoch = 1_787_965_323
|
|
candidate = {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch,
|
|
"archive": attempt,
|
|
"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 "2026.5761.123.0", "identity": "fixture.identity",
|
|
**({"executableVersion": "2026.5761.123.0"} if platform == "windows" else {}),
|
|
"teamId": "ABCDEFGHIJ", "publisher": "CN=Fixture", "applicationId": "Fixture",
|
|
"artifact": {"url": f"{pub.public_base}/releases/tag/{attempt}/fixture-{arch}." + ("zip" if platform == "macos" else "msixbundle"), "sha256": "d" * 64}})
|
|
raw = canonical_json(candidate)
|
|
key = f"releases/tag/{attempt}/release-candidates.json"
|
|
objects[key] = raw
|
|
candidate_env = {"CANDIDATE_MANIFEST_SHA256": hashlib.sha256(raw).hexdigest(), "CANDIDATE_MANIFEST_URL": pub.public_base + "/" + key}
|
|
assert channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch,
|
|
skip_tests=False) == candidate
|
|
# Passed smokes cannot stand behind a claim that skipped tests, or the reverse.
|
|
with pytest.raises(ValueError, match="test policy"):
|
|
channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch,
|
|
skip_tests=True)
|
|
faults["stale_public"] = b"{}"
|
|
with pytest.raises(ChannelError):
|
|
channel_releases.accepted_stable(pub, candidate_env, attempt, commit, release_epoch,
|
|
skip_tests=False)
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_canary_native_version_is_derived_from_the_current_tag():
|
|
from scripts.releases.channel_releases import canary_windows_version
|
|
|
|
assert canary_windows_version("v0.27.1+canary.20260829T010203Z") == "26.829.1.203"
|
|
|
|
|
|
def test_stable_requests_name_the_attempt_archive_only_when_given():
|
|
from hermes_cli.release_channels import ChannelError
|
|
from scripts.releases.channels import preview_identity
|
|
with object_server() as (url, objects, headers, requests, faults):
|
|
pub = publisher(url)
|
|
identity = preview_identity("archived", "3" * 16)
|
|
gate = lambda request: True
|
|
|
|
def allocate(commit, version, archive_ref):
|
|
return pub.allocate_protected(
|
|
"archived", commit, version, release_tag="v" + version, version=version,
|
|
windows_version=version + ".0", identity=identity, policy="stable-release",
|
|
release_gate=gate, archive_ref=archive_ref)
|
|
|
|
request = allocate("a" * 40, "2.0.0", "rc.2-v2.0.0")
|
|
assert request["archiveRef"] == "rc.2-v2.0.0"
|
|
assert pub.request(request["buildId"]) == request
|
|
bare = allocate("b" * 40, "2.1.0", None)
|
|
assert "archiveRef" not in bare
|
|
assert pub.request(bare["buildId"]) == bare
|
|
with pytest.raises(ChannelError, match="(?i)archive ref"):
|
|
allocate("c" * 40, "2.2.0", "rc.2-v2.9.9")
|