Files
hermes-agent/tests/ci/test_stable_release_graph.py
ethernet b5d583c4ac feat(release): start the gates and the signed candidates together
Every gate and every candidate now needs only admit, so the signed macOS
and Windows builds and the docker image stop waiting behind the full CI
run. acceptance stays the one join: it still needs ci, docker and all six
candidates, so what can be promoted is unchanged.

The four gates that skipped under --skip-tests only because ci skipped
(nix, termux-checks, windows-live, install-e2e) and pm-bundle needed their
own condition. SKIPPED_BY requires the gate to observe 'skipped', so
dropping the edge alone would have left them running and blocked the
release instead of failing it.
2026-09-25 12:50:53 -04:00

330 lines
18 KiB
Python

"""The release workflow's dependency graph enforces publication ordering."""
import os
import subprocess
from pathlib import Path
from ruamel.yaml import YAML
from tests.ci.desktop_release_roles import gate
ROOT = Path(__file__).resolve().parents[2]
def workflow(name):
return YAML(typ="base").load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"))
def direct_needs(jobs, name):
needs = jobs[name].get("needs", [])
return [needs] if isinstance(needs, str) else needs
def ancestors(jobs, name):
seen = set()
pending = [name]
while pending:
needs = jobs[pending.pop()].get("needs", [])
for item in [needs] if isinstance(needs, str) else needs:
if item not in seen:
seen.add(item)
pending.append(item)
return seen
def test_admit_can_read_the_claim_draft():
jobs = workflow("stable-release.yml")["jobs"]
assert jobs["admit"]["permissions"]["contents"] == "write"
def test_release_reuses_whole_ci_and_docker_before_publication():
jobs = workflow("stable-release.yml")["jobs"]
assert jobs["ci"]["uses"] == "./.github/workflows/ci.yaml"
assert jobs["ci"]["with"]["release"] == "true"
assert "secrets" not in jobs["ci"]
assert jobs["docker"]["uses"] == jobs["publish-docker"]["uses"]
assert jobs["docker"]["with"]["release-phase"] == "test"
# B7: every gate and every signed candidate starts straight after admit.
# The acceptance join requires CI, so a slow gate cannot delay a build.
for parallel in ("docker", "nix", "pm-bundle", "termux-checks", "windows-live", "install-e2e"):
assert direct_needs(jobs, parallel) == ["admit"], parallel
candidate_calls = ["candidates-darwin-arm64", "candidates-darwin-x64", "candidates-win32-arm64",
"candidates-win32-x64", "candidates-win32-bundle", "candidates-termux"]
# The Windows bundle group is the only candidate that waits on a peer.
for call in candidate_calls:
assert set(direct_needs(jobs, call)) <= {
"admit", "candidates-win32-arm64", "candidates-win32-x64"}, call
required = {"ci", "docker", "nix", "pm-bundle", "install-e2e", "windows-packaged",
"macos-packaged-arm64", "macos-packaged-x64", "termux-checks", "windows-live",
"candidate-manifest", "transitions-darwin-arm64", "transitions-darwin-x64",
"transitions-win32", "bootstrap-version", *candidate_calls}
assert required <= ancestors(jobs, "acceptance")
# B3: stable calls one build group at a time; the bundle group waits for
# both Windows arches, and the Linux groups are not called at all.
assert "candidates" not in jobs
for name, group in zip(candidate_calls, ("darwin-arm64", "darwin-x64", "win32-arm64",
"win32-x64", "win32-bundle", "termux")):
call = jobs[name]
assert call["uses"] == "./.github/workflows/desktop-bundled-release.yml"
assert call["with"]["release-phase"] == "candidate"
assert call["with"]["jobs"] == group
assert {"tag", "claim-tag", "claim-object"} <= set(call["with"])
assert {"candidates-win32-arm64", "candidates-win32-x64"} <= set(jobs["candidates-win32-bundle"]["needs"])
assert not any("linux" in name for name in jobs)
# B4: each install arm starts from its own receipt, and the manifest is
# written after every candidate call (so after every smoke).
for name in ("transitions", "macos-packaged"):
assert name not in jobs
assert set(jobs["candidate-manifest"]["needs"]) == \
{"admit", *candidate_calls}
for receipt, call, packaged, matrix in (
("transitions-darwin-arm64", "candidates-darwin-arm64", "macos-packaged-arm64", "macos"),
("transitions-darwin-x64", "candidates-darwin-x64", "macos-packaged-x64", "macos"),
("transitions-win32", "candidates-win32-bundle", "windows-packaged", "windows")):
assert jobs[receipt]["needs"] == ["admit", call]
assert jobs[packaged]["needs"] == receipt
assert jobs[packaged]["strategy"]["matrix"] == \
"${{ fromJSON(needs." + receipt + ".outputs." + matrix + ") }}"
# B5: publish-docker starts when the docker tests pass; it does not wait
# for the acceptance join. publish-bundles still does.
assert jobs["publish-docker"]["needs"] == ["admit", "docker"]
assert {"admit", "docker"} <= ancestors(jobs, "publish-docker")
assert "acceptance" not in ancestors(jobs, "publish-docker")
assert required <= ancestors(jobs, "publish-bundles")
assert {"publish-docker", "publish-bundles", "publication"} <= ancestors(jobs, "complete")
assert "promote-docker" not in jobs and "promote-bundles" not in jobs
# The gates must run to judge a failed or skipped prerequisite, not skip with it.
for name in ("acceptance", "publication", "complete"):
failed = {need: {"result": "failure"} for need in ancestors(jobs, name)}
assert gate(jobs[name]["if"], {}, failed), name
def test_claim_flags_remove_exactly_the_jobs_the_gate_expects_skipped():
"""The gate's SKIPPED_BY table and the workflow's conditions describe the same graph."""
from scripts.releases.stable import SKIPPED_BY
jobs = workflow("stable-release.yml")["jobs"]
outputs = {"skipTests": "needs.admit.outputs.skip-tests",
"skipBundles": "needs.admit.outputs.skip-bundles"}
def needs_of(name):
needs = jobs[name].get("needs", [])
return [needs] if isinstance(needs, str) else needs
def removed_by(name, flag):
condition = str(jobs[name].get("if", ""))
if f"{outputs[flag]} != 'true'" in condition:
return True
# Without a status function a job skips when any job it needs skipped.
return ("always()" not in condition and "!cancelled()" not in condition
and any(flag in SKIPPED_BY.get(need, ()) and removed_by(need, flag)
for need in needs_of(name)))
for name, flags in SKIPPED_BY.items():
assert name in jobs
for flag in flags:
assert removed_by(name, flag), f"{name} does not skip under {flag}"
# The image publish-docker pushes and the candidates are built under
# skip-tests; they are told to run without their own tests.
for name in ("docker", "candidates-darwin-arm64", "candidates-darwin-x64", "candidates-win32-arm64",
"candidates-win32-x64", "candidates-win32-bundle", "candidates-termux"):
assert jobs[name]["with"]["skip-tests"] == "${{ needs.admit.outputs.skip-tests == 'true' }}"
assert {"skip-bundles", "skip-tests"} <= set(jobs["admit"]["outputs"])
for name in ("acceptance", "publication", "complete"):
gate = next(step for step in jobs[name]["steps"]
if "scripts.releases.stable gate" in step.get("run", ""))
assert gate["env"]["SKIP_BUNDLES"] == "${{ needs.admit.outputs.skip-bundles }}"
assert gate["env"]["SKIP_TESTS"] == "${{ needs.admit.outputs.skip-tests }}"
gated = gate["run"].split(" gate ", 1)[1].split()
assert set(gated) <= set(needs_of(name)), name
def test_all_applicable_ci_jobs_are_aggregated_and_desktop_e2e_stays_deferred():
jobs = workflow("ci.yaml")["jobs"]
checks = {name for name, job in jobs.items() if "uses" in job}
assert checks <= set(jobs["all-checks-pass"]["needs"])
assert not gate(jobs["e2e-desktop"]["if"], {}, {})
assert "workflow_call" in workflow("ci.yaml")["on"]
def test_claim_custody_and_final_payload_identity_reach_every_privileged_phase():
release = workflow("stable-release.yml")
jobs = release["jobs"]
# The claim is the one record of the attempt's policy; a dispatch cannot override it.
inputs = set(release["on"]["workflow_dispatch"]["inputs"])
assert not {"autopublish", "skip-bundles", "skip-tests"}.intersection(inputs)
assert {"claim-tag", "claim-object", "tag", "commit", "version", "release-id", "release-epoch"} <= \
set(jobs["admit"]["outputs"])
for name in ("publish-bundles", *("candidates-darwin-arm64", "candidates-darwin-x64",
"candidates-win32-arm64", "candidates-win32-x64",
"candidates-win32-bundle", "candidates-termux")):
call = jobs[name]["with"]
assert call["tag"] == "${{ needs.admit.outputs.tag }}"
assert call["claim-tag"] == "${{ needs.admit.outputs.claim-tag }}"
assert call["claim-object"] == "${{ needs.admit.outputs.claim-object }}"
desktop = workflow("desktop-bundled-release.yml")
assert "release-epoch" in desktop["jobs"]["validate"]["outputs"]
assert "HERMES_RELEASE_EPOCH" not in desktop["jobs"]["termux-deb"]["env"]
# B3: each build group stages its own receipt before its smoke, and the
# receipt URL and digest cross the call boundary as workflow outputs.
assert "manifest-url" not in desktop["on"]["workflow_call"]["outputs"]
assert "manifest-sha256" not in desktop["on"]["workflow_call"]["outputs"]
receipts = {"darwin-arm64": "stage-receipt-darwin-arm64", "darwin-x64": "stage-receipt-darwin-x64",
"win32-bundle": "assemble-win32-bundle"}
for group, job in receipts.items():
producer = desktop["jobs"][job]
assert producer["outputs"]["receipt-url"] == "${{ steps.receipt.outputs.receipt-url }}"
assert producer["outputs"]["receipt-sha256"] == "${{ steps.receipt.outputs.receipt-sha256 }}"
for suffix, output in (("url", "receipt-url"), ("sha256", "receipt-sha256")):
expected = "${{ jobs." + job + ".outputs." + output + " }}"
assert desktop["on"]["workflow_call"]["outputs"][f"{group}-receipt-{suffix}"]["value"] == expected
for name in ("smoke-darwin-arm64", "smoke-darwin-x64"):
assert f"stage-receipt-{name.removeprefix('smoke-')}" in desktop["jobs"][name]["needs"]
for call, key, output in (("candidates-darwin-arm64", "RECEIPT_URL", "darwin-arm64-receipt-url"),
("candidates-darwin-arm64", "RECEIPT_SHA256", "darwin-arm64-receipt-sha256"),
("candidates-darwin-x64", "RECEIPT_URL", "darwin-x64-receipt-url"),
("candidates-darwin-x64", "RECEIPT_SHA256", "darwin-x64-receipt-sha256"),
("candidates-win32-bundle", "RECEIPT_URL", "win32-bundle-receipt-url"),
("candidates-win32-bundle", "RECEIPT_SHA256", "win32-bundle-receipt-sha256")):
receipt_job = {"candidates-darwin-arm64": "transitions-darwin-arm64",
"candidates-darwin-x64": "transitions-darwin-x64",
"candidates-win32-bundle": "transitions-win32"}[call]
expected = "${{ needs." + call + ".outputs." + output + " }}"
assert jobs[receipt_job]["steps"][-1]["env"][key] == expected
for name in ("docker", "nix"):
assert jobs[name]["with"]["version"] == "${{ needs.admit.outputs.version }}"
assert "version" not in jobs["pm-bundle"]["with"]
assert "release-epoch" not in jobs["docker"]["with"]
assert "release-epoch" not in jobs["nix"]["with"]
assert "release-epoch" not in jobs["pm-bundle"]["with"]
assert "release-epoch" not in jobs["publish-docker"]["with"]
complete = jobs["complete"]["steps"]
# A6: the green workflow validates the accepted candidate archive; the
# final tag and the retarget move to the publication pass.
validation = next(i for i, step in enumerate(complete)
if step.get("name", "").startswith("Validate the accepted candidate archive"))
assert "DOCKER_MANIFEST_DIGEST" not in complete[validation]["env"]
assert "RELEASE_ID" not in complete[validation]["env"]
# B4: complete and the render read the manifest that stable-release.yml's
# candidate-manifest job wrote.
assert complete[validation]["env"]["CANDIDATE_MANIFEST_SHA256"] == \
"${{ needs.candidate-manifest.outputs.manifest-sha256 }}"
assert jobs["publish-bundles"]["with"]["manifest-sha256"] == \
"${{ needs.candidate-manifest.outputs.manifest-sha256 }}"
render = next(i for i, step in enumerate(complete) if step.get("name", "").startswith("Render the admitted"))
reconcile = next(i for i, step in enumerate(complete) if step.get("name", "").startswith("Reconcile ordered"))
assert validation < render < reconcile
assert not any("Create the final tag" in step.get("name", "") for step in complete)
def test_docker_dev_stamp_checkout_has_release_history():
build = workflow("docker.yml")["jobs"]["build"]
checkout = next(step for step in build["steps"] if "actions/checkout@" in step.get("uses", ""))
assert checkout["with"]["fetch-depth"] == "0"
def test_release_gates_extract_consumer_facing_versions():
release_jobs = workflow("stable-release.yml")["jobs"]
bootstrap = next(
step["run"] for step in release_jobs["bootstrap-version"]["steps"]
if step.get("name") == "Stamp and verify the Cargo and Tauri release identity"
)
assert "cargo metadata" in bootstrap
assert "tauri.conf.json" in bootstrap
assert "uv build --wheel --sdist" not in bootstrap
docker = workflow("docker.yml")["jobs"]
image_check = next(
step["run"] for step in docker["build"]["steps"]
if step.get("name") == "Verify release image identity"
)
assert '["baseVersion"]' in image_check and "$RELEASE_VERSION" in image_check
nix = workflow("nix.yml")["jobs"]
nix_check = next(
step["run"] for step in nix["flake-check"]["steps"]
if step.get("name") == "Verify release package runtime identity"
)
assert '"$package/bin/hermes" --version' in nix_check
assert "actual != expected" in nix_check
def test_packaged_stamp_writers_receive_versions_without_rewriting_python_metadata():
docker_steps = workflow("docker.yml")["jobs"]["build"]["steps"]
assert not any(step.get("name") == "Stamp release build context" for step in docker_steps)
write = next(step["run"] for step in docker_steps if step.get("name") == "Write install stamp")
assert "--base-version" in write and "--display-version" in write
nix_steps = workflow("nix.yml")["jobs"]["flake-check"]["steps"]
prepare = next(step["run"] for step in nix_steps if step.get("name") == "Prepare isolated release source")
assert "scripts/releases/stamping.py" in prepare
def test_publication_reconciler_has_every_recovery_trigger_and_shared_lock():
stable = workflow("stable-release.yml")
publication = workflow("stable-release-publication.yml")
assert stable["concurrency"] == publication["concurrency"] == {
"group": "stable-release", "cancel-in-progress": "false",
}
assert {"workflow_dispatch", "workflow_run"} <= set(publication["on"])
assert publication["on"]["workflow_run"] == {
"workflows": ["Stable Release"], "types": ["completed"],
}
reconcile = publication["jobs"]["reconcile"]
assert reconcile["environment"] == "release-signing"
assert publication["permissions"] == {"contents": "write", "actions": "write"}
# Reconcile after a dispatched run of this repository that did not succeed;
# a manual dispatch of the reconciler always runs.
def run_of(**overrides):
workflow_run = {"conclusion": "failure", "event": "workflow_dispatch",
"head_repository": {"full_name": "o/r"}, **overrides}
return {"event_name": "workflow_run", "repository": "o/r", "event": {"workflow_run": workflow_run}}
assert gate(reconcile["if"], {}, {}, github=run_of())
assert gate(reconcile["if"], {}, {}, github={"event_name": "workflow_dispatch"})
for refused in ({"conclusion": "success"}, {"event": "push"}, {"head_repository": {"full_name": "fork/r"}}):
assert not gate(reconcile["if"], {}, {}, github=run_of(**refused)), refused
checkout = reconcile["steps"][0]
assert checkout["with"]["ref"] == "${{ github.event.repository.default_branch }}"
assert checkout["with"]["persist-credentials"] == "false"
assert not any(step.get("run", "").startswith("sleep ") for step in reconcile["steps"])
def test_docker_recovery_refuses_to_replace_a_divergent_version_tag(tmp_path):
publish = workflow("docker.yml")["jobs"]["release-publish-manifest"]
step = next(item for item in publish["steps"] if item.get("name") == "Create both immutable versioned manifest lists")
digest_dir = tmp_path / "digests"
digest_dir.mkdir()
for variant in ("slim", "desktop"):
for arch, digest in (("amd64", "a" * 64), ("arm64", "b" * 64)):
artifact = digest_dir / f"docker-publish-digest-{variant}-{arch}-0.21.5"
artifact.mkdir()
(artifact / f"{arch}.digest").write_text(f"sha256:{digest}\n", encoding="utf-8")
marker = tmp_path / "create-called"
bindir = tmp_path / "bin"
bindir.mkdir()
docker = bindir / "docker"
docker.write_text(
"#!/usr/bin/env python3\n"
"import json, os, pathlib, sys\n"
"if 'create' in sys.argv:\n"
" pathlib.Path(os.environ['CREATE_MARKER']).write_text('called')\n"
" raise SystemExit(0)\n"
"print(json.dumps({'manifests': [{'digest': 'sha256:' + 'c' * 64}]}))\n",
encoding="utf-8",
)
docker.chmod(0o755)
result = subprocess.run(
["bash", "-e", "-o", "pipefail", "-c",
step["run"].replace("/tmp/digests", str(digest_dir))], cwd=tmp_path,
env={**os.environ, "PATH": f"{bindir}:{os.environ['PATH']}",
"IMAGE_NAME": "owner/repo", "RELEASE_TAG": "0.21.5",
"CREATE_MARKER": str(marker)},
capture_output=True, text=True, encoding="utf-8",
)
assert result.returncode != 0
assert "versioned Docker manifest differs" in result.stderr
assert not marker.exists()