From 4dad60d72d36422a38c6413d6cf3b5779686e88a Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:36:33 +0000 Subject: [PATCH] Qualify native releases from the tag and build promotion evidence Qualifiers now admit only when dispatched from the final release tag. The release part of the qualification subject is derived from that tag, its commit and the requested index, which must be the index GHCR's exact version tag names. The subject file keeps only the pinned MySQL and QEMU images, so a release no longer needs a hand-edited commit, the feat/server-native32-qualification-runner branch or a merge to main. The verifier accepts only the v1 manifest shape again, where both qualifier runner commits equal the release source, while still requiring the full v2 receipt: tag runner ref, subject blob read at the release commit, owned resource scope, local image identity and every dependency image. The runner-branch v2 manifest, its frozen-delta and ancestry reads and the transport's compare shapes for them are removed. scripts/ci/native_alias_manifest.py builds the manifest from the release, corpus and Compose run IDs by reading the provider API. It selects each receipt artifact by exact name and refuses ambiguity. Promotion and GitHub Release creation verify it as before. Refs #1123. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/native-image-parity.yml | 4 +- README.md | 13 +- docs/native-release-promotion.md | 87 +++++- scripts/ci/check-docker-workflow-isolation.py | 6 +- scripts/ci/native-qualification-subject.json | 2 +- scripts/ci/native_alias_evidence.py | 160 ++++------- scripts/ci/native_alias_manifest.py | 110 ++++++++ scripts/ci/native_alias_transport.py | 19 +- scripts/ci/native_qualification.py | 101 +++---- .../Support/native_alias_evidence_test.py | 259 ++++++++++-------- .../Support/native_alias_manifest_test.py | 122 +++++++++ .../Unit/Support/native_qualification_test.py | 195 +++++++++---- 12 files changed, 730 insertions(+), 348 deletions(-) create mode 100644 scripts/ci/native_alias_manifest.py create mode 100644 tests/Unit/Support/native_alias_manifest_test.py diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml index 6d20b7a52..5130b5560 100644 --- a/.github/workflows/native-image-parity.yml +++ b/.github/workflows/native-image-parity.yml @@ -6,8 +6,8 @@ run-name: Native image parity ${{ inputs.native_image || github.ref }} # reference, embedded PHP and the native image itself, and compares the three # records, as the Shared Server fixtures corpus part does for a source build. # -# Dispatch it with a published native image by digest to qualify that -# artifact. A pull request that changes the comparison itself builds the image +# Dispatch it from a release tag with that tag's published native index by +# digest to qualify that artifact for promotion. A pull request that changes the comparison itself builds the image # from its own source and compares that build, so the comparison is exercised # before it is relied on. Nothing is pushed. diff --git a/README.md b/README.md index 07539d3a7..bbd8ffaef 100644 --- a/README.md +++ b/README.md @@ -154,13 +154,12 @@ Repository checks and focused test commands are documented in [native publication and alias-promotion runbook](docs/native-release-promotion.md) describes the separate qualification and protected release phases. -Published-image qualification can use a separately reviewed runner without -moving the producer tag. The v2 qualification subject binds the exact published -source/index/run and pinned MySQL and QEMU image subjects. The runner must be on -main and differ from the producer only in the closed qualification-source set. -The existing v1 model still requires runner and producer to be the same commit. -V2 receipts retain both identities, each actual dependency image, the owned -resource scope, body observations and verified cleanup. The four Compose cells +Published-image qualification runs from the release tag itself, so the runner +and the producer are the same commit. The qualification subject is the tag, its +commit and the published index, plus the pinned MySQL and QEMU images committed +in `scripts/ci/native-qualification-subject.json`. V2 receipts retain each actual +dependency image, the owned resource scope, body observations and verified +cleanup. The four Compose cells retain engine target IDs, platform manifests and config digests distinctly, and run on amd64 hosts; their arm64 server cells use the pinned QEMU helper. These receipts do not select alias promotion or a GitHub Release. diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md index 873c79f65..36a30e4a2 100644 --- a/docs/native-release-promotion.md +++ b/docs/native-release-promotion.md @@ -16,9 +16,11 @@ moves `X.Y`, `X` or `latest`. A final release follows this order: tag, then the existing published-Compose four-cell matrix on that same index from that tag. These are separately admitted invocations, not automatic promotion workflow dispatches. -3. Review the exact current run attempts, provider job identities and five - canonical receipt artifacts. Dispatch `native-alias-promotion.yml` from the - exact release tag, a matching `tag` input and that data-only selection manifest. +3. Build the data-only selection manifest from the three run IDs with + `scripts/ci/native_alias_manifest.py`, then review its exact current run + attempts, job identities and five canonical receipt artifacts. Dispatch + `native-alias-promotion.yml` from the exact release tag, a matching `tag` + input and that manifest. 4. An unconditional, unprivileged plan first binds ref, tag, manifest source and protected-main ancestry. The read-only job then verifies the selection. After environment approval, the mutation job repeats the entire verification while holding @@ -36,6 +38,69 @@ aliases. A final Release that already exists before promotion causes an explicit refusal. Escalate the exact legacy case to General; the promotion phase never edits a Release or treats it as qualification. Missing historical receipts refuse. +## Release checklist + +Every step runs from the tag's own workflow files. No commit, branch or +hand-written evidence file is needed. Run the commands from a checkout of +`main` with `gh` signed in. + +1. Create the final tag at its commit on `main` and set the shared values. + + ```sh + tag=3.6.0 + commit=$(git rev-parse "$tag^{commit}") + ``` + +2. Publish the tag and wait for the run to succeed. Note its run ID. + + ```sh + gh workflow run native-release.yml --ref "$tag" -f tag="$tag" -f release_commit="$commit" + release_run= + ``` + +3. Dispatch both qualifiers from the tag with the published index. The run + summary of the release run shows the same index. + + ```sh + index=$(docker buildx imagetools inspect --format '{{printf "%s" .Manifest.Digest}}' \ + "ghcr.io/durable-workflow/server:$tag") + gh workflow run native-image-parity.yml --ref "$tag" \ + -f native_image="ghcr.io/durable-workflow/server@$index" + gh workflow run published-compose.yml --ref "$tag" \ + -f server_image="ghcr.io/durable-workflow/server@$index" + corpus_run= + compose_run= + ``` + +4. After both qualifiers succeed, build the evidence manifest. + + ```sh + evidence=$(python3 -I scripts/ci/native_alias_manifest.py --tag "$tag" \ + --release-run "$release_run" --corpus-run "$corpus_run" --compose-run "$compose_run") + ``` + +5. Promote the aliases from the tag. The `dockerhub-native` Environment asks + for approval. + + ```sh + gh workflow run native-alias-promotion.yml --ref "$tag" -f tag="$tag" -f evidence="$evidence" + ``` + +6. Create the GitHub Release from `main` with the same manifest. + + ```sh + gh workflow run native-github-release.yml --ref main -f tag="$tag" \ + -f release_run_id="$release_run" \ + -f release_run_attempt="$(jq -r .release.run_attempt <<<"$evidence")" \ + -f promotion_evidence="$evidence" + ``` + +The manifest builder only reads the provider API. It refuses a run that was +not dispatched from the tag, a qualifier image that is not one canonical GHCR +index, and any receipt artifact name that does not match exactly one artifact. +Its output is a selection. Promotion and Release creation verify everything +again. + ## Evidence selection and authority The input is at most 8,192 UTF-8 bytes of JSON with the closed schema @@ -56,7 +121,16 @@ hex digits. The three run IDs and five artifact IDs are distinct. Compose cells are exactly `linux/amd64` and `linux/arm64`, each with `local` and `production`. Both qualifier runner commits equal the release source commit, with workflow blobs read at that exact commit; being another ancestor of main is insufficient. -No alternative runner allowlist exists in this version. +No alternative runner allowlist exists in this version. The earlier v2 manifest +and its separate runner branch are gone. + +The qualification subject is derived, not edited per release. Its release part +is the tag the qualifier was dispatched from, that tag's commit and the +requested index. Admission refuses the run unless GHCR's exact version tag +names that index. The pinned MySQL and QEMU helper images are the only +committed part, in `scripts/ci/native-qualification-subject.json`. The verifier +reads that file at the release commit and checks its Git blob identity against +every receipt. Selection is not proof. Fixed `durable-workflow/server` provider reads establish repository/head-repository IDs, numeric workflow identity/path, dispatch event, @@ -88,10 +162,13 @@ The existing corpus job retains its broad comparison artifact and adds contains only the canonical JSON receipt. PR/default/local image contexts emit a separate diagnostic schema with `eligible=false`, never positive evidence. -A positive `durable-workflow.native-qualification-receipt.v1` binds repository, +A positive `durable-workflow.native-qualification-receipt.v2` binds repository, run/attempt/job, runner/workflow blob, original requested index, platform/profile, resolved child manifest and image config, actual container config ID, architecture/OS, source/release labels, whitelisted observations and cleanup. +It also binds the runner ref, which must be the release tag, the subject blob, +the owned resource scope, the local image identity and each pinned dependency +image the job actually ran. The index, child manifest and config are separate identities. Registry membership and both registries' source labels are checked independently by the consumer. diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 254e5439d..134480981 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -16,9 +16,9 @@ # checks/CLI transport is bound to reviewed bytes. A source change requires an # explicit model update and countercontrol review before gaining coverage. QUALIFICATION_CLEANUP_SOURCES = { - "scripts/ci/native_qualification.py": "65916e73b7f0c6431c612f411e27f9429a2c37dbab287d1f17b840dee0f8d185", - "scripts/ci/native_alias_evidence.py": "dc213ce6d43e5ad050de2acb730140aa33e4c21825f8dc5bf86e66f69106b95d", - "scripts/ci/native_alias_transport.py": "16e495ed6874f7d62f29f0f4dc11aaf9e1715bd7046823f48d3443b3ed370c74", + "scripts/ci/native_qualification.py": "775655e8d60ecd9a468970cdc28ea40860e0b1549b75343786837f2a35f37eac", + "scripts/ci/native_alias_evidence.py": "7097eb55cf32b698539860b4a0a1bd010e251baf2def9f9e77f145aab96f60db", + "scripts/ci/native_alias_transport.py": "5a925299f3fc5c58fb937e85de579d3bed70942b19b464aca78a77e6d0d63273", } diff --git a/scripts/ci/native-qualification-subject.json b/scripts/ci/native-qualification-subject.json index 0370b8035..0a2e804ed 100644 --- a/scripts/ci/native-qualification-subject.json +++ b/scripts/ci/native-qualification-subject.json @@ -1 +1 @@ -{"mysql":{"child_manifest_digest":"sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b","image_config_digest":"sha256:6cd09145362dfe6831b14545de3d5fd6cc75c37cfd6ef8561429c1fc73518b39","requested_image":"mysql:8.0@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b"},"qemu":{"child_manifest_digest":"sha256:465d3fdd28d0f2b871ba4b4ec98bd183292e96167f00d9fd40bd249f8632d705","image_config_digest":"sha256:15935d0512bf0a8e5afa1df990d971cf97a619ede08fa60f2d5966a62de6d6b7","requested_image":"docker.io/tonistiigi/binfmt@sha256:465d3fdd28d0f2b871ba4b4ec98bd183292e96167f00d9fd40bd249f8632d705"},"release":{"index_digest":"sha256:b5776fea2a4627ebc477b3b350caaf600dc220e7ab75aa29d32b70e404fd7a68","run_attempt":1,"run_id":43109713180,"source_commit":"1b8e9ee4bc6402261cb465cb6996056bacedd97c","tag":"3.5.0"},"schema":"durable-workflow.native-qualification-subject.v1"} +{"mysql":{"child_manifest_digest":"sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b","image_config_digest":"sha256:6cd09145362dfe6831b14545de3d5fd6cc75c37cfd6ef8561429c1fc73518b39","requested_image":"mysql:8.0@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b"},"qemu":{"child_manifest_digest":"sha256:465d3fdd28d0f2b871ba4b4ec98bd183292e96167f00d9fd40bd249f8632d705","image_config_digest":"sha256:15935d0512bf0a8e5afa1df990d971cf97a619ede08fa60f2d5966a62de6d6b7","requested_image":"docker.io/tonistiigi/binfmt@sha256:465d3fdd28d0f2b871ba4b4ec98bd183292e96167f00d9fd40bd249f8632d705"},"schema":"durable-workflow.native-qualification-subject.v2"} diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py index 813921145..21db284b6 100644 --- a/scripts/ci/native_alias_evidence.py +++ b/scripts/ci/native_alias_evidence.py @@ -24,23 +24,13 @@ GHCR_IMAGE = "ghcr.io/durable-workflow/server" DOCKERHUB_IMAGE = "durableworkflow/server" MANIFEST_SCHEMA = "durable-workflow.native-alias-promotion-evidence.v1" -RECEIPT_SCHEMA = "durable-workflow.native-qualification-receipt.v1" -MANIFEST_SCHEMA_V2 = "durable-workflow.native-alias-promotion-evidence.v2" -RECEIPT_SCHEMA_V2 = "durable-workflow.native-qualification-receipt.v2" +RECEIPT_SCHEMA = "durable-workflow.native-qualification-receipt.v2" +SUBJECT_SCHEMA = "durable-workflow.native-qualification-subject.v2" LOCAL_IMAGE_KEYS = {"local_image_id", "local_manifest_digest", "config_digest_source"} +# Only the pinned dependency images are committed. Qualifiers run from the +# release tag, so the release part of the subject is the tag, its commit and +# the requested index, never a per-release edit. SUBJECT_PATH = "scripts/ci/native-qualification-subject.json" -RUNNER_REF = "refs/heads/feat/server-native32-qualification-runner" -RUNNER_PATHS = { - ".github/workflows/native-image-parity.yml", ".github/workflows/published-compose.yml", - ".github/workflows/native-promotion-contracts.yml", - "scripts/ci/native_alias_evidence.py", "scripts/ci/native_alias_transport.py", - "scripts/ci/native_qualification.py", SUBJECT_PATH, - "scripts/ci/native-release-verify-publication.sh", - "scripts/ci/check-docker-workflow-isolation.py", - "tests/Unit/Support/native_alias_evidence_test.py", "tests/Unit/Support/native_qualification_test.py", - "tests/Feature/TaskQueueAdmissionTest.php", - "README.md", -} MANIFEST_LIMIT = 8192 RECEIPT_LIMIT = 32768 ARCHIVE_LIMIT = 65536 @@ -163,12 +153,8 @@ def not_integer(_: str) -> Any: def qualification_subject(value: Any) -> dict[str, Any]: - value = closed(value, {"schema", "release", "mysql", "qemu"}, "qualification_subject_shape") - require(value["schema"] == "durable-workflow.native-qualification-subject.v1", "qualification_subject_schema") - release = closed(value["release"], {"tag", "source_commit", "index_digest", "run_id", "run_attempt"}, "release_shape") - require(matching(release["tag"], FINAL_TAG) and matching(release["source_commit"], SHA) - and matching(release["index_digest"], DIGEST) and positive(release["run_id"]) - and positive(release["run_attempt"]), "qualification_release_subject") + value = closed(value, {"schema", "mysql", "qemu"}, "qualification_subject_shape") + require(value["schema"] == SUBJECT_SCHEMA, "qualification_subject_schema") for name, repository in (("mysql", "mysql:8.0"), ("qemu", "docker.io/tonistiigi/binfmt")): dependency = closed(value[name], {"requested_image", "child_manifest_digest", "image_config_digest"}, "qualification_dependency_shape") @@ -180,24 +166,14 @@ def qualification_subject(value: Any) -> dict[str, Any]: return value -def runner_selection(value: Any) -> dict[str, str]: - value = closed(value, {"source_commit", "ref", "subject_blob"}, "qualification_runner_shape") - require(matching(value["source_commit"], SHA) and value["ref"] == RUNNER_REF - and matching(value["subject_blob"], SHA), "qualification_runner_identity") - return value - - -def commit_is(value: Any, sha: str) -> bool: - return type(value) is dict and value.get("sha") == sha - - -def runner_subject(provider: Provider, runner: dict[str, str], release: dict[str, Any]) -> dict[str, Any]: - """A reviewed runner may differ only in the bounded qualification source set.""" - runner = runner_selection(runner) - source = runner["source_commit"] +def release_subject(provider: Provider, release: dict[str, Any]) -> tuple[dict[str, str], dict[str, Any]]: + """The qualifier runner is the release tag itself. Read its committed dependency pins.""" + require(type(release) is dict and matching(release.get("tag"), FINAL_TAG) + and matching(release.get("source_commit"), SHA), "qualification_release_subject") + source = release["source_commit"] record = provider.api(f"repos/{REPOSITORY}/contents/{SUBJECT_PATH}?ref={source}") require(type(record) is dict and record.get("path") == SUBJECT_PATH and record.get("type") == "file" - and record.get("sha") == runner["subject_blob"] and record.get("encoding") == "base64" + and matching(record.get("sha"), SHA) and record.get("encoding") == "base64" and type(record.get("content")) is str and len(record["content"]) <= 12000, "qualification_subject_blob") try: @@ -205,40 +181,23 @@ def runner_subject(provider: Provider, runner: dict[str, str], release: dict[str except ValueError: raise Refusal("qualification_subject_encoding") from None # Git's object format requires SHA-1 for this object identifier. The - # selected commit, protected-main ancestry and frozen delta bind authority. + # immutable release commit and its protected-main ancestry bind authority. object_id = hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data, usedforsecurity=False).hexdigest() require(len(data) <= MANIFEST_LIMIT and literal(record.get("size"), len(data)) - and object_id == runner["subject_blob"], - "qualification_subject_digest") - subject = qualification_subject(strict_json(data, MANIFEST_LIMIT)) - require(subject["release"] == release, "qualification_subject_release") - difference = provider.api(f"repos/{REPOSITORY}/compare/{release['source_commit']}...{source}?per_page=100") - require(type(difference) is dict and difference.get("status") == "ahead" - and commit_is(difference.get("base_commit"),release["source_commit"]) - and commit_is(difference.get("merge_base_commit"),release["source_commit"]) - and type(difference.get("total_commits")) is int and 1 <= difference["total_commits"] <= 32 - and type(difference.get("commits")) is list - and len(difference["commits"]) == difference["total_commits"] - and commit_is(difference["commits"][-1],source), "qualification_runner_ancestry") - files = difference.get("files") - require(type(files) is list and 1 <= len(files) <= len(RUNNER_PATHS) - and all(type(item) is dict and item.get("filename") in RUNNER_PATHS - and item.get("status") in ("added", "modified") - and "previous_filename" not in item for item in files), "qualification_runner_frozen_source") - main = provider.api(f"repos/{REPOSITORY}/compare/{source}...main?per_page=1") - require(type(main) is dict and main.get("status") in ("ahead", "identical") - and commit_is(main.get("base_commit"),source) - and commit_is(main.get("merge_base_commit"),source), "qualification_runner_not_on_main") - return subject + and object_id == record["sha"], "qualification_subject_digest") + return release_runner(release, object_id), qualification_subject(strict_json(data, MANIFEST_LIMIT)) + + +def release_runner(release: dict[str, Any], subject_blob: Any) -> dict[str, str]: + require(matching(subject_blob, SHA), "qualification_subject_blob") + return {"source_commit": release["source_commit"], "ref": "refs/tags/" + release["tag"], + "subject_blob": subject_blob} def manifest(data: bytes) -> dict[str, Any]: - decoded = strict_json(data, MANIFEST_LIMIT) - v2 = type(decoded) is dict and decoded.get("schema") == MANIFEST_SCHEMA_V2 - value = closed(decoded, {"schema", "release", "corpus", "compose"} | ({"runner"} if v2 else set()), "manifest_shape") - require(value["schema"] in (MANIFEST_SCHEMA, MANIFEST_SCHEMA_V2), "manifest_schema") - runner = runner_selection(value["runner"]) if v2 else None + value = closed(strict_json(data, MANIFEST_LIMIT), {"schema", "release", "corpus", "compose"}, "manifest_shape") + require(value["schema"] == MANIFEST_SCHEMA, "manifest_schema") release = closed(value["release"], {"tag", "source_commit", "index_digest", "run_id", "run_attempt"}, "release_shape") @@ -254,7 +213,7 @@ def manifest(data: bytes) -> dict[str, Any]: "compose_shape") for role, selected in (("corpus", corpus), ("compose", compose)): require(positive(selected["run_id"]) and positive(selected["run_attempt"]), role + "_run") - require(selected["runner_commit"] == (runner["source_commit"] if runner else release["source_commit"]), role + "_runner_source") + require(selected["runner_commit"] == release["source_commit"], role + "_runner_source") require(matching(selected["workflow_blob"], SHA), role + "_workflow_blob") require(len({release["run_id"], corpus["run_id"], compose["run_id"]}) == 3, "distinct_workflow_runs_required") @@ -355,7 +314,7 @@ def publication_subjects(self, release: dict[str, Any]) -> dict[str, dict[str, A def workflow_run(provider: Provider, selected: dict[str, Any], role: str, - release: dict[str, Any], runner: dict[str,str] | None = None) -> dict[str, Any]: + release: dict[str, Any]) -> dict[str, Any]: run = provider.api(f"repos/{REPOSITORY}/actions/runs/{selected['run_id']}") require(type(run) is dict and literal(run.get("id"), selected["run_id"]), "provider_run_id") for field in ("repository", "head_repository"): @@ -365,10 +324,10 @@ def workflow_run(provider: Provider, selected: dict[str, Any], role: str, require(literal(run.get("run_attempt"), selected["run_attempt"]), "provider_current_attempt") require(run.get("status") == "completed" and run.get("conclusion") == "success", "provider_run_not_successful") - qualifier = runner if role != "release" else None + # Publication and both qualifiers are dispatched from the release tag. require(run.get("event") == "workflow_dispatch" - and run.get("head_sha") == (qualifier["source_commit"] if qualifier else release["source_commit"]) - and run.get("head_branch") == (qualifier["ref"].removeprefix("refs/heads/") if qualifier else release["tag"]) + and run.get("head_sha") == release["source_commit"] + and run.get("head_branch") == release["tag"] and run.get("path") == WORKFLOWS[role], "provider_run_source") workflow = provider.api(f"repos/{REPOSITORY}/actions/workflows/{WORKFLOWS[role].split('/')[-1]}") require(type(workflow) is dict and positive(workflow.get("id")) @@ -451,7 +410,8 @@ def receipt(provider: Provider, artifact_id: int, digest: str, name: str, "index_digest", "platform", "profile", "child_manifest_digest", "image_config_digest", "actual_image_config_digest", "container_image_architecture", "container_image_os", "image_source_commit", "image_release_tag", "observations", "cleanup", -} + "runner_ref", "subject_blob", "resource_scope", "dependencies", +} | LOCAL_IMAGE_KEYS # Exactly the status object native-image-corpus.sh writes (Server #325). CORPUS_OBSERVATIONS = {"reference_records", "native_record", "compare", "service_calls", "schedule_catalog", "namespace_delete_payloads", "retention_payloads"} @@ -463,15 +423,14 @@ def receipt(provider: Provider, artifact_id: int, digest: str, name: str, def bind_receipt(value: dict[str, Any], role: str, selected: dict[str, Any], run: dict[str, Any], job: dict[str, Any], release: dict[str, Any], platform: str, profile: str | None, subject: dict[str, Any], - runner: dict[str,str] | None = None, binding: dict[str,Any] | None = None) -> None: - extra = {"runner_ref", "subject_blob", "resource_scope", "dependencies"} | LOCAL_IMAGE_KEYS if runner else set() - value = closed(value, RECEIPT_KEYS | extra, "receipt_shape") - require(value["schema"] == (RECEIPT_SCHEMA_V2 if runner else RECEIPT_SCHEMA) and value["kind"] == role + runner: dict[str, str], binding: dict[str, Any]) -> None: + value = closed(value, RECEIPT_KEYS, "receipt_shape") + require(value["schema"] == RECEIPT_SCHEMA and value["kind"] == role and value["eligible"] is True, "receipt_not_eligible") expected = { "repository": REPOSITORY, "repository_id": REPOSITORY_ID, "run_id": run["id"], "run_attempt": run["run_attempt"], "job_id": job["id"], - "runner_commit": runner["source_commit"] if runner else release["source_commit"], "workflow_path": WORKFLOWS[role], + "runner_commit": release["source_commit"], "workflow_path": WORKFLOWS[role], "workflow_blob": selected["workflow_blob"], "requested_image": GHCR_IMAGE + "@" + release["index_digest"], "index_digest": release["index_digest"], "platform": platform, "profile": profile, @@ -483,14 +442,13 @@ def bind_receipt(value: dict[str, Any], role: str, selected: dict[str, Any], } require(all(literal(value[key], expected_value) for key, expected_value in expected.items()), "receipt_source_or_subject_binding") - if runner: - require(value["runner_ref"] == runner["ref"] and value["subject_blob"] == runner["subject_blob"], - "receipt_runner_binding") - require(binding is not None and binding["release"] == release, "receipt_binding_missing") - observed_scope = resource_scope(role, run["id"], run["run_attempt"], platform, profile) - require(value["resource_scope"] == observed_scope, "receipt_resource_scope") - bind_local_image(value, subject, release["index_digest"]) - bind_dependencies(value["dependencies"], role, platform, binding) + require(runner == release_runner(release, runner.get("subject_blob")) + and value["runner_ref"] == runner["ref"] and value["subject_blob"] == runner["subject_blob"], + "receipt_runner_binding") + observed_scope = resource_scope(role, run["id"], run["run_attempt"], platform, profile) + require(value["resource_scope"] == observed_scope, "receipt_resource_scope") + bind_local_image(value, subject, release["index_digest"]) + bind_dependencies(value["dependencies"], role, platform, binding) cleanup = closed(value["cleanup"], {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"}, "receipt_cleanup_shape") @@ -548,18 +506,18 @@ def bind_dependencies(value: Any, role: str, platform: str, subject: dict[str,An "receipt_database_container") +def qualification_jobs(role: str) -> dict[str, tuple[str, ...]]: + names = ([CORPUS_JOB] if role == "corpus" else + [platform + " " + profile + " published image" for platform in PLATFORMS for profile in PROFILES]) + return {name: ("Admit published qualification subject",) + QUALIFICATION_STEPS[role] + + (("Set up QEMU",) if name.startswith("linux/arm64") else ()) for name in names} + + def qualification_receipts(provider: Provider, role: str, selected: dict[str, Any], run: dict[str, Any], release: dict[str, Any], - subjects: dict[str, Any], runner: dict[str, str] | None, - binding: dict[str, Any] | None) -> list[tuple[Any, ...]]: - required = ({CORPUS_JOB: QUALIFICATION_STEPS[role]} if role == "corpus" else { - platform + " " + profile + " published image": QUALIFICATION_STEPS[role] - for platform in PLATFORMS for profile in PROFILES}) - if runner: - required = {name: ("Admit published qualification subject",) + steps - + (("Set up QEMU",) if name.startswith("linux/arm64") else ()) - for name, steps in required.items()} - current_jobs = jobs(provider, run, required) + subjects: dict[str, Any], runner: dict[str, str], + binding: dict[str, Any]) -> list[tuple[Any, ...]]: + current_jobs = jobs(provider, run, qualification_jobs(role)) checks = [] if role == "corpus": job = current_jobs[CORPUS_JOB] @@ -586,8 +544,7 @@ def verify(value: dict[str, Any], provider: Provider) -> dict[str, Any]: # Revalidate programmatic callers exactly as CLI manifest bytes. value = manifest(canonical(value)) release = value["release"] - runner = value.get("runner") - binding = runner_subject(provider, runner, release) if runner else None + runner, binding = release_subject(provider, release) publication = workflow_run(provider, release, "release", release) publication_jobs = jobs(provider, publication, RELEASE_JOBS) completed = max([timestamp(publication["updated_at"])] + @@ -608,21 +565,20 @@ def verify(value: dict[str, Any], provider: Provider) -> dict[str, Any]: selected = value[role] require(provider.workflow_blob(selected["runner_commit"], WORKFLOWS[role]) == selected["workflow_blob"], "qualifier_workflow_source") - run = workflow_run(provider, selected, role, release, runner) + run = workflow_run(provider, selected, role, release) require(timestamp(run["run_started_at"]) >= completed, "qualification_precedes_publication") checks = qualification_receipts(provider, role, selected, run, release, subjects, runner, binding) artifact_checks.extend(checks) artifact_ids.extend(check[0] for check in checks) snapshots.append((role, selected)) for role, selected in snapshots: - workflow_run(provider, selected, role, release, runner) - if runner: - runner_subject(provider, runner, release) + workflow_run(provider, selected, role, release) + require(release_subject(provider, release) == (runner, binding), "qualification_subject_changed") for selected_artifact in artifact_checks: artifact_metadata(provider, *selected_artifact) - return {"schema": "durable-workflow.native-alias-verification.v2" if runner else "durable-workflow.native-alias-verification.v1", + return {"schema": "durable-workflow.native-alias-verification.v2", "release": release, "subjects": subjects, "qualified_artifact_ids": artifact_ids, - **({"runner":runner,"qualification_subject":binding} if runner else {})} + "runner": runner, "qualification_subject": binding} if __name__ == "__main__": diff --git a/scripts/ci/native_alias_manifest.py b/scripts/ci/native_alias_manifest.py new file mode 100644 index 000000000..75e3918d7 --- /dev/null +++ b/scripts/ci/native_alias_manifest.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Build the native alias promotion evidence manifest from three run IDs. + +It reads the publication run and both qualifier runs, which must all be +dispatched from the release tag. Each qualifier's own receipt artifacts are +selected by exact name. The output is the closed v1 manifest. This is +selection, not proof: alias promotion and GitHub Release creation verify it. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import sys +from typing import Any + +# python3 -I leaves the script directory off sys.path. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from native_alias_evidence import (DIGEST, FINAL_TAG, GHCR_IMAGE, MANIFEST_SCHEMA, + PLATFORMS, PROFILES, REPOSITORY, SHA, WORKFLOWS, Refusal, + canonical, jobs, literal, manifest, matching, positive, + qualification_jobs, require) +from native_alias_transport import GitHubRegistryProvider + +TITLES = {"corpus": "Native image parity ", "compose": "Published Compose "} + + +def tag_run(provider: Any, run_id: int, role: str, tag: str) -> dict[str, Any]: + run = provider.api(f"repos/{REPOSITORY}/actions/runs/{run_id}") + require(type(run) is dict and literal(run.get("id"), run_id) and positive(run.get("run_attempt")) + and run.get("event") == "workflow_dispatch" and run.get("path") == WORKFLOWS[role] + and run.get("head_branch") == tag and matching(run.get("head_sha"), SHA) + and run.get("status") == "completed" and run.get("conclusion") == "success", + "selection_" + role + "_run") + return run + + +def receipt_artifact(provider: Any, run: dict[str, Any], name: str) -> dict[str, Any]: + page = provider.api(f"repos/{REPOSITORY}/actions/runs/{run['id']}/artifacts?name={name}&per_page=100") + require(type(page) is dict and type(page.get("artifacts")) is list + and literal(page.get("total_count"), len(page["artifacts"])), "selection_artifacts_incomplete") + # Exactly one artifact of that name, never the newest of several. + require(len(page["artifacts"]) == 1, "selection_artifact_ambiguous") + artifact = page["artifacts"][0] + require(type(artifact) is dict and artifact.get("name") == name and positive(artifact.get("id")) + and matching(artifact.get("digest"), DIGEST) and artifact.get("expired") is False + and type(artifact.get("workflow_run")) is dict + and literal(artifact["workflow_run"].get("id"), run["id"]), "selection_artifact_identity") + return {"artifact_id": artifact["id"], "artifact_digest": artifact["digest"]} + + +def build(provider: Any, tag: str, release_run: int, corpus_run: int, compose_run: int) -> dict[str, Any]: + require(matching(tag, FINAL_TAG), "final_release_required") + require(all(positive(run) for run in (release_run, corpus_run, compose_run)), "selection_run_ids") + publication = tag_run(provider, release_run, "release", tag) + source = publication["head_sha"] + require(publication.get("display_title") == f"Native {tag} at {source}", "selection_release_run") + runs = {"corpus": tag_run(provider, corpus_run, "corpus", tag), + "compose": tag_run(provider, compose_run, "compose", tag)} + images = set() + for role, run in runs.items(): + title = run.get("display_title") + require(run["head_sha"] == source and type(title) is str and title.startswith(TITLES[role]), + "selection_" + role + "_source") + images.add(title.removeprefix(TITLES[role])) + image = images.pop() + require(not images and re.fullmatch(re.escape(GHCR_IMAGE) + r"@sha256:[0-9a-f]{64}", image) is not None, + "selection_qualified_image") + selected = {} + for role, run in runs.items(): + current = jobs(provider, run, qualification_jobs(role)) + selected[role] = {"run_id": run["id"], "run_attempt": run["run_attempt"], "runner_commit": source, + "workflow_blob": provider.workflow_blob(source, WORKFLOWS[role])} + if role == "corpus": + selected[role].update(receipt_artifact(provider, run, "native-corpus-promotion-receipt")) + continue + selected[role]["cells"] = [ + {"platform": platform, "profile": profile, + "job_id": current[platform + " " + profile + " published image"]["id"], + **receipt_artifact(provider, run, "native-compose-promotion-receipt-" + + platform.split("/")[1] + "-" + profile)} + for platform in PLATFORMS for profile in PROFILES] + value = {"schema": MANIFEST_SCHEMA, + "release": {"tag": tag, "source_commit": source, "index_digest": image.split("@", 1)[1], + "run_id": publication["id"], "run_attempt": publication["run_attempt"]}, + **selected} + return manifest(canonical(value)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--release-run", type=int, required=True) + parser.add_argument("--corpus-run", type=int, required=True) + parser.add_argument("--compose-run", type=int, required=True) + args = parser.parse_args() + value = build(GitHubRegistryProvider(), args.tag, args.release_run, args.corpus_run, args.compose_run) + sys.stdout.buffer.write(canonical(value)) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError) as error: + reason = str(error) if isinstance(error, Refusal) else "local_input_or_transport" + if re.fullmatch(r"[a-z_]{1,80}", reason) is None: + reason = "local_input_or_transport" + print("Native promotion evidence refused: " + reason, file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/ci/native_alias_transport.py b/scripts/ci/native_alias_transport.py index adc8d1b00..77457521e 100644 --- a/scripts/ci/native_alias_transport.py +++ b/scripts/ci/native_alias_transport.py @@ -115,12 +115,10 @@ def __init__(self, commands: Commands | None = None): def api(self, path: str) -> Any: # GitHub comparisons use a three-dot separator, not a traversal - # segment. Only the immutable-source and protected-main reads used by - # the evidence predicates may contain it; every other path keeps the - # original dot/traversal refusal. + # segment. Only the protected-main read of an exact source may contain + # it; every other path keeps the original dot/traversal refusal. comparison = type(path) is str and re.fullmatch( - rf"repos/{REPOSITORY}/compare/[0-9a-f]{{40}}\.\.\.(?:" - r"[0-9a-f]{40}\?per_page=100|main(?:\?per_page=1)?)", path) is not None + rf"repos/{REPOSITORY}/compare/[0-9a-f]{{40}}\.\.\.main", path) is not None require(type(path) is str and path.startswith(f"repos/{REPOSITORY}/") and re.fullmatch(r"[A-Za-z0-9_./?=&-]+", path) is not None and (".." not in path or comparison), "provider_api_path") @@ -172,6 +170,17 @@ def registry_json(self, image: str, digest: str, image_config: bool = False) -> arguments += ["--format", "{{json .Image}}"] if image_config else ["--raw"] return strict_json(self.commands.run(arguments + [image + "@" + digest]), JSON_LIMIT) + def exact_tag_digest(self, image: str, tag: str) -> str: + """The index digest an exact final version tag names, read without pulling.""" + from native_alias_evidence import FINAL_TAG + require(image in (GHCR_IMAGE, DOCKERHUB_IMAGE) and matching(tag, FINAL_TAG), "registry_subject") + # Buildx prints a human manifest for templates that begin with {{.Manifest. + output = self.commands.run(["docker", "buildx", "imagetools", "inspect", "--format", + '{{printf "%s" .Manifest.Digest}}', image + ":" + tag.removeprefix("native-")], + 128).decode("utf-8", errors="strict").strip() + require(matching(output, DIGEST), "registry_exact_tag") + return output + def index_subjects(self, image: str, index_digest: str, source: str, tag: str) -> dict[str, dict[str, str]]: index = self.registry_json(image, index_digest) diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py index eeba2a814..d5e76c164 100644 --- a/scripts/ci/native_qualification.py +++ b/scripts/ci/native_qualification.py @@ -15,8 +15,8 @@ from native_alias_evidence import (COMPOSE_OBSERVATIONS, CORPUS_JOB, CORPUS_OBSERVATIONS, DIGEST, FINAL_TAG, GHCR_IMAGE, JSON_LIMIT, PLATFORMS, - RECEIPT_SCHEMA, RECEIPT_SCHEMA_V2, REPOSITORY, REPOSITORY_ID, SHA, WORKFLOWS, - SUBJECT_PATH, RUNNER_REF, qualification_subject, runner_subject, + RECEIPT_SCHEMA, REPOSITORY, REPOSITORY_ID, SHA, WORKFLOWS, + SUBJECT_PATH, qualification_subject, release_subject, resource_scope, bind_dependencies, LOCAL_IMAGE_KEYS, Refusal, canonical, closed, literal, matching, positive, require, strict_json) @@ -59,30 +59,25 @@ def write(path: Path, value: Any) -> None: stream.write(canonical(value)) -def eligible_context(role: str, image: str) -> bool: +def final_tag_dispatch() -> bool: tag = os.environ.get("GITHUB_REF_NAME", "") - return branch_context(role, image) or (os.environ.get("GITHUB_REPOSITORY") == REPOSITORY - and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" - and os.environ.get("GITHUB_REF") == "refs/tags/" + tag - and matching(tag, FINAL_TAG) and matching(os.environ.get("GITHUB_SHA"), SHA) - and os.environ.get("GITHUB_WORKFLOW_REF") == - f"{REPOSITORY}/{WORKFLOWS[role]}@refs/tags/{tag}" - and re.fullmatch(re.escape(GHCR_IMAGE) + r"@sha256:[0-9a-f]{64}", image) is not None) + return (os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" + and os.environ.get("GITHUB_REF") == "refs/tags/" + tag and matching(tag, FINAL_TAG)) -def branch_context(role: str, image: str) -> bool: - return (os.environ.get("GITHUB_REPOSITORY") == REPOSITORY - and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" - and os.environ.get("GITHUB_REF") == RUNNER_REF - and os.environ.get("GITHUB_REF_NAME") == RUNNER_REF.removeprefix("refs/heads/") +def eligible_context(role: str, image: str) -> bool: + tag = os.environ.get("GITHUB_REF_NAME", "") + return (final_tag_dispatch() and os.environ.get("GITHUB_REPOSITORY") == REPOSITORY and matching(os.environ.get("GITHUB_SHA"), SHA) - and os.environ.get("GITHUB_WORKFLOW_REF") == f"{REPOSITORY}/{WORKFLOWS[role]}@{RUNNER_REF}" + and os.environ.get("GITHUB_WORKFLOW_REF") == + f"{REPOSITORY}/{WORKFLOWS[role]}@refs/tags/{tag}" and re.fullmatch(re.escape(GHCR_IMAGE) + r"@sha256:[0-9a-f]{64}", image) is not None) def admit(provider: GitHubRegistryProvider, role: str, image: str) -> dict[str, Any]: - require(branch_context(role, image), "qualification_runner_context") - source = os.environ["GITHUB_SHA"] + """Derive the subject from the release tag this run was dispatched from.""" + require(eligible_context(role, image), "qualification_release_context") + source, tag = os.environ["GITHUB_SHA"], os.environ["GITHUB_REF_NAME"] actual = provider.commands.run(["git", "rev-parse", "HEAD"], 128).decode().strip() require(actual == source, "qualification_checkout") path = ROOT / SUBJECT_PATH @@ -90,14 +85,17 @@ def admit(provider: GitHubRegistryProvider, role: str, image: str) -> dict[str, with path.open("rb") as stream: subject = qualification_subject(strict_json(stream.read(8193), 8192)) blob = provider.commands.run(["git", "rev-parse", source + ":" + SUBJECT_PATH],128).decode().strip() - runner = {"source_commit": source, "ref": RUNNER_REF, "subject_blob": blob} - require(runner_subject(provider, runner, subject["release"]) == subject, "qualification_local_subject") - require(image == GHCR_IMAGE + "@" + subject["release"]["index_digest"], "qualification_requested_subject") + release = {"tag": tag, "source_commit": source, "index_digest": image.split("@", 1)[1]} + runner, published = release_subject(provider, release) + require(runner["subject_blob"] == blob and published == subject, "qualification_local_subject") + # The requested index must be the one the exact version tag publishes. + require(provider.exact_tag_digest(GHCR_IMAGE, tag) == release["index_digest"], + "qualification_requested_subject") if role == "compose": require("mysql:" + os.environ.get("MYSQL_VERSION", "") == subject["mysql"]["requested_image"] and os.environ.get("QUALIFICATION_QEMU_IMAGE") == subject["qemu"]["requested_image"], "qualification_dependency_configuration") - return {"runner": runner, "subject": subject} + return {"runner": runner, "subject": subject, "release": release} MANIFEST_TYPES = {"application/vnd.oci.image.manifest.v1+json", @@ -229,41 +227,29 @@ def observe_image(provider: GitHubRegistryProvider, role: str, image: str, if not eligible_context(role, image): return {"eligible": False, "reason": "nonpromotion_context"} require(platform in PLATFORMS, "qualification_platform") - admission = admit(provider, role, image) if branch_context(role, image) else None - release = admission["subject"]["release"] if admission else { - "source_commit": os.environ["GITHUB_SHA"], "tag": os.environ["GITHUB_REF_NAME"]} - source, tag = release["source_commit"], release["tag"] + admission = admit(provider, role, image) + release = admission["release"] + source, tag, index = release["source_commit"], release["tag"], release["index_digest"] actual_source = provider.commands.run(["git", "rev-parse", "HEAD"], 128).decode().strip() require(actual_source == os.environ["GITHUB_SHA"], "qualification_checkout") - index = image.split("@", 1)[1] subject = provider.index_subjects(GHCR_IMAGE, index, source, tag)[platform] instance = inspect(provider.commands, "container", container) - local = {} - if admission: - config, local = local_image(provider, subject, image, platform, index, instance) - else: - require(instance.get("Image") == subject["image_config_digest"], "actual_container_image") - config = inspect(provider.commands, "image", subject["image_config_digest"]) - require(config.get("Id") == subject["image_config_digest"] - and config.get("Architecture") == platform.split("/")[1] - and config.get("Os") == "linux", "actual_container_platform") + config, local = local_image(provider, subject, image, platform, index, instance) require(type(config.get("Config")) is dict, "actual_container_platform") labels = config["Config"].get("Labels") require(type(labels) is dict and labels.get("org.opencontainers.image.revision") == source and labels.get("dev.durable-workflow.release.tag") == tag, "actual_container_source") - extra = {} - if admission: - profile = os.environ.get("DW_PUBLISHED_COMPOSE_PROFILE") if role == "compose" else None - observed_scope = resource_scope(role, int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]), platform, profile) - require(os.environ.get("DW_PUBLISHED_COMPOSE_PROJECT" if role == "compose" else "RESOURCE_SCOPE") == observed_scope, - "qualification_resource_scope") - extra = {"runner_ref": admission["runner"]["ref"], "subject_blob": admission["runner"]["subject_blob"], - "resource_scope": observed_scope, **local, - "dependencies": observe_dependencies(provider, role, platform, observed_scope, admission["subject"])} + profile = os.environ.get("DW_PUBLISHED_COMPOSE_PROFILE") if role == "compose" else None + observed_scope = resource_scope(role, int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]), platform, profile) + require(os.environ.get("DW_PUBLISHED_COMPOSE_PROJECT" if role == "compose" else "RESOURCE_SCOPE") == observed_scope, + "qualification_resource_scope") return {"eligible": True, "requested_image": image, "index_digest": index, "platform": platform, **subject, "actual_image_config_digest": subject["image_config_digest"], "container_image_architecture": config["Architecture"], "container_image_os": config["Os"], - "image_source_commit": source, "image_release_tag": tag, **extra} + "image_source_commit": source, "image_release_tag": tag, + "runner_ref": admission["runner"]["ref"], "subject_blob": admission["runner"]["subject_blob"], + "resource_scope": observed_scope, **local, + "dependencies": observe_dependencies(provider, role, platform, observed_scope, admission["subject"])} def lines(commands: Commands, args: list[str], expression: str) -> set[str]: @@ -387,7 +373,7 @@ def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: "qualification_cleanup_failed") observations = qualification_observations(role, profile, observations) source = os.environ["GITHUB_SHA"] - admission = admit(provider, role, image) if branch_context(role, image) else None + admission = admit(provider, role, image) run_id, attempt = int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]) require(positive(run_id) and positive(attempt), "qualification_run") run = provider.api(f"repos/{REPOSITORY}/actions/runs/{run_id}") @@ -415,16 +401,15 @@ def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: # Only explicitly whitelisted image fields cross into the artifact. image_keys = {"requested_image", "index_digest", "platform", "child_manifest_digest", "image_config_digest", "actual_image_config_digest", "container_image_architecture", "container_image_os", - "image_source_commit", "image_release_tag"} - if admission: - image_keys |= {"runner_ref", "subject_blob", "resource_scope", "dependencies"} | LOCAL_IMAGE_KEYS - require(identity.get("runner_ref") == admission["runner"]["ref"] - and identity.get("subject_blob") == admission["runner"]["subject_blob"] - and identity.get("resource_scope") == resource_scope(role,run_id,attempt,platform,profile), - "qualification_identity_runner") - bind_dependencies(identity.get("dependencies"), role, platform, admission["subject"]) + "image_source_commit", "image_release_tag", + "runner_ref", "subject_blob", "resource_scope", "dependencies"} | LOCAL_IMAGE_KEYS + require(identity.get("runner_ref") == admission["runner"]["ref"] + and identity.get("subject_blob") == admission["runner"]["subject_blob"] + and identity.get("resource_scope") == resource_scope(role,run_id,attempt,platform,profile), + "qualification_identity_runner") + bind_dependencies(identity.get("dependencies"), role, platform, admission["subject"]) closed(identity, image_keys | {"eligible"}, "qualification_identity_shape") - return {"schema": RECEIPT_SCHEMA_V2 if admission else RECEIPT_SCHEMA, "kind": role, "eligible": True, + return {"schema": RECEIPT_SCHEMA, "kind": role, "eligible": True, "repository": REPOSITORY, "repository_id": REPOSITORY_ID, "run_id": run_id, "run_attempt": attempt, "job_id": selected[0]["id"], "runner_commit": source, "workflow_path": WORKFLOWS[role], "workflow_blob": workflow_blob, @@ -448,8 +433,8 @@ def main() -> None: args = parser.parse_args() provider = GitHubRegistryProvider() if args.phase == "admit": - value = (admit(provider, args.kind, args.image) if os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" - and os.environ.get("GITHUB_REF") == RUNNER_REF + # A dispatch from a final release tag must admit; nothing else can. + value = (admit(provider, args.kind, args.image) if final_tag_dispatch() else {"eligible": False, "reason": "nonpromotion_context"}) elif args.phase == "observe-image": value = observe_image(provider, args.kind, args.image, args.container, args.platform) diff --git a/tests/Unit/Support/native_alias_evidence_test.py b/tests/Unit/Support/native_alias_evidence_test.py index 54ec44e39..a623f852f 100644 --- a/tests/Unit/Support/native_alias_evidence_test.py +++ b/tests/Unit/Support/native_alias_evidence_test.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Pure provider/receipt countercontrols. No network, Docker or credentials.""" +import base64 import copy import hashlib import importlib.util @@ -36,11 +37,20 @@ def packed(value, name="receipt.json", mode=stat.S_IFREG | 0o644, extra=None): class FakeProvider: + """A release whose publication and both qualifiers ran from tag 3.2.0.""" + def __init__(self): self.runs, self.workflows, self.job_sets, self.metadata, self.archives = {}, {}, {}, {}, {} self.run_reads, self.artifact_reads = {}, {} self.on_final_run = self.on_final_artifact = None self.writes = [] + self.subject_reads = 0 + self.subject_change = self.on_final_subject = None + self.binding = E.qualification_subject(json.loads((ROOT / E.SUBJECT_PATH).read_text())) + self.binding_bytes = E.canonical(self.binding) + self.binding_blob = hashlib.sha1(b"blob " + str(len(self.binding_bytes)).encode() + b"\0" + + self.binding_bytes, usedforsecurity=False).hexdigest() + self.runner = {"source_commit": SOURCE, "ref": "refs/tags/3.2.0", "subject_blob": self.binding_blob} self.subjects = { "linux/amd64": {"child_manifest_digest": "sha256:" + "b" * 64, "image_config_digest": "sha256:" + "c" * 64}, @@ -73,13 +83,7 @@ def __init__(self): "run_started_at": "2026-11-05T00:00:00Z" if role == "release" else "2026-11-05T00:02:00Z", "updated_at": "2026-11-05T00:01:00Z" if role == "release" else "2026-11-05T00:04:00Z", } - if role == "release": - names = E.RELEASE_JOBS - elif role == "corpus": - names = {E.CORPUS_JOB: E.QUALIFICATION_STEPS[role]} - else: - names = {platform + " " + profile + " published image": E.QUALIFICATION_STEPS[role] - for platform in E.PLATFORMS for profile in E.PROFILES} + names = E.RELEASE_JOBS if role == "release" else E.qualification_jobs(role) self.job_sets[selection["run_id"]] = [ {"id": n * 1000 + i, "run_id": selection["run_id"], "run_attempt": 1, "head_sha": SOURCE, "name": name, "status": "completed", "conclusion": "success", @@ -106,10 +110,11 @@ def make_receipt(self, role, platform, profile, job_id): {k: True for k in E.COMPOSE_OBSERVATIONS}) if role == "compose" and profile == "local": observations["production_default_token_refused"] = None + run_id = 302 if role == "corpus" else 303 return { - "schema": "durable-workflow.native-qualification-receipt.v1", "kind": role, + "schema": "durable-workflow.native-qualification-receipt.v2", "kind": role, "eligible": True, "repository": E.REPOSITORY, "repository_id": E.REPOSITORY_ID, - "run_id": 302 if role == "corpus" else 303, "run_attempt": 1, "job_id": job_id, + "run_id": run_id, "run_attempt": 1, "job_id": job_id, "runner_commit": SOURCE, "workflow_path": E.WORKFLOWS[role], "workflow_blob": "2" * 40 if role == "corpus" else "3" * 40, "requested_image": E.GHCR_IMAGE + "@" + INDEX, "index_digest": INDEX, @@ -119,11 +124,30 @@ def make_receipt(self, role, platform, profile, job_id): "actual_image_config_digest": subject["image_config_digest"], "container_image_architecture": platform.split("/")[1], "container_image_os": "linux", "image_source_commit": SOURCE, "image_release_tag": "3.2.0", + "runner_ref": "refs/tags/3.2.0", "subject_blob": self.binding_blob, + "resource_scope": E.resource_scope(role, run_id, 1, platform, profile), + "local_image_id": subject["image_config_digest"], "local_manifest_digest": None, + "config_digest_source": "engine_id", + "dependencies": self.dependencies(role, platform), "observations": observations, "cleanup": {"api_ok": True, "verified_absent": True, "container_count": 0, "network_count": 0, "volume_count": 0}, } + def dependencies(self, role, platform): + if role == "corpus": + return {} + result = {} + for name in ("mysql", "qemu"): + result[name] = ({**self.binding[name], + "actual_image_config_digest": self.binding[name]["image_config_digest"], + "local_image_id": self.binding[name]["image_config_digest"], "local_manifest_digest": None, + "config_digest_source": "engine_id", + "container_image_architecture": "amd64", "container_image_os": "linux"} + if name == "mysql" or platform == "linux/arm64" else None) + result["mysql"]["container_id"] = "f" * 64 + return result + def set_artifact(self, artifact_id, record, name=None, run=None, data=None): if artifact_id in self.metadata: name = self.metadata[artifact_id]["name"] if name is None else name @@ -134,7 +158,7 @@ def set_artifact(self, artifact_id, record, name=None, run=None, data=None): "id": artifact_id, "name": name, "expired": False, "size_in_bytes": len(data), "digest": "sha256:" + hashlib.sha256(data).hexdigest(), "workflow_run": {"id": run, "repository_id": E.REPOSITORY_ID, - "head_repository_id": E.REPOSITORY_ID, "head_sha": SOURCE}, + "head_repository_id": E.REPOSITORY_ID, "head_sha": self.runs[run]["head_sha"]}, "created_at": "2026-11-05T00:03:00Z", } if artifact_id == 501: @@ -149,7 +173,22 @@ def mutate_receipt(self, artifact_id, change): change(value) self.set_artifact(artifact_id, value) + def subject_record(self, data=None): + data = self.binding_bytes if data is None else data + return {"path": E.SUBJECT_PATH, "type": "file", "encoding": "base64", "size": len(data), + "sha": hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data, + usedforsecurity=False).hexdigest(), + "content": base64.b64encode(data).decode()} + def api(self, path): + if path == f"repos/{E.REPOSITORY}/contents/{E.SUBJECT_PATH}?ref={SOURCE}": + self.subject_reads += 1 + value = self.subject_record() + if self.subject_change: + self.subject_change(value) + if self.on_final_subject and self.subject_reads > 1: + self.on_final_subject(value) + return value prefix = "repos/" + E.REPOSITORY + "/actions/" if not path.startswith(prefix): raise AssertionError("unexpected provider path") @@ -162,9 +201,14 @@ def api(self, path): if self.on_final_artifact and self.artifact_reads[identity] > 1: self.on_final_artifact(value) return value - identity = int(suffix.split("/")[1]) + identity = int(suffix.split("/")[1].split("?")[0]) if "/jobs?" in suffix: return {"total_count": len(self.job_sets[identity]), "jobs": copy.deepcopy(self.job_sets[identity])} + if "/artifacts?name=" in suffix: + name = suffix.split("?name=", 1)[1].split("&", 1)[0] + found = [copy.deepcopy(item) for item in self.metadata.values() + if item["name"] == name and item["workflow_run"]["id"] == identity] + return {"total_count": len(found), "artifacts": found} self.run_reads[identity] = self.run_reads.get(identity, 0) + 1 value = copy.deepcopy(self.runs[identity]) if self.on_final_run and self.run_reads[identity] > 1: @@ -190,81 +234,12 @@ def promote(self): RUNNER = "9" * 40 +RUNNER_REF = "refs/heads/feat/server-native32-qualification-runner" -class RunnerProvider(FakeProvider): - def __init__(self): - super().__init__() - self.binding = E.qualification_subject(json.loads((ROOT / E.SUBJECT_PATH).read_text())) - self.binding["release"] = copy.deepcopy(self.selected["release"]) - self.binding_bytes = E.canonical(self.binding) - self.binding_blob = hashlib.sha1(b"blob " + str(len(self.binding_bytes)).encode() + b"\0" + self.binding_bytes, - usedforsecurity=False).hexdigest() - self.selected.update(schema=E.MANIFEST_SCHEMA_V2, runner={"source_commit": RUNNER, - "ref": E.RUNNER_REF, "subject_blob": self.binding_blob}) - self.diff_files = [{"filename": path,"status": "modified"} for path in sorted(E.RUNNER_PATHS)] - self.on_main = True - for role in ("corpus", "compose"): - selected = self.selected[role] - selected["runner_commit"] = RUNNER - run = self.runs[selected["run_id"]] - run.update(head_sha=RUNNER,head_branch=E.RUNNER_REF.removeprefix("refs/heads/")) - for job in self.job_sets[run["id"]]: - job["head_sha"] = RUNNER - job["steps"].append({"name":"Admit published qualification subject","status":"completed","conclusion":"success"}) - if job["name"].startswith("linux/arm64"): - job["steps"].append({"name":"Set up QEMU","status":"completed","conclusion":"success"}) - for identity in list(self.archives): - record = E.archive_receipt(self.archives[identity]) - record.update(schema=E.RECEIPT_SCHEMA_V2,runner_commit=RUNNER,runner_ref=E.RUNNER_REF, - subject_blob=self.binding_blob, - resource_scope=E.resource_scope(record["kind"], record["run_id"],1,record["platform"],record["profile"]), - local_image_id=record["image_config_digest"],local_manifest_digest=None, - config_digest_source="engine_id", - dependencies=self.dependencies(record["kind"],record["platform"])) - self.set_artifact(identity,record) - - def dependencies(self, role, platform): - if role == "corpus": - return {} - result = {} - for name in ("mysql","qemu"): - result[name] = ({**self.binding[name], - "actual_image_config_digest": self.binding[name]["image_config_digest"], - "local_image_id":self.binding[name]["image_config_digest"],"local_manifest_digest":None, - "config_digest_source":"engine_id", - "container_image_architecture":"amd64","container_image_os":"linux"} - if name == "mysql" or platform == "linux/arm64" else None) - result["mysql"]["container_id"] = "f" * 64 - return result - - def set_artifact(self, identity, record, *args, **kwargs): - super().set_artifact(identity, record, *args, **kwargs) - run = self.metadata[identity]["workflow_run"]["id"] - self.metadata[identity]["workflow_run"]["head_sha"] = self.runs[run]["head_sha"] - - def api(self, path): - if "/contents/" in path: - import base64 - return {"path":E.SUBJECT_PATH,"type":"file","sha":self.binding_blob,"encoding":"base64", - "size":len(self.binding_bytes),"content":base64.b64encode(self.binding_bytes).decode()} - if "/compare/" in path: - if path.endswith("...main?per_page=1"): - return {"status":"ahead","base_commit":{"sha":RUNNER}, - "merge_base_commit":{"sha":RUNNER if self.on_main else SOURCE}} - return {"status":"ahead","base_commit":{"sha":SOURCE},"merge_base_commit":{"sha":SOURCE}, - "total_commits":1,"commits":[{"sha":RUNNER}],"files":copy.deepcopy(self.diff_files)} - return super().api(path) - - def workflow_blob(self, commit, path): - if commit != RUNNER: - raise E.Refusal("unapproved_runner") - return "2" * 40 if path.endswith("native-image-parity.yml") else "3" * 40 - - -class RunnerEvidenceTests(unittest.TestCase): +class TagQualificationEvidenceTests(unittest.TestCase): def test_containerd_receipts_bind_target_manifest_and_config_as_distinct_identities(self): - provider = RunnerProvider() + provider = FakeProvider() for identity in list(provider.archives): record = E.archive_receipt(provider.archives[identity]) record.update(local_image_id=INDEX, local_manifest_digest=record["child_manifest_digest"], @@ -281,51 +256,103 @@ def test_containerd_receipts_bind_target_manifest_and_config_as_distinct_identit with self.assertRaisesRegex(E.Refusal, "receipt_local_image_identity"): E.verify(provider.selected, provider) - def test_reviewed_runner_can_qualify_the_unchanged_published_producer(self): - provider = RunnerProvider() - proof = E.verify(provider.selected,provider) - self.assertEqual(SOURCE,proof["release"]["source_commit"]) - self.assertEqual(5,len(proof["qualified_artifact_ids"])) - self.assertEqual(RUNNER,provider.selected["runner"]["source_commit"]) - - def test_runner_changes_cannot_cross_the_frozen_product_boundary(self): - for path in ("rust/src/runtime/store.rs","resources/worker-protocol-api.openapi.yaml", - "tests/Fixtures/ServerParityBaselines/php-2.11.8.json"): - provider = RunnerProvider() - provider.diff_files.append({"filename":path,"status":"modified"}) - with self.subTest(path=path), self.assertRaises(E.Refusal): - E.verify(provider.selected,provider) - provider = RunnerProvider();provider.on_main=False - with self.assertRaisesRegex(E.Refusal,"qualification_runner_not_on_main"): - E.verify(provider.selected,provider) - - def test_v1_never_admits_a_different_runner_or_v2_receipt(self): - provider = FakeProvider();provider.selected["corpus"]["runner_commit"]=RUNNER - with self.assertRaisesRegex(E.Refusal,"corpus_runner_source"): - E.verify(provider.selected,provider) - provider = FakeProvider();provider.mutate_receipt(501,lambda r:r.update(schema=E.RECEIPT_SCHEMA_V2)) - with self.assertRaisesRegex(E.Refusal,"receipt_not_eligible"): - E.verify(provider.selected,provider) - - def test_every_new_runner_and_dependency_field_is_authoritative(self): + def test_qualifiers_dispatched_from_the_tag_qualify_the_published_release(self): + provider = FakeProvider() + proof = E.verify(provider.selected, provider) + self.assertEqual(provider.selected["release"], proof["release"]) + self.assertEqual(provider.runner, proof["runner"]) + self.assertEqual(provider.binding, proof["qualification_subject"]) + self.assertEqual([501, 502, 503, 504, 505], proof["qualified_artifact_ids"]) + self.assertEqual(2, provider.subject_reads, "the committed subject is read again before returning") + + def test_runner_branch_evidence_and_receipts_refuse(self): + provider = FakeProvider() + for role, run_id in (("corpus", 302), ("compose", 303)): + provider.selected[role]["runner_commit"] = RUNNER + provider.runs[run_id].update(head_sha=RUNNER, head_branch=RUNNER_REF.removeprefix("refs/heads/")) + with self.assertRaisesRegex(E.Refusal, "corpus_runner_source"): + E.verify(provider.selected, provider) + provider = FakeProvider() + provider.runs[302].update(head_branch=RUNNER_REF.removeprefix("refs/heads/")) + with self.assertRaisesRegex(E.Refusal, "provider_run_source"): + E.verify(provider.selected, provider) + provider = FakeProvider() + v2 = {**provider.selected, "schema": "durable-workflow.native-alias-promotion-evidence.v2", + "runner": {"source_commit": RUNNER, "ref": RUNNER_REF, "subject_blob": provider.binding_blob}} + with self.assertRaisesRegex(E.Refusal, "manifest_shape"): + E.verify(v2, provider) + provider.mutate_receipt(501, lambda record: record.update(runner_ref=RUNNER_REF)) + with self.assertRaisesRegex(E.Refusal, "receipt_runner_binding"): + E.verify(provider.selected, provider) + + def test_v1_receipts_without_dependency_and_scope_bindings_refuse(self): + def legacy(record): + for key in {"runner_ref", "subject_blob", "resource_scope", "dependencies"} | E.LOCAL_IMAGE_KEYS: + record.pop(key) + record.update(schema="durable-workflow.native-qualification-receipt.v1") + provider = FakeProvider() + provider.mutate_receipt(501, legacy) + with self.assertRaisesRegex(E.Refusal, "receipt_shape"): + E.verify(provider.selected, provider) + provider = FakeProvider() + provider.mutate_receipt(501, lambda record: record.update(schema="durable-workflow.native-qualification-receipt.v1")) + with self.assertRaisesRegex(E.Refusal, "receipt_not_eligible"): + E.verify(provider.selected, provider) + + def test_subject_is_the_exact_blob_committed_at_the_release_source(self): + cases = [ + (lambda record: record.update(sha="8" * 40), "qualification_subject_digest"), + (lambda record: record.update(content=base64.b64encode(b'{"x":1}\n').decode()), + "qualification_subject_digest"), + (lambda record: record.update(path="other.json"), "qualification_subject_blob"), + (lambda record: record.update(type="symlink"), "qualification_subject_blob"), + (lambda record: record.update(size=1), "qualification_subject_digest"), + ] + for change, reason in cases: + provider = FakeProvider() + provider.subject_change = change + with self.subTest(reason=reason), self.assertRaisesRegex(E.Refusal, reason): + provider.promote() + self.assertEqual([], provider.writes) + # The 3.5.0 runner-branch subject carried a hand-edited release block. + provider = FakeProvider() + legacy = {**provider.binding, "schema": "durable-workflow.native-qualification-subject.v1", + "release": provider.selected["release"]} + provider.subject_change = lambda record: record.update(provider.subject_record(E.canonical(legacy))) + with self.assertRaisesRegex(E.Refusal, "qualification_subject_shape"): + provider.promote() + + def test_subject_change_during_verification_refuses(self): + provider = FakeProvider() + changed = copy.deepcopy(provider.binding) + changed["mysql"]["image_config_digest"] = "sha256:" + "9" * 64 + data = E.canonical(changed) + provider.on_final_subject = lambda record: record.update(provider.subject_record(data)) + with self.assertRaisesRegex(E.Refusal, "qualification_subject_changed"): + provider.promote() + self.assertEqual([], provider.writes) + + def test_every_runner_and_dependency_field_is_authoritative(self): mutations = [ - lambda p:p.selected["runner"].update(ref="refs/heads/main"), - lambda p:p.selected["runner"].update(subject_blob="8"*40), - lambda p:p.runs[302].update(head_sha=SOURCE), + lambda p:p.runs[302].update(head_sha=RUNNER), + lambda p:p.mutate_receipt(501,lambda r:r.update(runner_ref="refs/tags/3.2.1")), + lambda p:p.mutate_receipt(501,lambda r:r.update(subject_blob="8"*40)), + lambda p:p.mutate_receipt(501,lambda r:r.update(runner_commit=RUNNER)), lambda p:p.mutate_receipt(501,lambda r:r.update(image_source_commit=RUNNER)), lambda p:p.mutate_receipt(502,lambda r:r.update(resource_scope="foreign")), lambda p:p.mutate_receipt(502,lambda r:r["dependencies"].pop("mysql")), lambda p:p.mutate_receipt(502,lambda r:r["dependencies"]["mysql"].update(actual_image_config_digest=INDEX)), lambda p:p.mutate_receipt(504,lambda r:r["dependencies"].update(qemu=None)), lambda p:p.mutate_receipt(502,lambda r:r["dependencies"].update(qemu=p.dependencies("compose","linux/arm64")["qemu"])), - lambda p:p.job_sets[302][0]["steps"].pop(), + lambda p:p.job_sets[302][0]["steps"].pop(0), + lambda p:p.job_sets[303][2]["steps"].pop(), lambda p:p.mutate_receipt(501,lambda r:r.update(local_image_id=[])), lambda p:p.mutate_receipt(502,lambda r:r["dependencies"]["mysql"].update(config_digest_source="unknown")), ] for i,change in enumerate(mutations): - provider=RunnerProvider();change(provider) + provider=FakeProvider();change(provider) with self.subTest(mutation=i),self.assertRaises(E.Refusal): - E.verify(provider.selected,provider) + provider.promote() self.assertEqual([],provider.writes) diff --git a/tests/Unit/Support/native_alias_manifest_test.py b/tests/Unit/Support/native_alias_manifest_test.py new file mode 100644 index 000000000..501b5a894 --- /dev/null +++ b/tests/Unit/Support/native_alias_manifest_test.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Evidence manifest selection from three run IDs. No network, Docker or credentials.""" +import copy +from pathlib import Path +import subprocess +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts/ci")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import native_alias_evidence as E +import native_alias_manifest as M +from native_alias_evidence_test import INDEX, RUNNER, RUNNER_REF, FakeProvider + +ROOT = Path(__file__).resolve().parents[3] + + +def build(provider, tag="3.2.0"): + return M.build(provider, tag, 301, 302, 303) + + +class ManifestSelectionTests(unittest.TestCase): + def test_three_run_ids_select_the_exact_manifest_the_verifier_accepts(self): + provider = FakeProvider() + value = build(provider) + self.assertEqual(value, provider.selected) + self.assertEqual(E.manifest(E.canonical(value)), value) + self.assertEqual(E.verify(value, provider)["qualified_artifact_ids"], [501, 502, 503, 504, 505]) + + def test_selection_follows_the_current_attempt_and_its_jobs(self): + provider = FakeProvider() + provider.runs[303]["run_attempt"] = 2 + for job in provider.job_sets[303]: + job.update(run_attempt=2, id=job["id"] + 10) + provider.job_sets[303] += [{**copy.deepcopy(job), "run_attempt": 1, "id": job["id"] - 10} + for job in provider.job_sets[303]] + value = build(provider) + self.assertEqual(value["compose"]["run_attempt"], 2) + self.assertEqual(sorted(cell["job_id"] for cell in value["compose"]["cells"]), [3011, 3012, 3013, 3014]) + + def test_runs_not_dispatched_from_the_release_tag_refuse(self): + cases = [ + (lambda p: p.runs[302].update(head_branch=RUNNER_REF.removeprefix("refs/heads/"), head_sha=RUNNER), + "selection_corpus_run"), + (lambda p: p.runs[303].update(head_branch="main"), "selection_compose_run"), + (lambda p: p.runs[301].update(head_branch="main"), "selection_release_run"), + (lambda p: p.runs[302].update(head_sha=RUNNER), "selection_corpus_source"), + (lambda p: p.runs[302].update(event="push"), "selection_corpus_run"), + (lambda p: p.runs[303].update(conclusion="failure"), "selection_compose_run"), + (lambda p: p.runs[303].update(status="in_progress"), "selection_compose_run"), + (lambda p: p.runs[302].update(path=E.WORKFLOWS["compose"]), "selection_corpus_run"), + (lambda p: p.runs[301].update(display_title="Native 3.2.0 at " + RUNNER), "selection_release_run"), + ] + for change, reason in cases: + provider = FakeProvider() + change(provider) + with self.subTest(reason=reason), self.assertRaisesRegex(E.Refusal, reason): + build(provider) + + def test_both_qualifiers_must_name_one_canonical_index(self): + cases = [ + lambda p: p.runs[303].update(display_title="Published Compose " + E.GHCR_IMAGE + "@sha256:" + "0" * 64), + lambda p: p.runs[302].update(display_title="Native image parity " + E.GHCR_IMAGE + ":3.2.0"), + lambda p: p.runs[303].update(display_title="Published Compose durableworkflow/server@" + INDEX), + ] + for change in cases: + provider = FakeProvider() + change(provider) + with self.subTest(change=change), self.assertRaisesRegex(E.Refusal, "selection_qualified_image"): + build(provider) + provider = FakeProvider() + provider.runs[302]["display_title"] = "Published Compose " + E.GHCR_IMAGE + "@" + INDEX + with self.assertRaisesRegex(E.Refusal, "selection_corpus_source"): + build(provider) + + def test_receipt_artifacts_are_exact_never_the_newest_of_several(self): + cases = [ + (lambda p: p.metadata.pop(503), "selection_artifact_ambiguous"), + (lambda p: p.metadata.__setitem__(599, {**copy.deepcopy(p.metadata[501]), "id": 599}), + "selection_artifact_ambiguous"), + (lambda p: p.metadata[501].update(expired=True), "selection_artifact_identity"), + (lambda p: p.metadata[502].update(digest="sha1:" + "0" * 40), "selection_artifact_identity"), + (lambda p: p.metadata[502].update(id=True), "selection_artifact_identity"), + ] + for change, reason in cases: + provider = FakeProvider() + change(provider) + with self.subTest(reason=reason), self.assertRaisesRegex(E.Refusal, reason): + build(provider) + + def test_qualifier_jobs_and_steps_are_checked_before_selection(self): + for change in (lambda p: p.job_sets[302][0]["steps"].pop(0), + lambda p: p.job_sets[303].pop(), + lambda p: p.job_sets[303][2]["steps"].pop()): + provider = FakeProvider() + change(provider) + with self.subTest(change=change), self.assertRaises(E.Refusal): + build(provider) + + def test_candidate_tags_and_coerced_run_ids_refuse_before_any_read(self): + class NoReads(FakeProvider): + def api(self, path): + raise AssertionError("no provider read") + for tag, runs in (("3.2.0-rc.1", (301, 302, 303)), ("latest", (301, 302, 303)), + ("3.2.0", (True, 302, 303)), ("3.2.0", (301, 0, 303))): + with self.subTest(tag=tag, runs=runs), self.assertRaises(E.Refusal): + M.build(NoReads(), tag, *runs) + + def test_cli_runs_isolated_and_prints_only_a_static_reason(self): + script = str(ROOT / "scripts/ci/native_alias_manifest.py") + result = subprocess.run([sys.executable, "-I", script, "--help"], capture_output=True, timeout=10, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([sys.executable, "-I", script, "--tag", "3.2.0-rc.1", "--release-run", "1", + "--corpus-run", "2", "--compose-run", "3"], + capture_output=True, timeout=10, check=False) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, b"") + self.assertEqual(result.stderr, b"Native promotion evidence refused: final_release_required\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index eea376398..4c8250462 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Transport/producer/cleanup countercontrols; no real Docker or API calls.""" +import contextlib import copy import json import os @@ -19,7 +20,7 @@ import native_alias_promote as P import native_alias_transport as T import native_qualification as Q -from native_alias_evidence_test import FakeProvider, RunnerProvider, RUNNER +from native_alias_evidence_test import FakeProvider, RUNNER, RUNNER_REF ROOT = Path(__file__).resolve().parents[3] @@ -265,7 +266,7 @@ def __init__(self, provider): def run(self, args, *unused, **kwargs): self.reads.append(args) if args[:2] == ["git","rev-parse"]: - return (RUNNER if args[2] == "HEAD" else self.provider.binding_blob if args[2].endswith(E.SUBJECT_PATH) else "3"*40).encode()+b"\n" + return (SOURCE if args[2] == "HEAD" else self.provider.binding_blob if args[2].endswith(E.SUBJECT_PATH) else "3"*40).encode()+b"\n" if args[:3] == ["docker","container","ls"]: return ("f"*64+"\n").encode() identity = args[-1] @@ -343,10 +344,35 @@ def run(self, args, *more, **kwargs): return json.dumps([value]).encode() -def containerd_provider(): - provider = RunnerProvider() - provider.commands = ContainerdCommands(provider) +def tag_provider(commands=None): + """The release fixture as seen by a qualifier dispatched from tag 3.2.0.""" + provider = FakeProvider() + provider.commands = (commands or RunnerCommands)(provider) provider.index_subjects = lambda *args: copy.deepcopy(provider.subjects) + provider.exact_tag_reads = [] + provider.exact_tag_digest = lambda image, tag: provider.exact_tag_reads.append((image, tag)) or INDEX + return provider + + +@contextlib.contextmanager +def tag_dispatch(provider, change=None, subject=None): + """A compose cell dispatched from tag 3.2.0 with the tag's checkout as ROOT.""" + project = E.resource_scope("compose", 303, 1, "linux/amd64", "local") + provider.commands.database_owner = project + context = {**CONTEXT, "DW_PUBLISHED_COMPOSE_PROJECT": project, "DW_PUBLISHED_COMPOSE_PROFILE": "local", + "MYSQL_VERSION": provider.binding["mysql"]["requested_image"].removeprefix("mysql:"), + "QUALIFICATION_QEMU_IMAGE": provider.binding["qemu"]["requested_image"], **(change or {})} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / E.SUBJECT_PATH + path.parent.mkdir(parents=True) + path.write_bytes(provider.binding_bytes if subject is None else subject) + with patch.dict(os.environ, context, clear=True), patch.object(Q, "ROOT", root): + yield + + +def containerd_provider(): + provider = tag_provider(ContainerdCommands) def registry_image(repository, digest): subjects = list(provider.subjects.values()) + [provider.binding[name] for name in ("mysql", "qemu")] @@ -358,18 +384,14 @@ def registry_image(repository, digest): return provider -class RunnerProducerTests(unittest.TestCase): +class TagProducerTests(unittest.TestCase): def context(self, provider): - return {**CONTEXT,"GITHUB_SHA":RUNNER,"GITHUB_REF":E.RUNNER_REF, - "GITHUB_REF_NAME":E.RUNNER_REF.removeprefix("refs/heads/"), - "GITHUB_WORKFLOW_REF":E.REPOSITORY+"/"+E.WORKFLOWS["compose"]+"@"+E.RUNNER_REF, - "DW_PUBLISHED_COMPOSE_PROJECT":SCOPE,"DW_PUBLISHED_COMPOSE_PROFILE":"local", + return {**CONTEXT,"DW_PUBLISHED_COMPOSE_PROJECT":SCOPE,"DW_PUBLISHED_COMPOSE_PROFILE":"local", "MYSQL_VERSION":provider.binding["mysql"]["requested_image"].removeprefix("mysql:"), "QUALIFICATION_QEMU_IMAGE":provider.binding["qemu"]["requested_image"]} def test_actual_producer_v2_receipt_is_consumable_without_restamping_the_image_source(self): - provider=RunnerProvider();provider.commands=RunnerCommands(provider) - provider.index_subjects=lambda *args:copy.deepcopy(provider.subjects) + provider=tag_provider() provider.runs[303]["status"]="in_progress" provider.job_sets[303][0]["status"]="in_progress" with tempfile.TemporaryDirectory() as directory: @@ -385,10 +407,12 @@ def test_actual_producer_v2_receipt_is_consumable_without_restamping_the_image_s clean={"api_ok":True,"verified_absent":True,"container_count":0,"network_count":0,"volume_count":0} record=Q.finalize(provider,"compose",image,"linux/amd64","local",observed,observations,clean,"success","success") E.bind_receipt(record,"compose",provider.selected["compose"],provider.runs[303],provider.job_sets[303][0], - provider.binding["release"],"linux/amd64","local",provider.subjects["linux/amd64"], - provider.selected["runner"],provider.binding) + provider.selected["release"],"linux/amd64","local",provider.subjects["linux/amd64"], + provider.runner,provider.binding) self.assertEqual(record["image_source_commit"],SOURCE) - self.assertEqual(record["runner_commit"],RUNNER) + self.assertEqual(record["runner_commit"],SOURCE) + self.assertEqual(record["runner_ref"],"refs/tags/3.2.0") + self.assertEqual(provider.exact_tag_reads[0],(E.GHCR_IMAGE,"3.2.0")) dependencies=Q.observe_dependencies(provider,"compose","linux/arm64",context["DW_PUBLISHED_COMPOSE_PROJECT"],provider.binding) E.bind_dependencies(dependencies,"compose","linux/arm64",provider.binding) provider.commands.wrong_qemu_digest=True @@ -405,7 +429,7 @@ def test_actual_producer_v2_receipt_is_consumable_without_restamping_the_image_s with self.subTest(field=field),patch.dict(os.environ,{field:value}),self.assertRaisesRegex(E.Refusal,"qualification_dependency_configuration"): Q.admit(provider,"compose",image) - def test_manual_runner_has_no_builder_and_admits_before_dependency_steps(self): + def test_qualifiers_have_no_builder_and_admit_before_dependency_steps(self): corpus=(ROOT/E.WORKFLOWS["corpus"]).read_text() builder=corpus.split(" - name: Set up Docker Buildx\n",1)[1].split(" - name:",1)[0] self.assertIn("if: github.event_name == 'pull_request'",builder) @@ -448,14 +472,14 @@ def test_containerd_index_and_child_ids_produce_consumable_platform_bound_receip record = Q.finalize(provider, "compose", image, platform, "local", observed, observations, clean, "success", "success") job = next(job for job in provider.job_sets[303] if job["name"] == platform + " local published image") E.bind_receipt(record, "compose", provider.selected["compose"], provider.runs[303], job, - provider.binding["release"], platform, "local", provider.subjects[platform], - provider.selected["runner"], provider.binding) + provider.selected["release"], platform, "local", provider.subjects[platform], + provider.runner, provider.binding) for field, value in (("local_image_id", "sha256:" + "0" * 64), ("local_manifest_digest", None), ("config_digest_source", "engine_id")): with self.subTest(field=field), self.assertRaises(E.Refusal): E.bind_receipt({**record, field: value}, "compose", provider.selected["compose"], provider.runs[303], job, - provider.binding["release"], platform, "local", provider.subjects[platform], - provider.selected["runner"], provider.binding) + provider.selected["release"], platform, "local", provider.subjects[platform], + provider.runner, provider.binding) def test_containerd_manifest_config_platform_and_local_id_conflicts_refuse(self): mutations = [ @@ -635,8 +659,10 @@ def test_corpus_observations_accept_every_written_status_and_refuse_a_missing_on Q.qualification_observations("corpus", None, {**written, "retention_payloads": 1}) def test_observed_image_and_finalized_receipt_bind_to_consumer(self): - provider = ProducerProvider() - with patch.dict(os.environ, CONTEXT, clear=True): + provider = tag_provider() + provider.runs[303]["status"] = "in_progress" + provider.job_sets[303][0]["status"] = "in_progress" + with tag_dispatch(provider): image = E.GHCR_IMAGE + "@" + INDEX identity = Q.observe_image(provider, "compose", image, CID, "linux/amd64") observations = {key: True for key in E.COMPOSE_OBSERVATIONS} @@ -644,9 +670,9 @@ def test_observed_image_and_finalized_receipt_bind_to_consumer(self): cleanup = Q.cleanup(ResourceCommands(), "compose", SCOPE) record = Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, observations, cleanup, "success", "success") - E.bind_receipt(record, "compose", {"workflow_blob": "3" * 40}, provider.run, provider.job, - {"source_commit": SOURCE, "tag": "3.2.0", "index_digest": INDEX}, - "linux/amd64", "local", provider.subject) + E.bind_receipt(record, "compose", provider.selected["compose"], provider.runs[303], + provider.job_sets[303][0], provider.selected["release"], + "linux/amd64", "local", provider.subjects["linux/amd64"], provider.runner, provider.binding) self.assertEqual(set(record), E.RECEIPT_KEYS) for field, replacement in (("api_ok", False), ("verified_absent", False), ("container_count", False), ("volume_count", 1)): @@ -658,17 +684,75 @@ def test_observed_image_and_finalized_receipt_bind_to_consumer(self): with self.subTest(body=body, clean=clean), self.assertRaises(E.Refusal): Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, observations, cleanup, body, clean) - provider.run["run_attempt"] = 2 + provider.runs[303]["run_attempt"] = 2 with self.assertRaises(E.Refusal): Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, observations, cleanup, "success", "success") def test_actual_config_id_cannot_be_substituted_with_index(self): - provider = ProducerProvider() - provider.subject["image_config_digest"] = INDEX - with patch.dict(os.environ, CONTEXT, clear=True), self.assertRaisesRegex(E.Refusal, "actual_container_image"): + provider = tag_provider() + published = copy.deepcopy(provider.subjects) + published["linux/amd64"]["image_config_digest"] = INDEX + provider.index_subjects = lambda *args: copy.deepcopy(published) + with tag_dispatch(provider), self.assertRaisesRegex(E.Refusal, "actual_container_image"): Q.observe_image(provider, "compose", E.GHCR_IMAGE + "@" + INDEX, CID, "linux/amd64") + def test_admission_derives_the_release_subject_from_the_dispatch_tag(self): + provider = tag_provider() + image = E.GHCR_IMAGE + "@" + INDEX + with tag_dispatch(provider): + admission = Q.admit(provider, "compose", image) + self.assertEqual(admission["release"], {"tag": "3.2.0", "source_commit": SOURCE, "index_digest": INDEX}) + self.assertEqual(admission["runner"], provider.runner) + self.assertEqual(admission["subject"], provider.binding) + self.assertEqual(provider.exact_tag_reads, [(E.GHCR_IMAGE, "3.2.0")]) + provider.exact_tag_digest = lambda *args: "sha256:" + "0" * 64 + with self.assertRaisesRegex(E.Refusal, "qualification_requested_subject"): + Q.admit(provider, "compose", image) + provider = tag_provider() + before = len(provider.commands.reads) + for change in ({"GITHUB_REF": RUNNER_REF, "GITHUB_REF_NAME": RUNNER_REF.removeprefix("refs/heads/")}, + {"GITHUB_WORKFLOW_REF": E.REPOSITORY + "/" + E.WORKFLOWS["compose"] + "@" + RUNNER_REF}, + {"GITHUB_REF": "refs/tags/3.2.0-rc.1", "GITHUB_REF_NAME": "3.2.0-rc.1"}, + {"GITHUB_EVENT_NAME": "push"}, {"GITHUB_REPOSITORY": "fork/server"}): + with self.subTest(change=change), tag_dispatch(provider, change), \ + self.assertRaisesRegex(E.Refusal, "qualification_release_context"): + Q.admit(provider, "compose", image) + with tag_dispatch(provider), self.assertRaisesRegex(E.Refusal, "qualification_release_context"): + Q.admit(provider, "compose", E.GHCR_IMAGE + ":3.2.0") + self.assertEqual(len(provider.commands.reads), before, "context refusal precedes every read") + + def test_admission_requires_the_checked_out_subject_to_equal_the_committed_blob(self): + local = copy.deepcopy(FakeProvider().binding) + local["mysql"]["image_config_digest"] = "sha256:" + "9" * 64 + provider = tag_provider() + with tag_dispatch(provider, subject=E.canonical(local)), \ + self.assertRaisesRegex(E.Refusal, "qualification_local_subject"): + Q.admit(provider, "compose", E.GHCR_IMAGE + "@" + INDEX) + provider = tag_provider() + provider.binding_blob = "8" * 40 + with tag_dispatch(provider), self.assertRaisesRegex(E.Refusal, "qualification_local_subject"): + Q.admit(provider, "compose", E.GHCR_IMAGE + "@" + INDEX) + + def test_admit_phase_requires_admission_only_for_a_final_tag_dispatch(self): + written = [] + for change, expected in (({}, "qualification_release_context"), + ({"GITHUB_REF": "refs/tags/3.2.0-rc.1", "GITHUB_REF_NAME": "3.2.0-rc.1"}, None), + ({"GITHUB_REF": "refs/heads/main", "GITHUB_REF_NAME": "main"}, None)): + provider = tag_provider() + argv = ["native_qualification.py", "admit", "--kind", "compose", "--image", E.GHCR_IMAGE + ":3.2.0", + "--output", "qualification-state/admission.json"] + with self.subTest(change=change), tag_dispatch(provider, change), patch.object(sys, "argv", argv), \ + patch.object(Q, "GitHubRegistryProvider", lambda: provider), \ + patch.object(Q, "write", lambda path, value: written.append(value)): + if expected: + with self.assertRaisesRegex(E.Refusal, expected): + Q.main() + else: + Q.main() + self.assertEqual(written.pop(), {"eligible": False, "reason": "nonpromotion_context"}) + self.assertEqual(written, []) + class ReadCommands: def __init__(self, result): @@ -729,18 +813,19 @@ def test_publication_verifier_uses_a_digest_template_buildx_actually_evaluates(s self.assertEqual(json.loads(result.stdout), {'tag':'3.2.0','source_commit':SOURCE, 'index_digest':INDEX,'run_id':41,'run_attempt':1}) - def test_git_comparisons_only_allow_the_exact_source_and_main_read_shapes(self): + def test_git_comparisons_only_allow_the_exact_source_and_main_read_shape(self): commands = ReadCommands(b"{}") provider = T.GitHubRegistryProvider(commands) prefix = "repos/" + E.REPOSITORY + "/compare/" - allowed = [prefix + SOURCE + "..." + RUNNER + "?per_page=100", - prefix + RUNNER + "...main?per_page=1", prefix + SOURCE + "...main"] + allowed = [prefix + SOURCE + "...main"] for path in allowed: self.assertEqual(provider.api(path), {}) self.assertEqual([call[0] for call in commands.calls], [["gh", "api", "--hostname", "github.com", "--method", "GET", path] for path in allowed]) - refused = [prefix + SOURCE + suffix for suffix in ( + # The removed runner-branch ancestry reads are refused like any traversal. + refused = [prefix + SOURCE + "..." + RUNNER + "?per_page=100", prefix + RUNNER + "...main?per_page=1"] + refused += [prefix + SOURCE + suffix for suffix in ( ".." + RUNNER, "...." + RUNNER, "...main/../secrets", "...main?per_page=2", "...main?per_page=100", "...main&per_page=1", "...main?per_page=1&page=2", "..." + RUNNER, "..." + RUNNER + "?per_page=1", "...foreign?per_page=1")] @@ -753,8 +838,8 @@ def test_git_comparisons_only_allow_the_exact_source_and_main_read_shapes(self): provider.api(path) self.assertEqual(len(commands.calls), len(allowed), "refused paths never reach the command transport") - def test_runner_ancestry_uses_the_actual_provider_path_guard(self): - fixture = RunnerProvider() + def test_release_subject_uses_the_actual_provider_path_guard(self): + fixture = FakeProvider() class ApiCommands: def __init__(self): @@ -769,20 +854,32 @@ def run(self, argv, limit=E.JSON_LIMIT, env=None, accepted=(0,)): self_test = self commands = ApiCommands() provider = T.GitHubRegistryProvider(commands) - selected = fixture.selected - self.assertEqual(E.runner_subject(provider, selected["runner"], selected["release"]), fixture.binding) - self.assertEqual(commands.paths, [ - f"repos/{E.REPOSITORY}/contents/{E.SUBJECT_PATH}?ref={RUNNER}", - f"repos/{E.REPOSITORY}/compare/{SOURCE}...{RUNNER}?per_page=100", - f"repos/{E.REPOSITORY}/compare/{RUNNER}...main?per_page=1", - ]) - fixture.on_main = False - with self.assertRaisesRegex(E.Refusal, "qualification_runner_not_on_main"): - E.runner_subject(provider, selected["runner"], selected["release"]) - fixture.on_main = True - fixture.diff_files.append({"filename": "rust/src/runtime/store.rs", "status": "modified"}) - with self.assertRaisesRegex(E.Refusal, "qualification_runner_frozen_source"): - E.runner_subject(provider, selected["runner"], selected["release"]) + release = fixture.selected["release"] + self.assertEqual(E.release_subject(provider, release), (fixture.runner, fixture.binding)) + self.assertEqual(commands.paths, [f"repos/{E.REPOSITORY}/contents/{E.SUBJECT_PATH}?ref={SOURCE}"]) + for invalid in ({**release, "tag": "3.2.0-rc.1"}, {**release, "source_commit": "main"}, + {**release, "tag": "3.2.0&ref=main"}): + with self.subTest(release=invalid), self.assertRaisesRegex(E.Refusal, "qualification_release_subject"): + E.release_subject(provider, invalid) + self.assertEqual(len(commands.paths), 1) + + def test_exact_tag_digest_reads_one_bounded_registry_tag(self): + commands = ReadCommands(INDEX.encode() + b"\n") + provider = T.GitHubRegistryProvider(commands) + self.assertEqual(provider.exact_tag_digest(E.GHCR_IMAGE, "3.2.0"), INDEX) + self.assertEqual(provider.exact_tag_digest(E.GHCR_IMAGE, "native-3.2.0"), INDEX) + self.assertEqual([call[0] for call in commands.calls], [ + ["docker", "buildx", "imagetools", "inspect", "--format", '{{printf "%s" .Manifest.Digest}}', + E.GHCR_IMAGE + ":3.2.0"]] * 2) + self.assertEqual({call[1] for call in commands.calls}, {128}) + for image, tag in (("foreign.invalid/server", "3.2.0"), (E.GHCR_IMAGE, "3.2.0-rc.1"), + (E.GHCR_IMAGE, "latest"), (E.GHCR_IMAGE, "3.2.0 --raw")): + with self.subTest(image=image, tag=tag), self.assertRaisesRegex(E.Refusal, "registry_subject"): + provider.exact_tag_digest(image, tag) + self.assertEqual(len(commands.calls), 2) + for output in (b"Name: x\nDigest: " + INDEX.encode(), b"", b"sha256:short"): + with self.subTest(output=output), self.assertRaisesRegex(E.Refusal, "registry_exact_tag"): + T.GitHubRegistryProvider(ReadCommands(output)).exact_tag_digest(E.GHCR_IMAGE, "3.2.0") def test_unprivileged_plan_requires_exact_tag_and_accepted_main_source(self): value = {"release": {"tag": "3.2.0", "source_commit": SOURCE}}