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}}