Files
hermes-agent/tests/ci/test_stable_release_graph.py
ethernet dc11e3b3bc feat(release): add --skip-bundles and --skip-tests to stable releases
`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.
2026-09-24 13:31:33 -04:00

304 lines
16 KiB
Python

"""The release workflow's dependency graph enforces publication ordering."""
import os
import subprocess
from pathlib import Path
from ruamel.yaml import YAML
ROOT = Path(__file__).resolve().parents[2]
def workflow(name):
return YAML(typ="base").load((ROOT / ".github/workflows" / name).read_text(encoding="utf-8"))
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"
assert "ci" in ancestors(jobs, "docker")
candidate_calls = ["candidates-darwin-arm64", "candidates-darwin-x64", "candidates-win32-arm64",
"candidates-win32-x64", "candidates-win32-bundle", "candidates-termux"]
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
for name in ("acceptance", "publication", "complete"):
assert jobs[name]["if"] == "always()"
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 jobs["e2e-desktop"]["if"] == "false"
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"}
assert "conclusion != 'success'" in reconcile["if"]
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()