`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.
304 lines
16 KiB
Python
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()
|