feat(release): stage one receipt per build group

Stable now calls the desktop bundle workflow once per build group, and
each Mac arch and the Windows bundle assembly stage a <group>-receipt.json
into its attempt archive before the group's smoke runs. The receipts let
the next commit start each install arm from its own group's bytes.

publish-bundles and complete temporarily lose their candidate-manifest
digest source; the next commit moves that job into stable-release.yml and
wires the digest back.
This commit is contained in:
ethernet
2026-09-23 14:43:26 -04:00
parent 525ead7866
commit 107af642b2
5 changed files with 516 additions and 43 deletions

View File

@@ -7,6 +7,7 @@ import os
import re
import subprocess
import sys
import tempfile
import tomllib
import urllib.error
import urllib.request
@@ -89,6 +90,17 @@ RECEIPT_TARGETS = {
"win32-bundle": ("windows/x64", "windows/arm64"),
}
# The staged handoffs each receipt is assembled from. The Windows bundle
# receipt reads the per-arch metadata handoffs plus the universal bundle
# handoff; fetch() re-verifies every staged byte against its receipt digest,
# and validate_receipt enforces the signing facts (teamId, publisher).
RECEIPT_HANDOFFS = {
"darwin-arm64": ("darwin-arm64",),
"darwin-x64": ("darwin-x64",),
"win32-bundle": ("win32-x64", "win32-arm64", "windows-universal"),
}
RECEIPT_INCLUDES = ("metadata-*.json", "*.zip", "*.msixbundle")
def _validated_rows(manifest: dict, tag: str, commit: str, public_base: str,
release_epoch: int | None, *, archive: str) -> dict:
@@ -215,6 +227,81 @@ def plan_receipt_transitions(previous: dict, receipt_manifest: dict, receipt: st
return [_transition_row(target, old[target], new[target]) for target in targets]
def _receipt_manifest(root: Path, receipt: str, tag: str, commit: str, archive: str,
public_base: str, release_epoch: int) -> dict:
"""Rebuild one group's manifest rows from its staged, digest-verified handoffs."""
from scripts.releases.handoff import fetch, receipt_name
from scripts.releases.r2 import staging_key_for
names = RECEIPT_HANDOFFS.get(receipt)
if names is None:
raise ValueError(f"Unknown receipt: {receipt}")
# Re-downloading the staged bytes proves the group's handoff is complete
# and matches its receipt before this receipt is published.
fetch(tag=archive, commit=commit, names=list(names), root=root,
includes=list(RECEIPT_INCLUDES))
digests = {}
for name in names:
for row in json.loads((root / receipt_name(name)).read_text(encoding="utf-8-sig"))["files"]:
digests[row["path"]] = row["sha256"]
rows = [json.loads(file.read_text(encoding="utf-8-sig"))
for file in sorted(root.glob("metadata-*.json"))]
universal = None
if receipt == "win32-bundle":
bundles = [file.name for file in root.glob("*.msixbundle") if not file.name.startswith("Store-")]
if len(bundles) != 1:
raise ValueError(f"Expected one universal bundle, found {len(bundles)}")
universal = bundles[0]
packages = []
for row in rows:
filename = universal if row["platform"] == "windows" else row.get("filename")
if not filename or filename not in digests or not (root / filename).is_file():
raise ValueError(f"Receipt {receipt} is missing staged bytes for "
f"{row['platform']}/{row['arch']}")
packages.append({
key: value for key, value in {
**row,
"artifact": {"url": f"{public_base.rstrip('/')}/{staging_key_for(archive, filename)}",
"sha256": digests[filename]},
}.items() if key != "filename"
})
if row["platform"] != "windows" and not filename.endswith(".zip"):
raise ValueError(f"Receipt {receipt} needs a signed app ZIP for {row['platform']}/{row['arch']}")
if receipt == "win32-bundle":
from scripts.bundles.release_artifacts import validate_windows_bundle
validate_windows_bundle(root / universal,
[row for row in rows if row["platform"] == "windows"])
return {"schema": 2, "tag": tag, "commit": commit, "releaseEpoch": release_epoch,
"archive": archive, "packages": packages}
def stage_receipt(env: dict, receipt: str) -> None:
"""Publish one group's signed receipt into its immutable attempt archive.
The receipt names exactly that group's rows (decision 11): it is staged
before the group's smokes run, so it carries no smoke results, while
acceptance still blocks publication.
"""
from scripts.releases.r2 import put
tag, commit, claim = stable_context(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
archive = claim["claim_tag"]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
manifest = _receipt_manifest(root, receipt, tag, commit, archive, base, claim["claim_epoch"])
validate_receipt(manifest, receipt, tag, commit, base, claim["claim_epoch"], archive=archive)
file = root / f"{receipt}-receipt.json"
file.write_text(json.dumps(manifest, sort_keys=True, indent=2) + "\n", encoding="utf-8")
put(tag=archive, key=file.name, file=str(file), immutable=True)
digest = hashlib.sha256(file.read_bytes()).hexdigest()
url = f"{base}/releases/tag/{archive}/{receipt}-receipt.json"
print(url)
print(digest)
emit({"receipt-url": url, "receipt-sha256": digest}, env)
def read_manifest(url: str, expected_hash: str | None = None, *, expected_origin: str | None = None,
opener=urllib.request.urlopen) -> dict:
location = urlsplit(url)
@@ -449,14 +536,30 @@ def verify(env: dict) -> None:
"release-epoch": claim["claim_epoch"]}, env)
def transitions(env: dict) -> None:
RECEIPT_SOURCES = {
"darwin-arm64": "RECEIPT_DARWIN_ARM64",
"darwin-x64": "RECEIPT_DARWIN_X64",
"win32-bundle": "RECEIPT_WIN32_BUNDLE",
}
def _stage_transition(env: dict, archive: str, base: str, row: dict) -> dict:
from scripts.releases.r2 import put
tag, commit, claim = stable_context(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
archive = claim["claim_tag"]
candidate = read_candidate(env)
validate_candidates(candidate, tag, commit, base, claim["claim_epoch"], archive=archive)
transition = row["transition"]
name = f"acceptance-{row['target']}.json"
file = Path(env["RUNNER_TEMP"]) / name
file.write_text(json.dumps(transition), encoding="utf-8")
put(tag=archive, key=name, file=str(file), immutable=True)
url = f"{base}/releases/tag/{archive}/{name}"
if read_manifest(url) != transition:
raise ValueError("Transition manifest read-back mismatch")
return {"arch": transition["arch"], "manifest": url, "old": transition["old"]["tag"],
"id": row["target"],
"manifest_sha256": hashlib.sha256(file.read_bytes()).hexdigest()}
def _published_baseline(env: dict, base: str) -> dict:
try:
previous = read_manifest(env.get("BASELINE_MANIFEST_URL") or f"{base}/releases/stable/release-candidates.json",
expected_origin=base)
@@ -467,17 +570,27 @@ def transitions(env: dict) -> None:
published = json.loads(output(["gh", "release", "view", previous["tag"], "--repo", env["GITHUB_REPOSITORY"], "--json", "tagName,isDraft,isPrerelease"]))
if published["tagName"] != previous["tag"] or published["isDraft"] or published["isPrerelease"]:
raise ValueError("Upgrade baseline must be a published stable release")
return previous
def _receipt_from_env(env: dict, receipt: str, prefix: str, base: str) -> dict:
digest = env.get(f"{prefix}_SHA256", "")
if not DIGEST.fullmatch(digest):
raise ValueError(f"Pinned {receipt} receipt digest is required")
return read_manifest(env[f"{prefix}_URL"], digest, expected_origin=base)
def transitions(env: dict) -> None:
tag, commit, claim = stable_context(env)
base = env["CLOUDFLARE_R2_PUBLIC_URL"].rstrip("/")
archive = claim["claim_tag"]
previous = _published_baseline(env, base)
matrices = {"windows": {"include": []}, "macos": {"include": []}}
for row in plan_transitions(previous, candidate, base):
transition = row["transition"]
name = f"acceptance-{row['target']}.json"
file = Path(env["RUNNER_TEMP"]) / name
file.write_text(json.dumps(transition), encoding="utf-8")
put(tag=archive, key=name, file=file, immutable=True)
url = f"{base}/releases/tag/{archive}/{name}"
if read_manifest(url) != transition:
raise ValueError("Transition manifest read-back mismatch")
matrices[transition["platform"]]["include"].append({"arch": transition["arch"], "manifest": url, "old": transition["old"]["tag"], "id": row["target"], "manifest_sha256": hashlib.sha256(file.read_bytes()).hexdigest()})
for receipt, prefix in RECEIPT_SOURCES.items():
receipt_manifest = _receipt_from_env(env, receipt, prefix, base)
for row in plan_receipt_transitions(previous, receipt_manifest, receipt, base):
matrices[row["transition"]["platform"]]["include"].append(
_stage_transition(env, archive, base, row))
emit(matrices, env)
@@ -635,9 +748,14 @@ def main(argv: list[str] | None = None, env: dict | None = None) -> None:
summary("\n".join(f"- {name}: {needs.get(name, {}).get('result', 'missing')}" for name in argv[1:]), env)
require_success(needs, argv[1:])
return
if argv and argv[0] == "stage-receipt":
if len(argv) != 3 or argv[1] != "--receipt" or argv[2] not in RECEIPT_TARGETS:
raise ValueError("Expected stage-receipt --receipt darwin-arm64|darwin-x64|win32-bundle")
stage_receipt(env, argv[2])
return
commands = {"admit": admit, "verify": verify, "transitions": transitions, "complete": complete}
if len(argv) != 1 or argv[0] not in commands:
raise ValueError("Expected admit, verify, gate, transitions or complete")
raise ValueError("Expected admit, verify, gate, transitions, stage-receipt or complete")
commands[argv[0]](env)