From 16210f90875311218d5d37e1f58ab09b84643d44 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:04:34 +0000 Subject: [PATCH 01/15] test: add native alias evidence predicates and countercontrols --- scripts/ci/native_alias_evidence.py | 445 ++++++++++++++++++ .../Support/native_alias_evidence_test.py | 302 ++++++++++++ 2 files changed, 747 insertions(+) create mode 100644 scripts/ci/native_alias_evidence.py create mode 100644 tests/Unit/Support/native_alias_evidence_test.py diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py new file mode 100644 index 000000000..300d450f6 --- /dev/null +++ b/scripts/ci/native_alias_evidence.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Read-only qualification predicates for protected native alias promotion. + +A manifest selects provider objects. Only provider metadata, immutable source +bindings and bounded canonical receipts satisfy the predicates. This module +contains no registry mutation and never executes archive contents. +""" +from __future__ import annotations + +import hashlib +import io +import json +import re +import stat +import struct +import zipfile +from datetime import datetime +from typing import Any, Protocol + +REPOSITORY = "durable-workflow/server" +REPOSITORY_ID = 1208272134 +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_LIMIT = 8192 +RECEIPT_LIMIT = 32768 +ARCHIVE_LIMIT = 65536 +JSON_LIMIT = 1048576 +SHA = re.compile(r"[0-9a-f]{40}\Z") +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +FINAL_TAG = re.compile(r"(?:native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+\Z") +PLATFORMS = ("linux/amd64", "linux/arm64") +PROFILES = ("local", "production") +WORKFLOWS = { + "release": ".github/workflows/native-release.yml", + "corpus": ".github/workflows/native-image-parity.yml", + "compose": ".github/workflows/published-compose.yml", +} +CORPUS_JOB = "Compare the native image over the shared fixture corpus (SQLite)" +RELEASE_JOBS = { + "Choose the registries for this tag": ( + "Require a dispatch from the release tag, at its commit, on main", + "Publish to both registries", + ), + **{ + "Build and smoke " + platform: ( + "Verify the release tag names the planned commit", + "Build and push " + platform + " by digest only", + "Smoke-test the pushed " + platform + " digest on its own architecture", + ) for platform in PLATFORMS + }, + "Tag, verify and attest the native index": ( + "Tag and verify the index in each registry", + "Attest the index's build provenance in GHCR", + "Attest the index's build provenance in Docker Hub", + ), +} +QUALIFICATION_STEPS = { + "corpus": ( + "Record and compare the corpus against the native image", + "Verify owned qualification cleanup", + "Finalize promotion receipt", + "Keep promotion receipt", + ), + "compose": ( + "Smoke published Compose stack", + "Verify owned qualification cleanup", + "Finalize promotion receipt", + "Keep promotion receipt", + ), +} + + +class Refusal(ValueError): + """A bounded static reason, safe to print without provider payloads.""" + + +def require(condition: bool, reason: str) -> None: + if not condition: + raise Refusal(reason) + + +def closed(value: Any, keys: set[str], reason: str) -> dict[str, Any]: + require(type(value) is dict and set(value) == keys, reason) + return value + + +def positive(value: Any) -> bool: + return type(value) is int and 0 < value <= 9007199254740991 + + +def literal(actual: Any, expected: Any) -> bool: + return type(actual) is type(expected) and actual == expected + + +def matching(value: Any, expression: re.Pattern[str]) -> bool: + return type(value) is str and expression.fullmatch(value) is not None + + +def canonical(value: Any) -> bytes: + return (json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") + + +def strict_json(data: bytes, limit: int) -> Any: + require(type(data) is bytes and len(data) <= limit, "json_size_refused") + + def pairs(items: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in items: + require(key not in result, "duplicate_json_member") + result[key] = value + return result + + def not_integer(_: str) -> Any: + raise Refusal("noninteger_json_number") + + try: + return json.loads(data.decode("utf-8", errors="strict"), + object_pairs_hook=pairs, parse_float=not_integer, + parse_constant=not_integer) + except (UnicodeError, json.JSONDecodeError, RecursionError, ValueError) as error: + if isinstance(error, Refusal): + raise + raise Refusal("invalid_json") from None + + +def manifest(data: bytes) -> dict[str, Any]: + 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") + require(matching(release["tag"], FINAL_TAG), "final_release_required") + require(matching(release["source_commit"], SHA), "release_source") + require(matching(release["index_digest"], DIGEST), "release_index") + require(positive(release["run_id"]) and positive(release["run_attempt"]), "release_run") + corpus = closed(value["corpus"], + {"run_id", "run_attempt", "runner_commit", "workflow_blob", + "artifact_id", "artifact_digest"}, "corpus_shape") + compose = closed(value["compose"], + {"run_id", "run_attempt", "runner_commit", "workflow_blob", "cells"}, + "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"] == 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") + require(positive(corpus["artifact_id"]) and matching(corpus["artifact_digest"], DIGEST), + "corpus_artifact") + cells = compose["cells"] + require(type(cells) is list and len(cells) == 4, "four_compose_cells_required") + keys, jobs, artifacts = set(), set(), {corpus["artifact_id"]} + for cell in cells: + cell = closed(cell, {"platform", "profile", "job_id", "artifact_id", "artifact_digest"}, + "compose_cell_shape") + require(cell["platform"] in PLATFORMS and cell["profile"] in PROFILES, + "compose_cell_key") + key = (cell["platform"], cell["profile"]) + require(key not in keys and positive(cell["job_id"]) and cell["job_id"] not in jobs, + "duplicate_compose_cell_or_job") + require(positive(cell["artifact_id"]) and cell["artifact_id"] not in artifacts + and matching(cell["artifact_digest"], DIGEST), "compose_artifact") + keys.add(key) + jobs.add(cell["job_id"]) + artifacts.add(cell["artifact_id"]) + require(keys == {(platform, profile) for platform in PLATFORMS for profile in PROFILES}, + "compose_cartesian_set") + return value + + +def archive_receipt(data: bytes) -> dict[str, Any]: + """Read one canonical regular JSON member, without extracting anything.""" + require(type(data) is bytes and len(data) <= ARCHIVE_LIMIT, "artifact_archive_size") + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + members = archive.infolist() + require(len(members) == 1 and not archive.comment, "artifact_inventory") + entry = members[0] + mode = stat.S_IFMT(entry.external_attr >> 16) + require(entry.filename == "receipt.json" and not entry.is_dir() + and not (entry.external_attr & 0x10) + and mode in (0, stat.S_IFREG), "artifact_member_type") + require(not (entry.flag_bits & 1) + and entry.compress_type in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED), + "artifact_member_encoding") + require(0 < entry.file_size <= RECEIPT_LIMIT + and entry.file_size <= 100 * max(entry.compress_size, 1), + "artifact_expansion") + require(not entry.comment and entry.header_offset == 0 and data[:4] == b"PK\x03\x04" + and len(data) >= 22 and data[-22:-18] == b"PK\x05\x06", + "artifact_archive_layout") + # No prefix, trailing carrier, extra disk or unindexed local member. + disk, directory_disk, disk_count, count, directory_size, directory_at, comment = ( + struct.unpack_from(" datetime: + require(type(value) is str and len(value) <= 40, "provider_timestamp") + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + raise Refusal("provider_timestamp") from None + require(result.tzinfo is not None, "provider_timestamp_timezone") + return result + + +class Provider(Protocol): + """Implementations perform bounded reads only; fake providers need no network.""" + + def api(self, path: str) -> Any: ... + def archive(self, artifact_id: int) -> bytes: ... + def workflow_blob(self, commit: str, path: str) -> str: ... + def publication_subjects(self, release: dict[str, Any]) -> dict[str, dict[str, Any]]: ... + + +def workflow_run(provider: Provider, selected: dict[str, Any], role: str, + 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"): + repository = run.get(field) + require(type(repository) is dict and literal(repository.get("id"), REPOSITORY_ID) + and repository.get("full_name") == REPOSITORY, "provider_repository") + 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") + require(run.get("event") == "workflow_dispatch" + 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")) + and literal(run.get("workflow_id"), workflow["id"]) + and workflow.get("path") == WORKFLOWS[role], "provider_workflow_identity") + image = GHCR_IMAGE + "@" + release["index_digest"] + title = (f"Native {release['tag']} at {release['source_commit']}" if role == "release" + else ("Native image parity " if role == "corpus" else "Published Compose ") + image) + require(run.get("display_title") == title, "provider_dispatch_input_binding") + timestamp(run.get("run_started_at")) + timestamp(run.get("updated_at")) + return run + + +def jobs(provider: Provider, run: dict[str, Any], required: dict[str, tuple[str, ...]]) -> dict[str, Any]: + page = provider.api(f"repos/{REPOSITORY}/actions/runs/{run['id']}/jobs?filter=all&per_page=100") + require(type(page) is dict and type(page.get("jobs")) is list + and type(page.get("total_count")) is int + and len(page["jobs"]) == page["total_count"] <= 100, "provider_jobs_incomplete") + selected, ids = {}, set() + for job in page["jobs"]: + require(type(job) is dict and positive(job.get("id")) + and job["id"] not in ids and literal(job.get("run_id"), run["id"]) + and positive(job.get("run_attempt")), "provider_job_identity") + ids.add(job["id"]) + require(job["run_attempt"] <= run["run_attempt"], "provider_later_job_attempt") + if job["run_attempt"] != run["run_attempt"]: + continue + name = job.get("name") + require(type(name) is str and name in required and name not in selected, + "provider_job_set") + require(job.get("head_sha") == run["head_sha"] and job.get("status") == "completed" + and job.get("conclusion") == "success", "provider_job_not_successful") + steps = job.get("steps") + require(type(steps) is list, "provider_steps_missing") + for critical in required[name]: + matches = [step for step in steps if type(step) is dict and step.get("name") == critical] + require(len(matches) == 1 and matches[0].get("status") == "completed" + and matches[0].get("conclusion") == "success", "provider_required_step") + timestamp(job.get("completed_at")) + selected[name] = job + require(set(selected) == set(required), "provider_required_jobs_missing") + return selected + + +def artifact_metadata(provider: Provider, artifact_id: int, digest: str, name: str, + run: dict[str, Any], job: dict[str, Any]) -> dict[str, Any]: + metadata = provider.api(f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}") + require(type(metadata) is dict and literal(metadata.get("id"), artifact_id) + and metadata.get("name") == name and metadata.get("expired") is False + and metadata.get("digest") == digest, "provider_artifact_identity") + require(positive(metadata.get("size_in_bytes")) and metadata["size_in_bytes"] <= ARCHIVE_LIMIT, + "provider_artifact_size") + owner = metadata.get("workflow_run") + require(type(owner) is dict and literal(owner.get("id"), run["id"]) + and literal(owner.get("repository_id"), REPOSITORY_ID) + and literal(owner.get("head_repository_id"), REPOSITORY_ID) + and owner.get("head_sha") == run["head_sha"], "provider_artifact_owner") + require(timestamp(run["run_started_at"]) <= timestamp(metadata.get("created_at")) + <= timestamp(job["completed_at"]), "provider_artifact_attempt_window") + return metadata + + +def receipt(provider: Provider, artifact_id: int, digest: str, name: str, + run: dict[str, Any], job: dict[str, Any]) -> dict[str, Any]: + artifact_metadata(provider, artifact_id, digest, name, run, job) + archive = provider.archive(artifact_id) + require(len(archive) <= ARCHIVE_LIMIT + and "sha256:" + hashlib.sha256(archive).hexdigest() == digest, + "provider_artifact_digest") + return archive_receipt(archive) + + +RECEIPT_KEYS = { + "schema", "kind", "eligible", "repository", "repository_id", "run_id", "run_attempt", + "job_id", "runner_commit", "workflow_path", "workflow_blob", "requested_image", + "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", +} +CORPUS_OBSERVATIONS = {"reference_records", "native_record", "compare", "service_calls", + "schedule_catalog", "namespace_delete_payloads"} +COMPOSE_OBSERVATIONS = {"health_serving", "ready", "cluster_version_matches_release", + "worker_registered", "loopback_binding", "unset_token_refused", + "production_default_token_refused"} + + +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]) -> 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": 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, + "child_manifest_digest": subject["child_manifest_digest"], + "image_config_digest": subject["image_config_digest"], + "actual_image_config_digest": subject["image_config_digest"], + "container_image_architecture": platform.split("/")[1], "container_image_os": "linux", + "image_source_commit": release["source_commit"], "image_release_tag": release["tag"], + } + require(all(literal(value[key], expected_value) for key, expected_value in expected.items()), + "receipt_source_or_subject_binding") + cleanup = closed(value["cleanup"], + {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"}, + "receipt_cleanup_shape") + require(cleanup["api_ok"] is True and cleanup["verified_absent"] is True + and all(literal(cleanup[key], 0) for key in ("container_count", "network_count", "volume_count")), + "receipt_cleanup_unqualified") + observations = closed(value["observations"], + CORPUS_OBSERVATIONS if role == "corpus" else COMPOSE_OBSERVATIONS, + "receipt_observation_shape") + if role == "corpus": + require(all(literal(result, 0) for result in observations.values()), "receipt_corpus_failed") + else: + require(all(result is True for key, result in observations.items() + if key != "production_default_token_refused") + and literal(observations["production_default_token_refused"], + True if profile == "production" else None), "receipt_compose_failed") + + +def verify(value: dict[str, Any], provider: Provider) -> dict[str, Any]: + """Verify once. Re-read mutable run/artifact authority before returning.""" + # Revalidate programmatic callers exactly as CLI manifest bytes. + value = manifest(canonical(value)) + release = value["release"] + publication = workflow_run(provider, release, "release", release) + publication_jobs = jobs(provider, publication, RELEASE_JOBS) + completed = max([timestamp(publication["updated_at"])] + + [timestamp(job["completed_at"]) for job in publication_jobs.values()]) + subjects = provider.publication_subjects(release) + require(type(subjects) is dict and set(subjects) == set(PLATFORMS), "publication_platform_set") + for subject in subjects.values(): + require(type(subject) is dict + and matching(subject.get("child_manifest_digest"), DIGEST) + and matching(subject.get("image_config_digest"), DIGEST) + and len({release["index_digest"], subject["child_manifest_digest"], + subject["image_config_digest"]}) == 3, "publication_subject_identity") + require(len({subject["child_manifest_digest"] for subject in subjects.values()}) == 2 + and len({subject["image_config_digest"] for subject in subjects.values()}) == 2, + "publication_duplicate_platform_subject") + snapshots, artifact_ids, artifact_checks = [("release", release)], [], [] + for role in ("corpus", "compose"): + selected = value[role] + require(provider.workflow_blob(release["source_commit"], WORKFLOWS[role]) == selected["workflow_blob"], + "qualifier_workflow_source") + run = workflow_run(provider, selected, role, release) + require(timestamp(run["run_started_at"]) >= completed, "qualification_precedes_publication") + 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}) + current_jobs = jobs(provider, run, required) + if role == "corpus": + job = current_jobs[CORPUS_JOB] + artifact_checks.append((selected["artifact_id"], selected["artifact_digest"], + "native-corpus-promotion-receipt", run, job)) + record = receipt(provider, *artifact_checks[-1]) + bind_receipt(record, role, selected, run, job, release, "linux/amd64", None, + subjects["linux/amd64"]) + artifact_ids.append(selected["artifact_id"]) + else: + for cell in selected["cells"]: + platform, profile = cell["platform"], cell["profile"] + job = current_jobs[platform + " " + profile + " published image"] + require(literal(cell["job_id"], job["id"]), "compose_selected_job") + name = "native-compose-promotion-receipt-" + platform.split("/")[1] + "-" + profile + artifact_checks.append((cell["artifact_id"], cell["artifact_digest"], name, run, job)) + record = receipt(provider, *artifact_checks[-1]) + bind_receipt(record, role, selected, run, job, release, platform, profile, subjects[platform]) + artifact_ids.append(cell["artifact_id"]) + snapshots.append((role, selected)) + for role, selected in snapshots: + workflow_run(provider, selected, role, release) + for selected_artifact in artifact_checks: + artifact_metadata(provider, *selected_artifact) + return {"schema": "durable-workflow.native-alias-verification.v1", + "release": release, "subjects": subjects, "qualified_artifact_ids": artifact_ids} + + +if __name__ == "__main__": + raise SystemExit("native_alias_evidence is a library; executable verification is not wired") diff --git a/tests/Unit/Support/native_alias_evidence_test.py b/tests/Unit/Support/native_alias_evidence_test.py new file mode 100644 index 000000000..a2f7cd03c --- /dev/null +++ b/tests/Unit/Support/native_alias_evidence_test.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Pure provider/receipt countercontrols. No network, Docker or credentials.""" +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import stat +import struct +import subprocess +import sys +import unittest +import zipfile + +ROOT = Path(__file__).resolve().parents[3] +SPEC = importlib.util.spec_from_file_location("native_alias_evidence", ROOT / "scripts/ci/native_alias_evidence.py") +E = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(E) +SOURCE = "1" * 40 +INDEX = "sha256:" + "a" * 64 + + +def packed(value, name="receipt.json", mode=stat.S_IFREG | 0o644, extra=None): + target = io.BytesIO() + with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as archive: + entry = zipfile.ZipInfo(name, date_time=(2026, 11, 5, 0, 0, 0)) + entry.external_attr = mode << 16 + entry.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(entry, value if type(value) is bytes else + (json.dumps(value, sort_keys=True, ensure_ascii=False, + separators=(",", ":")) + "\n").encode()) + if extra is not None: + archive.writestr(extra, b"extra") + return target.getvalue() + + +class FakeProvider: + 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.subjects = { + "linux/amd64": {"child_manifest_digest": "sha256:" + "b" * 64, + "image_config_digest": "sha256:" + "c" * 64}, + "linux/arm64": {"child_manifest_digest": "sha256:" + "d" * 64, + "image_config_digest": "sha256:" + "e" * 64}, + } + self.selected = { + "schema": "durable-workflow.native-alias-promotion-evidence.v1", + "release": {"tag": "3.2.0", "source_commit": SOURCE, "index_digest": INDEX, + "run_id": 301, "run_attempt": 1}, + "corpus": {"run_id": 302, "run_attempt": 1, "runner_commit": SOURCE, + "workflow_blob": "2" * 40, "artifact_id": 501, "artifact_digest": None}, + "compose": {"run_id": 303, "run_attempt": 1, "runner_commit": SOURCE, + "workflow_blob": "3" * 40, "cells": []}, + } + owner = {"id": E.REPOSITORY_ID, "full_name": E.REPOSITORY} + for n, role in enumerate(("release", "corpus", "compose"), 1): + selection = self.selected[role] + workflow_id = 10 + n + self.workflows[E.WORKFLOWS[role].split("/")[-1]] = { + "id": workflow_id, "path": E.WORKFLOWS[role]} + title = ("Native 3.2.0 at " + SOURCE if role == "release" else + ("Native image parity " if role == "corpus" else "Published Compose ") + + E.GHCR_IMAGE + "@" + INDEX) + self.runs[selection["run_id"]] = { + "id": selection["run_id"], "repository": owner, "head_repository": owner, + "run_attempt": 1, "status": "completed", "conclusion": "success", + "event": "workflow_dispatch", "head_sha": SOURCE, "head_branch": "3.2.0", + "path": E.WORKFLOWS[role], "workflow_id": workflow_id, "display_title": title, + "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} + 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", + "completed_at": "2026-11-05T00:00:59Z" if role == "release" else "2026-11-05T00:03:59Z", + "steps": [{"name": step, "status": "completed", "conclusion": "success"} + for step in critical]} + for i, (name, critical) in enumerate(names.items(), 1)] + corpus = self.make_receipt("corpus", "linux/amd64", None, 2001) + self.set_artifact(501, corpus, "native-corpus-promotion-receipt", 302) + self.selected["corpus"]["artifact_digest"] = self.metadata[501]["digest"] + for i, (platform, profile) in enumerate( + ((p, r) for p in E.PLATFORMS for r in E.PROFILES), 1): + artifact_id = 501 + i + record = self.make_receipt("compose", platform, profile, 3000 + i) + self.set_artifact(artifact_id, record, + "native-compose-promotion-receipt-" + platform.split("/")[1] + "-" + profile, 303) + self.selected["compose"]["cells"].append({ + "platform": platform, "profile": profile, "job_id": 3000 + i, + "artifact_id": artifact_id, "artifact_digest": self.metadata[artifact_id]["digest"]}) + + def make_receipt(self, role, platform, profile, job_id): + subject = self.subjects[platform] + observations = ({k: 0 for k in E.CORPUS_OBSERVATIONS} if role == "corpus" else + {k: True for k in E.COMPOSE_OBSERVATIONS}) + if role == "compose" and profile == "local": + observations["production_default_token_refused"] = None + return { + "schema": "durable-workflow.native-qualification-receipt.v1", "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, + "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, + "platform": platform, "profile": profile, + "child_manifest_digest": subject["child_manifest_digest"], + "image_config_digest": subject["image_config_digest"], + "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", + "observations": observations, + "cleanup": {"api_ok": True, "verified_absent": True, + "container_count": 0, "network_count": 0, "volume_count": 0}, + } + + 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 + run = self.metadata[artifact_id]["workflow_run"]["id"] if run is None else run + data = packed(record) if data is None else data + self.archives[artifact_id] = data + self.metadata[artifact_id] = { + "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}, + "created_at": "2026-11-05T00:03:00Z", + } + if artifact_id == 501: + self.selected["corpus"]["artifact_digest"] = self.metadata[artifact_id]["digest"] + else: + for cell in self.selected["compose"]["cells"]: + if cell["artifact_id"] == artifact_id: + cell["artifact_digest"] = self.metadata[artifact_id]["digest"] + + def mutate_receipt(self, artifact_id, change): + value = E.archive_receipt(self.archives[artifact_id]) + change(value) + self.set_artifact(artifact_id, value) + + def api(self, path): + prefix = "repos/" + E.REPOSITORY + "/actions/" + if not path.startswith(prefix): + raise AssertionError("unexpected provider path") + suffix = path[len(prefix):] + if suffix.startswith("workflows/"): + return copy.deepcopy(self.workflows[suffix.split("/")[-1]]) + if suffix.startswith("artifacts/"): + identity = int(suffix.split("/")[-1]);self.artifact_reads[identity] = self.artifact_reads.get(identity, 0) + 1 + value = copy.deepcopy(self.metadata[identity]) + if self.on_final_artifact and self.artifact_reads[identity] > 1: + self.on_final_artifact(value) + return value + identity = int(suffix.split("/")[1]) + if "/jobs?" in suffix: + return {"total_count": len(self.job_sets[identity]), "jobs": copy.deepcopy(self.job_sets[identity])} + 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: + self.on_final_run(value) + return value + + def archive(self, artifact_id): + return self.archives[artifact_id] + + def workflow_blob(self, commit, path): + if commit != SOURCE: + raise E.Refusal("unapproved_source") + return "2" * 40 if path.endswith("native-image-parity.yml") else "3" * 40 + + def publication_subjects(self, release): + if release["index_digest"] != INDEX: + raise E.Refusal("unattested_index") + return copy.deepcopy(self.subjects) + + def promote(self): + proof = E.verify(self.selected, self) + self.writes.append(proof["release"]["index_digest"]) + + +class EvidenceTests(unittest.TestCase): + def refused(self, change): + provider = FakeProvider();change(provider) + with self.assertRaises(E.Refusal):provider.promote() + self.assertEqual([], provider.writes) + + def test_unwired_library_entrypoint_cannot_succeed_as_a_guard(self): + result=subprocess.run([sys.executable,str(ROOT / "scripts/ci/native_alias_evidence.py")], + capture_output=True,timeout=2,check=False) + self.assertNotEqual(0,result.returncode) + self.assertIn(b"executable verification is not wired",result.stderr) + + def test_complete_exact_tuple_reaches_write_ledger(self): + provider = FakeProvider();provider.promote();self.assertEqual([INDEX], provider.writes) + + def test_run_authority_refusals_leave_zero_writes(self): + mutations = [ + lambda p: p.runs[302].update(event="pull_request"), + lambda p: p.runs[302].update(head_sha="f" * 40), + lambda p: p.runs[302].update(head_branch="main"), + lambda p: p.runs[302].update(path=E.WORKFLOWS["compose"]), + lambda p: p.runs[302].update(workflow_id=999), + lambda p: p.runs[302].update(run_attempt=2), + lambda p: p.runs[302].update(conclusion="failure"), + lambda p: p.runs[302].update(status="in_progress"), + lambda p: p.runs[302].update(display_title="Native image parity ghcr.io/durable-workflow/server:3.2.0"), + lambda p: p.runs[302].update(run_started_at="2026-11-05T00:00:01Z"), + lambda p: p.runs[302].update(head_repository={"id":999,"full_name":"fork/server"}), + ] + for i, change in enumerate(mutations): + with self.subTest(i=i):self.refused(change) + + def test_required_job_and_step_refusals(self): + def missing_record_step(p): + p.job_sets[302][0]["steps"] = [s for s in p.job_sets[302][0]["steps"] + if s["name"] != "Record and compare the corpus against the native image"] + for change in [missing_record_step, + lambda p: p.job_sets[302][0].update(run_attempt=2), + lambda p: p.job_sets[302][0].update(conclusion="skipped"), + lambda p: p.job_sets[302][0]["steps"][0].update(conclusion="skipped"), + lambda p: p.job_sets[303].pop(), + lambda p: p.job_sets[303].append(copy.deepcopy(p.job_sets[303][0]))]: + with self.subTest(change=change):self.refused(change) + + def test_manifest_refuses_coercion_unknown_members_and_cell_substitution(self): + for change in [lambda p:p.selected["release"].update(run_id=True), + lambda p:p.selected["release"].update(tag="3.2.0-rc.1"), + lambda p:p.selected.update(repository="other/repo"), + lambda p:p.selected["compose"]["cells"].pop(), + lambda p:p.selected["compose"]["cells"].__setitem__(1,copy.deepcopy(p.selected["compose"]["cells"][0])), + lambda p:p.selected["corpus"].update(runner_commit="f" * 40), + lambda p:p.selected["corpus"].update(workflow_blob="f" * 40)]: + with self.subTest(change=change):self.refused(change) + + def test_artifact_ownership_digest_expiry_and_attempt_window(self): + for change in [lambda p:p.metadata[501].update(expired=True), + lambda p:p.metadata[501].update(name="another-artifact"), + lambda p:p.metadata[501]["workflow_run"].update(id=303), + lambda p:p.metadata[501].update(created_at="2026-11-05T00:00:01Z"), + lambda p:p.archives.__setitem__(501,p.archives[501]+b"altered")]: + with self.subTest(change=change):self.refused(change) + + def test_receipt_negative_controls_observe_zero_writes(self): + changes = [lambda v:v.update(eligible=False), + lambda v:v.update(schema="durable-workflow.native-qualification-diagnostic.v1"), + lambda v:v.update(run_attempt=2),lambda v:v.update(job_id=999), + lambda v:v.update(runner_commit="f"*40),lambda v:v.update(requested_image=E.GHCR_IMAGE+":3.2.0"), + lambda v:v.update(child_manifest_digest=INDEX),lambda v:v.update(actual_image_config_digest=INDEX), + lambda v:v.update(platform="linux/arm64"),lambda v:v.update(extra="refuse"), + lambda v:v["cleanup"].update(api_ok=False),lambda v:v["cleanup"].update(container_count=1), + lambda v:v["cleanup"].update(volume_count=False),lambda v:v["observations"].update(compare=False)] + for i, change in enumerate(changes): + with self.subTest(i=i):self.refused(lambda p:p.mutate_receipt(501,change)) + + def test_production_default_token_refusal_is_required(self): + self.refused(lambda p:p.mutate_receipt(503,lambda v:v["observations"].update(production_default_token_refused=False))) + + def test_current_attempt_and_artifact_are_rechecked(self): + self.refused(lambda p:setattr(p,"on_final_run",lambda run:run.update(run_attempt=2))) + self.refused(lambda p:setattr(p,"on_final_artifact",lambda artifact:artifact.update(expired=True))) + self.refused(lambda p:setattr(p,"on_final_artifact",lambda artifact:artifact.update(digest="sha256:"+"f"*64))) + self.refused(lambda p:setattr(p,"on_final_artifact",lambda artifact:artifact["workflow_run"].update(id=999))) + + def test_hostile_archive_members_and_canonical_json(self): + record=FakeProvider().make_receipt("corpus","linux/amd64",None,2001) + invalid = [packed(record,"../receipt.json"),packed(record,mode=stat.S_IFLNK|0o777), + packed(record,extra="extra.json"),packed(b'{"x":1,"x":2}\n'), + packed(b'{"x":1.0}\n'),packed(b' {"x":1}\n'), + packed(b'{"padding":"'+b'a'*40000+b'"}\n'),b'prefix'+packed(record),packed(record)+b'trailing'] + for i, data in enumerate(invalid): + with self.subTest(i=i),self.assertRaises(E.Refusal):E.archive_receipt(data) + + def test_streaming_zip_descriptor_is_supported_without_extraction(self): + class NonSeekable(io.BytesIO): + def seek(self, *_):raise io.UnsupportedOperation("stream") + output=NonSeekable() + with zipfile.ZipFile(output,"w",compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("receipt.json",b'{"ok":true}\n') + self.assertEqual({"ok":True},E.archive_receipt(output.getvalue())) + + def test_subject_type_and_cross_platform_identity_refusals(self): + self.refused(lambda p:p.subjects["linux/amd64"].update(child_manifest_digest=[])) + self.refused(lambda p:p.subjects.__setitem__("linux/arm64",copy.deepcopy(p.subjects["linux/amd64"]))) + + def test_parser_rejects_duplicate_fields_nonfinite_and_noninteger_ids(self): + for raw in [b'{"x":1,"x":2}',b'{"x":NaN}',b'{"x":1e0}',b'{"x":Infinity}']: + with self.subTest(raw=raw),self.assertRaises(E.Refusal):E.strict_json(raw,8192) + + +if __name__ == "__main__": + unittest.main() From 8fba2e037a625c9c86e18e6905669d423021217f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:33:42 +0000 Subject: [PATCH 02/15] Gate native alias promotion on exact qualification receipts --- .github/workflows/native-alias-promotion.yml | 82 +++++ .github/workflows/native-github-release.yml | 11 + .github/workflows/native-image-parity.yml | 42 ++- .../workflows/native-promotion-contracts.yml | 28 ++ .github/workflows/native-release.yml | 60 +--- .github/workflows/published-compose.yml | 50 ++- .github/workflows/release.yml | 2 +- README.md | 4 +- docs/native-release-promotion.md | 132 +++++++ scripts/ci/check-native-release-tags.sh | 78 ++++- scripts/ci/native-release-github-release.sh | 59 +--- scripts/ci/native-release-rolling-tags.sh | 70 +++- .../ci/native-release-verify-publication.sh | 70 ++++ scripts/ci/native_alias_evidence.py | 26 +- scripts/ci/native_alias_promote.py | 87 +++++ scripts/ci/native_alias_transport.py | 166 +++++++++ scripts/ci/native_qualification.py | 265 ++++++++++++++ scripts/ci/select-published-compose-image.sh | 2 + .../server-parity/native-image-corpus.sh | 23 +- .../server-parity/update-validation-sqlite.sh | 6 +- scripts/smoke-published-compose.sh | 85 +++-- .../Support/native_alias_evidence_test.py | 2 +- .../Unit/Support/native_qualification_test.py | 323 ++++++++++++++++++ 23 files changed, 1503 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/native-alias-promotion.yml create mode 100644 .github/workflows/native-promotion-contracts.yml create mode 100644 docs/native-release-promotion.md create mode 100644 scripts/ci/native-release-verify-publication.sh create mode 100644 scripts/ci/native_alias_promote.py create mode 100644 scripts/ci/native_alias_transport.py create mode 100644 scripts/ci/native_qualification.py create mode 100644 tests/Unit/Support/native_qualification_test.py diff --git a/.github/workflows/native-alias-promotion.yml b/.github/workflows/native-alias-promotion.yml new file mode 100644 index 000000000..22b66f375 --- /dev/null +++ b/.github/workflows/native-alias-promotion.yml @@ -0,0 +1,82 @@ +name: Native alias promotion +run-name: Native alias promotion ${{ github.ref_name }} + +# Consumes existing exact-index qualification. Never dispatches a qualifier, +# builds/pulls an image, creates a Release, or changes candidate aliases. +on: + workflow_dispatch: + inputs: + evidence: + description: 'Closed native-alias-promotion-evidence.v1 JSON manifest with exact current run attempts and artifact IDs/digests' + required: true + type: string + +permissions: + contents: read + actions: read + attestations: read + packages: read + +jobs: + verify: + name: Verify existing native qualification + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out exact tag tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Read GHCR with the repository token + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Verify provider-owned qualification without writes + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_EVIDENCE: ${{ inputs.evidence }} + run: python3 scripts/ci/native_alias_promote.py verify + + promote: + name: Promote qualified native aliases + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: dockerhub-native + concurrency: + group: durable-workflow-server-rolling-aliases + cancel-in-progress: false + permissions: + contents: read + actions: read + attestations: read + packages: write + steps: + - name: Check out exact tag tooling + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Log in to Docker Hub + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Repeat qualification under approval and lock before promotion + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_EVIDENCE: ${{ inputs.evidence }} + run: python3 scripts/ci/native_alias_promote.py promote diff --git a/.github/workflows/native-github-release.yml b/.github/workflows/native-github-release.yml index 92fc5bcd8..38a355abd 100644 --- a/.github/workflows/native-github-release.yml +++ b/.github/workflows/native-github-release.yml @@ -27,6 +27,15 @@ on: required: true type: string + release_run_attempt: + description: 'Exact current attempt of the publication run' + required: true + type: string + promotion_evidence: + description: 'Final releases require the exact qualification manifest used for alias promotion; candidates leave empty' + required: false + type: string + permissions: contents: read @@ -71,4 +80,6 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + PROMOTION_EVIDENCE: ${{ inputs.promotion_evidence }} run: bash scripts/ci/native-release-github-release.sh diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml index 878cbd0f0..85832f25b 100644 --- a/.github/workflows/native-image-parity.yml +++ b/.github/workflows/native-image-parity.yml @@ -1,4 +1,5 @@ name: Native image parity +run-name: Native image parity ${{ inputs.native_image || github.ref }} # Differential qualification of a native server image (Server #325). It # records the shared fixture corpus on SQLite against the published PHP @@ -18,6 +19,9 @@ on: required: true pull_request: paths: + - scripts/ci/native_qualification.py + - scripts/ci/native_alias_transport.py + - scripts/ci/native_alias_evidence.py - scripts/conformance/server-parity/native-image-corpus.sh - scripts/conformance/server-parity/update-validation-sqlite.sh - scripts/conformance/server-parity/php-maintenance-window.sh @@ -25,6 +29,7 @@ on: permissions: contents: read + actions: read concurrency: group: native-image-parity-${{ github.event.pull_request.number || github.run_id }} @@ -51,7 +56,6 @@ jobs: composer:2.10.3@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee - name: Set up Docker Buildx - if: github.event_name == 'pull_request' uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Build this pull request's native image into the local daemon @@ -69,15 +73,45 @@ jobs: tags: ${{ env.LOCAL_IMAGE }} - name: Record and compare the corpus against the native image + id: body env: NATIVE_IMAGE: ${{ github.event_name == 'pull_request' && env.LOCAL_IMAGE || inputs.native_image }} run: bash scripts/conformance/server-parity/native-image-corpus.sh "$NATIVE_IMAGE" - - name: Remove this job's containers and network + - name: Verify owned qualification cleanup + id: cleanup if: always() run: | - docker rm -f "$RESOURCE_SCOPE-rust" "$RESOURCE_SCOPE-server" "$RESOURCE_SCOPE-php-maintenance" || true - docker network rm "$RESOURCE_SCOPE" || true + # Strict ownership/readback is the receipt authority. Preserve its + # failure even if the harmless partial-setup fallback can remove more. + cleanup_status=0 + python3 scripts/ci/native_qualification.py cleanup --kind corpus \ + --scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json || cleanup_status=$? + docker rm -fv "$RESOURCE_SCOPE-rust" "$RESOURCE_SCOPE-server" "$RESOURCE_SCOPE-php-maintenance" >/dev/null 2>&1 || true + docker network rm "$RESOURCE_SCOPE" >/dev/null 2>&1 || true + test "$cleanup_status" = 0 + + - name: Finalize promotion receipt + if: always() + env: + GH_TOKEN: ${{ github.token }} + NATIVE_IMAGE: ${{ github.event_name == 'pull_request' && env.LOCAL_IMAGE || inputs.native_image }} + QUALIFICATION_BODY_OUTCOME: ${{ steps.body.outcome }} + QUALIFICATION_CLEANUP_OUTCOME: ${{ steps.cleanup.outcome }} + run: >- + python3 scripts/ci/native_qualification.py finalize --kind corpus --image "$NATIVE_IMAGE" + --identity qualification-state/image.json + --observations parity-evidence/native-image-corpus.json + --cleanup qualification-state/cleanup.json --output promotion-receipt/receipt.json + + - name: Keep promotion receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-corpus-promotion-receipt + path: promotion-receipt/receipt.json + retention-days: 14 + if-no-files-found: error - name: Keep the comparison evidence if: always() diff --git a/.github/workflows/native-promotion-contracts.yml b/.github/workflows/native-promotion-contracts.yml new file mode 100644 index 000000000..286a633f5 --- /dev/null +++ b/.github/workflows/native-promotion-contracts.yml @@ -0,0 +1,28 @@ +name: Native promotion contracts +on: + pull_request: + paths: + - scripts/ci/native_alias_*.py + - scripts/ci/native_qualification.py + - scripts/ci/native-release-*.sh + - scripts/ci/check-native-release-tags.sh + - scripts/ci/select-published-compose-image.sh + - scripts/smoke-published-compose.sh + - scripts/conformance/server-parity/native-image-corpus.sh + - tests/Unit/Support/native_*test.py + - .github/workflows/native-*.yml + - .github/workflows/published-compose.yml +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Countercheck provider receipts, cleanup and fail-before writes + run: python3 -m unittest discover -s tests/Unit/Support -p 'native_*test.py' -v + - name: Preserve release, candidate and monotonic alias contracts + run: bash scripts/ci/check-native-release-tags.sh diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index be1010e28..97b4088ab 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -19,14 +19,14 @@ name: Native release candidate # decision, November 1), with the repository's Docker Hub credentials. A # native version is a major version above every PHP release, and the # publish job refuses a tag either registry already has, so it never -# replaces a PHP image. A final release then moves X.Y, X and latest -# forward to its index (operator decision, November 1); a candidate moves -# none, and the PHP Release workflow leaves latest to a newer native +# replaces a PHP image. Future final aliases move only in the separate +# protected native-alias-promotion.yml phase after exact-index corpus and +# four-cell Compose qualification (Server #1020); a candidate moves none, and the PHP Release workflow leaves latest to a newer native # release. It is dispatched from the release tag's own ref # (refs/tags/native-), at the tag's commit, which protected main must # contain; its build-provenance attestation then names the release commit as -# its source. It publishes from the dockerhub-native Environment, whose only -# deployment rule is the tag pattern native-[0-9]*; creating those tags is +# its source. It publishes from the dockerhub-native Environment, whose +# deployment rules permit native-[0-9]* and 3.[0-9]*.[0-9]*; creating those tags is # restricted by the repository's tag ruleset. # # Every version, release candidates included, publishes to both registries. @@ -353,51 +353,5 @@ jobs: subject-digest: ${{ steps.index.outputs.digest }} push-to-registry: true - # A final release (never a candidate) moves X.Y, X and latest forward to the - # attested index. This is its own job so a failed alias write can be rerun - # alone ("Re-run failed jobs") from the already published index; it first - # re-verifies that both exact tags name that index. One repository-wide - # group serializes alias writes across native releases, and the script - # rechecks each alias's current version in the registry right before - # writing, so an older release never moves an alias backward. - aliases: - name: Move the rolling tags of a final release - needs: [plan, publish] - if: github.ref == format('refs/tags/{0}', inputs.tag) && needs.plan.outputs.dockerhub == 'true' - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: dockerhub-native - concurrency: - group: durable-workflow-server-rolling-aliases - cancel-in-progress: false - permissions: - contents: read - packages: write - steps: - - name: Check out trusted release tooling - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.sha }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - - - name: Log in to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Reconcile the rolling tags with the attested index - env: - RELEASE_TAG: ${{ inputs.tag }} - INDEX_DIGEST: ${{ needs.publish.outputs.digest }} - RELEASE_IMAGE_TAG_SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git - run: bash scripts/ci/native-release-rolling-tags.sh + # Rolling aliases are deliberately absent. Future final tags containing + # this source use native-alias-promotion.yml after existing qualification. diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index cff310952..4e9164022 100644 --- a/.github/workflows/published-compose.yml +++ b/.github/workflows/published-compose.yml @@ -1,9 +1,13 @@ name: Published Compose Smoke +run-name: Published Compose ${{ inputs.server_image || github.ref }} on: pull_request: paths: - docker-compose.published.yml + - scripts/ci/native_qualification.py + - scripts/ci/native_alias_transport.py + - scripts/ci/native_alias_evidence.py - scripts/smoke-published-compose.sh - scripts/ci/select-published-compose-image.sh - .github/workflows/published-compose.yml @@ -16,6 +20,7 @@ on: permissions: contents: read + actions: read concurrency: group: published-compose-${{ github.event_name }}-${{ github.ref }} @@ -26,6 +31,8 @@ jobs: name: ${{ matrix.platform }} ${{ matrix.profile }} published image runs-on: ubuntu-latest timeout-minutes: 35 + env: + DW_PUBLISHED_COMPOSE_PROJECT: published-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.shard }} strategy: fail-fast: false @@ -58,20 +65,51 @@ jobs: INPUT_SERVER_IMAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.server_image || '' }} run: scripts/ci/select-published-compose-image.sh + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + - name: Smoke published Compose stack + id: body env: DW_SERVER_PLATFORM: ${{ matrix.platform }} DW_PUBLISHED_COMPOSE_PROFILE: ${{ matrix.profile }} - DW_PUBLISHED_COMPOSE_PROJECT: published-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.shard }} SERVER_PORT: "0" DW_AUTH_TOKEN: published-compose-token run: scripts/smoke-published-compose.sh - - name: Clean up published Compose stack + - name: Verify owned qualification cleanup + id: cleanup + if: always() + run: | + # Only strict owned cleanup/readback can qualify the receipt. The + # partial-setup fallback never changes its saved failure into success. + cleanup_status=0 + python3 scripts/ci/native_qualification.py cleanup --kind compose \ + --scope "$DW_PUBLISHED_COMPOSE_PROJECT" --output qualification-state/cleanup.json || cleanup_status=$? + docker ps -aq --filter "label=com.docker.compose.project=$DW_PUBLISHED_COMPOSE_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker network ls -q --filter "label=com.docker.compose.project=$DW_PUBLISHED_COMPOSE_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true + docker volume ls -q --filter "label=com.docker.compose.project=$DW_PUBLISHED_COMPOSE_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true + test "$cleanup_status" = 0 + + - name: Finalize promotion receipt if: always() env: - DW_PUBLISHED_COMPOSE_PROJECT: published-compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.shard }} + GH_TOKEN: ${{ github.token }} + QUALIFICATION_BODY_OUTCOME: ${{ steps.body.outcome }} + QUALIFICATION_CLEANUP_OUTCOME: ${{ steps.cleanup.outcome }} + PLATFORM: ${{ matrix.platform }} + PROFILE: ${{ matrix.profile }} run: >- - docker compose -p "$DW_PUBLISHED_COMPOSE_PROJECT" - -f docker-compose.published.yml down -v --remove-orphans - >/dev/null 2>&1 || true + python3 scripts/ci/native_qualification.py finalize --kind compose --image "${DW_SERVER_IMAGE:-}" + --platform "$PLATFORM" --profile "$PROFILE" + --identity qualification-state/image.json --observations qualification-state/observations.json + --cleanup qualification-state/cleanup.json --output promotion-receipt/receipt.json + + - name: Keep promotion receipt + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native-compose-promotion-receipt-${{ matrix.shard }} + path: promotion-receipt/receipt.json + retention-days: 14 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41721262b..022321297 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: name: Build and push Docker image runs-on: ubuntu-latest environment: dockerhub - # The rolling-alias lock the native release's alias job also holds, so a + # The rolling-alias lock the native promotion job also holds, so a # PHP and a native release never move rolling tags at the same time. # cancel-in-progress is false: a running job is never cancelled. concurrency: diff --git a/README.md b/README.md index db3c4229d..629b2a644 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,9 @@ docker compose up -d --wait Repository checks and focused test commands are documented in [CONTRIBUTING.md](CONTRIBUTING.md). Bugs and feature requests belong in -[GitHub Issues](https://github.com/durable-workflow/server/issues). +[GitHub Issues](https://github.com/durable-workflow/server/issues). The +[native publication and alias-promotion runbook](docs/native-release-promotion.md) +describes the separate qualification and protected release phases. ## License diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md new file mode 100644 index 000000000..366f1643e --- /dev/null +++ b/docs/native-release-promotion.md @@ -0,0 +1,132 @@ +# Native publication and rolling alias promotion + +This source contract applies to future release tags containing the #1020 +workflows and receipt producers. It does not change immutable older tag +workflows or qualify historical runs. An old green run without these receipts +cannot be annotated into eligibility. Release execution, registry writes and +any frozen release remain subject to their separate operator and Security gates. + +## When the rolling tags move + +`native-release.yml` publishes and attests exact version tags only. It never +moves `X.Y`, `X` or `latest`. A final release follows this order: + +1. Publish both architecture children and the same index in both registries. +2. Run the existing native-image corpus on that exact GHCR index from the release + 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 with that data-only selection manifest. +4. The read-only job verifies the selection. After environment approval, the + mutation job repeats the entire verification while holding + `durable-workflow-server-rolling-aliases`, then calls the existing monotonic + alias helper. Only this job receives packages-write and existing Docker Hub + credentials; it has no contents-write. +5. Create the GitHub Release from protected main using + `native-github-release.yml`, the exact publication run attempt and the same + qualification manifest. Final Release creation verifies qualification again + and requires each relevant live alias in both registries to name this exact + index or a strictly newer final release. It cannot perform promotion. + +Candidates keep their existing publication/prerelease behavior and never move +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. + +## Evidence selection and authority + +The input is at most 8,192 UTF-8 bytes of JSON with the closed schema +`durable-workflow.native-alias-promotion-evidence.v1`. Unknown/duplicate keys, +coerced IDs, extra/missing/duplicate cells and nonfinal tags refuse. + +| Object | Required fields | +| --- | --- | +| Root | `schema`, `release`, `corpus`, `compose` | +| `release` | `tag`, `source_commit`, `index_digest`, `run_id`, `run_attempt` | +| `corpus` | `run_id`, `run_attempt`, `runner_commit`, `workflow_blob`, `artifact_id`, `artifact_digest` | +| `compose` | `run_id`, `run_attempt`, `runner_commit`, `workflow_blob`, `cells` | +| Each Compose cell | `platform`, `profile`, `job_id`, `artifact_id`, `artifact_digest` | + +IDs/attempts are positive actual integers no greater than 2^53−1. Commits and Git +blob identities are lowercase full SHA-1; digests are `sha256:` plus 64 lowercase +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. + +Selection is not proof. Fixed `durable-workflow/server` provider reads establish +repository/head-repository IDs, numeric workflow identity/path, dispatch event, +tag/ref/source, exact current attempt, terminal successful jobs and every +critical step. All attempts' jobs are enumerated with a finite complete inventory; +newer/ambiguous attempts refuse. Trusted workflow run titles bind the original +canonical `ghcr.io/durable-workflow/server@sha256:...` dispatch input because the +run API does not expose dispatch inputs. Qualifiers must start after publication +completes. Tag resolution, protected-main ancestry, exact version tags and +provenance for the exact publication attempt are reverified in both registries. +There is no newest-run search, polling, automatic retry or invented age cutoff. + +The manifest selects exact artifact IDs and provider archive digests, never the +newest artifact by name. The verifier bounds each archive to 65,536 bytes and +accepts only one regular `receipt.json`, at most 32,768 expanded bytes, with a +bounded compression ratio. It refuses links, special files, directories, extra +members, prefixes/trailing carriers, encryption, duplicate/noncanonical JSON and +unknown ZIP extra fields other than a bounded standard timestamp. It verifies +downloaded SHA-256 before parsing and never extracts or executes the archive. +Artifacts must be nonexpired, belong to the selected run/source and have been +created within the selected attempt/job window. Current run and artifact metadata +are read again before verification returns; unavailable APIs are failures. + +## Producer receipts and cleanup + +The existing corpus job retains its broad comparison artifact and adds +`native-corpus-promotion-receipt`. Each existing Compose job adds +`native-compose-promotion-receipt--`. Each promotion artifact +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, +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. +The index, child manifest and config are separate identities. Registry membership +and both registries' source labels are checked independently by the consumer. + +Corpus observations require all six existing partition/status values to be +integer zero. Compose observations require serving health, readiness, exact +reported release version, worker registration, loopback binding, unset-token +refusal and, for production, default-token refusal. No token, registration body, +raw environment or host configuration enters a promotion receipt. Compose HTTP +responses use private temporary files and bounded requests; failures print a +static reason rather than authorization arguments or server logs. + +Cleanup retains the original body result. It removes only exact owned Compose +project resources or the corpus scope's labelled/named/attached containers, +owned network and observed owned volumes, including labelled standalone helper +containers. Successful independent Docker inventory reads must then show zero +owned containers, networks and volumes. API failure is unknown, never absence. +A harmless partial-setup fallback runs after the strict check while preserving +its exit status; fallback removal cannot repair a failed cleanup proof. +Only after successful body and cleanup steps does the existing job finalize its +receipt, repeating provider job and immutable workflow ownership. An `always()` +upload, failed/cancelled job or diagnostic receipt cannot satisfy promotion. +Arm64 Compose retains its existing QEMU scope, without a native-hardware claim. + +## Monotonic writes and recovery + +The alias helper preserves Git-version selection and native/PHP latest +arbitration. It reads current release labels and digests, then immediately before +each registry write re-reads both exact tags and every selected alias. A changed +or unreadable state stops. Previously confirmed writes must still name the +qualified index. Each write is verified before proceeding to the other registry. +Superseded aliases remain in place; a same-index recovery is idempotent. + +The shared lock coordinates repository-owned writers. It is not registry CAS: +an external actor can race after the last read. A write failure, timeout or +post-write mismatch stops with an explicit attempted/confirmed partial state; +there is no automatic rollback. General owns any concrete retry decision with +unchanged qualified evidence, including uncertain cross-registry outcomes. +No production execution, release dispatch or registry mutation is admitted by +source tests or by merging these workflows. diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index fd4ec8879..48b8416ed 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -62,11 +62,25 @@ cat > "$scratch/docker" <<'FAKE' printf '%s\n' "$*" >> "$CALL_LOG" ref=${!#} if [[ "$*" == *"imagetools inspect --format {{json .Image}}"* ]]; then + if [[ "${RACE_REF:-}" == "$ref" ]]; then + count=$(( $(cat "$RACE_COUNTER" 2>/dev/null || echo 0) + 1 )); echo "$count" > "$RACE_COUNTER" + if ((count > 1)); then echo '{"config":{"Labels":{"dev.durable-workflow.release.tag":"3.9.0"}}}'; exit 0; fi + fi label=$(awk -v r="$ref" '$1 == r { print $2 }' <<<"${ALIAS_STATE:-}") [[ -z "$label" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } [[ "$label" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } printf '{"linux/amd64":{"config":{"Labels":{"dev.durable-workflow.release.tag":"%s"}}}}\n' "$label" +elif [[ "$*" == *"imagetools create"* ]]; then + touch "$CALL_LOG.written" + if [[ "${FAIL_HUB_WRITE:-false}" == true && "$ref" == example/native@* ]]; then exit 73; fi elif [[ "$*" == *"imagetools inspect"* ]]; then + if [[ "${RACE_EXACT:-false}" == true && "$ref" == ghcr.io/example/native:3.0.0 ]]; then + count=$(( $(cat "$RACE_COUNTER" 2>/dev/null || echo 0) + 1 )); echo "$count" > "$RACE_COUNTER" + if ((count > 1)); then printf 'sha256:%064d\n' 0; exit 0; fi + fi + if [[ "${POST_WRITE_MISMATCH:-false}" == true && -f "$CALL_LOG.written" && "$ref" == ghcr.io/example/native:3.0 ]]; then + printf 'sha256:%064d\n' 0; exit 0 + fi if [[ "$ref" =~ :[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf '%s\n' "${EXACT_DIGEST:-$INDEX_DIGEST}"; else printf '%s\n' "$INDEX_DIGEST"; fi fi FAKE @@ -108,6 +122,35 @@ grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh +# Detectable changes before the first write leave zero writes. A second +# registry failure or post-write mismatch records partial/uncertain state and +# never issues a rollback. These are fake registries, not network experiments. +rolling_control() { + : > "$scratch/calls"; : > "$scratch/summary"; rm -f "$scratch/counter" "$scratch/calls.written" + env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" GITHUB_STEP_SUMMARY="$scratch/summary" \ + INDEX_DIGEST="$index" "${images[@]}" RELEASE_TAG=3.0.0 RELEASE_IMAGE_KNOWN_TAGS=3.0.0 \ + RACE_COUNTER="$scratch/counter" "$@" bash scripts/ci/native-release-rolling-tags.sh >/dev/null 2>&1 +} +for race in 'RACE_REF=ghcr.io/example/native:3.0' RACE_EXACT=true; do + if rolling_control "$race" ALIAS_STATE='ghcr.io/example/native:3.0 3.0.0'; then fail "changed pre-write state must refuse."; fi + grep -q 'imagetools create' "$scratch/calls" && fail "pre-write race wrote aliases." +done +if rolling_control FAIL_HUB_WRITE=true; then fail "second-registry failure must stop."; fi +test "$(grep -c 'imagetools create' "$scratch/calls")" = 2 || fail "partial run must attempt exactly two forward writes." +grep -q 'confirmed=ghcr.io/example/native; no rollback' "$scratch/summary" || fail "partial run lost confirmed registry record." +if rolling_control POST_WRITE_MISMATCH=true; then fail "post-write mismatch must stop."; fi +test "$(grep -c 'imagetools create' "$scratch/calls")" = 1 || fail "post-write mismatch must not touch registry two or roll back." +grep -q 'no rollback' "$scratch/summary" || fail "uncertain run must record no rollback." +# Final Release ordering cannot run the writer. Exact live aliases pass; +# missing/older aliases refuse; uniformly newer final aliases may supersede. +for state in "" 'ghcr.io/example/native:3.0 2.11.8'; do + if rolling_control VERIFY_ONLY=true ALIAS_STATE="$state"; then fail "unpromoted live state must refuse."; fi + grep -q 'imagetools create' "$scratch/calls" && fail "live-state verification mutated aliases." +done +live_state=$'ghcr.io/example/native:3.0 3.0.0\nexample/native:3.0 3.0.0\nghcr.io/example/native:3 3.0.0\nexample/native:3 3.0.0\nghcr.io/example/native:latest 3.0.0\nexample/native:latest 3.0.0' +rolling_control VERIFY_ONLY=true ALIAS_STATE="$live_state" || fail "qualified same-index live state must pass." +grep -q 'imagetools create' "$scratch/calls" && fail "same-index live-state verification wrote." + # The GitHub Release, from protected main (native-github-release.yml): it # verifies the tag, the run, both exact tags and the provenance, then marks # a final Latest only when the live latest alias names its index in both @@ -122,8 +165,9 @@ case "$1 $2" in while [[ $# -gt 0 && "$1" != --notes ]]; do shift; done printf '%s' "${2:-}" > "$GH_NOTES"; exit 0 ;; "attestation verify") - printf '%s\n' "$*" > "$GH_ATTEST_LOG" + printf '%s\n' "$*" >> "$GH_ATTEST_LOG" [[ "${ATTEST:-ok}" == ok ]] || exit 1 + [[ "${ATTEST_FAIL_IMAGE:-}" != "$3" ]] || exit 1 printf '%s\n' "${ATTEST_URI:-https://github.com/example/server/actions/runs/${RUN_ID}/attempts/1}"; exit 0 ;; esac path=$2 @@ -132,7 +176,7 @@ case "$path" in repos/example/server/git/tags/*) printf 'commit %s\n' "$COMMIT" ;; repos/example/server/compare/*) printf '%s\n' "${COMPARE:-ahead}" ;; repos/example/server/releases/latest) printf '%s\n' "${LATEST_TAG:-}" ;; - repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT}" ;; + repos/example/server/actions/runs/*) printf '%s\n' "${RUN_LINE:-.github/workflows/native-release.yml workflow_dispatch completed success $RELEASE_TAG $COMMIT ${RUN_ATTEMPT:-1}}" ;; *) exit 1 ;; esac FAKE @@ -151,13 +195,22 @@ else fi FAKE chmod +x "$scratch/gh" "$scratch/release-docker" +# The publication/Release unit controls stub only the independently tested +# qualification verifier; its actual provider/receipt/zero-write tests run below. +cat > "$scratch/python3" <<'FAKE' +#!/usr/bin/env bash +[[ "$1" == */native_alias_promote.py && "$2" == release-order ]] || exit 1 +[[ -n "${PROMOTION_EVIDENCE:-}" ]] || exit 1 +exit "${QUALIFICATION_STATUS:-0}" +FAKE +chmod +x "$scratch/python3" newer_index=sha256:$(printf '%064d' 0 | tr 0 b) gh_release() { # release tag, latest state [, VAR=value ...] -> the Latest choice local tag=$1 state=$2; shift 2 : > "$scratch/gh_calls"; : > "$scratch/gh_attest" - env -i PATH="$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" GH_NOTES="$scratch/gh_notes" \ + env -i PATH="$scratch:$PATH" GH="$scratch/gh" GH_LOG="$scratch/gh_calls" GH_ATTEST_LOG="$scratch/gh_attest" GH_NOTES="$scratch/gh_notes" \ DOCKER="$scratch/release-docker" "${images[@]}" GITHUB_REPOSITORY=example/server \ - RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ + RELEASE_TAG="$tag" RELEASE_RUN_ID=41 RELEASE_RUN_ATTEMPT=1 PROMOTION_EVIDENCE=synthetic-selected-evidence RUN_ID=41 COMMIT="$commit" INDEX="$index" LATEST_STATE="$state" "$@" \ bash scripts/ci/native-release-github-release.sh >/dev/null 2>&1 || { echo FAILED; return; } grep -q -- '^release create ' "$scratch/gh_calls" || { echo none; return; } grep -q -- '--verify-tag --title ' "$scratch/gh_calls" || { echo FAILED; return; } @@ -174,6 +227,8 @@ grep -q -- "--source-ref refs/tags/3.1.0 --source-digest $commit" "$scratch/gh_a || fail "the provenance must be checked against the tag ref and its commit." grep -q -- "--signer-workflow github.com/example/server/.github/workflows/native-release.yml" "$scratch/gh_attest" \ || fail "the provenance must name native-release.yml as its signer." +grep -q 'oci://ghcr.io/example/native@' "$scratch/gh_attest" && grep -q 'oci://example/native@' "$scratch/gh_attest" \ + || fail "both registry provenance subjects must verify." backport=$(rolling $'2.11.8\nnative-3.0.1\n3.1.0' 3.0.2 $'ghcr.io/example/native:3 3.1.0\nexample/native:3 3.1.0\nghcr.io/example/native:latest 3.1.0\nexample/native:latest 3.1.0') want "3.0.2 backport behind 3.1.0" "$backport" "3.0" want_release "3.0.2 backport behind 3.1.0" "$(gh_release 3.0.2 "$theirs")" "--latest=false" @@ -218,9 +273,19 @@ want_release "run from another ref" "$(gh_release 3.0.2 "$ours" RUN_LINE=".githu want_release "run at another commit" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success 3.0.2 fedcba9876543210fedcba9876543210fedcba98")" "FAILED" want_release "registries name different indexes" "$(gh_release 3.0.2 "$ours" HUB_EXACT="$newer_index")" "FAILED" want_release "provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST=fail)" "FAILED" +want_release "Docker Hub provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST_FAIL_IMAGE="oci://example/native@$index")" "FAILED" +test ! -s "$scratch/gh_calls" || fail "Release was created after Docker Hub provenance failure." want_release "provenance from another run" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/42/attempts/1)" "FAILED" want_release "provenance from a run id prefix" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/411/attempts/1)" "FAILED" +want_release "missing exact publication attempt" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=)" "FAILED" +want_release "publication current attempt changed" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=2)" "FAILED" +want_release "provenance from another attempt" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/41/attempts/2)" "FAILED" +want_release "qualification refusal" "$(gh_release 3.0.2 "$ours" QUALIFICATION_STATUS=1)" "FAILED" +test ! -s "$scratch/gh_calls" || fail "Release was created despite qualification refusal." +want_release "final missing qualification" "$(gh_release 3.0.2 "$ours" PROMOTION_EVIDENCE=)" "FAILED" +want_release "candidate needs no alias qualification" "$(gh_release 3.1.0-rc.1 "$ours" PROMOTION_EVIDENCE= QUALIFICATION_STATUS=1)" "--prerelease --latest=false" + # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. identity=scripts/ci/native-release-identity.sh @@ -311,7 +376,7 @@ rm -f "$scratch/git" "$scratch/git-count" # the resolver's inventory rather than reading the tags again. # The GitHub Release job holds it too, so Latest is decided with no alias # moving underneath it. -for workflow in .github/workflows/release.yml .github/workflows/native-release.yml .github/workflows/native-github-release.yml; do +for workflow in .github/workflows/release.yml .github/workflows/native-alias-promotion.yml .github/workflows/native-github-release.yml; do test "$(grep -c '^ group: durable-workflow-server-rolling-aliases$' "$workflow")" = 1 \ || fail "$workflow must hold the durable-workflow-server-rolling-aliases lock in one job." done @@ -450,8 +515,7 @@ tag_gate="github.ref == format('refs/tags/{0}', inputs.tag)" expected_conditions="plan=none build-amd64=$tag_gate build-arm64=$tag_gate -publish=$tag_gate -aliases=$tag_gate && needs.plan.outputs.dockerhub == 'true'" +publish=$tag_gate" test "$job_conditions" = "$expected_conditions" \ || fail "native-release.yml job conditions changed; expected: $expected_conditions diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 8fbdd298d..7ddf8de32 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -24,59 +24,16 @@ # DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker commands. set -euo pipefail fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } -gh_bin=${GH:-gh} -docker_bin=${DOCKER:-docker} -: "${GITHUB_REPOSITORY:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" -repo=$GITHUB_REPOSITORY -workflow=.github/workflows/native-release.yml +# Reuse the same read-only authority checks as protected alias promotion. +source "$(dirname "${BASH_SOURCE[0]}")/native-release-verify-publication.sh" -# A plain tag is native only from major 3; native- names the earlier ones. -tag=${RELEASE_TAG:-} -if ! [[ "$tag" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then - fail "RELEASE_TAG must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)." -fi -version=${tag#native-} -run_id=${RELEASE_RUN_ID:-} -[[ "$run_id" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ID must be the release run's numeric id." - -# The tag's commit, through an annotated tag object if there is one. -object=$("$gh_bin" api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha') \ - || fail "cannot read tag $tag." -if [[ "$object" == tag\ * ]]; then - object=$("$gh_bin" api "repos/$repo/git/tags/${object#tag }" --jq '.object.type + " " + .object.sha') \ - || fail "cannot read the tag object of $tag." +# A future final Release cannot precede exact qualification and live alias +# promotion (or a strictly newer live successor). Old/missing evidence refuses; +# the operator must decide a concrete legacy case, not annotate an old run. +if [[ "$version" != *-* ]]; then + python3 "$(dirname "${BASH_SOURCE[0]}")/native_alias_promote.py" release-order \ + || fail "qualified live-alias order is not proved." fi -[[ "$object" =~ ^commit\ ([0-9a-f]{40})$ ]] || fail "tag $tag does not name a commit ($object)." -commit=${BASH_REMATCH[1]} -status=$("$gh_bin" api "repos/$repo/compare/$commit...main" --jq .status) || fail "cannot compare $commit with main." -[[ "$status" == ahead || "$status" == identical ]] || fail "$commit is not on main (compare status: $status)." - -# The release run. -run=$("$gh_bin" api "repos/$repo/actions/runs/$run_id" \ - --jq '[.path, .event, .status, .conclusion, .head_branch, .head_sha] | join(" ")') \ - || fail "cannot read run $run_id." -expected="$workflow workflow_dispatch completed success $tag $commit" -[[ "$run" == "$expected" ]] || fail "run $run_id is '$run', not '$expected'." - -# Both exact tags name one index. -digest() { - "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1" || fail "cannot read $1." -} -index=$(digest "$GHCR_IMAGE:$version") -[[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "$GHCR_IMAGE:$version has no index digest ('$index')." -hub=$(digest "$DOCKERHUB_IMAGE:$version") -[[ "$hub" == "$index" ]] || fail "$DOCKERHUB_IMAGE:$version is $hub, not $index." - -# Its provenance: signed by the release workflow from the tag, at the -# commit, in this run. -invocations=$("$gh_bin" attestation verify "oci://$GHCR_IMAGE@$index" --repo "$repo" \ - --signer-workflow "github.com/$repo/$workflow" --source-ref "refs/tags/$tag" --source-digest "$commit" \ - --deny-self-hosted-runners --format json \ - --jq '.[].verificationResult.signature.certificate.runInvocationURI') \ - || fail "the provenance of $index does not verify for $tag at $commit." -grep -qE "^https://github\.com/$repo/actions/runs/$run_id/attempts/[0-9]+$" <<<"$invocations" \ - || fail "the provenance of $index names no attempt of run $run_id." - # The digest the latest alias names now; empty when it does not exist. latest_digest() { diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh index f4840d77e..75a4bc6cb 100755 --- a/scripts/ci/native-release-rolling-tags.sh +++ b/scripts/ci/native-release-rolling-tags.sh @@ -22,7 +22,7 @@ # GHCR_IMAGE, DOCKERHUB_IMAGE, and the Git tags as RELEASE_IMAGE_KNOWN_TAGS # (one per line) or RELEASE_IMAGE_TAG_SOURCE_URL (git ls-remote). DOCKER # overrides the docker command. -set -euo pipefail +set -eEuo pipefail fail() { printf 'Native rolling tags failed: %s\n' "$1" >&2; exit 1; } docker_bin=${DOCKER:-docker} @@ -85,22 +85,48 @@ current_release() { local ref=$1 output label if ! output=$("$docker_bin" buildx imagetools inspect --format '{{json .Image}}' "$ref" 2>&1); then grep -qiE 'not found|manifest unknown|name unknown' <<<"$output" && return 0 - fail "cannot read the current $ref: $(head -c 300 <<<"$output")" + fail "cannot read a current alias." fi label=$(jq -r 'if has("linux/amd64") then .["linux/amd64"] else . end | .config.Labels["dev.durable-workflow.release.tag"] // empty' <<<"$output" 2>/dev/null) \ || fail "cannot read the release of the current $ref." label=${label#native-} [[ "$label" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]] \ - || fail "the current $ref carries no readable release tag ('$label')." + || fail "a current alias carries no readable release tag." printf '%s\n' "$label" } +# Final Release creation uses the same version rules, but cannot manufacture +# promotion: every applicable alias in BOTH registries must already name this +# index or a strictly newer final release. Candidates never enter this script. +if [[ "${VERIFY_ONLY:-false}" == true ]]; then + for alias in "$minor" "$major" latest; do + for image in "${images[@]}"; do + current=$(current_release "$image:$alias") + [[ -n "$current" ]] || fail "qualified live alias is absent." + [[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "qualified live alias must be final." + if newer "$current" "$version"; then continue; fi + [[ "$current" == "$version" ]] || fail "qualified live alias is older." + digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + [[ "$digest" == "$INDEX_DIGEST" ]] || fail "qualified live alias names another index." + done + done + printf 'Qualified live alias state verified.\n' + exit 0 +fi + +declare -A snapshots=() aliases=() for alias in "${candidates[@]}"; do keep=1 for image in "${images[@]}"; do current=$(current_release "$image:$alias") + state=absent + if [[ -n "$current" ]]; then + state=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + [[ "$state" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "alias digest unreadable." + fi + snapshots["$image:$alias"]="$current $state" if [[ -n "$current" ]] && newer "$current" "$version"; then printf '%s:%s already names %s, newer than %s; it stays.\n' "$image" "$alias" "$current" "$version" keep=0 @@ -115,10 +141,48 @@ if ((${#aliases[@]} == 0)); then exit 0 fi +# This repository lock coordinates owned writers. Registries offer no CAS: +# an external writer can still race AFTER these reads. A mismatch is a stop, +# with explicit partial/uncertain state and no automatic rollback. +confirmed=() +attempted=none +partial() { + printf 'Native alias promotion stopped; attempted=%s confirmed=%s; no rollback.\n' \ + "$attempted" "${confirmed[*]:-none}" >&2 + printf -- '- alias promotion stopped; attempted=%s confirmed=%s; no rollback.\n' \ + "$attempted" "${confirmed[*]:-none}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" +} +trap partial ERR for image in "${images[@]}"; do + # Re-read BOTH exact tags and every selected alias immediately before EACH + # registry write. Previously confirmed writes must still name this index. + for checked in "${images[@]}"; do + digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$checked:$version") + [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "exact tag changed before write."; } + for alias in "${aliases[@]}"; do + if [[ " ${confirmed[*]:-} " == *" $checked "* ]]; then + state=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$checked:$alias") + [[ "$state" == "$INDEX_DIGEST" ]] || { partial; fail "confirmed alias changed."; } + else + current=$(current_release "$checked:$alias") + state=absent + if [[ -n "$current" ]]; then + state=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$checked:$alias") + fi + [[ "$current $state" == "${snapshots["$checked:$alias"]}" ]] \ + || { partial; fail "alias changed before write."; } + fi + done + done args=() for alias in "${aliases[@]}"; do args+=(--tag "$image:$alias"); done + attempted=$image "$docker_bin" buildx imagetools create "${args[@]}" "$image@$INDEX_DIGEST" + for alias in "${aliases[@]}"; do + digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "post-write alias mismatch."; } + done + confirmed+=("$image") done # Each moved alias names exactly the attested index. diff --git a/scripts/ci/native-release-verify-publication.sh b/scripts/ci/native-release-verify-publication.sh new file mode 100644 index 000000000..3a8d58b05 --- /dev/null +++ b/scripts/ci/native-release-verify-publication.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Shared read-only tag/source/run-attempt/exact-tag/provenance verification. +# Sourced by Release creation; direct invocation emits only validated identity. +# No registry, Git ref or Release mutation is present in this helper. +set -euo pipefail +fail() { printf 'Native publication refused: %s\n' "$1" >&2; exit 1; } +gh_bin=${GH:-gh} +docker_bin=${DOCKER:-docker} +: "${GITHUB_REPOSITORY:?}" "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" +repo=$GITHUB_REPOSITORY +[[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "invalid repository." +workflow=.github/workflows/native-release.yml + +# A plain tag is native only from major 3; native- names the earlier ones. +tag=${RELEASE_TAG:-} +if ! [[ "$tag" =~ ^(native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + fail "RELEASE_TAG must be X.Y.Z or X.Y.Z-rc.N from major 3, or native-X.Y.Z(-rc.N)." +fi +version=${tag#native-} +run_id=${RELEASE_RUN_ID:-} +[[ "$run_id" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ID must be the release run's numeric id." + +run_attempt=${RELEASE_RUN_ATTEMPT:-} +[[ "$run_attempt" =~ ^[1-9][0-9]*$ ]] || fail "RELEASE_RUN_ATTEMPT must be the exact numeric attempt." + +# The tag's commit, through an annotated tag object if there is one. +object=$("$gh_bin" api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha') \ + || fail "cannot read tag $tag." +if [[ "$object" == tag\ * ]]; then + object=$("$gh_bin" api "repos/$repo/git/tags/${object#tag }" --jq '.object.type + " " + .object.sha') \ + || fail "cannot read the tag object of $tag." +fi +[[ "$object" =~ ^commit\ ([0-9a-f]{40})$ ]] || fail "tag $tag does not name a commit." +commit=${BASH_REMATCH[1]} +status=$("$gh_bin" api "repos/$repo/compare/$commit...main" --jq .status) || fail "cannot compare $commit with main." +[[ "$status" == ahead || "$status" == identical ]] || fail "$commit is not on main (compare status: $status)." + +# The release run. +run=$("$gh_bin" api "repos/$repo/actions/runs/$run_id" \ + --jq '[.path, .event, .status, .conclusion, .head_branch, .head_sha, (.run_attempt | tostring)] | join(" ")') \ + || fail "cannot read run $run_id." +expected="$workflow workflow_dispatch completed success $tag $commit $run_attempt" +[[ "$run" == "$expected" ]] || fail "release run identity, attempt or success does not match." + +# Both exact tags name one index. +digest() { + "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1" || fail "cannot read $1." +} +index=$(digest "$GHCR_IMAGE:$version") +[[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "exact version tag has no index digest." +hub=$(digest "$DOCKERHUB_IMAGE:$version") +[[ "$hub" == "$index" ]] || fail "exact version tags disagree." + +# Its provenance: signed by the release workflow from the tag, at the +# commit, in this run. +for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + invocations=$("$gh_bin" attestation verify "oci://$image@$index" --repo "$repo" \ + --signer-workflow "github.com/$repo/$workflow" --source-ref "refs/tags/$tag" --source-digest "$commit" \ + --deny-self-hosted-runners --format json \ + --jq '.[].verificationResult.signature.certificate.runInvocationURI') \ + || fail "release index provenance does not verify." + grep -Fxq "https://github.com/$repo/actions/runs/$run_id/attempts/$run_attempt" <<<"$invocations" \ + || fail "release provenance does not name the exact run attempt." +done + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + jq -nc --arg tag "$tag" --arg source "$commit" --arg index "$index" \ + --argjson run "$run_id" --argjson attempt "$run_attempt" \ + '{tag:$tag,source_commit:$source,index_digest:$index,run_id:$run,run_attempt:$attempt}' +fi diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py index 300d450f6..8b8f0a538 100644 --- a/scripts/ci/native_alias_evidence.py +++ b/scripts/ci/native_alias_evidence.py @@ -14,6 +14,7 @@ import stat import struct import zipfile +import zlib from datetime import datetime from typing import Any, Protocol @@ -99,8 +100,25 @@ def matching(value: Any, expression: re.Pattern[str]) -> bool: def canonical(value: Any) -> bytes: - return (json.dumps(value, ensure_ascii=False, sort_keys=True, - separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") + try: + return (json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") + except (UnicodeError, ValueError, RecursionError): + raise Refusal("invalid_canonical_json") from None + + +def zip_extra(data: bytes) -> None: + # GitHub's ZIP writer may carry the standard extended timestamp. Unknown + # fields are unnecessary carriers; no ZIP64 is needed for a <=32-KiB member. + seen = set() + while data: + require(len(data) >= 4, "artifact_extra_field") + kind, length = struct.unpack_from("= 4 + length, "artifact_extra_field") + value, data = data[4:4 + length], data[4 + length:] + require(value[0] <= 7 and length == 1 + 4 * value[0].bit_count(), "artifact_extra_timestamp") + seen.add(kind) def strict_json(data: bytes, limit: int) -> Any: @@ -204,6 +222,8 @@ def archive_receipt(data: bytes) -> dict[str, Any]: and not (local_flags & 1), "artifact_local_encoding") name_length, extra_length = struct.unpack_from(" dict[str, Any]: else: require(not descriptor, "artifact_unindexed_data") body = archive.read(entry) - except (zipfile.BadZipFile, RuntimeError, OSError, EOFError, struct.error): + except (zipfile.BadZipFile, RuntimeError, OSError, EOFError, struct.error, zlib.error): raise Refusal("artifact_archive_invalid") from None value = strict_json(body, RECEIPT_LIMIT) require(type(value) is dict and canonical(value) == body, "receipt_not_canonical") diff --git a/scripts/ci/native_alias_promote.py b/scripts/ci/native_alias_promote.py new file mode 100644 index 000000000..2c8d4ac95 --- /dev/null +++ b/scripts/ci/native_alias_promote.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Verify existing qualification; optionally enter the protected alias writer.""" +from __future__ import annotations + +import argparse +import os +import signal +from pathlib import Path +import subprocess +import sys + +from native_alias_evidence import (DOCKERHUB_IMAGE, GHCR_IMAGE, MANIFEST_LIMIT, + REPOSITORY, Refusal, canonical, manifest, require, verify) +from native_alias_transport import GitHubRegistryProvider + + +def dispatch(value: dict) -> None: + release = value["release"] + require(os.environ.get("GITHUB_REPOSITORY") == REPOSITORY + and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" + and os.environ.get("GITHUB_REF") == "refs/tags/" + release["tag"] + and os.environ.get("GITHUB_SHA") == release["source_commit"] + and os.environ.get("GITHUB_WORKFLOW_REF") == + f"{REPOSITORY}/.github/workflows/native-alias-promotion.yml@refs/tags/{release['tag']}", + "promotion_dispatch_identity") + + +def qualify(value: dict, provider, mode: str) -> dict: + if mode != "release-order": + dispatch(value) + proof = verify(value, provider) + if mode in ("verify", "promote"): + require(not provider.release_exists(value["release"]["tag"]), "preexisting_release_requires_decision") + return proof + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=("verify", "promote", "release-order")) + args = parser.parse_args() + raw = os.environ.get("PROMOTION_EVIDENCE", "").encode("utf-8") + require(len(raw) <= MANIFEST_LIMIT, "manifest_size") + value = manifest(raw) + provider = GitHubRegistryProvider() + proof = qualify(value, provider, args.mode) + if args.mode == "verify": + sys.stdout.buffer.write(canonical(proof)) + return + release = proof["release"] + if args.mode == "release-order": + require(os.environ.get("RELEASE_TAG") == release["tag"] + and os.environ.get("RELEASE_RUN_ID") == str(release["run_id"]) + and os.environ.get("RELEASE_RUN_ATTEMPT") == str(release["run_attempt"]), + "release_evidence_identity") + environment = os.environ.copy() + # No caller-supplied registry, tag inventory, executable or tag source can + # redirect the protected writer. Its only mutation is the existing helper. + environment.pop("RELEASE_IMAGE_KNOWN_TAGS", None) + environment.update(RELEASE_TAG=release["tag"], INDEX_DIGEST=release["index_digest"], + GHCR_IMAGE=GHCR_IMAGE, DOCKERHUB_IMAGE=DOCKERHUB_IMAGE, DOCKER="docker", + RELEASE_IMAGE_TAG_SOURCE_URL="https://github.com/" + REPOSITORY + ".git", + VERIFY_ONLY="true" if args.mode == "release-order" else "false") + helper = Path(__file__).with_name("native-release-rolling-tags.sh") + # Bounded mutation preserves the helper's content-free partial-state record. + # A timeout is an uncertain write, never a rollback or automatic retry. + process = subprocess.Popen(["bash", str(helper)], env=environment, stdin=subprocess.DEVNULL, + start_new_session=True) + try: + status = process.wait(timeout=180) + except subprocess.TimeoutExpired: + raise Refusal("alias_write_timeout_uncertain_no_rollback") from None + finally: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + require(status == 0, "alias_write_stopped_no_rollback") + + +if __name__ == "__main__": + try: + main() + except (Refusal, OSError, UnicodeError, ValueError, KeyError) as error: + reason = str(error) if isinstance(error, Refusal) else "local_input_or_transport" + print("Native promotion 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 new file mode 100644 index 000000000..6156f818d --- /dev/null +++ b/scripts/ci/native_alias_transport.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Bounded read-only GitHub/registry transport for native promotion checks.""" +from __future__ import annotations + +import os +from pathlib import Path +import re +import selectors +import signal +import subprocess +import time +from typing import Any + +from native_alias_evidence import (ARCHIVE_LIMIT, DIGEST, DOCKERHUB_IMAGE, GHCR_IMAGE, + JSON_LIMIT, PLATFORMS, REPOSITORY, SHA, Refusal, + matching, require, strict_json) + + +class Commands: + """No shell, polling, automatic retries, unbounded output or provider logs.""" + + def __init__(self, seconds: float = 180): + self.deadline = time.monotonic() + seconds + + def run(self, argv: list[str], limit: int = JSON_LIMIT, + env: dict[str, str] | None = None, accepted: tuple[int, ...] = (0,)) -> bytes: + remaining = min(30, self.deadline - time.monotonic()) + require(remaining > 0, "read_deadline") + environment = os.environ.copy() + environment.pop("GH_DEBUG", None) + environment.update(GH_HOST="github.com", GH_PROMPT_DISABLED="1", GH_PAGER="cat") + if env: + environment.update(env) + process = None + try: + process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=environment, start_new_session=True) + output, errors = bytearray(), bytearray() + until = time.monotonic() + remaining + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ, output) + selector.register(process.stderr, selectors.EVENT_READ, errors) + while selector.get_map(): + require(time.monotonic() < until, "read_timeout") + for key, _ in selector.select(min(0.1, max(0, until - time.monotonic()))): + block = os.read(key.fd, 8192) + if not block: + selector.unregister(key.fileobj) + continue + key.data.extend(block) + require(len(output) <= limit and len(errors) <= 4096, "read_output_bound") + require(process.wait(timeout=max(0.01, until - time.monotonic())) in accepted, + "provider_read_failed") + return bytes(output) + except (OSError, subprocess.SubprocessError): + raise Refusal("provider_command_failed") from None + finally: + if process is not None: + # Kill the whole owned subprocess group, including a blocked CLI child. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + process.stdout.close() + process.stderr.close() + + +class GitHubRegistryProvider: + def __init__(self, commands: Commands | None = None): + self.commands = commands if commands is not None else Commands() + + def api(self, path: str) -> Any: + 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, "provider_api_path") + return strict_json(self.commands.run( + ["gh", "api", "--hostname", "github.com", "--method", "GET", path]), JSON_LIMIT) + + def archive(self, artifact_id: int) -> bytes: + require(type(artifact_id) is int and artifact_id > 0, "artifact_id") + return self.commands.run(["gh", "api", "--hostname", "github.com", "--method", "GET", + f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}/zip"], + ARCHIVE_LIMIT) + + def release_exists(self, tag: str) -> bool: + # gh's exit=1 alone cannot distinguish a 404 from failed authentication, + # transport or rate limits. Only the fixed API's parsed HTTP 404 counts. + from native_alias_evidence import FINAL_TAG + require(matching(tag, FINAL_TAG), "final_release_required") + response = self.commands.run(["gh", "api", "--hostname", "github.com", "--method", "GET", + "--include", f"repos/{REPOSITORY}/releases/tags/{tag}"], + accepted=(0, 1)) + headers, separator, body = response.partition(b"\r\n\r\n") + if not separator: + headers, separator, body = response.partition(b"\n\n") + require(bool(separator), "release_http_response") + status = re.fullmatch(rb"HTTP/[0-9.]+ (200|404)(?: [^\r\n]*)?", headers.splitlines()[0]) + require(status is not None and b"content-type: application/json" in headers.lower(), "release_http_status") + value = strict_json(body, JSON_LIMIT) + if status[1] == b"404": + require(type(value) is dict and value.get("message") == "Not Found", "release_absence_unknown") + return False + require(type(value) is dict and type(value.get("id")) is int and value["id"] > 0 + and value.get("tag_name") == tag, "release_existing_identity") + return True + + def workflow_blob(self, commit: str, path: str) -> str: + require(matching(commit, SHA) and re.fullmatch(r"\.github/workflows/[a-z-]+\.yml", path), + "workflow_source_path") + value = self.api(f"repos/{REPOSITORY}/contents/{path}?ref={commit}") + require(type(value) is dict and value.get("type") == "file" and value.get("path") == path + and matching(value.get("sha"), SHA), "workflow_source_blob") + return value["sha"] + + def registry_json(self, image: str, digest: str, image_config: bool = False) -> Any: + require(image in (GHCR_IMAGE, DOCKERHUB_IMAGE) and matching(digest, DIGEST), + "registry_subject") + arguments = ["docker", "buildx", "imagetools", "inspect"] + arguments += ["--format", "{{json .Image}}"] if image_config else ["--raw"] + return strict_json(self.commands.run(arguments + [image + "@" + digest]), JSON_LIMIT) + + 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) + require(type(index) is dict and type(index.get("manifests")) is list + and len(index["manifests"]) == 2, "publication_index_members") + subjects = {} + for member in index["manifests"]: + require(type(member) is dict and type(member.get("platform")) is dict, + "publication_member_platform") + platform = member["platform"] + key = str(platform.get("os")) + "/" + str(platform.get("architecture")) + require(key in PLATFORMS and key not in subjects + and matching(member.get("digest"), DIGEST), "publication_member_set") + child = self.registry_json(image, member["digest"]) + require(type(child) is dict and type(child.get("config")) is dict + and matching(child["config"].get("digest"), DIGEST), "publication_child_config") + config = self.registry_json(image, member["digest"], image_config=True) + require(type(config) is dict and config.get("os") == "linux" + and config.get("architecture") == platform["architecture"] + and type(config.get("config")) is dict, "publication_config_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, + "publication_config_source") + subjects[key] = {"child_manifest_digest": member["digest"], + "image_config_digest": child["config"]["digest"]} + require(set(subjects) == set(PLATFORMS), "publication_platform_set") + return subjects + + def publication_subjects(self, release: dict[str, Any]) -> dict[str, dict[str, Any]]: + helper = Path(__file__).with_name("native-release-verify-publication.sh") + identity = strict_json(self.commands.run(["bash", str(helper)], env={ + "GITHUB_REPOSITORY": REPOSITORY, "GHCR_IMAGE": GHCR_IMAGE, + "DOCKERHUB_IMAGE": DOCKERHUB_IMAGE, "RELEASE_TAG": release["tag"], + "RELEASE_RUN_ID": str(release["run_id"]), + "RELEASE_RUN_ATTEMPT": str(release["run_attempt"]), "GH": "gh", "DOCKER": "docker", + }), JSON_LIMIT) + require(identity == release, "publication_identity_changed") + ghcr = self.index_subjects(GHCR_IMAGE, release["index_digest"], + release["source_commit"], release["tag"]) + hub = self.index_subjects(DOCKERHUB_IMAGE, release["index_digest"], + release["source_commit"], release["tag"]) + require(ghcr == hub, "publication_registry_members_disagree") + return ghcr diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py new file mode 100644 index 000000000..ce3491e1c --- /dev/null +++ b/scripts/ci/native_qualification.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Sanitized image observations, exact owned cleanup and qualification receipts. + +Only the existing qualification jobs call these phases. A PR/default/local +image produces a diagnostic receipt; it cannot be annotated into eligibility. +""" +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import sys +from typing import Any + +from native_alias_evidence import (COMPOSE_OBSERVATIONS, CORPUS_JOB, CORPUS_OBSERVATIONS, + DIGEST, FINAL_TAG, GHCR_IMAGE, JSON_LIMIT, PLATFORMS, + RECEIPT_SCHEMA, REPOSITORY, REPOSITORY_ID, SHA, WORKFLOWS, + Refusal, canonical, closed, literal, matching, positive, + require, strict_json) +from native_alias_transport import Commands, GitHubRegistryProvider + +OWNER = "dev.durable-workflow.qualification.scope" +CLEANUP_KEYS = {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"} + + +def read(path: Path) -> Any: + with path.open("rb") as stream: + return strict_json(stream.read(JSON_LIMIT + 1), JSON_LIMIT) + + +def write(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + # Owned job workspace files only; artifact finalization uses a fresh directory. + path.write_bytes(canonical(value)) + + +def eligible_context(role: str, image: str) -> bool: + tag = os.environ.get("GITHUB_REF_NAME", "") + return (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) + + +def inspect(commands: Commands, resource: str, identity: str) -> dict[str, Any]: + value = strict_json(commands.run(["docker", resource, "inspect", identity]), JSON_LIMIT) + require(type(value) is list and len(value) == 1 and type(value[0]) is dict, + "docker_inspect_shape") + return value[0] + + +def observe_image(provider: GitHubRegistryProvider, role: str, image: str, + container: str, platform: str) -> dict[str, Any]: + if not eligible_context(role, image): + return {"eligible": False, "reason": "nonpromotion_context"} + require(platform in PLATFORMS, "qualification_platform") + source, tag = os.environ["GITHUB_SHA"], os.environ["GITHUB_REF_NAME"] + actual_source = provider.commands.run(["git", "rev-parse", "HEAD"], 128).decode().strip() + require(actual_source == source, "qualification_checkout") + index = image.split("@", 1)[1] + subject = provider.index_subjects(GHCR_IMAGE, index, source, tag)[platform] + instance = inspect(provider.commands, "container", container) + 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" and 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") + return {"eligible": True, "requested_image": image, "index_digest": index, + "platform": platform, **subject, "actual_image_config_digest": instance["Image"], + "container_image_architecture": config["Architecture"], "container_image_os": config["Os"], + "image_source_commit": source, "image_release_tag": tag} + + +def lines(commands: Commands, args: list[str], expression: str) -> set[str]: + value = commands.run(["docker", *args]).decode("utf-8", errors="strict").splitlines() + require(len(value) == len(set(value)) and len(value) <= 256 + and all(re.fullmatch(expression, line) for line in value), "cleanup_inventory") + return set(value) + + +def owned_inventory(commands: Commands, role: str, scope: str) -> tuple[set[str], set[str], set[str]]: + containers, networks, volumes = set(), set(), set() + if role == "compose": + label = "com.docker.compose.project=" + scope + containers = lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", "label=" + label], + r"[0-9a-f]{64}") + networks = lines(commands, ["network", "ls", "-q", "--no-trunc", "--filter", "label=" + label], + r"[0-9a-f]{64}") + volumes = lines(commands, ["volume", "ls", "-q", "--filter", "label=" + label], r"[A-Za-z0-9_.-]+") + else: + networks = lines(commands, ["network", "ls", "-q", "--no-trunc", "--filter", "name=^" + scope + "$"], + r"[0-9a-f]{64}") + containers = lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", "name=^/" + scope + "-"], + r"[0-9a-f]{64}") + containers |= lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", "label=" + OWNER + "=" + scope], + r"[0-9a-f]{64}") + for network in networks: + data = inspect(commands, "network", network) + require(data.get("Name") == scope and type(data.get("Containers")) is dict + and type(data.get("Labels")) is dict and data["Labels"].get(OWNER) == scope, + "cleanup_network_ownership") + require(all(re.fullmatch(r"[0-9a-f]{64}", identity) for identity in data["Containers"]), + "cleanup_network_container") + containers.update(data["Containers"]) + for identity in containers: + data = inspect(commands, "container", identity) + require(data.get("Id") == identity and type(data.get("Config")) is dict + and type(data.get("Mounts")) is list, "cleanup_container_identity") + if role == "compose": + labels = data["Config"].get("Labels") + require(type(labels) is dict and labels.get("com.docker.compose.project") == scope, + "cleanup_container_owner") + else: + attached = data.get("NetworkSettings", {}).get("Networks", {}) + labels = data["Config"].get("Labels") or {} + require(data.get("Name", "").startswith("/" + scope + "-") or scope in attached + or (type(labels) is dict and labels.get(OWNER) == scope), + "cleanup_container_owner") + for mount in data["Mounts"]: + if type(mount) is dict and mount.get("Type") == "volume": + name = mount.get("Name") + require(type(name) is str and re.fullmatch(r"[A-Za-z0-9_.-]+", name), "cleanup_volume_identity") + metadata = inspect(commands, "volume", name) + labels = metadata.get("Labels") or {} + # An unlabelled anonymous volume belongs to the observed owned container. + # A named Compose volume must independently carry the same project label. + require((re.fullmatch(r"[0-9a-f]{64}", name) and not labels) + or (role == "compose" and type(labels) is dict + and labels.get("com.docker.compose.project") == scope), "cleanup_volume_owner") + volumes.add(name) + require(len(containers) <= 64 and len(networks) <= 8 and len(volumes) <= 64, "cleanup_resource_bound") + return containers, networks, volumes + + +def cleanup(commands: Commands, role: str, scope: str) -> dict[str, Any]: + require(role in ("corpus", "compose") and re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,150}", scope), + "cleanup_scope") + containers, networks, volumes = owned_inventory(commands, role, scope) + if containers: + commands.run(["docker", "container", "rm", "-f", "-v", *sorted(containers)]) + if networks: + commands.run(["docker", "network", "rm", *sorted(networks)]) + # rm -v can already remove anonymous volumes. A successful list distinguishes + # absence from unavailable Docker; never reinterpret an inspect failure as 404. + remaining = lines(commands, ["volume", "ls", "-q"], r"[A-Za-z0-9_.-]+") & volumes + if remaining: + commands.run(["docker", "volume", "rm", *sorted(remaining)]) + after_containers, after_networks, after_volumes = owned_inventory(commands, role, scope) + after_volumes |= lines(commands, ["volume", "ls", "-q"], r"[A-Za-z0-9_.-]+") & volumes + require(not after_containers and not after_networks and not after_volumes, "cleanup_not_absent") + return {"api_ok": True, "verified_absent": True, "container_count": 0, + "network_count": 0, "volume_count": 0} + + +def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: str, + profile: str | None, identity: Any, observations: Any, cleanup_result: Any, + body_outcome: str, cleanup_outcome: str) -> dict[str, Any]: + if not eligible_context(role, image): + return {"schema": "durable-workflow.native-qualification-diagnostic.v1", + "eligible": False, "reason": "nonpromotion_context"} + require(body_outcome == cleanup_outcome == "success", "qualification_step_failed") + require(type(identity) is dict and identity.get("eligible") is True + and identity.get("requested_image") == image and identity.get("platform") == platform, + "qualification_identity_missing") + cleanup_result = closed(cleanup_result, CLEANUP_KEYS, "qualification_cleanup_shape") + require(cleanup_result["api_ok"] is True and cleanup_result["verified_absent"] is True + and all(literal(cleanup_result[key], 0) for key in CLEANUP_KEYS - {"api_ok", "verified_absent"}), + "qualification_cleanup_failed") + keys = CORPUS_OBSERVATIONS if role == "corpus" else COMPOSE_OBSERVATIONS + observations = closed(observations, keys, "qualification_observation_shape") + if role == "corpus": + require(all(literal(value, 0) for value in observations.values()), "qualification_corpus_failed") + else: + require(profile in ("local", "production") + and all(value is True for key, value in observations.items() + if key != "production_default_token_refused") + and literal(observations["production_default_token_refused"], True if profile == "production" else None), + "qualification_compose_failed") + source = os.environ["GITHUB_SHA"] + 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}") + require(type(run) is dict and literal(run.get("id"), run_id) and literal(run.get("run_attempt"), attempt) + and run.get("head_sha") == source and run.get("path") == WORKFLOWS[role] + and run.get("event") == "workflow_dispatch" and run.get("head_branch") == os.environ["GITHUB_REF_NAME"], + "qualification_provider_run") + for field in ("repository", "head_repository"): + owner = run.get(field) + require(type(owner) is dict and literal(owner.get("id"), REPOSITORY_ID) + and owner.get("full_name") == REPOSITORY, "qualification_provider_owner") + name = CORPUS_JOB if role == "corpus" else platform + " " + str(profile) + " published image" + page = provider.api(f"repos/{REPOSITORY}/actions/runs/{run_id}/jobs?filter=all&per_page=100") + require(type(page) is dict and type(page.get("jobs")) is list + and type(page.get("total_count")) is int + and len(page["jobs"]) == page["total_count"] <= 100, "qualification_job_inventory") + selected = [job for job in page["jobs"] if type(job) is dict + and literal(job.get("run_attempt"), attempt) and job.get("name") == name] + require(len(selected) == 1 and positive(selected[0].get("id")) + and literal(selected[0].get("run_id"), run_id) and selected[0].get("head_sha") == source + and selected[0].get("status") == "in_progress", "qualification_job_owner") + workflow_blob = provider.workflow_blob(source, WORKFLOWS[role]) + actual_blob = provider.commands.run(["git", "rev-parse", source + ":" + WORKFLOWS[role]], 128).decode().strip() + require(actual_blob == workflow_blob, "qualification_workflow_blob") + # 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"} + closed(identity, image_keys | {"eligible"}, "qualification_identity_shape") + 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, + "profile": profile, **{key: identity[key] for key in image_keys}, + "observations": observations, "cleanup": cleanup_result} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("phase", choices=("observe-image", "cleanup", "finalize")) + parser.add_argument("--kind", choices=("corpus", "compose"), required=True) + parser.add_argument("--image", default="") + parser.add_argument("--container", default="") + parser.add_argument("--scope", default="") + parser.add_argument("--platform", default="linux/amd64") + parser.add_argument("--profile", choices=("local", "production")) + parser.add_argument("--identity", type=Path) + parser.add_argument("--observations", type=Path) + parser.add_argument("--cleanup", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + provider = GitHubRegistryProvider() + if args.phase == "observe-image": + value = observe_image(provider, args.kind, args.image, args.container, args.platform) + elif args.phase == "cleanup": + value = cleanup(provider.commands, args.kind, args.scope) + elif not eligible_context(args.kind, args.image): + value = {"schema": "durable-workflow.native-qualification-diagnostic.v1", + "eligible": False, "reason": "nonpromotion_context"} + else: + require(args.identity is not None and args.observations is not None and args.cleanup is not None, + "qualification_inputs_missing") + observations = read(args.observations) + if args.kind == "corpus": + observations = observations.get("status") if type(observations) is dict else None + value = finalize(provider, args.kind, args.image, args.platform, args.profile, read(args.identity), + observations, read(args.cleanup), os.environ.get("QUALIFICATION_BODY_OUTCOME", ""), + os.environ.get("QUALIFICATION_CLEANUP_OUTCOME", "")) + write(args.output, value) + + +if __name__ == "__main__": + try: + main() + except (Refusal, OSError, UnicodeError, ValueError, KeyError): + # Never print Docker/API bodies, HTTP registration payloads or credentials. + print("native qualification refused; no positive receipt finalized", file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/ci/select-published-compose-image.sh b/scripts/ci/select-published-compose-image.sh index 89b358e8b..74c8f55b3 100755 --- a/scripts/ci/select-published-compose-image.sh +++ b/scripts/ci/select-published-compose-image.sh @@ -17,6 +17,8 @@ curl_bin="${CURL:-curl}" : "${GITHUB_ENV:?}" if [ -n "${INPUT_SERVER_IMAGE:-}" ]; then + [[ "$INPUT_SERVER_IMAGE" =~ ^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,255}$ ]] \ + || fail "manual image reference must be one bounded single-line reference." printf 'DW_SERVER_IMAGE=%s\n' "$INPUT_SERVER_IMAGE" >> "$GITHUB_ENV" exit 0 fi diff --git a/scripts/conformance/server-parity/native-image-corpus.sh b/scripts/conformance/server-parity/native-image-corpus.sh index 4a1a70b8e..367d0367a 100755 --- a/scripts/conformance/server-parity/native-image-corpus.sh +++ b/scripts/conformance/server-parity/native-image-corpus.sh @@ -44,33 +44,36 @@ mkdir -p parity-state parity-evidence chmod 0777 parity-state parity-evidence touch parity-state/php.sqlite parity-state/embedded.sqlite chmod 0666 parity-state/*.sqlite -bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ +bash scripts/ci/run-composer.sh --rm --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress -docker network create "$RESOURCE_SCOPE" >/dev/null +docker network create --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" "$RESOURCE_SCOPE" >/dev/null # The native image under test, as an operator runs it. The shared state # directory is also its /state, where fixtures put their external payload # storage roots (file:///state/...), as on every corpus server. -docker run -d --name "$RESOURCE_SCOPE-rust" --network "$RESOURCE_SCOPE" --network-alias=parity-rust \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -d --name "$RESOURCE_SCOPE-rust" --network "$RESOURCE_SCOPE" --network-alias=parity-rust \ --cpus=1 --memory=256m --memory-swap=256m -e DW_RUST_EXPERIMENTAL=1 -e DW_AUTH_TOKEN=parity-test-token \ -e DB_DATABASE=/app/database/rust.sqlite -v "$PWD/parity-state:/app/database" -v "$PWD/parity-state:/state" \ "$native" >/dev/null +mkdir -p qualification-state +python3 scripts/ci/native_qualification.py observe-image --kind corpus --image "$native" \ + --container "$RESOURCE_SCOPE-rust" --platform linux/amd64 --output qualification-state/image.json # Published PHP and embedded PHP, as the corpus part runs them. for target in php embedded; do - docker run --rm --user=1000:1000 --entrypoint php \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint php \ -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE="/state/$target.sqlite" \ -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ "$image" artisan server:bootstrap --force >> parity-evidence/bootstrap.txt done -docker run -d --name "$RESOURCE_SCOPE-server" --network "$RESOURCE_SCOPE" --network-alias=parity-server \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -d --name "$RESOURCE_SCOPE-server" --network "$RESOURCE_SCOPE" --network-alias=parity-server \ --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/php.sqlite -e DB_SQLITE_BUSY_TIMEOUT=30000 \ -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token "$image" >/dev/null for target in server rust; do ready=0 for _ in $(seq 1 30); do - if docker run --rm --user=1000:1000 --entrypoint curl --network "$RESOURCE_SCOPE" "$image" \ + if docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint curl --network "$RESOURCE_SCOPE" "$image" \ -fsS --connect-timeout 2 --max-time 5 "http://parity-$target:8080/api/ready" > "parity-evidence/$target-readiness.json"; then ready=1 break @@ -79,7 +82,7 @@ for target in server rust; do done test "$ready" = 1 done -docker run -d --name "$RESOURCE_SCOPE-php-maintenance" --network "$RESOURCE_SCOPE" \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" -d --name "$RESOURCE_SCOPE-php-maintenance" --network "$RESOURCE_SCOPE" \ --user=1000:1000 --cpus=0.25 --memory=256m --memory-swap=256m --entrypoint sh \ -v "$PWD/parity-state:/state" -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/php.sqlite -e DB_SQLITE_BUSY_TIMEOUT=30000 \ -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_AUTH_TOKEN=parity-test-token \ @@ -95,11 +98,11 @@ http_flags=(--network "$RESOURCE_SCOPE" -v "$PWD:/source:ro" -v "$PWD/parity-evi -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION -e DW_PARITY_CLI_PHAR) record_http() { local target=$1 url=$2 - docker run --rm --user=1000:1000 --entrypoint node "${http_flags[@]}" "$image" scripts/conformance/server-parity.mjs record \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node "${http_flags[@]}" "$image" scripts/conformance/server-parity.mjs record \ --mode http --url "$url" --target "$target" --output "/evidence/$target.json" "${selection[@]}" } record_embedded() { - docker run --rm --user=1000:1000 --entrypoint node \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node \ -v "$PWD:/source:ro" -v "$PWD/parity-state:/state" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/embedded.sqlite -e DW_MODE=embedded \ -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ @@ -111,7 +114,7 @@ record_embedded || status=$? native_status=0 record_http rust http://parity-rust:8080 || native_status=$? compare_status=0 -docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ scripts/conformance/server-parity.mjs compare /evidence/php.json /evidence/rust.json /evidence/embedded.json "${selection[@]}" \ > parity-evidence/comparison.json || compare_status=$? service_calls_status=0 diff --git a/scripts/conformance/server-parity/update-validation-sqlite.sh b/scripts/conformance/server-parity/update-validation-sqlite.sh index b1e1ae7ae..1e6aa2387 100644 --- a/scripts/conformance/server-parity/update-validation-sqlite.sh +++ b/scripts/conformance/server-parity/update-validation-sqlite.sh @@ -45,7 +45,7 @@ image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; " prefix=parity-v1 http_record() { local url="$1" target="$2" - docker run --rm --user=1000:1000 --entrypoint node --network "$RESOURCE_SCOPE" \ + docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node --network "$RESOURCE_SCOPE" \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DW_PARITY_TOKEN=parity-test-token -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ --mode http --url "$url" --target "$target" --output "/evidence/$name-$target.json" --fixture "$fixture" \ @@ -83,14 +83,14 @@ if ((php_status != 0)); then run_without_maintenance "parity-evidence/$name-maintenance-schedule-retry.txt" -- php_record ((php_status == 0)) || exit "$php_status" fi -docker run --rm --user=1000:1000 --entrypoint node \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node \ -v "$PWD:/source:ro" -v "$PWD/parity-state:/state" -v "$PWD/parity-evidence:/evidence" -w /source \ -e DB_CONNECTION=sqlite -e DB_DATABASE=/state/embedded.sqlite -e DW_MODE=embedded \ -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DW_PARITY_RUNNER_REVISION "$image" scripts/conformance/server-parity.mjs record \ --mode embedded --application-root /app --target embedded --output "/evidence/$name-embedded.json" --fixture "$fixture" \ --prefix "$prefix" http_record "$native_url" rust -docker run --rm --user=1000:1000 --entrypoint node \ +docker run --label "dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE" --rm --user=1000:1000 --entrypoint node \ -v "$PWD:/source:ro" -v "$PWD/parity-evidence:/evidence" -w /source "$image" \ scripts/conformance/server-parity.mjs compare \ "/evidence/$name-php.json" "/evidence/$name-rust.json" "/evidence/$name-embedded.json" \ diff --git a/scripts/smoke-published-compose.sh b/scripts/smoke-published-compose.sh index 44ed0f105..1c2232d6c 100755 --- a/scripts/smoke-published-compose.sh +++ b/scripts/smoke-published-compose.sh @@ -18,6 +18,7 @@ export DW_SERVER_PLATFORM="$SERVER_PLATFORM" unset DOCKER_DEFAULT_PLATFORM BASE_URL="http://server:8080" +published="" export SERVER_PORT @@ -58,27 +59,26 @@ compose() { docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" } +OBSERVATION_DIR="${DW_QUALIFICATION_DIR:-$ROOT_DIR/qualification-state}" +mkdir -p "$OBSERVATION_DIR" +HTTP_DIR=$(mktemp -d) +chmod 0700 "$HTTP_DIR" + curl_json_with_retry() { local output="$1" shift for attempt in $(seq 1 60); do if [ "$ENDPOINT_MODE" = "container" ]; then - if compose exec -T server curl -fsS "$@" >"$output"; then - cat "$output" - echo + if compose exec -T server curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 "$@" >"$output"; then return 0 fi - elif curl -fsS "$@" >"$output"; then - cat "$output" - echo + elif curl -fsS --connect-timeout 2 --max-time 5 --max-filesize 1048576 "$@" >"$output"; then return 0 fi if [ "$attempt" -eq 60 ]; then - echo "Request failed after ${attempt} attempts: curl $*" >&2 - compose ps >&2 || true - compose logs server >&2 || true + echo "Published Compose request did not succeed within its bound" >&2 return 1 fi @@ -87,7 +87,13 @@ curl_json_with_retry() { } cleanup() { - compose down -v --remove-orphans >/dev/null 2>&1 || true + local body_status=$? cleanup_status=0 + trap - EXIT + python3 "$ROOT_DIR/scripts/ci/native_qualification.py" cleanup --kind compose --scope "$PROJECT" \ + --output "$OBSERVATION_DIR/body-cleanup.json" || cleanup_status=$? + rm -rf "$HTTP_DIR" + if [ "$body_status" -ne 0 ]; then exit "$body_status"; fi + exit "$cleanup_status" } trap cleanup EXIT @@ -115,6 +121,10 @@ fi compose pull compose up -d --wait +container=$(compose ps -q server) +python3 "$ROOT_DIR/scripts/ci/native_qualification.py" observe-image --kind compose \ + --image "${DW_SERVER_IMAGE:-}" --container "$container" --platform "$SERVER_PLATFORM" \ + --output "$OBSERVATION_DIR/image.json" if [ "$ENDPOINT_MODE" = "host" ]; then published="$(compose port server 8080)" @@ -133,13 +143,13 @@ fi echo " resolved endpoint: $BASE_URL" -curl_json_with_retry /tmp/dw-server-compose-health.json "${BASE_URL}/api/health" -curl_json_with_retry /tmp/dw-server-compose-ready.json "${BASE_URL}/api/ready" -curl_json_with_retry /tmp/dw-server-compose-cluster.json \ +curl_json_with_retry "$HTTP_DIR"/health.json "${BASE_URL}/api/health" +curl_json_with_retry "$HTTP_DIR"/ready.json "${BASE_URL}/api/ready" +curl_json_with_retry "$HTTP_DIR"/cluster.json \ -H "Authorization: Bearer ${AUTH_TOKEN}" \ "${BASE_URL}/api/cluster/info" -curl_json_with_retry /tmp/dw-server-compose-worker.json \ +curl_json_with_retry "$HTTP_DIR"/worker.json \ -X POST "${BASE_URL}/api/worker/register" \ -H "Authorization: Bearer ${WORKER_AUTH_TOKEN}" \ -H "Content-Type: application/json" \ @@ -147,29 +157,50 @@ curl_json_with_retry /tmp/dw-server-compose-worker.json \ -H "X-Durable-Workflow-Protocol-Version: 1.0" \ -d "{\"worker_id\":\"${WORKER_ID}\",\"task_queue\":\"${TASK_QUEUE}\",\"runtime\":\"python\"}" -DW_SMOKE_WORKER_ID="$WORKER_ID" DW_SMOKE_TASK_QUEUE="$TASK_QUEUE" python3 - <<'PY' +DW_SMOKE_HTTP_DIR="$HTTP_DIR" DW_SMOKE_WORKER_ID="$WORKER_ID" DW_SMOKE_NATIVE="$NATIVE_PUBLISHED" \ +DW_SMOKE_ENDPOINT_MODE="$ENDPOINT_MODE" DW_SMOKE_BIND_ADDRESS="${published%%:*}" \ +DW_SMOKE_OBSERVATION_DIR="$OBSERVATION_DIR" python3 - <<'OBSERVATIONS' import json import os from pathlib import Path -health = json.loads(Path("/tmp/dw-server-compose-health.json").read_text()) -ready = json.loads(Path("/tmp/dw-server-compose-ready.json").read_text()) -cluster = json.loads(Path("/tmp/dw-server-compose-cluster.json").read_text()) -worker = json.loads(Path("/tmp/dw-server-compose-worker.json").read_text()) - -assert health.get("status") == "serving", health -assert ready.get("status") == "ready", ready -assert cluster.get("version"), cluster -assert worker.get("worker_id") == os.environ["DW_SMOKE_WORKER_ID"], worker -assert worker.get("registered") is True, worker -PY +root = Path(os.environ["DW_SMOKE_HTTP_DIR"]) +try: + health, ready, cluster, worker = [json.loads((root / name).read_bytes()) for name in + ("health.json", "ready.json", "cluster.json", "worker.json")] + identity = json.loads((Path(os.environ["DW_SMOKE_OBSERVATION_DIR"]) / "image.json").read_bytes()) + expected_version = identity.get("image_release_tag", "").removeprefix("native-") + results = { + "health_serving": health.get("status") == "serving", + "ready": ready.get("status") == "ready", + "cluster_version_matches_release": cluster.get("version") == expected_version if identity.get("eligible") else bool(cluster.get("version")), + "worker_registered": worker.get("worker_id") == os.environ["DW_SMOKE_WORKER_ID"] and worker.get("registered") is True, + "loopback_binding": os.environ["DW_SMOKE_ENDPOINT_MODE"] == "host" and os.environ["DW_SMOKE_BIND_ADDRESS"] == "127.0.0.1", + "unset_token_refused": os.environ["DW_SMOKE_NATIVE"] == "true", + "production_default_token_refused": None, + } + if not all(results[key] for key in ("health_serving", "ready", "cluster_version_matches_release", "worker_registered")): + raise ValueError("smoke assertions") + (Path(os.environ["DW_SMOKE_OBSERVATION_DIR"]) / "observations.json").write_text(json.dumps(results) + "\n") +except (ValueError, OSError, AttributeError): + raise SystemExit("Published Compose response assertions failed") from None +OBSERVATIONS if [ "$PROFILE" = "production" ]; then - status="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer dev-token" "${BASE_URL}/api/cluster/info" || true)" + status="$(curl --connect-timeout 2 --max-time 5 -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer dev-token" "${BASE_URL}/api/cluster/info")" if [ "$status" != "401" ]; then echo "production profile accepted dev-token (HTTP $status)" >&2 exit 1 fi + python3 - "$OBSERVATION_DIR/observations.json" <<'PRODUCTION' +import json +import sys +from pathlib import Path +path = Path(sys.argv[1]) +value = json.loads(path.read_bytes()) +value["production_default_token_refused"] = True +path.write_text(json.dumps(value) + "\n") +PRODUCTION fi echo "Published Compose smoke passed" diff --git a/tests/Unit/Support/native_alias_evidence_test.py b/tests/Unit/Support/native_alias_evidence_test.py index a2f7cd03c..7e6d6f0ff 100644 --- a/tests/Unit/Support/native_alias_evidence_test.py +++ b/tests/Unit/Support/native_alias_evidence_test.py @@ -276,7 +276,7 @@ def test_hostile_archive_members_and_canonical_json(self): record=FakeProvider().make_receipt("corpus","linux/amd64",None,2001) invalid = [packed(record,"../receipt.json"),packed(record,mode=stat.S_IFLNK|0o777), packed(record,extra="extra.json"),packed(b'{"x":1,"x":2}\n'), - packed(b'{"x":1.0}\n'),packed(b' {"x":1}\n'), + packed(b'{"x":1.0}\n'),packed(b'{"x":"\\ud800"}\n'),packed(b' {"x":1}\n'), packed(b'{"padding":"'+b'a'*40000+b'"}\n'),b'prefix'+packed(record),packed(record)+b'trailing'] for i, data in enumerate(invalid): with self.subTest(i=i),self.assertRaises(E.Refusal):E.archive_receipt(data) diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py new file mode 100644 index 000000000..a59db5acf --- /dev/null +++ b/tests/Unit/Support/native_qualification_test.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Transport/producer/cleanup countercontrols; no real Docker or API calls.""" +import copy +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts/ci")) +import native_alias_evidence as E +import native_alias_promote as P +import native_alias_transport as T +import native_qualification as Q +from native_alias_evidence_test import FakeProvider + +ROOT = Path(__file__).resolve().parents[3] + +SOURCE = "1" * 40 +INDEX, CHILD, CONFIG = ["sha256:" + letter * 64 for letter in "abc"] +CID, NID, VOLUME = [letter * 64 for letter in "def"] +SCOPE = "published-compose-301-1-smoke-amd64-local" +CONTEXT = { + "GITHUB_REPOSITORY": E.REPOSITORY, "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REF": "refs/tags/3.2.0", "GITHUB_REF_NAME": "3.2.0", "GITHUB_SHA": SOURCE, + "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/" + E.WORKFLOWS["compose"] + "@refs/tags/3.2.0", + "GITHUB_RUN_ID": "303", "GITHUB_RUN_ATTEMPT": "1", +} + + +class ResourceCommands: + def __init__(self): + self.containers, self.networks, self.volumes = {CID}, {NID}, {VOLUME} + self.removed, self.reads = [], [] + self.owner = SCOPE + self.volume_owner = None + self.fail_list = self.leak = False + + def run(self, argv, limit=E.JSON_LIMIT, env=None): + self.reads.append(argv) + resource, operation = argv[1:3] + if operation == "ls": + if self.fail_list: + raise E.Refusal("fake_api_unavailable") + return ("\n".join(sorted(getattr(self, resource + "s"))) + + ("\n" if getattr(self, resource + "s") else "")).encode() + if operation == "inspect": + if resource == "container": + value = {"Id": CID, "Name": "/" + SCOPE + "-server", "Image": CONFIG, + "Config": {"Labels": {"com.docker.compose.project": self.owner}}, + "Mounts": [{"Type": "volume", "Name": VOLUME}]} + elif resource == "volume": + value = {"Name": VOLUME, "Labels": self.volume_owner} + elif resource == "network": + value = {"Id": NID, "Name": SCOPE, "Containers": {CID: {}}} + else: + value = {"Id": CONFIG, "Architecture": "amd64", "Os": "linux", + "Config": {"Labels": {"org.opencontainers.image.revision": SOURCE, + "dev.durable-workflow.release.tag": "3.2.0"}}} + return json.dumps([value]).encode() + if operation == "rm": + identities = {item for item in argv[3:] if not item.startswith("-")} + self.removed.append((resource, identities)) + if not self.leak: + getattr(self, resource + "s").difference_update(identities) + return b"" + if argv[:3] == ["git", "rev-parse", "HEAD"]: + return SOURCE.encode() + if argv[:2] == ["git", "rev-parse"]: + return ("3" * 40).encode() + raise AssertionError(argv) + + +class ProducerProvider: + def __init__(self): + self.commands = ResourceCommands() + self.subject = {"child_manifest_digest": CHILD, "image_config_digest": CONFIG} + owner = {"id": E.REPOSITORY_ID, "full_name": E.REPOSITORY} + self.run = {"id": 303, "run_attempt": 1, "head_sha": SOURCE, + "path": E.WORKFLOWS["compose"], "event": "workflow_dispatch", + "head_branch": "3.2.0", "repository": owner, "head_repository": owner} + self.job = {"id": 3001, "run_id": 303, "run_attempt": 1, "head_sha": SOURCE, + "status": "in_progress", "name": "linux/amd64 local published image"} + + def index_subjects(self, *args): + return {"linux/amd64": self.subject} + + def workflow_blob(self, *args): + return "3" * 40 + + def api(self, path): + return {"jobs": [self.job], "total_count": 1} if "/jobs?" in path else self.run + + +class ProducerTests(unittest.TestCase): + def test_cleanup_removes_only_observed_owned_objects_and_checks_absence(self): + commands = ResourceCommands() + result = Q.cleanup(commands, "compose", SCOPE) + self.assertEqual(result, {"api_ok": True, "verified_absent": True, + "container_count": 0, "network_count": 0, "volume_count": 0}) + self.assertEqual(commands.removed, [("container", {CID}), ("network", {NID}), ("volume", {VOLUME})]) + self.assertGreaterEqual(sum(args[2] == "ls" for args in commands.reads), 8) + + def test_cleanup_unavailable_is_unknown_and_foreign_resources_are_not_removed(self): + for field, value in (("fail_list", True), ("owner", "another-project"), + ("volume_owner", {"com.docker.compose.project": "foreign"})): + commands = ResourceCommands() + setattr(commands, field, value) + with self.subTest(field=field), self.assertRaises(E.Refusal): + Q.cleanup(commands, "compose", SCOPE) + self.assertEqual(commands.removed, []) + + def test_cleanup_remaining_resource_is_failure(self): + commands = ResourceCommands() + commands.leak = True + with self.assertRaisesRegex(E.Refusal, "cleanup_not_absent"): + Q.cleanup(commands, "compose", SCOPE) + + def test_diagnostic_context_does_not_query_provider_or_docker(self): + provider = ProducerProvider() + with patch.dict(os.environ, CONTEXT, clear=True): + for field, value in (("GITHUB_EVENT_NAME", "pull_request"), + ("GITHUB_SHA", "unknown"), ("GITHUB_REF", "refs/heads/main")): + with patch.dict(os.environ, {field: value}): + result = Q.observe_image(provider, "compose", E.GHCR_IMAGE + "@" + INDEX, CID, "linux/amd64") + self.assertIs(result["eligible"], False) + self.assertEqual(provider.commands.reads, []) + + def test_observed_image_and_finalized_receipt_bind_to_consumer(self): + provider = ProducerProvider() + with patch.dict(os.environ, CONTEXT, clear=True): + image = E.GHCR_IMAGE + "@" + INDEX + identity = Q.observe_image(provider, "compose", image, CID, "linux/amd64") + observations = {key: True for key in E.COMPOSE_OBSERVATIONS} + observations["production_default_token_refused"] = None + 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) + self.assertEqual(set(record), E.RECEIPT_KEYS) + for field, replacement in (("api_ok", False), ("verified_absent", False), + ("container_count", False), ("volume_count", 1)): + invalid = {**cleanup, field: replacement} + with self.subTest(cleanup=field), self.assertRaises(E.Refusal): + Q.finalize(provider, "compose", image, "linux/amd64", "local", identity, + observations, invalid, "success", "success") + for body, clean in (("failure", "success"), ("success", "failure"), ("cancelled", "success")): + 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 + 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"): + Q.observe_image(provider, "compose", E.GHCR_IMAGE + "@" + INDEX, CID, "linux/amd64") + + +class ReadCommands: + def __init__(self, result): + self.result, self.calls = result, [] + + def run(self, argv, limit=E.JSON_LIMIT, env=None, accepted=(0,)): + self.calls.append((argv, limit, env, accepted)) + return self.result + + +class TransportTests(unittest.TestCase): + def test_api_only_targets_fixed_owning_repository_and_host(self): + commands = ReadCommands(b"{}") + provider = T.GitHubRegistryProvider(commands) + provider.api("repos/" + E.REPOSITORY + "/actions/runs/301") + self.assertEqual(commands.calls[0][0][:6], ["gh", "api", "--hostname", "github.com", "--method", "GET"]) + for path in ("https://foreign.invalid/", "repos/fork/server/actions/runs/301", + "repos/" + E.REPOSITORY + "/../secrets", "repos/" + E.REPOSITORY + "/runs\nanything"): + with self.subTest(path=path), self.assertRaises(E.Refusal): + provider.api(path) + self.assertEqual(len(commands.calls), 1) + + def test_registry_subjects_bind_platform_child_config_and_source(self): + class Registry(T.GitHubRegistryProvider): + def __init__(self): + self.members = { + INDEX: {"manifests": [ + {"platform": {"os": "linux", "architecture": "amd64"}, "digest": CHILD}, + {"platform": {"os": "linux", "architecture": "arm64"}, "digest": "sha256:" + "d" * 64}]}, + CHILD: {"config": {"digest": CONFIG}}, + "sha256:" + "d" * 64: {"config": {"digest": "sha256:" + "e" * 64}}, + } + labels = {"org.opencontainers.image.revision": SOURCE, "dev.durable-workflow.release.tag": "3.2.0"} + self.configs = {CHILD: {"os": "linux", "architecture": "amd64", "config": {"Labels": dict(labels)}}, + "sha256:" + "d" * 64: {"os": "linux", "architecture": "arm64", "config": {"Labels": dict(labels)}}} + + def registry_json(self, image, digest, image_config=False): + return (self.configs if image_config else self.members)[digest] + + registry = Registry() + self.assertEqual(registry.index_subjects(E.GHCR_IMAGE, INDEX, SOURCE, "3.2.0")["linux/amd64"], + {"child_manifest_digest": CHILD, "image_config_digest": CONFIG}) + for fault in ("platform", "source", "release", "config", "extra_member"): + registry = Registry() + if fault == "platform": registry.configs[CHILD]["architecture"] = "arm64" + elif fault == "source": registry.configs[CHILD]["config"]["Labels"]["org.opencontainers.image.revision"] = "f" * 40 + elif fault == "release": registry.configs[CHILD]["config"]["Labels"]["dev.durable-workflow.release.tag"] = "3.1.0" + elif fault == "config": registry.members[CHILD]["config"]["digest"] = "not-a-config" + else: registry.members[INDEX]["manifests"].append(copy.deepcopy(registry.members[INDEX]["manifests"][0])) + with self.subTest(fault=fault), self.assertRaises(E.Refusal): + registry.index_subjects(E.GHCR_IMAGE, INDEX, SOURCE, "3.2.0") + + def test_release_absence_requires_authoritative_http_404(self): + for status, body, expected in (("404 Not Found", {"message": "Not Found"}, False), + ("200 OK", {"id": 42, "tag_name": "3.2.0"}, True)): + commands = ReadCommands(("HTTP/2.0 " + status + "\r\nContent-Type: application/json\r\n\r\n" + json.dumps(body)).encode()) + self.assertIs(T.GitHubRegistryProvider(commands).release_exists("3.2.0"), expected) + for response in (b"unauthorized", b"HTTP/2.0 503 Error\r\n\r\n{}", + b"HTTP/2.0 404 Not Found\r\nContent-Type: application/json\r\n\r\n{}"): + with self.subTest(response=response), self.assertRaises(E.Refusal): + T.GitHubRegistryProvider(ReadCommands(response)).release_exists("3.2.0") + + def test_commands_bound_output_time_and_sanitize_errors(self): + for code, limit, seconds in (("print('x'*10000)", 10, 1), + ("import time; time.sleep(1)", 100, .03), + ("import sys; sys.stderr.write('private-marker'); sys.exit(1)", 100, 1)): + with self.subTest(code=code), self.assertRaises(E.Refusal) as caught: + T.Commands(seconds).run([sys.executable, "-c", code], limit) + self.assertNotIn("private-marker", str(caught.exception)) + self.assertEqual(T.Commands().run([sys.executable, "-c", "print('bounded')"]), b"bounded\n") + + def test_promotion_dispatch_cannot_be_a_main_or_foreign_workflow(self): + value = {"release": {"tag": "3.2.0", "source_commit": SOURCE}} + context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"} + with patch.dict(os.environ, context, clear=True): + P.dispatch(value) + for field, replacement in (("GITHUB_REF", "refs/heads/main"), ("GITHUB_SHA", "2" * 40), + ("GITHUB_WORKFLOW_REF", "foreign/workflow")): + with patch.dict(os.environ, {field: replacement}), self.assertRaises(E.Refusal): + P.dispatch(value) + + def test_protected_entry_refusals_leave_zero_write_ledger(self): + context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"} + for fault in ("legacy_release", "attempt", "workflow_blob", "archive"): + provider = FakeProvider() + provider.release_exists = lambda tag: fault == "legacy_release" + if fault == "attempt": + provider.runs[302]["run_attempt"] = 2 + elif fault == "workflow_blob": + provider.selected["corpus"]["workflow_blob"] = "f" * 40 + elif fault == "archive": + provider.archives[501] += b"extra" + writes = [] + with patch.dict(os.environ, context, clear=True), self.subTest(fault=fault): + with self.assertRaises(E.Refusal): + proof = P.qualify(provider.selected, provider, "promote") + writes.append(proof) + self.assertEqual(writes, []) + + +class WorkflowContracts(unittest.TestCase): + def test_only_separate_protected_job_may_move_native_aliases(self): + publisher = (ROOT / ".github/workflows/native-release.yml").read_text() + promotion = (ROOT / ".github/workflows/native-alias-promotion.yml").read_text() + self.assertNotIn("\n aliases:", publisher) + self.assertNotIn("native-release-rolling-tags.sh", publisher) + readonly, protected = promotion.split("\n promote:\n", 1) + self.assertNotIn(": write", readonly) + self.assertNotIn("secrets.DOCKERHUB", readonly) + self.assertIn("environment: dockerhub-native", protected) + self.assertIn("group: durable-workflow-server-rolling-aliases", protected) + self.assertIn("packages: write", protected) + self.assertNotIn("contents: write", promotion) + self.assertIn("native_alias_promote.py verify", readonly) + self.assertIn("native_alias_promote.py promote", protected) + for name, steps in E.RELEASE_JOBS.items(): + self.assertIn("name: " + name, publisher) + for step in steps: + self.assertIn("name: " + step, publisher) + + def test_receipt_authority_follows_body_and_cleanup_in_existing_jobs(self): + for role in ("corpus", "compose"): + source = (ROOT / E.WORKFLOWS[role]).read_text() + steps = E.QUALIFICATION_STEPS[role] + positions = [source.index("name: " + step) for step in steps] + self.assertEqual(positions, sorted(positions)) + self.assertIn("QUALIFICATION_BODY_OUTCOME: ${{ steps.body.outcome }}", source) + self.assertIn("QUALIFICATION_CLEANUP_OUTCOME: ${{ steps.cleanup.outcome }}", source) + self.assertIn("promotion-receipt/receipt.json", source) + self.assertIn("if-no-files-found: error", source) + + def test_partial_setup_fallback_cannot_turn_strict_cleanup_failure_into_success(self): + # Execute the actual workflow run block with inert CLI fakes, rather than + # accepting a source string that merely says cleanup was successful. + with tempfile.TemporaryDirectory() as directory: + scratch = Path(directory) + for name, status in (("python3", 71), ("docker", 0), ("xargs", 0)): + target = scratch / name + target.write_text("#!/bin/sh\nexit " + str(status) + "\n") + target.chmod(0o755) + for role in ("corpus", "compose"): + source = (ROOT / E.WORKFLOWS[role]).read_text() + block = source.split(" - name: Verify owned qualification cleanup\n", 1)[1] + block = block.split(" - name:", 1)[0] + body = textwrap.dedent(block.split(" run: |\n", 1)[1]) + result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], + env={"PATH": directory + ":" + os.environ["PATH"], + "RESOURCE_SCOPE": "dw-parity-301-1-corpus", + "DW_PUBLISHED_COMPOSE_PROJECT": SCOPE}, + capture_output=True, timeout=5, check=False) + self.assertEqual(result.returncode, 1, role) + + +if __name__ == "__main__": + unittest.main() From a27f11f80ae2f98f8bca30a6500b8e5840290aab Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:34:44 +0000 Subject: [PATCH 03/15] Persist alias write progress before possible timeout --- scripts/ci/native-release-rolling-tags.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh index 75a4bc6cb..d2168e035 100755 --- a/scripts/ci/native-release-rolling-tags.sh +++ b/scripts/ci/native-release-rolling-tags.sh @@ -177,12 +177,16 @@ for image in "${images[@]}"; do args=() for alias in "${aliases[@]}"; do args+=(--tag "$image:$alias"); done attempted=$image + printf 'Native alias write attempted: %s at %s.\n' "$image" "$INDEX_DIGEST" + printf -- '- alias write attempted: `%s` at `%s`\n' "$image" "$INDEX_DIGEST" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" "$docker_bin" buildx imagetools create "${args[@]}" "$image@$INDEX_DIGEST" for alias in "${aliases[@]}"; do digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "post-write alias mismatch."; } done confirmed+=("$image") + printf 'Native alias write confirmed: %s at %s.\n' "$image" "$INDEX_DIGEST" + printf -- '- alias write confirmed: `%s` at `%s`\n' "$image" "$INDEX_DIGEST" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" done # Each moved alias names exactly the attested index. From 3bbc6602c5725ff36ab8718f7604f7e5fce80d10 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:36:42 +0000 Subject: [PATCH 04/15] Check persisted progress in partial alias failures --- scripts/ci/check-native-release-tags.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 48b8416ed..2b59b56e0 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -138,6 +138,8 @@ done if rolling_control FAIL_HUB_WRITE=true; then fail "second-registry failure must stop."; fi test "$(grep -c 'imagetools create' "$scratch/calls")" = 2 || fail "partial run must attempt exactly two forward writes." grep -q 'confirmed=ghcr.io/example/native; no rollback' "$scratch/summary" || fail "partial run lost confirmed registry record." +test "$(grep -c 'alias write attempted:' "$scratch/summary")" = 2 || fail "each attempt must be persisted before a failed or hanging write." +test "$(grep -c 'alias write confirmed:' "$scratch/summary")" = 1 || fail "only verified writes may be persisted as confirmed." if rolling_control POST_WRITE_MISMATCH=true; then fail "post-write mismatch must stop."; fi test "$(grep -c 'imagetools create' "$scratch/calls")" = 1 || fail "post-write mismatch must not touch registry two or roll back." grep -q 'no rollback' "$scratch/summary" || fail "uncertain run must record no rollback." From 86202dbb699a6e8f03e33f63ca1b6a7f26c63806 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:59:55 +0000 Subject: [PATCH 05/15] Refuse ambiguous registry absence and foreign cleanup resources --- .github/workflows/native-image-parity.yml | 8 +- .github/workflows/native-image.yml | 1 + .../workflows/native-promotion-contracts.yml | 1 + .github/workflows/published-compose.yml | 9 +- docs/native-release-promotion.md | 20 +++- scripts/ci/check-docker-workflow-isolation.py | 52 ++++++++++ scripts/ci/check-native-release-tags.sh | 51 ++++++++++ scripts/ci/native-registry-absence.sh | 9 ++ scripts/ci/native-release-github-release.sh | 6 +- scripts/ci/native-release-publish.sh | 5 +- scripts/ci/native-release-rolling-tags.sh | 3 +- scripts/ci/native_qualification.py | 8 +- .../docker_workflow_isolation_policy_test.py | 62 ++++++++++++ .../Unit/Support/native_qualification_test.py | 98 ++++++++++++++++--- 14 files changed, 288 insertions(+), 45 deletions(-) create mode 100644 scripts/ci/native-registry-absence.sh diff --git a/.github/workflows/native-image-parity.yml b/.github/workflows/native-image-parity.yml index 85832f25b..84d967f56 100644 --- a/.github/workflows/native-image-parity.yml +++ b/.github/workflows/native-image-parity.yml @@ -82,14 +82,8 @@ jobs: id: cleanup if: always() run: | - # Strict ownership/readback is the receipt authority. Preserve its - # failure even if the harmless partial-setup fallback can remove more. - cleanup_status=0 python3 scripts/ci/native_qualification.py cleanup --kind corpus \ - --scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json || cleanup_status=$? - docker rm -fv "$RESOURCE_SCOPE-rust" "$RESOURCE_SCOPE-server" "$RESOURCE_SCOPE-php-maintenance" >/dev/null 2>&1 || true - docker network rm "$RESOURCE_SCOPE" >/dev/null 2>&1 || true - test "$cleanup_status" = 0 + --scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json - name: Finalize promotion receipt if: always() diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml index da44f9eed..9a1bee384 100644 --- a/.github/workflows/native-image.yml +++ b/.github/workflows/native-image.yml @@ -12,6 +12,7 @@ on: - Dockerfile.native - Dockerfile.native.dockerignore - scripts/ci/native-image-smoke.sh + - scripts/ci/native-registry-absence.sh - scripts/ci/native-release-publish.sh - scripts/ci/native-release-rolling-tags.sh - scripts/ci/native-release-identity.sh diff --git a/.github/workflows/native-promotion-contracts.yml b/.github/workflows/native-promotion-contracts.yml index 286a633f5..c61550567 100644 --- a/.github/workflows/native-promotion-contracts.yml +++ b/.github/workflows/native-promotion-contracts.yml @@ -4,6 +4,7 @@ on: paths: - scripts/ci/native_alias_*.py - scripts/ci/native_qualification.py + - scripts/ci/native-registry-absence.sh - scripts/ci/native-release-*.sh - scripts/ci/check-native-release-tags.sh - scripts/ci/select-published-compose-image.sh diff --git a/.github/workflows/published-compose.yml b/.github/workflows/published-compose.yml index 4e9164022..a3878511f 100644 --- a/.github/workflows/published-compose.yml +++ b/.github/workflows/published-compose.yml @@ -81,15 +81,8 @@ jobs: id: cleanup if: always() run: | - # Only strict owned cleanup/readback can qualify the receipt. The - # partial-setup fallback never changes its saved failure into success. - cleanup_status=0 python3 scripts/ci/native_qualification.py cleanup --kind compose \ - --scope "$DW_PUBLISHED_COMPOSE_PROJECT" --output qualification-state/cleanup.json || cleanup_status=$? - docker ps -aq --filter "label=com.docker.compose.project=$DW_PUBLISHED_COMPOSE_PROJECT" | xargs -r docker rm -f >/dev/null 2>&1 || true - docker network ls -q --filter "label=com.docker.compose.project=$DW_PUBLISHED_COMPOSE_PROJECT" | xargs -r docker network rm >/dev/null 2>&1 || true - docker volume ls -q --filter "label=com.docker.compose.project=$DW_PUBLISHED_COMPOSE_PROJECT" | xargs -r docker volume rm -f >/dev/null 2>&1 || true - test "$cleanup_status" = 0 + --scope "$DW_PUBLISHED_COMPOSE_PROJECT" --output qualification-state/cleanup.json - name: Finalize promotion receipt if: always() diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md index 366f1643e..c6785ffbe 100644 --- a/docs/native-release-promotion.md +++ b/docs/native-release-promotion.md @@ -103,12 +103,16 @@ responses use private temporary files and bounded requests; failures print a static reason rather than authorization arguments or server logs. Cleanup retains the original body result. It removes only exact owned Compose -project resources or the corpus scope's labelled/named/attached containers, +project resources or containers carrying the exact corpus scope OWNER label, owned network and observed owned volumes, including labelled standalone helper -containers. Successful independent Docker inventory reads must then show zero +containers. Names and network attachment discover candidates but never authorize +removal. Every discovered corpus container must have the exact OWNER label +before any removal; a foreign collision stops with zero removals. Successful independent Docker inventory reads must then show zero owned containers, networks and volumes. API failure is unknown, never absence. -A harmless partial-setup fallback runs after the strict check while preserving -its exit status; fallback removal cannot repair a failed cleanup proof. +There is no raw by-name fallback. The isolation checker recognizes only this +closed always-run kind/scope invocation bound to job identity and reviewed +helper/transport/predicate source bytes. Unknown ownership/API state retains +any remaining resource for escalation and never broadens deletion. Only after successful body and cleanup steps does the existing job finalize its receipt, repeating provider job and immutable workflow ownership. An `always()` upload, failed/cancelled job or diagnostic receipt cannot satisfy promotion. @@ -119,7 +123,11 @@ Arm64 Compose retains its existing QEMU scope, without a native-hardware claim. The alias helper preserves Git-version selection and native/PHP latest arbitration. It reads current release labels and digests, then immediately before each registry write re-reads both exact tags and every selected alias. A changed -or unreadable state stops. Previously confirmed writes must still name the +or unreadable state stops. Absence is recognized only for the exact single-line +Buildx diagnostic `ERROR: : not found` for that requested tag. +Credential-helper, auth, transport, rate-limit, mixed/extra or unknown diagnostic +formats refuse. This is an explicit fail-closed client-format contract, not an +HTTP-status parser; a future client format requires separate source review. Previously confirmed writes must still name the qualified index. Each write is verified before proceeding to the other registry. Superseded aliases remain in place; a same-index recovery is idempotent. @@ -128,5 +136,7 @@ an external actor can race after the last read. A write failure, timeout or post-write mismatch stops with an explicit attempted/confirmed partial state; there is no automatic rollback. General owns any concrete retry decision with unchanged qualified evidence, including uncertain cross-registry outcomes. +Cloud retains its separate immutable-artifact/profile/installed-closure and +deployment gates; alias qualification does not grant deployment admission. No production execution, release dispatch or registry mutation is admitted by source tests or by merging these workflows. diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 9a79a2a15..2392972f8 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import re import shlex import sys @@ -11,6 +12,15 @@ from dataclasses import dataclass from pathlib import Path +# Closed cleanup model, not a path exemption: every module used by the owner +# 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": "bea634249dfd4bdf9f869f02ecc800a0294e6b4422ed576fc9d9176a8b7b0865", + "scripts/ci/native_alias_evidence.py": "52a565f81adb977e597ddaaa26cc1a9478be86d87b052b2ed53107eb88e18897", + "scripts/ci/native_alias_transport.py": "c1b210c785eff1e82e3e154c2f9c44a4d9214491525c3f681343cee44849ee85", +} + WORKFLOW_SCOPE_PARTS = ( "${{ github.run_id }}", @@ -897,9 +907,47 @@ def resource_aliases( return aliases +def qualification_cleanup_resources( + command: str, resolution_source: str, root: Path | None, +) -> tuple[set[OwnedResource], list[str]]: + """Recognize one exact, idempotent, owner-gated helper invocation.""" + try: + tokens = shlex.split(re.sub(r"^run:\s*", "", command)) + except ValueError: + return set(), [] + if tokens[:3] != ["python3", "scripts/ci/native_qualification.py", "cleanup"]: + return set(), [] + reason = "strict qualification cleanup requires reviewed bytes, closed argv and job-scoped scope" + if (root is None or len(tokens) != 9 or tokens[3] != "--kind" + or tokens[4] not in {"corpus", "compose"} or tokens[5] != "--scope" + or tokens[7:] != ["--output", "qualification-state/cleanup.json"] + or re.fullmatch(r"\$(?:[A-Z][A-Z0-9_]*|\{[A-Z][A-Z0-9_]*\})", tokens[6]) is None + or not value_is_job_scoped(resolution_source, tokens[6])): + return set(), [reason] + try: + for relative, expected in QUALIFICATION_CLEANUP_SOURCES.items(): + path = root / relative + # Symlinked code could import a different adjacent module closure. + if path.resolve() != root.resolve() / relative: + return set(), [reason] + with path.open("rb") as stream: + data = stream.read(131073) + if len(data) > 131072 or hashlib.sha256(data).hexdigest() != expected: + return set(), [reason] + except OSError: + return set(), [reason] + scope = tokens[6] + if tokens[4] == "compose": + return {OwnedResource("compose", scope)}, [] + return {OwnedResource("network", scope), *( + OwnedResource("container", scope + "-" + suffix) + for suffix in ("rust", "server", "php-maintenance"))}, [] + + def cleanup_resources( job_source: str, resolution_source: str, + root: Path | None = None, ) -> tuple[set[OwnedResource], list[str]]: """Return resources removed by tolerant always-steps and cleanup violations.""" @@ -936,6 +984,9 @@ def cleanup_resources( ] for _, command in commands: + modeled, errors = qualification_cleanup_resources(command, resolution_source, root) + resources.update(modeled) + violations.extend(errors) for match in COMPOSE_COMMAND.finditer(command): arguments = command[match.end() :] if not COMPOSE_DOWN_OPERATION.search(arguments): @@ -2427,6 +2478,7 @@ def validate(root: Path) -> list[str]: cleaned, cleanup_violations = cleanup_resources( job_source, resource_source, + root, ) violations.extend( f"{workflow} job {job_name}: {violation}" diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 2b59b56e0..19d9a3257 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -61,7 +61,15 @@ cat > "$scratch/docker" <<'FAKE' #!/usr/bin/env bash printf '%s\n' "$*" >> "$CALL_LOG" ref=${!#} +if [[ -n "${PUBLISH_LOOKUP_FAILURE:-}" && "$*" == *"imagetools inspect"* ]]; then + printf '%s\n' "$PUBLISH_LOOKUP_FAILURE" >&2; exit 73 +fi if [[ "$*" == *"imagetools inspect --format {{json .Image}}"* ]]; then + if [[ -n "${CURRENT_READ_FAILURE:-}" ]]; then + if [[ "$CURRENT_READ_FAILURE" == mixed-subject ]]; then printf 'ERROR: %s: not found\nerror getting credentials: helper not found\n' "$ref" >&2 + else printf '%s\n' "$CURRENT_READ_FAILURE" >&2; fi + exit 73 + fi if [[ "${RACE_REF:-}" == "$ref" ]]; then count=$(( $(cat "$RACE_COUNTER" 2>/dev/null || echo 0) + 1 )); echo "$count" > "$RACE_COUNTER" if ((count > 1)); then echo '{"config":{"Labels":{"dev.durable-workflow.release.tag":"3.9.0"}}}'; exit 0; fi @@ -153,6 +161,37 @@ live_state=$'ghcr.io/example/native:3.0 3.0.0\nexample/native:3.0 3.0.0\nghcr.io rolling_control VERIFY_ONLY=true ALIAS_STATE="$live_state" || fail "qualified same-index live state must pass." grep -q 'imagetools create' "$scratch/calls" && fail "same-index live-state verification wrote." +# Credential/transport/auth/rate failures are never evidence of alias or +# version-tag absence, even when their text contains the words "not found". +read_failures=( + 'error getting credentials: credential helper not found' + 'error getting credentials: executable file not found in $PATH' + 'HTTP 401 Unauthorized: authentication required' + 'insufficient_scope: authorization failed' + 'dial tcp: registry host not found' + 'TLS CA file not found' + 'HTTP 429 Too Many Requests' + 'manifest unknown' + 'ERROR: unrelated.invalid/server:latest: not found' + mixed-subject +) +for failure in "${read_failures[@]}"; do + if rolling_control CURRENT_READ_FAILURE="$failure"; then fail "failed alias lookup was accepted as absent."; fi + grep -q 'imagetools create' "$scratch/calls" && fail "failed alias lookup reached registry writes." + : > "$scratch/calls" + if env -i PATH="$scratch:$PATH" CALL_LOG="$scratch/calls" PUBLISH_LOOKUP_FAILURE="$failure" \ + "${images[@]}" RELEASE_TAG=3.0.0 PUBLISH_DOCKERHUB=true DIGEST_AMD64="$index" DIGEST_ARM64="$index" \ + bash "$publish" >/dev/null 2>&1; then fail "failed exact-tag lookup was accepted as absent."; fi + grep -q 'imagetools create' "$scratch/calls" && fail "failed exact-tag lookup reached publication writes." +done +source scripts/ci/native-registry-absence.sh +native_registry_subject_missing ghcr.io/example/native:latest 'ERROR: ghcr.io/example/native:latest: not found' \ + || fail "the exact subject-bound not-found format must be recognized." +for output in 'not found' 'ERROR: example/native:latest: not found' 'ERROR: ghcr.io/example/native:latest: manifest unknown' \ + $'ERROR: ghcr.io/example/native:latest: not found\ncredential helper not found'; do + if native_registry_subject_missing ghcr.io/example/native:latest "$output"; then fail "unbound/mixed absence diagnostic accepted."; fi +done + # The GitHub Release, from protected main (native-github-release.yml): it # verifies the tag, the run, both exact tags and the provenance, then marks # a final Latest only when the live latest alias names its index in both @@ -186,6 +225,11 @@ cat > "$scratch/release-docker" <<'FAKE' #!/usr/bin/env bash ref=${!#} if [[ "$ref" == *:latest ]]; then + if [[ -n "${LATEST_READ_FAILURE:-}" ]]; then + if [[ "$LATEST_READ_FAILURE" == mixed-subject ]]; then printf 'ERROR: %s: not found\ncredential helper not found\n' "$ref" >&2 + else printf '%s\n' "$LATEST_READ_FAILURE" >&2; fi + exit 73 + fi digest=$(awk -v r="${ref%:latest}" '$1 == r { print $2 }' <<<"${LATEST_STATE:-}") [[ -z "$digest" ]] && { echo "ERROR: $ref: not found" >&2; exit 1; } [[ "$digest" == unreadable ]] && { echo "ERROR: unauthorized" >&2; exit 1; } @@ -288,6 +332,13 @@ test ! -s "$scratch/gh_calls" || fail "Release was created despite qualification want_release "final missing qualification" "$(gh_release 3.0.2 "$ours" PROMOTION_EVIDENCE=)" "FAILED" want_release "candidate needs no alias qualification" "$(gh_release 3.1.0-rc.1 "$ours" PROMOTION_EVIDENCE= QUALIFICATION_STATUS=1)" "--prerelease --latest=false" +for failure in "${read_failures[@]}"; do + want_release "failed latest read remains unknown" "$(gh_release 3.0.2 "$ours" LATEST_READ_FAILURE="$failure")" "FAILED" + test ! -s "$scratch/gh_calls" || fail "failed latest lookup reached Release creation." +done +want_release "malformed successful latest digest" "$(gh_release 3.0.2 'ghcr.io/example/native not-a-digest')" "FAILED" +test ! -s "$scratch/gh_calls" || fail "malformed latest digest reached Release creation." + # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. identity=scripts/ci/native-release-identity.sh diff --git a/scripts/ci/native-registry-absence.sh b/scripts/ci/native-registry-absence.sh new file mode 100644 index 000000000..84936322b --- /dev/null +++ b/scripts/ci/native-registry-absence.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# The one recognized Buildx not-found diagnostic, for the exact requested tag. +# This is a fail-closed client-format contract, not a substring or HTTP parser. +# Unknown formats, multiple lines and all auth/helper/transport failures refuse. +native_registry_subject_missing() { + local expected_ref=$1 diagnostic=$2 + [[ ${#expected_ref} -le 512 && "$expected_ref" =~ ^[a-z0-9][a-z0-9./_-]*:[A-Za-z0-9_.-]+$ ]] || return 1 + [[ "$diagnostic" == "ERROR: $expected_ref: not found" ]] +} diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 7ddf8de32..9cd4c4608 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -23,6 +23,7 @@ # Env: RELEASE_TAG, RELEASE_RUN_ID, GITHUB_REPOSITORY, GHCR_IMAGE, # DOCKERHUB_IMAGE. GH and DOCKER override the gh and docker commands. set -euo pipefail +source scripts/ci/native-registry-absence.sh fail() { printf 'GitHub Release failed: %s\n' "$1" >&2; exit 1; } # Reuse the same read-only authority checks as protected alias promotion. source "$(dirname "${BASH_SOURCE[0]}")/native-release-verify-publication.sh" @@ -39,9 +40,10 @@ fi latest_digest() { local output if ! output=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1:latest" 2>&1); then - grep -qiE 'not found|manifest unknown|name unknown' <<<"$output" && return 0 - fail "cannot read the current $1:latest: $(head -c 300 <<<"$output")" + native_registry_subject_missing "$1:latest" "$output" && return 0 + fail "cannot establish the current latest alias." fi + [[ "$output" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "latest alias returned no valid digest." printf '%s\n' "$output" } diff --git a/scripts/ci/native-release-publish.sh b/scripts/ci/native-release-publish.sh index c885000e0..62867bd0a 100755 --- a/scripts/ci/native-release-publish.sh +++ b/scripts/ci/native-release-publish.sh @@ -10,6 +10,7 @@ # DIGEST_ARM64 from the build jobs, which push by digest and fail unless that # exact digest passed its smoke test. Writes digest= to $GITHUB_OUTPUT. set -euo pipefail +source scripts/ci/native-registry-absence.sh fail() { printf 'Native release publication failed: %s\n' "$1" >&2; exit 1; } # A plain tag is native only from major 3, above every PHP release; native- # names the tags published before plain ones. @@ -41,8 +42,8 @@ for image in "${registries[@]}"; do if lookup=$(docker buildx imagetools inspect "$image:$image_tag" 2>&1); then fail "$image:$image_tag already exists." fi - grep -qiE 'not found|manifest unknown|name unknown' <<<"$lookup" \ - || fail "could not confirm that $image:$image_tag is absent: $(head -c 300 <<<"$lookup")" + native_registry_subject_missing "$image:$image_tag" "$lookup" \ + || fail "could not establish exact version-tag absence." done for image in "${registries[@]}"; do diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh index d2168e035..c5f63801e 100755 --- a/scripts/ci/native-release-rolling-tags.sh +++ b/scripts/ci/native-release-rolling-tags.sh @@ -23,6 +23,7 @@ # (one per line) or RELEASE_IMAGE_TAG_SOURCE_URL (git ls-remote). DOCKER # overrides the docker command. set -eEuo pipefail +source scripts/ci/native-registry-absence.sh fail() { printf 'Native rolling tags failed: %s\n' "$1" >&2; exit 1; } docker_bin=${DOCKER:-docker} @@ -84,7 +85,7 @@ fi current_release() { local ref=$1 output label if ! output=$("$docker_bin" buildx imagetools inspect --format '{{json .Image}}' "$ref" 2>&1); then - grep -qiE 'not found|manifest unknown|name unknown' <<<"$output" && return 0 + native_registry_subject_missing "$ref" "$output" && return 0 fail "cannot read a current alias." fi label=$(jq -r 'if has("linux/amd64") then .["linux/amd64"] else . end diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py index ce3491e1c..183ba7673 100644 --- a/scripts/ci/native_qualification.py +++ b/scripts/ci/native_qualification.py @@ -119,10 +119,10 @@ def owned_inventory(commands: Commands, role: str, scope: str) -> tuple[set[str] require(type(labels) is dict and labels.get("com.docker.compose.project") == scope, "cleanup_container_owner") else: - attached = data.get("NetworkSettings", {}).get("Networks", {}) - labels = data["Config"].get("Labels") or {} - require(data.get("Name", "").startswith("/" + scope + "-") or scope in attached - or (type(labels) is dict and labels.get(OWNER) == scope), + # Prefixes and network membership discover candidates, never grant + # removal authority. Validate every candidate before the first rm. + labels = data["Config"].get("Labels") + require(type(labels) is dict and labels.get(OWNER) == scope, "cleanup_container_owner") for mount in data["Mounts"]: if type(mount) is dict and mount.get("Type") == "volume": diff --git a/tests/Unit/Support/docker_workflow_isolation_policy_test.py b/tests/Unit/Support/docker_workflow_isolation_policy_test.py index 53941bd4c..49e09935d 100644 --- a/tests/Unit/Support/docker_workflow_isolation_policy_test.py +++ b/tests/Unit/Support/docker_workflow_isolation_policy_test.py @@ -12,6 +12,68 @@ class DockerWorkflowIsolationPolicyTest(unittest.TestCase): + def qualification_cleanup_sources(self) -> dict[str, str]: + return {relative: (ROOT / relative).read_text() for relative in ( + "scripts/ci/native_qualification.py", "scripts/ci/native_alias_evidence.py", + "scripts/ci/native_alias_transport.py")} + + def qualification_workflow(self, invocation: str, always: bool = True) -> str: + condition = "if: always()" if always else "if: success()" + return """ + jobs: + corpus: + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + steps: + - run: docker network create "$RESOURCE_SCOPE" + - run: docker run -d --name "$RESOURCE_SCOPE-rust" image + - CONDITION + run: INVOCATION + """.replace("CONDITION", condition).replace("INVOCATION", invocation) + + def test_accepts_exact_reviewed_strict_qualification_cleanup(self) -> None: + invocation = ('python3 scripts/ci/native_qualification.py cleanup --kind corpus ' + '--scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json') + result = self.run_policy(self.qualification_workflow(invocation), self.qualification_cleanup_sources()) + self.assertEqual(0, result.returncode, result.stderr) + + def test_strict_qualification_cleanup_refuses_helper_and_import_byte_changes(self) -> None: + invocation = ('python3 scripts/ci/native_qualification.py cleanup --kind corpus ' + '--scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json') + for changed in self.qualification_cleanup_sources(): + sources = self.qualification_cleanup_sources() + sources[changed] += "\n# changed source is not the reviewed closure\n" + result = self.run_policy(self.qualification_workflow(invocation), sources) + self.assertNotEqual(0, result.returncode, changed) + self.assertIn("strict qualification cleanup requires reviewed bytes", result.stderr) + + def test_strict_qualification_cleanup_refuses_open_argv_scope_or_non_always(self) -> None: + invocation = ('python3 scripts/ci/native_qualification.py cleanup --kind corpus ' + '--scope "$RESOURCE_SCOPE" --output qualification-state/cleanup.json') + for invalid in (invocation + " --extra value", invocation.replace("corpus", "unknown"), + invocation.replace('"$RESOURCE_SCOPE"', "shared-name"), + invocation.replace("qualification-state/cleanup.json", "another.json"), + invocation.replace("cleanup --kind", "observe-image --kind")): + result = self.run_policy(self.qualification_workflow(invalid), self.qualification_cleanup_sources()) + self.assertNotEqual(0, result.returncode, invalid) + result = self.run_policy(self.qualification_workflow(invocation, always=False), self.qualification_cleanup_sources()) + self.assertNotEqual(0, result.returncode) + + def test_accepts_closed_strict_compose_cleanup_with_job_scoped_project(self) -> None: + workflow = """ + jobs: + smoke: + env: + PROJECT: compose-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + SERVER_PORT: "0" + steps: + - run: docker compose -p "$PROJECT" up -d + - if: always() + run: python3 scripts/ci/native_qualification.py cleanup --kind compose --scope "$PROJECT" --output qualification-state/cleanup.json + """ + result = self.run_policy(workflow, self.qualification_cleanup_sources()) + self.assertEqual(0, result.returncode, result.stderr) + def run_policy( self, workflow: str, scripts: dict[str, str] | None = None ) -> subprocess.CompletedProcess[str]: diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index a59db5acf..7bcbd5fe7 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re +import shutil import subprocess import sys import tempfile @@ -38,6 +39,8 @@ def __init__(self): self.containers, self.networks, self.volumes = {CID}, {NID}, {VOLUME} self.removed, self.reads = [], [] self.owner = SCOPE + self.corpus_owner = SCOPE + self.container_name = "/" + SCOPE + "-server" self.volume_owner = None self.fail_list = self.leak = False @@ -51,13 +54,17 @@ def run(self, argv, limit=E.JSON_LIMIT, env=None): ("\n" if getattr(self, resource + "s") else "")).encode() if operation == "inspect": if resource == "container": - value = {"Id": CID, "Name": "/" + SCOPE + "-server", "Image": CONFIG, - "Config": {"Labels": {"com.docker.compose.project": self.owner}}, + labels = {"com.docker.compose.project": self.owner} + if self.corpus_owner is not None: + labels[Q.OWNER] = self.corpus_owner + value = {"Id": CID, "Name": self.container_name, "Image": CONFIG, + "Config": {"Labels": labels}, + "NetworkSettings": {"Networks": {SCOPE: {}}}, "Mounts": [{"Type": "volume", "Name": VOLUME}]} elif resource == "volume": value = {"Name": VOLUME, "Labels": self.volume_owner} elif resource == "network": - value = {"Id": NID, "Name": SCOPE, "Containers": {CID: {}}} + value = {"Id": NID, "Name": SCOPE, "Containers": {CID: {}}, "Labels": {Q.OWNER: SCOPE}} else: value = {"Id": CONFIG, "Architecture": "amd64", "Os": "linux", "Config": {"Labels": {"org.opencontainers.image.revision": SOURCE, @@ -116,10 +123,37 @@ def test_cleanup_unavailable_is_unknown_and_foreign_resources_are_not_removed(se self.assertEqual(commands.removed, []) def test_cleanup_remaining_resource_is_failure(self): + for role in ("corpus", "compose"): + commands = ResourceCommands() + commands.leak = True + with self.subTest(role=role), self.assertRaisesRegex(E.Refusal, "cleanup_not_absent"): + Q.cleanup(commands, role, SCOPE) + + def test_corpus_unavailable_inventory_is_unknown_with_zero_removals(self): commands = ResourceCommands() - commands.leak = True - with self.assertRaisesRegex(E.Refusal, "cleanup_not_absent"): - Q.cleanup(commands, "compose", SCOPE) + commands.fail_list = True + with self.assertRaisesRegex(E.Refusal, "fake_api_unavailable"): + Q.cleanup(commands, "corpus", SCOPE) + self.assertEqual(commands.removed, []) + + def test_corpus_prefix_attachment_and_missing_label_never_authorize_removal(self): + for name, owner in (("/foreign-owner", "foreign"), ("/" + SCOPE + "-rust", "foreign"), + ("/" + SCOPE + "-rust", None)): + commands = ResourceCommands() + commands.container_name, commands.corpus_owner = name, owner + with self.subTest(name=name, owner=owner), self.assertRaisesRegex(E.Refusal, "cleanup_container_owner"): + Q.cleanup(commands, "corpus", SCOPE) + self.assertEqual(commands.removed, []) + + def test_corpus_labelled_attached_or_standalone_helpers_are_owned(self): + for attached in (True, False): + commands = ResourceCommands() + commands.container_name = "/anonymous-helper" + if not attached: + commands.networks.clear() + result = Q.cleanup(commands, "corpus", SCOPE) + self.assertIs(result["verified_absent"], True) + self.assertIn(("container", {CID}), commands.removed) def test_diagnostic_context_does_not_query_provider_or_docker(self): provider = ProducerProvider() @@ -297,26 +331,58 @@ def test_receipt_authority_follows_body_and_cleanup_in_existing_jobs(self): self.assertIn("promotion-receipt/receipt.json", source) self.assertIn("if-no-files-found: error", source) - def test_partial_setup_fallback_cannot_turn_strict_cleanup_failure_into_success(self): - # Execute the actual workflow run block with inert CLI fakes, rather than - # accepting a source string that merely says cleanup was successful. + def test_workflow_strict_cleanup_failure_has_no_fallback_removal(self): + # Execute the actual workflow block AND helper against an inert Docker + # provider that presents a foreign exact-name/attached collision. Both + # helper broadening and a raw workflow fallback would produce removals. with tempfile.TemporaryDirectory() as directory: scratch = Path(directory) - for name, status in (("python3", 71), ("docker", 0), ("xargs", 0)): - target = scratch / name - target.write_text("#!/bin/sh\nexit " + str(status) + "\n") - target.chmod(0o755) + scripts = scratch / "scripts/ci" + scripts.mkdir(parents=True) + for name in ("native_qualification.py", "native_alias_evidence.py", "native_alias_transport.py"): + shutil.copyfile(ROOT / "scripts/ci" / name, scripts / name) + docker = scratch / "docker" + docker.write_text("#!" + sys.executable + "\n" + textwrap.dedent(''' + import json, os, sys + from pathlib import Path + args = sys.argv[1:] + with Path(os.environ["FAKE_LEDGER"]).open("a") as log: + log.write(json.dumps(args) + "\\n") + cid, nid, scope = "d" * 64, "e" * 64, os.environ["RESOURCE_SCOPE"] + resource, operation = args[:2] + if operation == "ls": + print(cid if resource == "container" else nid if resource == "network" else "") + elif operation == "inspect": + value = ({"Id": nid, "Name": scope, "Labels": {"dev.durable-workflow.qualification.scope": scope}, + "Containers": {cid: {}}} if resource == "network" else + {"Id": cid, "Name": "/" + scope + "-rust", "Mounts": [], + "Config": {"Labels": {"owner": "foreign", "com.docker.compose.project": "foreign"}}, + "NetworkSettings": {"Networks": {scope: {}}}}) + print(json.dumps([value])) + elif operation == "rm" or resource == "rm": + pass + else: + raise SystemExit(71) + ''')) + docker.chmod(0o755) for role in ("corpus", "compose"): source = (ROOT / E.WORKFLOWS[role]).read_text() block = source.split(" - name: Verify owned qualification cleanup\n", 1)[1] block = block.split(" - name:", 1)[0] body = textwrap.dedent(block.split(" run: |\n", 1)[1]) + ledger = scratch / (role + "-ledger") result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", body], env={"PATH": directory + ":" + os.environ["PATH"], - "RESOURCE_SCOPE": "dw-parity-301-1-corpus", - "DW_PUBLISHED_COMPOSE_PROJECT": SCOPE}, + "RESOURCE_SCOPE": SCOPE, "DW_PUBLISHED_COMPOSE_PROJECT": SCOPE, + "FAKE_LEDGER": str(ledger)}, cwd=scratch, capture_output=True, timeout=5, check=False) - self.assertEqual(result.returncode, 1, role) + self.assertNotEqual(result.returncode, 0, role) + calls = [json.loads(line) for line in ledger.read_text().splitlines()] + self.assertTrue(calls) + self.assertFalse(any("rm" in call for call in calls), calls) + self.assertFalse((scratch / "qualification-state/cleanup.json").exists()) + self.assertNotIn("docker ", body) + self.assertNotIn("||", body) if __name__ == "__main__": From 378bdcffe6575137a1e5028397ceb0c8e6dfc7ce Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:01:30 +0000 Subject: [PATCH 06/15] Require unconditional always context for strict cleanup model --- scripts/ci/check-docker-workflow-isolation.py | 6 ++++++ tests/Unit/Support/docker_workflow_isolation_policy_test.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 2392972f8..85e97a804 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -982,9 +982,15 @@ def cleanup_resources( for line_number, command in commands for scan_command in resource_command_variants(command) ] + unconditional_always = any(re.fullmatch( + r"\s+(?:-\s+)?if:\s*(?:always\(\)|\$\{\{\s*always\(\)\s*\}\})\s*", line) + for line in step.splitlines()) for _, command in commands: modeled, errors = qualification_cleanup_resources(command, resolution_source, root) + if modeled and not unconditional_always: + modeled = set() + errors.append("strict qualification cleanup requires an unconditional always() step") resources.update(modeled) violations.extend(errors) for match in COMPOSE_COMMAND.finditer(command): diff --git a/tests/Unit/Support/docker_workflow_isolation_policy_test.py b/tests/Unit/Support/docker_workflow_isolation_policy_test.py index 49e09935d..3d3db9455 100644 --- a/tests/Unit/Support/docker_workflow_isolation_policy_test.py +++ b/tests/Unit/Support/docker_workflow_isolation_policy_test.py @@ -58,6 +58,10 @@ def test_strict_qualification_cleanup_refuses_open_argv_scope_or_non_always(self self.assertNotEqual(0, result.returncode, invalid) result = self.run_policy(self.qualification_workflow(invocation, always=False), self.qualification_cleanup_sources()) self.assertNotEqual(0, result.returncode) + conditional = self.qualification_workflow(invocation).replace("if: always()", "if: always() && false") + result = self.run_policy(conditional, self.qualification_cleanup_sources()) + self.assertNotEqual(0, result.returncode) + self.assertIn("unconditional always()", result.stderr) def test_accepts_closed_strict_compose_cleanup_with_job_scoped_project(self) -> None: workflow = """ From 3882eed1befe4435ca621e4d0de12b64c65766bc Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:26:01 +0000 Subject: [PATCH 07/15] Fix promotion dispatch trust and constrain qualification CLI inputs --- .github/workflows/native-alias-promotion.yml | 47 +++-- docs/native-release-promotion.md | 8 +- scripts/ci/check-docker-workflow-isolation.py | 6 +- scripts/ci/check-native-release-tags.sh | 128 +++++++------- scripts/ci/native-release-github-release.sh | 6 +- scripts/ci/native-release-rolling-tags.sh | 17 +- .../ci/native-release-verify-publication.sh | 3 +- scripts/ci/native_alias_evidence.py | 20 ++- scripts/ci/native_alias_promote.py | 17 +- scripts/ci/native_alias_transport.py | 11 +- scripts/ci/native_qualification.py | 167 +++++++++++------- scripts/smoke-published-compose.sh | 2 +- tests/Unit/Support/composer-sinks.json | 2 +- .../Unit/Support/native_qualification_test.py | 76 +++++++- 14 files changed, 333 insertions(+), 177 deletions(-) diff --git a/.github/workflows/native-alias-promotion.yml b/.github/workflows/native-alias-promotion.yml index 22b66f375..5e29be68e 100644 --- a/.github/workflows/native-alias-promotion.yml +++ b/.github/workflows/native-alias-promotion.yml @@ -6,22 +6,48 @@ run-name: Native alias promotion ${{ github.ref_name }} on: workflow_dispatch: inputs: + tag: + description: 'Exact final native tag used as this dispatch ref and by the evidence manifest' + required: true + type: string evidence: description: 'Closed native-alias-promotion-evidence.v1 JSON manifest with exact current run attempts and artifact IDs/digests' required: true type: string -permissions: - contents: read - actions: read - attestations: read - packages: read +permissions: {} jobs: + plan: + name: Validate exact promotion dispatch + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out dispatch commit + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Refuse a wrong ref or source before privileged jobs + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_EVIDENCE: ${{ inputs.evidence }} + PROMOTION_TAG: ${{ inputs.tag }} + run: python3 scripts/ci/native_alias_promote.py plan + verify: name: Verify existing native qualification + needs: plan + if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + actions: read + attestations: read + packages: read steps: - name: Check out exact tag tooling uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -30,21 +56,17 @@ jobs: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - - name: Read GHCR with the repository token - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - name: Verify provider-owned qualification without writes env: GH_TOKEN: ${{ github.token }} PROMOTION_EVIDENCE: ${{ inputs.evidence }} + PROMOTION_TAG: ${{ inputs.tag }} run: python3 scripts/ci/native_alias_promote.py verify promote: name: Promote qualified native aliases - needs: verify + needs: [plan, verify] + if: github.ref == format('refs/tags/{0}', inputs.tag) runs-on: ubuntu-latest timeout-minutes: 15 environment: dockerhub-native @@ -79,4 +101,5 @@ jobs: env: GH_TOKEN: ${{ github.token }} PROMOTION_EVIDENCE: ${{ inputs.evidence }} + PROMOTION_TAG: ${{ inputs.tag }} run: python3 scripts/ci/native_alias_promote.py promote diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md index c6785ffbe..8930fb8c6 100644 --- a/docs/native-release-promotion.md +++ b/docs/native-release-promotion.md @@ -18,8 +18,9 @@ moves `X.Y`, `X` or `latest`. A final release follows this order: 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 with that data-only selection manifest. -4. The read-only job verifies the selection. After environment approval, the + exact release tag, a matching `tag` input and that data-only selection 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 `durable-workflow-server-rolling-aliases`, then calls the existing monotonic alias helper. Only this job receives packages-write and existing Docker Hub @@ -109,6 +110,9 @@ containers. Names and network attachment discover candidates but never authorize removal. Every discovered corpus container must have the exact OWNER label before any removal; a foreign collision stops with zero removals. Successful independent Docker inventory reads must then show zero owned containers, networks and volumes. API failure is unknown, never absence. +An anonymous volume must be mounted by an inspected owned container and have +either no labels or only the exact Engine anonymous marker. Foreign labels +refuse. Cleanup logs only a bounded static reason code when refusing. There is no raw by-name fallback. The isolation checker recognizes only this closed always-run kind/scope invocation bound to job identity and reviewed helper/transport/predicate source bytes. Unknown ownership/API state retains diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 85e97a804..d10449add 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": "bea634249dfd4bdf9f869f02ecc800a0294e6b4422ed576fc9d9176a8b7b0865", - "scripts/ci/native_alias_evidence.py": "52a565f81adb977e597ddaaa26cc1a9478be86d87b052b2ed53107eb88e18897", - "scripts/ci/native_alias_transport.py": "c1b210c785eff1e82e3e154c2f9c44a4d9214491525c3f681343cee44849ee85", + "scripts/ci/native_qualification.py": "1103b4c8bd0e5779f1ee5580cd2500308d990e91ccfc6266cc7b393851b3f48b", + "scripts/ci/native_alias_evidence.py": "647d184c7a5e456410933a7188debc3fe23360d002ef6458ced742bb85f32c37", + "scripts/ci/native_alias_transport.py": "81ad7f58625774c818297019d0f220e97dbae271b27805d1e24f4842378365d0", } diff --git a/scripts/ci/check-native-release-tags.sh b/scripts/ci/check-native-release-tags.sh index 19d9a3257..9527d1cce 100755 --- a/scripts/ci/check-native-release-tags.sh +++ b/scripts/ci/check-native-release-tags.sh @@ -8,6 +8,8 @@ # candidate (-rc.N) skip Docker Hub. # Each script is stopped at its input checks, before any API or registry call. set -euo pipefail +write_call="imagetools create" +failed="FAILED" fail() { printf 'Native release tag check failed: %s\n' "$1" >&2; exit 1; } # The retired PHP Release workflow (server#1002): no push trigger at all, so @@ -125,10 +127,10 @@ grep -q -- '--tag ghcr.io/example/native:3 \|--tag ghcr.io/example/native:latest && fail "an older release moved 3 or latest backward." # Exact tags that do not name the attested index, or an alias whose release # cannot be read, stop the run before any alias write. -want "exact tag mismatch" "$(rolling $'2.11.8' native-3.0.0 '' "sha256:$(printf '%064d' 0 | tr 0 b)")" "FAILED" -grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written after an exact-tag mismatch." -want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "FAILED" -grep -q 'imagetools create' "$scratch/calls" && fail "aliases were written though an alias could not be read." +want "exact tag mismatch" "$(rolling $'2.11.8' native-3.0.0 '' "sha256:$(printf '%064d' 0 | tr 0 b)")" "$failed" +grep -q "$write_call" "$scratch/calls" && fail "aliases were written after an exact-tag mismatch." +want "unreadable alias" "$(rolling $'2.11.8' native-3.0.0 'example/native:latest unreadable')" "$failed" +grep -q "$write_call" "$scratch/calls" && fail "aliases were written though an alias could not be read." expect "INDEX_DIGEST must be the verified index digest" RELEASE_TAG=native-3.0.0 "${images[@]}" RELEASE_IMAGE_KNOWN_TAGS=x bash scripts/ci/native-release-rolling-tags.sh # Detectable changes before the first write leave zero writes. A second # registry failure or post-write mismatch records partial/uncertain state and @@ -141,25 +143,25 @@ rolling_control() { } for race in 'RACE_REF=ghcr.io/example/native:3.0' RACE_EXACT=true; do if rolling_control "$race" ALIAS_STATE='ghcr.io/example/native:3.0 3.0.0'; then fail "changed pre-write state must refuse."; fi - grep -q 'imagetools create' "$scratch/calls" && fail "pre-write race wrote aliases." + grep -q "$write_call" "$scratch/calls" && fail "pre-write race wrote aliases." done if rolling_control FAIL_HUB_WRITE=true; then fail "second-registry failure must stop."; fi -test "$(grep -c 'imagetools create' "$scratch/calls")" = 2 || fail "partial run must attempt exactly two forward writes." +[[ "$(grep -c "$write_call" "$scratch/calls")" = 2 ]] || fail "partial run must attempt exactly two forward writes." grep -q 'confirmed=ghcr.io/example/native; no rollback' "$scratch/summary" || fail "partial run lost confirmed registry record." -test "$(grep -c 'alias write attempted:' "$scratch/summary")" = 2 || fail "each attempt must be persisted before a failed or hanging write." -test "$(grep -c 'alias write confirmed:' "$scratch/summary")" = 1 || fail "only verified writes may be persisted as confirmed." +[[ "$(grep -c 'alias write attempted:' "$scratch/summary")" = 2 ]] || fail "each attempt must be persisted before a failed or hanging write." +[[ "$(grep -c 'alias write confirmed:' "$scratch/summary")" = 1 ]] || fail "only verified writes may be persisted as confirmed." if rolling_control POST_WRITE_MISMATCH=true; then fail "post-write mismatch must stop."; fi -test "$(grep -c 'imagetools create' "$scratch/calls")" = 1 || fail "post-write mismatch must not touch registry two or roll back." +[[ "$(grep -c "$write_call" "$scratch/calls")" = 1 ]] || fail "post-write mismatch must not touch registry two or roll back." grep -q 'no rollback' "$scratch/summary" || fail "uncertain run must record no rollback." # Final Release ordering cannot run the writer. Exact live aliases pass; # missing/older aliases refuse; uniformly newer final aliases may supersede. for state in "" 'ghcr.io/example/native:3.0 2.11.8'; do if rolling_control VERIFY_ONLY=true ALIAS_STATE="$state"; then fail "unpromoted live state must refuse."; fi - grep -q 'imagetools create' "$scratch/calls" && fail "live-state verification mutated aliases." + grep -q "$write_call" "$scratch/calls" && fail "live-state verification mutated aliases." done live_state=$'ghcr.io/example/native:3.0 3.0.0\nexample/native:3.0 3.0.0\nghcr.io/example/native:3 3.0.0\nexample/native:3 3.0.0\nghcr.io/example/native:latest 3.0.0\nexample/native:latest 3.0.0' rolling_control VERIFY_ONLY=true ALIAS_STATE="$live_state" || fail "qualified same-index live state must pass." -grep -q 'imagetools create' "$scratch/calls" && fail "same-index live-state verification wrote." +grep -q "$write_call" "$scratch/calls" && fail "same-index live-state verification wrote." # Credential/transport/auth/rate failures are never evidence of alias or # version-tag absence, even when their text contains the words "not found". @@ -177,12 +179,12 @@ read_failures=( ) for failure in "${read_failures[@]}"; do if rolling_control CURRENT_READ_FAILURE="$failure"; then fail "failed alias lookup was accepted as absent."; fi - grep -q 'imagetools create' "$scratch/calls" && fail "failed alias lookup reached registry writes." + grep -q "$write_call" "$scratch/calls" && fail "failed alias lookup reached registry writes." : > "$scratch/calls" if env -i PATH="$scratch:$PATH" CALL_LOG="$scratch/calls" PUBLISH_LOOKUP_FAILURE="$failure" \ "${images[@]}" RELEASE_TAG=3.0.0 PUBLISH_DOCKERHUB=true DIGEST_AMD64="$index" DIGEST_ARM64="$index" \ bash "$publish" >/dev/null 2>&1; then fail "failed exact-tag lookup was accepted as absent."; fi - grep -q 'imagetools create' "$scratch/calls" && fail "failed exact-tag lookup reached publication writes." + grep -q "$write_call" "$scratch/calls" && fail "failed exact-tag lookup reached publication writes." done source scripts/ci/native-registry-absence.sh native_registry_subject_missing ghcr.io/example/native:latest 'ERROR: ghcr.io/example/native:latest: not found' \ @@ -297,58 +299,58 @@ existing() { # tag, title, draft, prerelease, notes want_release "rerun of the same final, Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "none" want_release "rerun of the same candidate" "$(gh_release 3.1.0-rc.1 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0-rc.1 3.1.0-rc.1 false true "$notes_candidate")")" "none" want_release "rerun of the same backport, not Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.1.0 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "none" -want_release "existing draft" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 true false "$notes_final")")" "FAILED" -want_release "existing release with other notes" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final extra")")" "FAILED" -want_release "existing notes naming everything but ambiguous" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$index $commit https://github.com/example/server/actions/runs/41")")" "FAILED" -want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 native-3.1.0 false false "$notes_final")")" "FAILED" -want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false true "$notes_final")")" "FAILED" -want_release "existing final the alias owns but not Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" -want_release "existing backport marked Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "FAILED" +want_release "existing draft" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 true false "$notes_final")")" "$failed" +want_release "existing release with other notes" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final extra")")" "$failed" +want_release "existing notes naming everything but ambiguous" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$index $commit https://github.com/example/server/actions/runs/41")")" "$failed" +want_release "existing release with another title" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 native-3.1.0 false false "$notes_final")")" "$failed" +want_release "existing final marked prerelease" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false true "$notes_final")")" "$failed" +want_release "existing final the alias owns but not Latest" "$(gh_release 3.1.0 "$ours" LATEST_TAG=3.0.2 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" +want_release "existing backport marked Latest" "$(gh_release 3.0.2 "$theirs" LATEST_TAG=3.0.2 EXISTING="$(existing 3.0.2 3.0.2 false false "$notes_backport")")" "$failed" # The bindings come first: an existing Release does not excuse a failed one. -want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" -want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "FAILED" +want_release "existing release, unverifiable provenance" "$(gh_release 3.1.0 "$ours" ATTEST=fail LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" +want_release "existing release, commit off main" "$(gh_release 3.1.0 "$ours" COMPARE=diverged LATEST_TAG=3.1.0 EXISTING="$(existing 3.1.0 3.1.0 false false "$notes_final")")" "$failed" # Every binding fails closed, before any write. -want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "FAILED" -want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "FAILED" -want_release "no run id" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ID=)" "FAILED" -want_release "tag commit not on main" "$(gh_release 3.0.2 "$ours" COMPARE=diverged)" "FAILED" -want_release "tag names a tree" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tree $commit")" "FAILED" -want_release "run failed" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed failure 3.0.2 $commit")" "FAILED" -want_release "run of another workflow" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/release.yml workflow_dispatch completed success 3.0.2 $commit")" "FAILED" -want_release "run from another ref" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success main $commit")" "FAILED" -want_release "run at another commit" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success 3.0.2 fedcba9876543210fedcba9876543210fedcba98")" "FAILED" -want_release "registries name different indexes" "$(gh_release 3.0.2 "$ours" HUB_EXACT="$newer_index")" "FAILED" -want_release "provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST=fail)" "FAILED" -want_release "Docker Hub provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST_FAIL_IMAGE="oci://example/native@$index")" "FAILED" -test ! -s "$scratch/gh_calls" || fail "Release was created after Docker Hub provenance failure." -want_release "provenance from another run" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/42/attempts/1)" "FAILED" -want_release "provenance from a run id prefix" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/411/attempts/1)" "FAILED" +want_release "unreadable latest alias" "$(gh_release 3.0.2 $'ghcr.io/example/native unreadable\nexample/native '"$index")" "$failed" +want_release "a PHP tag" "$(gh_release 2.11.9 "$ours")" "$failed" +want_release "no run id" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ID=)" "$failed" +want_release "tag commit not on main" "$(gh_release 3.0.2 "$ours" COMPARE=diverged)" "$failed" +want_release "tag names a tree" "$(gh_release 3.0.2 "$ours" TAG_OBJECT="tree $commit")" "$failed" +want_release "run failed" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed failure 3.0.2 $commit")" "$failed" +want_release "run of another workflow" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/release.yml workflow_dispatch completed success 3.0.2 $commit")" "$failed" +want_release "run from another ref" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success main $commit")" "$failed" +want_release "run at another commit" "$(gh_release 3.0.2 "$ours" RUN_LINE=".github/workflows/native-release.yml workflow_dispatch completed success 3.0.2 fedcba9876543210fedcba9876543210fedcba98")" "$failed" +want_release "registries name different indexes" "$(gh_release 3.0.2 "$ours" HUB_EXACT="$newer_index")" "$failed" +want_release "provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST=fail)" "$failed" +want_release "Docker Hub provenance does not verify" "$(gh_release 3.0.2 "$ours" ATTEST_FAIL_IMAGE="oci://example/native@$index")" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "Release was created after Docker Hub provenance failure." +want_release "provenance from another run" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/42/attempts/1)" "$failed" +want_release "provenance from a run id prefix" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/411/attempts/1)" "$failed" -want_release "missing exact publication attempt" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=)" "FAILED" -want_release "publication current attempt changed" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=2)" "FAILED" -want_release "provenance from another attempt" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/41/attempts/2)" "FAILED" -want_release "qualification refusal" "$(gh_release 3.0.2 "$ours" QUALIFICATION_STATUS=1)" "FAILED" -test ! -s "$scratch/gh_calls" || fail "Release was created despite qualification refusal." -want_release "final missing qualification" "$(gh_release 3.0.2 "$ours" PROMOTION_EVIDENCE=)" "FAILED" +want_release "missing exact publication attempt" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=)" "$failed" +want_release "publication current attempt changed" "$(gh_release 3.0.2 "$ours" RELEASE_RUN_ATTEMPT=2)" "$failed" +want_release "provenance from another attempt" "$(gh_release 3.0.2 "$ours" ATTEST_URI=https://github.com/example/server/actions/runs/41/attempts/2)" "$failed" +want_release "qualification refusal" "$(gh_release 3.0.2 "$ours" QUALIFICATION_STATUS=1)" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "Release was created despite qualification refusal." +want_release "final missing qualification" "$(gh_release 3.0.2 "$ours" PROMOTION_EVIDENCE=)" "$failed" want_release "candidate needs no alias qualification" "$(gh_release 3.1.0-rc.1 "$ours" PROMOTION_EVIDENCE= QUALIFICATION_STATUS=1)" "--prerelease --latest=false" for failure in "${read_failures[@]}"; do - want_release "failed latest read remains unknown" "$(gh_release 3.0.2 "$ours" LATEST_READ_FAILURE="$failure")" "FAILED" - test ! -s "$scratch/gh_calls" || fail "failed latest lookup reached Release creation." + want_release "failed latest read remains unknown" "$(gh_release 3.0.2 "$ours" LATEST_READ_FAILURE="$failure")" "$failed" + [[ ! -s "$scratch/gh_calls" ]] || fail "failed latest lookup reached Release creation." done -want_release "malformed successful latest digest" "$(gh_release 3.0.2 'ghcr.io/example/native not-a-digest')" "FAILED" -test ! -s "$scratch/gh_calls" || fail "malformed latest digest reached Release creation." +want_release "malformed successful latest digest" "$(gh_release 3.0.2 'ghcr.io/example/native not-a-digest')" "$failed" +[[ ! -s "$scratch/gh_calls" ]] || fail "malformed latest digest reached Release creation." # Release identity: only a strict native- label admits a release image, and # only when its server reports exactly that version. identity=scripts/ci/native-release-identity.sh -test "$(bash "$identity" expect native-3.0.0)" = 3.0.0 || fail "native-3.0.0 must admit 3.0.0." -test "$(bash "$identity" expect 3.0.2)" = 3.0.2 || fail "3.0.2 must admit 3.0.2." -test "$(bash "$identity" expect 3.1.0-rc.1)" = 3.1.0-rc.1 || fail "3.1.0-rc.1 must admit 3.1.0-rc.1." +[[ "$(bash "$identity" expect native-3.0.0)" = 3.0.0 ]] || fail "native-3.0.0 must admit 3.0.0." +[[ "$(bash "$identity" expect 3.0.2)" = 3.0.2 ]] || fail "3.0.2 must admit 3.0.2." +[[ "$(bash "$identity" expect 3.1.0-rc.1)" = 3.1.0-rc.1 ]] || fail "3.1.0-rc.1 must admit 3.1.0-rc.1." if bash "$identity" expect 2.11.9 >/dev/null 2>&1; then fail "a plain 2.x tag is a PHP release, not native."; fi -test "$(bash "$identity" expect native-3.0.0-rc.3)" = 3.0.0-rc.3 || fail "native-3.0.0-rc.3 must admit 3.0.0-rc.3." -test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." -test -z "$(bash "$identity" expect '')" || fail "an unlabelled image is a development image." +[[ "$(bash "$identity" expect native-3.0.0-rc.3)" = 3.0.0-rc.3 ]] || fail "native-3.0.0-rc.3 must admit 3.0.0-rc.3." +[[ -z "$(bash "$identity" expect '')" ]] || fail "an unlabelled image is a development image." +[[ -z "$(bash "$identity" expect '')" ]] || fail "an unlabelled image is a development image." for label in 2.11.9 3.0 3.0.0-beta native-3.0 native-3.0.0-beta native-3.0.0x 'native-3.0.0 '; do if bash "$identity" expect "$label" >/dev/null 2>&1; then fail "label '$label' must be refused."; fi done @@ -381,7 +383,7 @@ for script in promote-release-image-rolling-tags.sh resolve-release-image-rollin sh "scripts/ci/$script" >/dev/null 2>&1; then fail "$script must fail when the tag lookup fails." fi - test ! -s "$scratch/calls" || fail "$script wrote to a registry after a failed tag lookup." + [[ ! -s "$scratch/calls" ]] || fail "$script wrote to a registry after a failed tag lookup." done rm -f "$scratch/git" @@ -410,10 +412,10 @@ php_stage() { # $1: how the promoter gets tags (inventory|lookup) -> promoter ex fi printf '%s\n' "$status" } -test "$(php_stage lookup)" != 0 || fail "a promoter whose second lookup fails must exit nonzero." -test ! -s "$scratch/calls" || fail "a promoter whose second lookup fails wrote to a registry." -test "$(php_stage inventory)" = 0 || fail "the promoter must succeed from the resolver's inventory." -test "$(cat "$scratch/git-count")" = 1 || fail "the PHP stage must read the tags exactly once." +[[ "$(php_stage lookup)" != 0 ]] || fail "a promoter whose second lookup fails must exit nonzero." +[[ ! -s "$scratch/calls" ]] || fail "a promoter whose second lookup fails wrote to a registry." +[[ "$(php_stage inventory)" = 0 ]] || fail "the promoter must succeed from the resolver's inventory." +[[ "$(cat "$scratch/git-count")" = 1 ]] || fail "the PHP stage must read the tags exactly once." grep -q -- '--tag durableworkflow/server:2.11 --tag durableworkflow/server:2 ' "$scratch/calls" \ || fail "the PHP release must still move 2 and 2.11." grep -q ':latest' "$scratch/calls" && fail "the PHP release moved latest past native-3.0.0." @@ -423,7 +425,7 @@ if env -i PATH="$PATH" DOCKER="$scratch/docker" CALL_LOG="$scratch/calls" RELEAS VERIFIED_IMAGE_DIGEST="$index" sh scripts/ci/promote-release-image-rolling-tags.sh >/dev/null 2>&1; then fail "the promoter must refuse without a tag inventory." fi -test ! -s "$scratch/calls" || fail "the promoter wrote without a tag inventory." +[[ ! -s "$scratch/calls" ]] || fail "the promoter wrote without a tag inventory." rm -f "$scratch/git" "$scratch/git-count" # Both workflows hold the one rolling-alias lock, and the PHP promoter takes # the resolver's inventory rather than reading the tags again. @@ -443,17 +445,17 @@ unprotected=$(awk ' jobs && /^ [a-z-]+: write$/ { writes = 1 } END { flush() } ' .github/workflows/native-release.yml | paste -sd' ' -) -test -z "$unprotected" || fail "native-release.yml jobs with a write scope outside dockerhub-native: $unprotected." +[[ -z "$unprotected" ]] || fail "native-release.yml jobs with a write scope outside dockerhub-native: $unprotected." # The GitHub Release workflow: one job, from protected main only, in its own # main-only Environment, the only contents: write in either workflow. github_release=.github/workflows/native-github-release.yml -test "$(grep -c '^ [a-z0-9-]*:$' <(sed -n '/^jobs:/,$p' "$github_release"))" = 1 || fail "$github_release must have one job." +[[ "$(grep -c '^ [a-z0-9-]*:$' <(sed -n '/^jobs:/,$p' "$github_release"))" = 1 ]] || fail "$github_release must have one job." grep -q "^ if: github.ref == 'refs/heads/main'$" "$github_release" || fail "$github_release must run only from main." grep -q '^ environment: github-release$' "$github_release" || fail "$github_release must run in the github-release Environment." grep -q '^ workflow_dispatch:$' "$github_release" && test "$(sed -n '/^on:/,/^permissions:/p' "$github_release" | grep -c '^ [a-z_]*:$')" = 1 \ || fail "$github_release must be triggered only by workflow_dispatch." -test "$(grep -c ' contents: write' "$github_release")" = 1 || fail "$github_release must hold contents: write once." -test "$(grep -c '^ *contents: write$' .github/workflows/native-release.yml)" = 0 || fail "native-release.yml must not hold contents: write." +[[ "$(grep -c ' contents: write' "$github_release")" = 1 ]] || fail "$github_release must hold contents: write once." +[[ "$(grep -c '^ *contents: write$' .github/workflows/native-release.yml)" = 0 ]] || fail "native-release.yml must not hold contents: write." grep -q 'RELEASE_IMAGE_KNOWN_TAGS: ${{ steps.rolling.outputs.release_inventory }}' .github/workflows/release.yml \ || fail "the PHP promoter must use the resolver's inventory." awk '/- name: Promote rolling image aliases/,/run: scripts\/ci\/promote-release-image-rolling-tags.sh/' .github/workflows/release.yml \ diff --git a/scripts/ci/native-release-github-release.sh b/scripts/ci/native-release-github-release.sh index 9cd4c4608..379441c3d 100755 --- a/scripts/ci/native-release-github-release.sh +++ b/scripts/ci/native-release-github-release.sh @@ -38,9 +38,9 @@ fi # The digest the latest alias names now; empty when it does not exist. latest_digest() { - local output - if ! output=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1:latest" 2>&1); then - native_registry_subject_missing "$1:latest" "$output" && return 0 + local image="$1" output + if ! output=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:latest" 2>&1); then + native_registry_subject_missing "$image:latest" "$output" && return 0 fail "cannot establish the current latest alias." fi [[ "$output" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "latest alias returned no valid digest." diff --git a/scripts/ci/native-release-rolling-tags.sh b/scripts/ci/native-release-rolling-tags.sh index c5f63801e..cc8604435 100755 --- a/scripts/ci/native-release-rolling-tags.sh +++ b/scripts/ci/native-release-rolling-tags.sh @@ -26,6 +26,7 @@ set -eEuo pipefail source scripts/ci/native-registry-absence.sh fail() { printf 'Native rolling tags failed: %s\n' "$1" >&2; exit 1; } docker_bin=${DOCKER:-docker} +manifest_digest_format='{{.Manifest.Digest}}' # A plain tag is native only from major 3, above every PHP release; native- # names the tags published before plain ones. @@ -46,7 +47,7 @@ images=("$GHCR_IMAGE" "$DOCKERHUB_IMAGE") # The published release first: both exact tags must name the attested index. for image in "${images[@]}"; do - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$version") \ + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$version") \ || fail "cannot read $image:$version." [[ "$digest" == "$INDEX_DIGEST" ]] || fail "$image:$version is $digest, not the attested $INDEX_DIGEST." done @@ -108,7 +109,7 @@ if [[ "${VERIFY_ONLY:-false}" == true ]]; then [[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "qualified live alias must be final." if newer "$current" "$version"; then continue; fi [[ "$current" == "$version" ]] || fail "qualified live alias is older." - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") [[ "$digest" == "$INDEX_DIGEST" ]] || fail "qualified live alias names another index." done done @@ -124,7 +125,7 @@ for alias in "${candidates[@]}"; do current=$(current_release "$image:$alias") state=absent if [[ -n "$current" ]]; then - state=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + state=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") [[ "$state" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "alias digest unreadable." fi snapshots["$image:$alias"]="$current $state" @@ -158,17 +159,17 @@ for image in "${images[@]}"; do # Re-read BOTH exact tags and every selected alias immediately before EACH # registry write. Previously confirmed writes must still name this index. for checked in "${images[@]}"; do - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$checked:$version") + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$checked:$version") [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "exact tag changed before write."; } for alias in "${aliases[@]}"; do if [[ " ${confirmed[*]:-} " == *" $checked "* ]]; then - state=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$checked:$alias") + state=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$checked:$alias") [[ "$state" == "$INDEX_DIGEST" ]] || { partial; fail "confirmed alias changed."; } else current=$(current_release "$checked:$alias") state=absent if [[ -n "$current" ]]; then - state=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$checked:$alias") + state=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$checked:$alias") fi [[ "$current $state" == "${snapshots["$checked:$alias"]}" ]] \ || { partial; fail "alias changed before write."; } @@ -182,7 +183,7 @@ for image in "${images[@]}"; do printf -- '- alias write attempted: `%s` at `%s`\n' "$image" "$INDEX_DIGEST" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" "$docker_bin" buildx imagetools create "${args[@]}" "$image@$INDEX_DIGEST" for alias in "${aliases[@]}"; do - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") [[ "$digest" == "$INDEX_DIGEST" ]] || { partial; fail "post-write alias mismatch."; } done confirmed+=("$image") @@ -193,7 +194,7 @@ done # Each moved alias names exactly the attested index. for image in "${images[@]}"; do for alias in "${aliases[@]}"; do - digest=$("$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$image:$alias") + digest=$("$docker_bin" buildx imagetools inspect --format "$manifest_digest_format" "$image:$alias") [[ "$digest" == "$INDEX_DIGEST" ]] || fail "$image:$alias is $digest, not $INDEX_DIGEST." done done diff --git a/scripts/ci/native-release-verify-publication.sh b/scripts/ci/native-release-verify-publication.sh index 3a8d58b05..53e9f8136 100644 --- a/scripts/ci/native-release-verify-publication.sh +++ b/scripts/ci/native-release-verify-publication.sh @@ -44,7 +44,8 @@ expected="$workflow workflow_dispatch completed success $tag $commit $run_attemp # Both exact tags name one index. digest() { - "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$1" || fail "cannot read $1." + local subject="$1" + "$docker_bin" buildx imagetools inspect --format '{{.Manifest.Digest}}' "$subject" || fail "cannot read $subject." } index=$(digest "$GHCR_IMAGE:$version") [[ "$index" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "exact version tag has no index digest." diff --git a/scripts/ci/native_alias_evidence.py b/scripts/ci/native_alias_evidence.py index 8b8f0a538..c88935b3e 100644 --- a/scripts/ci/native_alias_evidence.py +++ b/scripts/ci/native_alias_evidence.py @@ -30,8 +30,9 @@ JSON_LIMIT = 1048576 SHA = re.compile(r"[0-9a-f]{40}\Z") DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") -FINAL_TAG = re.compile(r"(?:native-[0-9]+|[3-9]|[1-9][0-9]+)\.[0-9]+\.[0-9]+\Z") -PLATFORMS = ("linux/amd64", "linux/arm64") +FINAL_TAG = re.compile(r"(?:native-\d+|[3-9]|[1-9]\d+)\.\d+\.\d+\Z", re.ASCII) +AMD64 = "linux/amd64" +PLATFORMS = (AMD64, "linux/arm64") PROFILES = ("local", "production") WORKFLOWS = { "release": ".github/workflows/native-release.yml", @@ -103,7 +104,7 @@ def canonical(value: Any) -> bytes: try: return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8") - except (UnicodeError, ValueError, RecursionError): + except (ValueError, RecursionError): raise Refusal("invalid_canonical_json") from None @@ -138,7 +139,7 @@ def not_integer(_: str) -> Any: return json.loads(data.decode("utf-8", errors="strict"), object_pairs_hook=pairs, parse_float=not_integer, parse_constant=not_integer) - except (UnicodeError, json.JSONDecodeError, RecursionError, ValueError) as error: + except (RecursionError, ValueError) as error: if isinstance(error, Refusal): raise raise Refusal("invalid_json") from None @@ -283,8 +284,11 @@ def workflow_run(provider: Provider, selected: dict[str, Any], role: str, and literal(run.get("workflow_id"), workflow["id"]) and workflow.get("path") == WORKFLOWS[role], "provider_workflow_identity") image = GHCR_IMAGE + "@" + release["index_digest"] - title = (f"Native {release['tag']} at {release['source_commit']}" if role == "release" - else ("Native image parity " if role == "corpus" else "Published Compose ") + image) + if role == "release": + title = f"Native {release['tag']} at {release['source_commit']}" + else: + prefix = "Native image parity " if role == "corpus" else "Published Compose " + title = prefix + image require(run.get("display_title") == title, "provider_dispatch_input_binding") timestamp(run.get("run_started_at")) timestamp(run.get("updated_at")) @@ -439,8 +443,8 @@ def verify(value: dict[str, Any], provider: Provider) -> dict[str, Any]: artifact_checks.append((selected["artifact_id"], selected["artifact_digest"], "native-corpus-promotion-receipt", run, job)) record = receipt(provider, *artifact_checks[-1]) - bind_receipt(record, role, selected, run, job, release, "linux/amd64", None, - subjects["linux/amd64"]) + bind_receipt(record, role, selected, run, job, release, AMD64, None, + subjects[AMD64]) artifact_ids.append(selected["artifact_id"]) else: for cell in selected["cells"]: diff --git a/scripts/ci/native_alias_promote.py b/scripts/ci/native_alias_promote.py index 2c8d4ac95..a9a062015 100644 --- a/scripts/ci/native_alias_promote.py +++ b/scripts/ci/native_alias_promote.py @@ -17,6 +17,7 @@ def dispatch(value: dict) -> None: release = value["release"] require(os.environ.get("GITHUB_REPOSITORY") == REPOSITORY + and os.environ.get("PROMOTION_TAG") == release["tag"] and os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch" and os.environ.get("GITHUB_REF") == "refs/tags/" + release["tag"] and os.environ.get("GITHUB_SHA") == release["source_commit"] @@ -34,14 +35,26 @@ def qualify(value: dict, provider, mode: str) -> dict: return proof +def plan(value: dict, provider) -> None: + dispatch(value) + source = value["release"]["source_commit"] + comparison = provider.api(f"repos/{REPOSITORY}/compare/{source}...main") + require(type(comparison) is dict and comparison.get("status") in ("ahead", "identical") + and type(comparison.get("base_commit")) is dict + and comparison["base_commit"].get("sha") == source, "promotion_source_not_on_main") + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("mode", choices=("verify", "promote", "release-order")) + parser.add_argument("mode", choices=("plan", "verify", "promote", "release-order")) args = parser.parse_args() raw = os.environ.get("PROMOTION_EVIDENCE", "").encode("utf-8") require(len(raw) <= MANIFEST_LIMIT, "manifest_size") value = manifest(raw) provider = GitHubRegistryProvider() + if args.mode == "plan": + plan(value, provider) + return proof = qualify(value, provider, args.mode) if args.mode == "verify": sys.stdout.buffer.write(canonical(proof)) @@ -81,7 +94,7 @@ def main() -> None: if __name__ == "__main__": try: main() - except (Refusal, OSError, UnicodeError, ValueError, KeyError) as error: + except (OSError, ValueError, KeyError) as error: reason = str(error) if isinstance(error, Refusal) else "local_input_or_transport" print("Native promotion 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 6156f818d..16e875748 100644 --- a/scripts/ci/native_alias_transport.py +++ b/scripts/ci/native_alias_transport.py @@ -16,6 +16,9 @@ matching, require, strict_json) +GITHUB_HOST = "github.com" + + class Commands: """No shell, polling, automatic retries, unbounded output or provider logs.""" @@ -28,7 +31,7 @@ def run(self, argv: list[str], limit: int = JSON_LIMIT, require(remaining > 0, "read_deadline") environment = os.environ.copy() environment.pop("GH_DEBUG", None) - environment.update(GH_HOST="github.com", GH_PROMPT_DISABLED="1", GH_PAGER="cat") + environment.update(GH_HOST=GITHUB_HOST, GH_PROMPT_DISABLED="1", GH_PAGER="cat") if env: environment.update(env) process = None @@ -75,11 +78,11 @@ def api(self, path: str) -> Any: and re.fullmatch(r"[A-Za-z0-9_./?=&-]+", path) is not None and ".." not in path, "provider_api_path") return strict_json(self.commands.run( - ["gh", "api", "--hostname", "github.com", "--method", "GET", path]), JSON_LIMIT) + ["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", path]), JSON_LIMIT) def archive(self, artifact_id: int) -> bytes: require(type(artifact_id) is int and artifact_id > 0, "artifact_id") - return self.commands.run(["gh", "api", "--hostname", "github.com", "--method", "GET", + return self.commands.run(["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", f"repos/{REPOSITORY}/actions/artifacts/{artifact_id}/zip"], ARCHIVE_LIMIT) @@ -88,7 +91,7 @@ def release_exists(self, tag: str) -> bool: # transport or rate limits. Only the fixed API's parsed HTTP 404 counts. from native_alias_evidence import FINAL_TAG require(matching(tag, FINAL_TAG), "final_release_required") - response = self.commands.run(["gh", "api", "--hostname", "github.com", "--method", "GET", + response = self.commands.run(["gh", "api", "--hostname", GITHUB_HOST, "--method", "GET", "--include", f"repos/{REPOSITORY}/releases/tags/{tag}"], accepted=(0, 1)) headers, separator, body = response.partition(b"\r\n\r\n") diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py index 183ba7673..bb51639b9 100644 --- a/scripts/ci/native_qualification.py +++ b/scripts/ci/native_qualification.py @@ -21,18 +21,38 @@ from native_alias_transport import Commands, GitHubRegistryProvider OWNER = "dev.durable-workflow.qualification.scope" +COMPOSE_OWNER = "com.docker.compose.project" +LABEL_FILTER = "label=" +OBJECT_ID = r"[0-9a-f]{64}" +VOLUME_NAME = r"[A-Za-z0-9_.-]+" CLEANUP_KEYS = {"api_ok", "verified_absent", "container_count", "network_count", "volume_count"} +ROOT = Path.cwd().resolve() +STATE_PATHS = ( + "qualification-state/image.json", "qualification-state/observations.json", + "qualification-state/cleanup.json", "qualification-state/body-cleanup.json", + "parity-evidence/native-image-corpus.json", "promotion-receipt/receipt.json", +) + + +def state_path(path: Path) -> Path: + # Select a fixed workspace path, never use a caller-controlled path at a file sink. + for relative in STATE_PATHS: + target = ROOT / relative + if path in (Path(relative), target): + require(not target.is_symlink() and not target.parent.is_symlink(), "qualification_path_symlink") + return target + raise Refusal("qualification_path_not_allowed") def read(path: Path) -> Any: - with path.open("rb") as stream: + with state_path(path).open("rb") as stream: return strict_json(stream.read(JSON_LIMIT + 1), JSON_LIMIT) def write(path: Path, value: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - # Owned job workspace files only; artifact finalization uses a fresh directory. - path.write_bytes(canonical(value)) + target = state_path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(canonical(value)) def eligible_context(role: str, image: str) -> bool: @@ -47,6 +67,11 @@ def eligible_context(role: str, image: str) -> bool: def inspect(commands: Commands, resource: str, identity: str) -> dict[str, Any]: + if resource not in ("container", "image", "network", "volume"): + raise Refusal("docker_resource_kind") + # Leading CLI options, whitespace, separators and paths are never identities. + if not re.fullmatch(r"(?:sha256:[0-9a-f]{64}|[A-Za-z0-9][A-Za-z0-9_.-]{0,180})", identity): + raise Refusal("docker_resource_identity") value = strict_json(commands.run(["docker", resource, "inspect", identity]), JSON_LIMIT) require(type(value) is list and len(value) == 1 and type(value[0]) is dict, "docker_inspect_shape") @@ -86,56 +111,66 @@ def lines(commands: Commands, args: list[str], expression: str) -> set[str]: return set(value) +def owned_networks(commands: Commands, role: str, scope: str) -> tuple[set[str], set[str]]: + selector = LABEL_FILTER + COMPOSE_OWNER + "=" + scope if role == "compose" else "name=^" + scope + "$" + networks = lines(commands, ["network", "ls", "-q", "--no-trunc", "--filter", selector], OBJECT_ID) + attached = set() + for identity in networks: + data = inspect(commands, "network", identity) + labels = data.get("Labels") + owner = COMPOSE_OWNER if role == "compose" else OWNER + require(data.get("Id") == identity and type(labels) is dict and labels.get(owner) == scope + and type(data.get("Containers")) is dict, "cleanup_network_ownership") + if role == "corpus": + require(data.get("Name") == scope, "cleanup_network_name") + require(all(re.fullmatch(OBJECT_ID, item) for item in data["Containers"]), "cleanup_network_container") + attached.update(data["Containers"]) + return networks, attached + + +def owned_mounts(commands: Commands, mounts: list, role: str, scope: str) -> set[str]: + volumes = set() + for mount in mounts: + if type(mount) is not dict or mount.get("Type") != "volume": + continue + name = mount.get("Name") + require(type(name) is str and re.fullmatch(VOLUME_NAME, name), "cleanup_volume_identity") + metadata = inspect(commands, "volume", name) + labels = metadata.get("Labels") + anonymous = (re.fullmatch(OBJECT_ID, name) is not None + and labels in (None, {}, {"com.docker.volume.anonymous": ""})) + compose = role == "compose" and type(labels) is dict and labels.get(COMPOSE_OWNER) == scope + # The exact Engine anonymous marker is not an owner grant: the volume + # must also be mounted by the already inspected, positively owned container. + require(metadata.get("Name") == name and (anonymous or compose), "cleanup_volume_owner") + volumes.add(name) + return volumes + + def owned_inventory(commands: Commands, role: str, scope: str) -> tuple[set[str], set[str], set[str]]: - containers, networks, volumes = set(), set(), set() - if role == "compose": - label = "com.docker.compose.project=" + scope - containers = lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", "label=" + label], - r"[0-9a-f]{64}") - networks = lines(commands, ["network", "ls", "-q", "--no-trunc", "--filter", "label=" + label], - r"[0-9a-f]{64}") - volumes = lines(commands, ["volume", "ls", "-q", "--filter", "label=" + label], r"[A-Za-z0-9_.-]+") + networks, containers = owned_networks(commands, role, scope) + owner = COMPOSE_OWNER if role == "compose" else OWNER + containers |= lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", + LABEL_FILTER + owner + "=" + scope], OBJECT_ID) + volumes = set() + if role == "corpus": + containers |= lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", + "name=^/" + scope + "-"], OBJECT_ID) else: - networks = lines(commands, ["network", "ls", "-q", "--no-trunc", "--filter", "name=^" + scope + "$"], - r"[0-9a-f]{64}") - containers = lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", "name=^/" + scope + "-"], - r"[0-9a-f]{64}") - containers |= lines(commands, ["container", "ls", "-aq", "--no-trunc", "--filter", "label=" + OWNER + "=" + scope], - r"[0-9a-f]{64}") - for network in networks: - data = inspect(commands, "network", network) - require(data.get("Name") == scope and type(data.get("Containers")) is dict - and type(data.get("Labels")) is dict and data["Labels"].get(OWNER) == scope, - "cleanup_network_ownership") - require(all(re.fullmatch(r"[0-9a-f]{64}", identity) for identity in data["Containers"]), - "cleanup_network_container") - containers.update(data["Containers"]) + volumes = lines(commands, ["volume", "ls", "-q", "--filter", LABEL_FILTER + owner + "=" + scope], VOLUME_NAME) + for name in volumes: + data = inspect(commands, "volume", name) + require(data.get("Name") == name and type(data.get("Labels")) is dict + and data["Labels"].get(owner) == scope, "cleanup_volume_owner") + # Discovering a prefix or attached resource never grants ownership. Validate + # the complete candidate set, including mounts, before the first removal. for identity in containers: data = inspect(commands, "container", identity) require(data.get("Id") == identity and type(data.get("Config")) is dict and type(data.get("Mounts")) is list, "cleanup_container_identity") - if role == "compose": - labels = data["Config"].get("Labels") - require(type(labels) is dict and labels.get("com.docker.compose.project") == scope, - "cleanup_container_owner") - else: - # Prefixes and network membership discover candidates, never grant - # removal authority. Validate every candidate before the first rm. - labels = data["Config"].get("Labels") - require(type(labels) is dict and labels.get(OWNER) == scope, - "cleanup_container_owner") - for mount in data["Mounts"]: - if type(mount) is dict and mount.get("Type") == "volume": - name = mount.get("Name") - require(type(name) is str and re.fullmatch(r"[A-Za-z0-9_.-]+", name), "cleanup_volume_identity") - metadata = inspect(commands, "volume", name) - labels = metadata.get("Labels") or {} - # An unlabelled anonymous volume belongs to the observed owned container. - # A named Compose volume must independently carry the same project label. - require((re.fullmatch(r"[0-9a-f]{64}", name) and not labels) - or (role == "compose" and type(labels) is dict - and labels.get("com.docker.compose.project") == scope), "cleanup_volume_owner") - volumes.add(name) + labels = data["Config"].get("Labels") + require(type(labels) is dict and labels.get(owner) == scope, "cleanup_container_owner") + volumes |= owned_mounts(commands, data["Mounts"], role, scope) require(len(containers) <= 64 and len(networks) <= 8 and len(volumes) <= 64, "cleanup_resource_bound") return containers, networks, volumes @@ -150,16 +185,30 @@ def cleanup(commands: Commands, role: str, scope: str) -> dict[str, Any]: commands.run(["docker", "network", "rm", *sorted(networks)]) # rm -v can already remove anonymous volumes. A successful list distinguishes # absence from unavailable Docker; never reinterpret an inspect failure as 404. - remaining = lines(commands, ["volume", "ls", "-q"], r"[A-Za-z0-9_.-]+") & volumes + remaining = lines(commands, ["volume", "ls", "-q"], VOLUME_NAME) & volumes if remaining: commands.run(["docker", "volume", "rm", *sorted(remaining)]) after_containers, after_networks, after_volumes = owned_inventory(commands, role, scope) - after_volumes |= lines(commands, ["volume", "ls", "-q"], r"[A-Za-z0-9_.-]+") & volumes + after_volumes |= lines(commands, ["volume", "ls", "-q"], VOLUME_NAME) & volumes require(not after_containers and not after_networks and not after_volumes, "cleanup_not_absent") return {"api_ok": True, "verified_absent": True, "container_count": 0, "network_count": 0, "volume_count": 0} +def qualification_observations(role: str, profile: str | None, value: Any) -> dict: + keys = CORPUS_OBSERVATIONS if role == "corpus" else COMPOSE_OBSERVATIONS + observations = closed(value, keys, "qualification_observation_shape") + if role == "corpus": + require(all(literal(item, 0) for item in observations.values()), "qualification_corpus_failed") + else: + require(profile in ("local", "production") + and all(item is True for key, item in observations.items() + if key != "production_default_token_refused") + and literal(observations["production_default_token_refused"], profile == "production" or None), + "qualification_compose_failed") + return observations + + def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: str, profile: str | None, identity: Any, observations: Any, cleanup_result: Any, body_outcome: str, cleanup_outcome: str) -> dict[str, Any]: @@ -174,16 +223,7 @@ def finalize(provider: GitHubRegistryProvider, role: str, image: str, platform: require(cleanup_result["api_ok"] is True and cleanup_result["verified_absent"] is True and all(literal(cleanup_result[key], 0) for key in CLEANUP_KEYS - {"api_ok", "verified_absent"}), "qualification_cleanup_failed") - keys = CORPUS_OBSERVATIONS if role == "corpus" else COMPOSE_OBSERVATIONS - observations = closed(observations, keys, "qualification_observation_shape") - if role == "corpus": - require(all(literal(value, 0) for value in observations.values()), "qualification_corpus_failed") - else: - require(profile in ("local", "production") - and all(value is True for key, value in observations.items() - if key != "production_default_token_refused") - and literal(observations["production_default_token_refused"], True if profile == "production" else None), - "qualification_compose_failed") + observations = qualification_observations(role, profile, observations) source = os.environ["GITHUB_SHA"] run_id, attempt = int(os.environ["GITHUB_RUN_ID"]), int(os.environ["GITHUB_RUN_ATTEMPT"]) require(positive(run_id) and positive(attempt), "qualification_run") @@ -259,7 +299,10 @@ def main() -> None: if __name__ == "__main__": try: main() - except (Refusal, OSError, UnicodeError, ValueError, KeyError): + except (OSError, ValueError, KeyError) as error: # Never print Docker/API bodies, HTTP registration payloads or credentials. - print("native qualification refused; no positive receipt finalized", file=sys.stderr) + 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 qualification refused: " + reason + "; no positive receipt finalized", file=sys.stderr) raise SystemExit(1) from None diff --git a/scripts/smoke-published-compose.sh b/scripts/smoke-published-compose.sh index 1c2232d6c..c06ec7f55 100755 --- a/scripts/smoke-published-compose.sh +++ b/scripts/smoke-published-compose.sh @@ -92,7 +92,7 @@ cleanup() { python3 "$ROOT_DIR/scripts/ci/native_qualification.py" cleanup --kind compose --scope "$PROJECT" \ --output "$OBSERVATION_DIR/body-cleanup.json" || cleanup_status=$? rm -rf "$HTTP_DIR" - if [ "$body_status" -ne 0 ]; then exit "$body_status"; fi + if [[ "$body_status" -ne 0 ]]; then exit "$body_status"; fi exit "$cleanup_status" } diff --git a/tests/Unit/Support/composer-sinks.json b/tests/Unit/Support/composer-sinks.json index a6d5226cd..6c6fbb051 100644 --- a/tests/Unit/Support/composer-sinks.json +++ b/tests/Unit/Support/composer-sinks.json @@ -908,7 +908,7 @@ }, { "path": "scripts/conformance/server-parity/native-image-corpus.sh", - "line": "bash scripts/ci/run-composer.sh --rm -v \"$PWD:/source\" -w /source -- \\", + "line": "bash scripts/ci/run-composer.sh --rm --label \"dev.durable-workflow.qualification.scope=$RESOURCE_SCOPE\" -v \"$PWD:/source\" -w /source -- \\", "count": 1, "confinement": "scripts/ci/run-composer.sh runs Composer in the pinned image after Alpine's fixed Brotli decoder (brotli-safe-composer.sh), never in the image under test. It sets COMPOSER_CAFILE to the checked store and COMPOSER_CAPATH to an empty directory after the caller's options, which may not name either, an entrypoint, an env file, /checked-ca or --volumes-from.", "guards": [ diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index 7bcbd5fe7..8953d2d1c 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -27,6 +27,7 @@ CID, NID, VOLUME = [letter * 64 for letter in "def"] SCOPE = "published-compose-301-1-smoke-amd64-local" CONTEXT = { + "PROMOTION_TAG": "3.2.0", "GITHUB_REPOSITORY": E.REPOSITORY, "GITHUB_EVENT_NAME": "workflow_dispatch", "GITHUB_REF": "refs/tags/3.2.0", "GITHUB_REF_NAME": "3.2.0", "GITHUB_SHA": SOURCE, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/" + E.WORKFLOWS["compose"] + "@refs/tags/3.2.0", @@ -42,6 +43,7 @@ def __init__(self): self.corpus_owner = SCOPE self.container_name = "/" + SCOPE + "-server" self.volume_owner = None + self.network_owner = SCOPE self.fail_list = self.leak = False def run(self, argv, limit=E.JSON_LIMIT, env=None): @@ -50,6 +52,9 @@ def run(self, argv, limit=E.JSON_LIMIT, env=None): if operation == "ls": if self.fail_list: raise E.Refusal("fake_api_unavailable") + if resource == "volume" and "--filter" in argv and ( + type(self.volume_owner) is not dict or self.volume_owner.get(Q.COMPOSE_OWNER) != SCOPE): + return b"" return ("\n".join(sorted(getattr(self, resource + "s"))) + ("\n" if getattr(self, resource + "s") else "")).encode() if operation == "inspect": @@ -64,7 +69,8 @@ def run(self, argv, limit=E.JSON_LIMIT, env=None): elif resource == "volume": value = {"Name": VOLUME, "Labels": self.volume_owner} elif resource == "network": - value = {"Id": NID, "Name": SCOPE, "Containers": {CID: {}}, "Labels": {Q.OWNER: SCOPE}} + value = {"Id": NID, "Name": SCOPE, "Containers": {CID: {}}, + "Labels": {Q.OWNER: self.network_owner, Q.COMPOSE_OWNER: self.network_owner}} else: value = {"Id": CONFIG, "Architecture": "amd64", "Os": "linux", "Config": {"Labels": {"org.opencontainers.image.revision": SOURCE, @@ -113,6 +119,43 @@ def test_cleanup_removes_only_observed_owned_objects_and_checks_absence(self): self.assertEqual(commands.removed, [("container", {CID}), ("network", {NID}), ("volume", {VOLUME})]) self.assertGreaterEqual(sum(args[2] == "ls" for args in commands.reads), 8) + def test_engine_anonymous_marker_requires_owned_mount_and_no_foreign_labels(self): + for role in ("compose", "corpus"): + commands = ResourceCommands() + commands.volume_owner = {"com.docker.volume.anonymous": ""} + self.assertIs(Q.cleanup(commands, role, SCOPE)["verified_absent"], True) + for labels in ({"com.docker.volume.anonymous": "", "owner": "foreign"}, + {"com.docker.volume.anonymous": "not-the-engine-marker"}): + commands = ResourceCommands() + commands.volume_owner = labels + with self.subTest(role=role, labels=labels), self.assertRaisesRegex(E.Refusal, "cleanup_volume_owner"): + Q.cleanup(commands, role, SCOPE) + self.assertEqual(commands.removed, []) + + def test_foreign_network_never_authorizes_any_removal(self): + for role in ("compose", "corpus"): + commands = ResourceCommands() + commands.network_owner = "foreign" + with self.subTest(role=role), self.assertRaisesRegex(E.Refusal, "cleanup_network_ownership"): + Q.cleanup(commands, role, SCOPE) + self.assertEqual(commands.removed, []) + + def test_cli_resource_and_file_paths_are_closed_before_any_sink(self): + commands = ResourceCommands() + for identity in ("--format={{.Config.Env}}", "../private", "server\n--format=env", "x/y"): + with self.subTest(identity=identity), self.assertRaisesRegex(E.Refusal, "docker_resource_identity"): + Q.inspect(commands, "container", identity) + self.assertEqual(commands.reads, []) + for path in ("../private", "/tmp/private", "qualification-state/../private", "qualification-state/other.json"): + candidate = Path(path) + with self.subTest(path=path), self.assertRaisesRegex(E.Refusal, "qualification_path_not_allowed"): + Q.state_path(candidate) + with tempfile.TemporaryDirectory() as directory, patch.object(Q, "ROOT", Path(directory)): + target = Path(directory) / "qualification-state" + target.symlink_to(Path(directory), target_is_directory=True) + with self.assertRaisesRegex(E.Refusal, "qualification_path_symlink"): + Q.state_path(Path("qualification-state/image.json")) + def test_cleanup_unavailable_is_unknown_and_foreign_resources_are_not_removed(self): for field, value in (("fail_list", True), ("owner", "another-project"), ("volume_owner", {"com.docker.compose.project": "foreign"})): @@ -211,6 +254,26 @@ def run(self, argv, limit=E.JSON_LIMIT, env=None, accepted=(0,)): class TransportTests(unittest.TestCase): + def test_unprivileged_plan_requires_exact_tag_and_accepted_main_source(self): + value = {"release": {"tag": "3.2.0", "source_commit": SOURCE}} + context = {**CONTEXT, "GITHUB_WORKFLOW_REF": E.REPOSITORY + "/.github/workflows/native-alias-promotion.yml@refs/tags/3.2.0"} + from unittest.mock import Mock + provider = Mock() + with patch.dict(os.environ, context, clear=True): + for status in ("ahead", "identical"): + provider.api.return_value = {"status": status, "base_commit": {"sha": SOURCE}} + P.plan(value, provider) + for comparison in ({"status": "behind", "base_commit": {"sha": SOURCE}}, + {"status": "diverged", "base_commit": {"sha": SOURCE}}, + {"status": "ahead", "base_commit": {"sha": "f" * 40}}, {}): + provider.api.return_value = comparison + with self.subTest(comparison=comparison), self.assertRaisesRegex(E.Refusal, "promotion_source_not_on_main"): + P.plan(value, provider) + provider.reset_mock() + with patch.dict(os.environ, {"PROMOTION_TAG": "3.2.1"}), self.assertRaisesRegex(E.Refusal, "promotion_dispatch_identity"): + P.plan(value, provider) + provider.api.assert_not_called() + def test_api_only_targets_fixed_owning_repository_and_host(self): commands = ReadCommands(b"{}") provider = T.GitHubRegistryProvider(commands) @@ -259,15 +322,17 @@ def test_release_absence_requires_authoritative_http_404(self): self.assertIs(T.GitHubRegistryProvider(commands).release_exists("3.2.0"), expected) for response in (b"unauthorized", b"HTTP/2.0 503 Error\r\n\r\n{}", b"HTTP/2.0 404 Not Found\r\nContent-Type: application/json\r\n\r\n{}"): + provider = T.GitHubRegistryProvider(ReadCommands(response)) with self.subTest(response=response), self.assertRaises(E.Refusal): - T.GitHubRegistryProvider(ReadCommands(response)).release_exists("3.2.0") + provider.release_exists("3.2.0") def test_commands_bound_output_time_and_sanitize_errors(self): for code, limit, seconds in (("print('x'*10000)", 10, 1), ("import time; time.sleep(1)", 100, .03), ("import sys; sys.stderr.write('private-marker'); sys.exit(1)", 100, 1)): + commands = T.Commands(seconds) with self.subTest(code=code), self.assertRaises(E.Refusal) as caught: - T.Commands(seconds).run([sys.executable, "-c", code], limit) + commands.run([sys.executable, "-c", code], limit) self.assertNotIn("private-marker", str(caught.exception)) self.assertEqual(T.Commands().run([sys.executable, "-c", "print('bounded')"]), b"bounded\n") @@ -292,12 +357,9 @@ def test_protected_entry_refusals_leave_zero_write_ledger(self): provider.selected["corpus"]["workflow_blob"] = "f" * 40 elif fault == "archive": provider.archives[501] += b"extra" - writes = [] with patch.dict(os.environ, context, clear=True), self.subTest(fault=fault): with self.assertRaises(E.Refusal): - proof = P.qualify(provider.selected, provider, "promote") - writes.append(proof) - self.assertEqual(writes, []) + P.qualify(provider.selected, provider, "promote") class WorkflowContracts(unittest.TestCase): From d98738db9d60543188758bac5c665405a8aee975 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:32:24 +0000 Subject: [PATCH 08/15] Make registry argument and state-file boundaries explicit --- scripts/ci/check-docker-workflow-isolation.py | 4 ++-- scripts/ci/native_alias_transport.py | 6 ++++-- scripts/ci/native_qualification.py | 3 ++- tests/Unit/Support/native_qualification_test.py | 10 ++++++++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index d10449add..b1330b7e4 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": "1103b4c8bd0e5779f1ee5580cd2500308d990e91ccfc6266cc7b393851b3f48b", + "scripts/ci/native_qualification.py": "ea74b534004c04209843a0ca28bb5a338cd2f2bec56fde1e511da9ffc20a911e", "scripts/ci/native_alias_evidence.py": "647d184c7a5e456410933a7188debc3fe23360d002ef6458ced742bb85f32c37", - "scripts/ci/native_alias_transport.py": "81ad7f58625774c818297019d0f220e97dbae271b27805d1e24f4842378365d0", + "scripts/ci/native_alias_transport.py": "067cec7ebed20de0d927ca747a257e54722eed5725310d3ff2743d43375fce76", } diff --git a/scripts/ci/native_alias_transport.py b/scripts/ci/native_alias_transport.py index 16e875748..121425a98 100644 --- a/scripts/ci/native_alias_transport.py +++ b/scripts/ci/native_alias_transport.py @@ -117,8 +117,10 @@ def workflow_blob(self, commit: str, path: str) -> str: return value["sha"] def registry_json(self, image: str, digest: str, image_config: bool = False) -> Any: - require(image in (GHCR_IMAGE, DOCKERHUB_IMAGE) and matching(digest, DIGEST), - "registry_subject") + if image not in (GHCR_IMAGE, DOCKERHUB_IMAGE): + raise Refusal("registry_subject") + if type(digest) is not str or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None: + raise Refusal("registry_subject") arguments = ["docker", "buildx", "imagetools", "inspect"] arguments += ["--format", "{{json .Image}}"] if image_config else ["--raw"] return strict_json(self.commands.run(arguments + [image + "@" + digest]), JSON_LIMIT) diff --git a/scripts/ci/native_qualification.py b/scripts/ci/native_qualification.py index bb51639b9..2d661dee5 100644 --- a/scripts/ci/native_qualification.py +++ b/scripts/ci/native_qualification.py @@ -52,7 +52,8 @@ def read(path: Path) -> Any: def write(path: Path, value: Any) -> None: target = state_path(path) target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(canonical(value)) + with target.open("wb") as stream: + stream.write(canonical(value)) def eligible_context(role: str, image: str) -> bool: diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index 8953d2d1c..3f531a215 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -315,6 +315,16 @@ def registry_json(self, image, digest, image_config=False): with self.subTest(fault=fault), self.assertRaises(E.Refusal): registry.index_subjects(E.GHCR_IMAGE, INDEX, SOURCE, "3.2.0") + def test_registry_cli_rejects_option_or_argument_injection_before_any_command(self): + commands = ReadCommands(b"{}") + provider = T.GitHubRegistryProvider(commands) + for image, digest in (("--format=env", INDEX), ("foreign.invalid/server", INDEX), + (E.GHCR_IMAGE, "--format=env"), (E.GHCR_IMAGE, INDEX + "\n--format=env"), + (E.GHCR_IMAGE, "sha256:" + "a" * 63)): + with self.subTest(image=image, digest=digest), self.assertRaisesRegex(E.Refusal, "registry_subject"): + provider.registry_json(image, digest) + self.assertEqual(commands.calls, []) + def test_release_absence_requires_authoritative_http_404(self): for status, body, expected in (("404 Not Found", {"message": "Not Found"}, False), ("200 OK", {"id": 42, "tag_name": "3.2.0"}, True)): From cc93ae7d17e4e1ab38857017b9f7b894dd34eca5 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:35:31 +0000 Subject: [PATCH 09/15] Keep CLI child identity reserved until process-group cleanup --- docs/native-release-promotion.md | 10 ++ scripts/ci/check-docker-workflow-isolation.py | 2 +- scripts/ci/native_alias_transport.py | 60 ++++++-- .../Unit/Support/native_qualification_test.py | 140 ++++++++++++++++++ 4 files changed, 201 insertions(+), 11 deletions(-) diff --git a/docs/native-release-promotion.md b/docs/native-release-promotion.md index 8930fb8c6..390049109 100644 --- a/docs/native-release-promotion.md +++ b/docs/native-release-promotion.md @@ -103,6 +103,16 @@ raw environment or host configuration enters a promotion receipt. Compose HTTP responses use private temporary files and bounded requests; failures print a static reason rather than authorization arguments or server logs. +Provider CLI reads have a 30-second per-command deadline within a 180-second +read budget, bounded stdout and 4 KiB stderr. The synchronous helper owns each +direct child with default `SIGCHLD` handling. It observes exit using +[`waitid(WNOWAIT)`](https://man7.org/linux/man-pages/man2/wait.2.html), retaining +the child's kernel PID reservation until process-group cleanup finishes, then +reaps with at most one second of cleanup grace. It never reaps first and signals +the released number afterward. Missing non-reaping primitives or an automatic +reaper refuse before process creation. Unknown child authority refuses without +a numeric PID signal; signal/reap failure cannot return a successful read. + Cleanup retains the original body result. It removes only exact owned Compose project resources or containers carrying the exact corpus scope OWNER label, owned network and observed owned volumes, including labelled standalone helper diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index b1330b7e4..4ee9ed480 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -18,7 +18,7 @@ QUALIFICATION_CLEANUP_SOURCES = { "scripts/ci/native_qualification.py": "ea74b534004c04209843a0ca28bb5a338cd2f2bec56fde1e511da9ffc20a911e", "scripts/ci/native_alias_evidence.py": "647d184c7a5e456410933a7188debc3fe23360d002ef6458ced742bb85f32c37", - "scripts/ci/native_alias_transport.py": "067cec7ebed20de0d927ca747a257e54722eed5725310d3ff2743d43375fce76", + "scripts/ci/native_alias_transport.py": "e9ab362d47046438fa8bddfd0c3ef25e8f08f68f6855a65f8a4d2a338c072b68", } diff --git a/scripts/ci/native_alias_transport.py b/scripts/ci/native_alias_transport.py index 121425a98..0892b885f 100644 --- a/scripts/ci/native_alias_transport.py +++ b/scripts/ci/native_alias_transport.py @@ -25,10 +25,50 @@ class Commands: def __init__(self, seconds: float = 180): self.deadline = time.monotonic() + seconds + @staticmethod + def require_wait_authority() -> None: + require(callable(getattr(os, "waitid", None)) + and all(hasattr(os, name) for name in ( + "P_PID", "WEXITED", "WNOHANG", "WNOWAIT", "CLD_EXITED", "CLD_KILLED", "CLD_DUMPED")) + and signal.getsignal(signal.SIGCHLD) == signal.SIG_DFL, + "provider_child_authority_unavailable") + + @staticmethod + def child_state(pid: int): + # The synchronous caller owns this direct child. WNOWAIT retains the + # child's kernel PID reservation until group cleanup has finished. + Commands.require_wait_authority() + try: + state = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) + except OSError: + raise Refusal("provider_child_authority_unknown") from None + if state is None or state.si_pid == 0: + return None + require(state.si_pid == pid and state.si_code in (os.CLD_EXITED, os.CLD_KILLED, os.CLD_DUMPED), + "provider_child_authority_unknown") + return state + + @staticmethod + def finish_child(process) -> None: + try: + # Never signal after releasing the direct child's PID reservation. + Commands.child_state(process.pid) + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=1) + except (OSError, subprocess.SubprocessError): + raise Refusal("provider_child_cleanup_failed") from None + finally: + process.stdout.close() + process.stderr.close() + def run(self, argv: list[str], limit: int = JSON_LIMIT, env: dict[str, str] | None = None, accepted: tuple[int, ...] = (0,)) -> bytes: remaining = min(30, self.deadline - time.monotonic()) require(remaining > 0, "read_deadline") + self.require_wait_authority() environment = os.environ.copy() environment.pop("GH_DEBUG", None) environment.update(GH_HOST=GITHUB_HOST, GH_PROMPT_DISABLED="1", GH_PAGER="cat") @@ -52,21 +92,21 @@ def run(self, argv: list[str], limit: int = JSON_LIMIT, continue key.data.extend(block) require(len(output) <= limit and len(errors) <= 4096, "read_output_bound") - require(process.wait(timeout=max(0.01, until - time.monotonic())) in accepted, - "provider_read_failed") + require(time.monotonic() < until, "read_timeout") + state = self.child_state(process.pid) + while state is None: + require(time.monotonic() < until, "read_timeout") + time.sleep(min(0.01, max(0, until - time.monotonic()))) + state = self.child_state(process.pid) + code = state.si_status if state.si_code == os.CLD_EXITED else -state.si_status + require(time.monotonic() < until, "read_timeout") + require(code in accepted, "provider_read_failed") return bytes(output) except (OSError, subprocess.SubprocessError): raise Refusal("provider_command_failed") from None finally: if process is not None: - # Kill the whole owned subprocess group, including a blocked CLI child. - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - process.stdout.close() - process.stderr.close() + self.finish_child(process) class GitHubRegistryProvider: diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index 3f531a215..d1ab6cd9e 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -10,6 +10,7 @@ import sys import tempfile import textwrap +from types import SimpleNamespace import unittest from unittest.mock import patch @@ -35,6 +36,145 @@ } +def fake_command_lifecycle(module, exit_code=0, authority_error=False, cleanup_error=None): + """No process or signal: an early reap makes the fake PID foreign.""" + events = [] + + class Pipe: + def __init__(self, descriptor): + self.descriptor = descriptor + + def close(self): + events.append("close-pipe") + + class Process: + pid = 43210 + reaped = False + stdout, stderr = Pipe(10), Pipe(11) + + def wait(self, timeout=None): + events.append("reap") + if cleanup_error == "reap": + raise module.subprocess.TimeoutExpired("synthetic-private-marker", timeout) + self.reaped = True + return exit_code if exit_code is not None else -9 + + process = Process() + + class Selector: + def __init__(self): + self.keys = {} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def register(self, stream, event, data): + self.keys[stream.descriptor] = SimpleNamespace(fd=stream.descriptor, fileobj=stream, data=data) + + def unregister(self, stream): + del self.keys[stream.descriptor] + + def get_map(self): + return self.keys + + def select(self, timeout): + return [(value, 1) for value in list(self.keys.values())] + + def observe(kind, pid, flags): + assert kind == os.P_PID and pid == process.pid and flags & os.WNOWAIT and flags & os.WNOHANG + events.append("observe") + if authority_error or process.reaped: + raise ChildProcessError("synthetic unowned child") + return None if exit_code is None else SimpleNamespace(si_pid=pid, si_code=os.CLD_EXITED, si_status=exit_code) + + def signal_group(pid, sig): + assert pid == process.pid and sig == module.signal.SIGKILL + events.append("foreign-signal" if process.reaped else "owned-signal") + if cleanup_error == "signal": + raise PermissionError("synthetic-private-marker") + + clock = [0.0] + + def now(): + clock[0] += 0.1 + return clock[0] + + error, result = None, None + with patch.object(module.subprocess, "Popen", return_value=process), \ + patch.object(module.selectors, "DefaultSelector", Selector), \ + patch.object(module.os, "read", return_value=b""), \ + patch.object(module.os, "waitid", side_effect=observe), \ + patch.object(module.os, "killpg", side_effect=signal_group), \ + patch.object(module.signal, "getsignal", return_value=module.signal.SIG_DFL), \ + patch.object(module.time, "monotonic", side_effect=now), \ + patch.object(module.time, "sleep"): + commands = module.Commands(1) + try: + result = commands.run(["synthetic-read-only-cli"]) + except E.Refusal as caught: + error = caught + return result, error, events + + +class CommandChildAuthorityTest(unittest.TestCase): + def test_success_signals_only_before_the_direct_child_is_reaped(self): + result, error, events = fake_command_lifecycle(T) + self.assertIsNone(error) + self.assertEqual(result, b"") + self.assertNotIn("foreign-signal", events) + self.assertIn("owned-signal", events) + self.assertLess(events.index("owned-signal"), events.index("reap")) + + def test_failure_and_timeout_also_clean_up_before_reaping(self): + for code, reason in ((1, "provider_read_failed"), (None, "read_timeout")): + with self.subTest(code=code): + _, error, events = fake_command_lifecycle(T, exit_code=code) + self.assertIsInstance(error, E.Refusal) + self.assertEqual(str(error), reason) + self.assertNotIn("foreign-signal", events) + self.assertIn("owned-signal", events) + self.assertLess(events.index("owned-signal"), events.index("reap")) + + def test_unknown_child_authority_never_signals_or_reaps_a_numeric_pid(self): + _, error, events = fake_command_lifecycle(T, authority_error=True) + self.assertIsInstance(error, E.Refusal) + self.assertEqual(str(error), "provider_child_authority_unknown") + self.assertNotIn("foreign-signal", events) + self.assertNotIn("owned-signal", events) + self.assertNotIn("reap", events) + self.assertEqual(events.count("close-pipe"), 2) + + def test_missing_nonreaping_primitive_refuses_before_process_creation(self): + commands = T.Commands() + with patch.object(T.os, "waitid", None), patch.object(T.subprocess, "Popen") as spawn: + with self.assertRaisesRegex(E.Refusal, "provider_child_authority_unavailable"): + commands.run(["synthetic-read-only-cli"]) + spawn.assert_not_called() + + def test_an_automatic_or_custom_reaper_refuses_before_process_creation(self): + for handler in (T.signal.SIG_IGN, lambda *args: None): + commands = T.Commands() + with self.subTest(handler=handler), \ + patch.object(T.signal, "getsignal", return_value=handler), \ + patch.object(T.subprocess, "Popen") as spawn: + with self.assertRaisesRegex(E.Refusal, "provider_child_authority_unavailable"): + commands.run(["synthetic-read-only-cli"]) + spawn.assert_not_called() + + def test_signal_or_bounded_reap_failure_cannot_return_a_successful_read(self): + for fault in ("signal", "reap"): + with self.subTest(fault=fault): + result, error, events = fake_command_lifecycle(T, cleanup_error=fault) + self.assertIsNone(result) + self.assertIsInstance(error, E.Refusal) + self.assertEqual(str(error), "provider_child_cleanup_failed") + self.assertNotIn("foreign-signal", events) + self.assertEqual(events.count("close-pipe"), 2) + + class ResourceCommands: def __init__(self): self.containers, self.networks, self.volumes = {CID}, {NID}, {VOLUME} From 65bb6e08752ec51e7c846f6568f05c2ca71c597e Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:05:46 +0000 Subject: [PATCH 10/15] Resolve native child-state and lifecycle assertion findings --- scripts/ci/check-docker-workflow-isolation.py | 2 +- scripts/ci/native_alias_transport.py | 2 +- tests/Unit/Support/native_qualification_test.py | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/ci/check-docker-workflow-isolation.py b/scripts/ci/check-docker-workflow-isolation.py index 4ee9ed480..4c6eb10fd 100644 --- a/scripts/ci/check-docker-workflow-isolation.py +++ b/scripts/ci/check-docker-workflow-isolation.py @@ -18,7 +18,7 @@ QUALIFICATION_CLEANUP_SOURCES = { "scripts/ci/native_qualification.py": "ea74b534004c04209843a0ca28bb5a338cd2f2bec56fde1e511da9ffc20a911e", "scripts/ci/native_alias_evidence.py": "647d184c7a5e456410933a7188debc3fe23360d002ef6458ced742bb85f32c37", - "scripts/ci/native_alias_transport.py": "e9ab362d47046438fa8bddfd0c3ef25e8f08f68f6855a65f8a4d2a338c072b68", + "scripts/ci/native_alias_transport.py": "053af14689c7c69d6620860390801fc900a331126f32a4f68fac6eb0e6593fef", } diff --git a/scripts/ci/native_alias_transport.py b/scripts/ci/native_alias_transport.py index 0892b885f..2519aaf90 100644 --- a/scripts/ci/native_alias_transport.py +++ b/scripts/ci/native_alias_transport.py @@ -42,7 +42,7 @@ def child_state(pid: int): state = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT) except OSError: raise Refusal("provider_child_authority_unknown") from None - if state is None or state.si_pid == 0: + if state is None: return None require(state.si_pid == pid and state.si_code in (os.CLD_EXITED, os.CLD_KILLED, os.CLD_DUMPED), "provider_child_authority_unknown") diff --git a/tests/Unit/Support/native_qualification_test.py b/tests/Unit/Support/native_qualification_test.py index d1ab6cd9e..000373f7c 100644 --- a/tests/Unit/Support/native_qualification_test.py +++ b/tests/Unit/Support/native_qualification_test.py @@ -84,14 +84,18 @@ def select(self, timeout): return [(value, 1) for value in list(self.keys.values())] def observe(kind, pid, flags): - assert kind == os.P_PID and pid == process.pid and flags & os.WNOWAIT and flags & os.WNOHANG + assert kind == os.P_PID + assert pid == process.pid + assert flags & os.WNOWAIT + assert flags & os.WNOHANG events.append("observe") if authority_error or process.reaped: raise ChildProcessError("synthetic unowned child") return None if exit_code is None else SimpleNamespace(si_pid=pid, si_code=os.CLD_EXITED, si_status=exit_code) def signal_group(pid, sig): - assert pid == process.pid and sig == module.signal.SIGKILL + assert pid == process.pid + assert sig == module.signal.SIGKILL events.append("foreign-signal" if process.reaped else "owned-signal") if cleanup_error == "signal": raise PermissionError("synthetic-private-marker") From ebd3bf1db57c4b0d56f44206ababa8214c127929 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:56:04 +0000 Subject: [PATCH 11/15] Stage reviewed native promotion source without package jobs [skip ci] From 7b82310137a561960bca869eebca3ec51101541f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:50:53 +0000 Subject: [PATCH 12/15] Split MariaDB native execution tests across the existing two shards --- .github/workflows/server-parity.yml | 15 ++++++++++----- tests/Unit/ServerParityPartsTest.mjs | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 36bc297d0..afe5e2b4d 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -484,8 +484,8 @@ jobs: # Each database runs in six parts on separate runners: the native # database suites and TLS checks, the shared corpus, its profiles and # restart cases, the published-SDK direction profiles, the corrected - # marker cohort, and the other cohorts. The two MySQL 8 labels add a - # seventh, native-tests-b, for half their execution tests. Every part + # marker cohort, and the other cohorts. Each label adds a seventh, + # native-tests-b, for half its execution tests. Every part # bootstraps the same database and native build; no part depends on # another. matrix: @@ -496,9 +496,8 @@ jobs: image: mirror.gcr.io/library/mysql@sha256:62fb722c78b24245ddff1796a0fcee4a49cc5b87e0aaaf20c92d1da9e0a2497b # The expanded original was OOM-killed at 1 GiB during native pressure. memory: 2g - # The two MySQL 8 labels' native suites were the slowest parts - # (about 21 min each, most of it the execution tests), so their - # execution tests run in two halves: native-tests runs the first + # The native execution suites run in two halves to keep one + # database label from holding the result: native-tests runs the first # and native-tests-b the second. The bytewise-sorted test list is # dealt round-robin, so each listed test runs in exactly one half # and a new test joins one. @@ -522,6 +521,12 @@ jobs: image: mirror.gcr.io/library/mariadb@sha256:7db29378d4fdab73f8123bbc2b48905c90d1a4b00cf848b028f1e81e623257f2 # Both expanded originals exhausted the 512 MiB DB fixture ceiling. memory: 1g + execution_shards: 2 + - label: mariadb-10.11 + part: native-tests-b + image: mirror.gcr.io/library/mariadb@sha256:7db29378d4fdab73f8123bbc2b48905c90d1a4b00cf848b028f1e81e623257f2 + memory: 1g + execution_shards: 2 env: DW_PARITY_PART: ${{ matrix.part }} DW_EXECUTION_SHARDS: ${{ matrix.execution_shards || 1 }} diff --git a/tests/Unit/ServerParityPartsTest.mjs b/tests/Unit/ServerParityPartsTest.mjs index e9d3a7781..e53c37545 100644 --- a/tests/Unit/ServerParityPartsTest.mjs +++ b/tests/Unit/ServerParityPartsTest.mjs @@ -144,7 +144,7 @@ test('the database bootstraps run the native suites only in the native-tests par assert.match(mysql.text, /'case "\$DW_PARITY_PART" in native-tests\) shard=1 suffix= ;; native-tests-b\) shard=2 suffix=-b ;; \*\) shard= ;; esac; if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']*runtime::mysql::[^']* > \/evidence\/mysql-tests\.txt \|\| exit 1; fi; if \[ -n "\$shard" \]; then [^']*cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 \$\(cat \/evidence\/execution-shard\$suffix\.txt\) > \/evidence\/mysql-execution-tests\$suffix\.txt \|\| exit 1; fi; case "\$DW_PARITY_PART" in native-tests\|native-tests-b\) cargo build --locked --offline ;; \*\) test -x \/target\/debug\/durable-workflow-server ;; esac'/); }); -test('the MySQL 8 labels split their execution tests into two parts, every test in exactly one', () => { +test('all MySQL and MariaDB labels split execution tests into two parts, every test in exactly one', () => { const block = job('mysql-execution'); assert.match(block, /^ {6}DW_EXECUTION_SHARDS: \$\{\{ matrix\.execution_shards \|\| 1 \}\}$/m); // Matrix values come from the label's include entry; the added @@ -152,7 +152,7 @@ test('the MySQL 8 labels split their execution tests into two parts, every test const include = block.slice(block.indexOf('\n include:\n'), block.indexOf('\n env:\n')); const entries = include.split(/\n {10}- /).slice(1).map(text => Object.fromEntries( [...('label: ' + text.replace(/^label: /, '')).matchAll(/^\s*([a-z_]+): (\S+)$/gm)].map(match => match.slice(1)))); - const sharded = ['mysql-8.0', 'existing-php-mysql-matrix']; + const sharded = ['mysql-8.0', 'existing-php-mysql-matrix', 'mariadb-10.11']; for (const name of sharded) { const pair = entries.filter(entry => entry.label === name); assert.equal(pair.length, 2, JSON.stringify(entries)); From 66a2b0bfc7c21b696020feb3c4d27897892bef61 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:04:08 +0000 Subject: [PATCH 13/15] Retain bounded execution selections and refuse failed listings --- .github/workflows/server-parity.yml | 2 +- .../server-parity/artifact-summary.mjs | 1 + tests/Unit/NativeDebugArtifactTest.mjs | 23 +++++++++++++++---- .../Unit/ServerParityArtifactSummaryTest.mjs | 19 +++++++++++++++ tests/Unit/ServerParityPartsTest.mjs | 4 ++-- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index afe5e2b4d..f3473f0b3 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -712,7 +712,7 @@ jobs: -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ "${NATIVE_RUST_IMAGE}" -c \ - 'case "$DW_PARITY_PART" in native-tests) shard=1 suffix= ;; native-tests-b) shard=2 suffix=-b ;; *) shard= ;; esac; if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt || exit 1; fi; if [ -n "$shard" ]; then cargo test --locked --offline --test execution -- --list --format terse | sed -n "s/: test\$//p" | LC_ALL=C sort | awk -v count="$DW_EXECUTION_SHARDS" -v shard="$shard" "(NR - 1) % count == shard - 1" > /evidence/execution-shard$suffix.txt && test -s /evidence/execution-shard$suffix.txt && cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 $(cat /evidence/execution-shard$suffix.txt) > /evidence/mysql-execution-tests$suffix.txt || exit 1; fi; case "$DW_PARITY_PART" in native-tests|native-tests-b) cargo build --locked --offline ;; *) test -x /target/debug/durable-workflow-server ;; esac' + 'case "$DW_PARITY_PART" in native-tests) shard=1 suffix= ;; native-tests-b) shard=2 suffix=-b ;; *) shard= ;; esac; set -o pipefail; if [ "$DW_PARITY_PART" = native-tests ]; then cargo test --locked --offline --lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child --nocapture --test-threads=2 > /evidence/mysql-tests.txt || exit 1; fi; if [ -n "$shard" ]; then cargo test --locked --offline --test execution -- --list --format terse | sed -n "s/: test\$//p" | LC_ALL=C sort | tee /evidence/execution-listed-tests.txt | awk -v count="$DW_EXECUTION_SHARDS" -v shard="$shard" "(NR - 1) % count == shard - 1" > /evidence/execution-shard$suffix.txt && test -s /evidence/execution-shard$suffix.txt && cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 $(cat /evidence/execution-shard$suffix.txt) > /evidence/mysql-execution-tests$suffix.txt || exit 1; fi; case "$DW_PARITY_PART" in native-tests|native-tests-b) cargo build --locked --offline ;; *) test -x /target/debug/durable-workflow-server ;; esac' docker run --rm --user="$(id -u):$(id -g)" --network "$RESOURCE_SCOPE" \ -e DW_RUST_EXPERIMENTAL=1 -e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_DATABASE=native_ref \ -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password \ diff --git a/scripts/conformance/server-parity/artifact-summary.mjs b/scripts/conformance/server-parity/artifact-summary.mjs index 424392a1b..f780fa838 100644 --- a/scripts/conformance/server-parity/artifact-summary.mjs +++ b/scripts/conformance/server-parity/artifact-summary.mjs @@ -18,6 +18,7 @@ function priority(path, failed) { if (failed && (/\.stderr$/.test(name) || /(?:stderr|error|failure|bootstrap|install|build).*(?:\.log|\.txt)$/.test(name) || /^(?:php|rust|native)(?:[-.].*)?\.log$/.test(name) || /server\.log$/.test(name))) return 2; if (/^(identity|build|database-state|database-memory-limit)\.json$/.test(name)) return 3; + if (/^execution-(listed-tests|shard(?:-b)?)\.txt$/.test(name)) return 4; if (/(?:-status|-integrity|-version|-revision|-controls|-contract)\.txt$/.test(name) || /\.status$/.test(name) || /^(SHA256SUMS|source-hashes\.sha256)$/.test(name)) return 4; return null; diff --git a/tests/Unit/NativeDebugArtifactTest.mjs b/tests/Unit/NativeDebugArtifactTest.mjs index e081cba4c..3f134ab71 100644 --- a/tests/Unit/NativeDebugArtifactTest.mjs +++ b/tests/Unit/NativeDebugArtifactTest.mjs @@ -279,7 +279,7 @@ function databaseCommand(id) { return line.trim().slice(1, -1); } -function runDatabaseCommand(t, id, part, binary = true) { +function runDatabaseCommand(t, id, part, binary = true, listExit = 0) { const root = mkdtempSync(join(tmpdir(), 'native-debug-cargo-')); t.after(() => rmSync(root, {recursive: true, force: true})); mkdirSync(join(root, 'bin')); @@ -292,13 +292,20 @@ function runDatabaseCommand(t, id, part, binary = true) { const fs = require('node:fs'); const args = process.argv.slice(2); fs.appendFileSync(process.env.CARGO_CALL_LOG, JSON.stringify(args) + '\\n'); -if(args.includes('--list')) console.log('case_a: test\\ncase_b: test'); +if(args.includes('--list')) { + console.log('case_a: test\\ncase_b: test'); + process.exit(Number(process.env.CARGO_LIST_EXIT || 0)); +} `, {mode: 0o755}); const script = databaseCommand(id).replaceAll('/evidence/', `${root}/evidence/`).replaceAll('/target/', `${root}/target/`); const run = spawnSync('bash', ['-c', script], {encoding: 'utf8', env: {...process.env, - PATH: `${root}/bin:${process.env.PATH}`, CARGO_CALL_LOG: log, DW_PARITY_PART: part, DW_EXECUTION_SHARDS: '2'}}); + PATH: `${root}/bin:${process.env.PATH}`, CARGO_CALL_LOG: log, CARGO_LIST_EXIT: String(listExit), + DW_PARITY_PART: part, DW_EXECUTION_SHARDS: '2'}}); const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); - return {...run, calls}; + const listed = join(root, 'evidence/execution-listed-tests.txt'); + const selected = join(root, `evidence/execution-shard${part === 'native-tests-b' ? '-b' : ''}.txt`); + return {...run, calls, listed: existsSync(listed) ? readFileSync(listed, 'utf8') : null, + selected: existsSync(selected) ? readFileSync(selected, 'utf8') : null}; } for (const part of ['native-tests', 'native-tests-b']) { @@ -314,6 +321,14 @@ for (const part of ['native-tests', 'native-tests-b']) { ['test', ...flags, '--test', 'execution', '--', '--exact', '--nocapture', '--test-threads=2', part === 'native-tests' ? 'case_a' : 'case_b'], ['build', ...flags]); assert.deepEqual(run.calls, expected); + assert.equal(run.listed, 'case_a\ncase_b\n'); + assert.equal(run.selected, part === 'native-tests' ? 'case_a\n' : 'case_b\n'); + }); + test(`MySQL ${part} refuses a failing test-list command even with partial output`, t => { + const run = runDatabaseCommand(t, 'mysql-execution', part, true, 7); + assert.notEqual(run.status, 0); + assert.equal(run.calls.some(args => args.includes('--exact')), false); + assert.equal(run.calls.some(args => args[0] === 'build'), false); }); } diff --git a/tests/Unit/ServerParityArtifactSummaryTest.mjs b/tests/Unit/ServerParityArtifactSummaryTest.mjs index dce60536a..30b7140d9 100644 --- a/tests/Unit/ServerParityArtifactSummaryTest.mjs +++ b/tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -40,6 +40,25 @@ test('successful summaries preserve exact reports and omit journals, DBs, payloa assert.throws(() => summarizeEvidence(source, output), /fresh empty directory/); }); +test('execution inventories retain exact bytes with explicit truncation and omit unrelated lists', t => { + const {source, output} = fixture(t); + const inventories = {'execution-listed-tests.txt': 'case_a\ncase_b\n', + 'execution-shard.txt': 'case_a\n', 'execution-shard-b.txt': 'case_b\n'}; + for (const [name, content] of Object.entries(inventories)) writeFileSync(join(source, name), content); + writeFileSync(join(source, 'execution-unrelated.txt'), 'not a selected-test inventory'); + const summary = summarizeEvidence(source, output, {outcome: 'success'}); + for (const [name, content] of Object.entries(inventories)) { + assert.equal(readFileSync(join(output, name), 'utf8'), content); + assert.equal(summary.retained.find(entry => entry.path === name).tail_only, false); + } + assert.equal(existsSync(join(output, 'execution-unrelated.txt')), false); + const other = fixture(t); + writeFileSync(join(other.source, 'execution-listed-tests.txt'), 'x'.repeat(64 * 1024 + 1)); + const truncated = summarizeEvidence(other.source, other.output, {outcome: 'success'}).retained[0]; + assert.equal(truncated.retained_bytes, 64 * 1024); + assert.equal(truncated.tail_only, true); +}); + test('failed summaries keep bounded diagnostic tails without truncating JSON into a false report', t => { const {source, output} = fixture(t); const diagnostic = `${'x'.repeat(128 * 1024)}\nHTTP 500 while installing pinned dependency\n`; diff --git a/tests/Unit/ServerParityPartsTest.mjs b/tests/Unit/ServerParityPartsTest.mjs index e53c37545..4685aaa79 100644 --- a/tests/Unit/ServerParityPartsTest.mjs +++ b/tests/Unit/ServerParityPartsTest.mjs @@ -141,7 +141,7 @@ test('the database bootstraps run the native suites only in the native-tests par assert.match(mysql.text, /-e DW_PARITY_PART -e DW_EXECUTION_SHARDS \\\n/); // native-tests runs the database suite and the first half of the execution // tests; native-tests-b, where a label has it, runs the second half. - assert.match(mysql.text, /'case "\$DW_PARITY_PART" in native-tests\) shard=1 suffix= ;; native-tests-b\) shard=2 suffix=-b ;; \*\) shard= ;; esac; if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']*runtime::mysql::[^']* > \/evidence\/mysql-tests\.txt \|\| exit 1; fi; if \[ -n "\$shard" \]; then [^']*cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 \$\(cat \/evidence\/execution-shard\$suffix\.txt\) > \/evidence\/mysql-execution-tests\$suffix\.txt \|\| exit 1; fi; case "\$DW_PARITY_PART" in native-tests\|native-tests-b\) cargo build --locked --offline ;; \*\) test -x \/target\/debug\/durable-workflow-server ;; esac'/); + assert.match(mysql.text, /'case "\$DW_PARITY_PART" in native-tests\) shard=1 suffix= ;; native-tests-b\) shard=2 suffix=-b ;; \*\) shard= ;; esac; set -o pipefail; if \[ "\$DW_PARITY_PART" = native-tests \]; then cargo test [^']*runtime::mysql::[^']* > \/evidence\/mysql-tests\.txt \|\| exit 1; fi; if \[ -n "\$shard" \]; then [^']*cargo test --locked --offline --test execution -- --exact --nocapture --test-threads=2 \$\(cat \/evidence\/execution-shard\$suffix\.txt\) > \/evidence\/mysql-execution-tests\$suffix\.txt \|\| exit 1; fi; case "\$DW_PARITY_PART" in native-tests\|native-tests-b\) cargo build --locked --offline ;; \*\) test -x \/target\/debug\/durable-workflow-server ;; esac'/); }); test('all MySQL and MariaDB labels split execution tests into two parts, every test in exactly one', () => { @@ -164,7 +164,7 @@ test('all MySQL and MariaDB labels split execution tests into two parts, every t assert.ok(entries.filter(entry => !sharded.includes(entry.label)).every(entry => !entry.execution_shards && !entry.part)); // The selection the bootstrap runs: list, sort bytewise, deal round-robin. const bootstrap = steps(block).find(step => step.name.startsWith('Bootstrap published ')).text; - const selection = /cargo test --locked --offline --test execution -- --list --format terse \| sed -n "s\/: test\\\$\/\/p" \| LC_ALL=C sort \| awk -v count="\$DW_EXECUTION_SHARDS" -v shard="\$shard" "([^"]+)" > \/evidence\/execution-shard\$suffix\.txt && test -s \/evidence\/execution-shard\$suffix\.txt && /.exec(bootstrap); + const selection = /cargo test --locked --offline --test execution -- --list --format terse \| sed -n "s\/: test\\\$\/\/p" \| LC_ALL=C sort \| tee \/evidence\/execution-listed-tests\.txt \| awk -v count="\$DW_EXECUTION_SHARDS" -v shard="\$shard" "([^"]+)" > \/evidence\/execution-shard\$suffix\.txt && test -s \/evidence\/execution-shard\$suffix\.txt && /.exec(bootstrap); assert.ok(selection, 'the execution tests are selected by the listed round-robin'); const names = Array.from({length: 489}, (_, index) => `suite::test_${String(index).padStart(3, '0')}`); const deal = (shard, count) => { From 0cb2b45e6c0863154cf001c9e8a71fce8af38475 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:19:44 +0000 Subject: [PATCH 14/15] Isolate frozen PHP post-prune cleanup from its SQLite maintenance writer --- .github/workflows/server-parity.yml | 8 ++++++++ .../server-parity/native-image-corpus.sh | 14 +++++++++++--- .../server-parity/update-validation-sqlite.sh | 1 + tests/Unit/NativeImageCorpusTest.mjs | 7 ++++--- tests/Unit/ServerParityFixtureSelectionTest.mjs | 5 ++++- .../ServerParityRetentionPayloadsContractTest.mjs | 1 + 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 36bc297d0..ed17c055a 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -2400,6 +2400,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2415,6 +2416,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2430,6 +2432,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2444,6 +2447,7 @@ jobs: --exclude-fixture tests/Fixtures/ServerParity/service-calls.json \ --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json \ --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json \ + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-pending.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-insertion-activity-completed.json \ --include-fixture tests/Fixtures/ServerParityPending/patch-created-activity-pending.json \ @@ -2465,6 +2469,9 @@ jobs: # under the frozen PHP reference's SQLite maintenance writer. namespace_delete_payloads_status=0 bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 namespace-delete-payloads || namespace_delete_payloads_status=$? + # Post-prune payload cleanup has the same frozen PHP writer contention. + retention_payloads_status=0 + bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 retention-external-payloads || retention_payloads_status=$? # A native fixture difference fails the step only after the # comparison has run, so one difference cannot hide the rest. test "$native_record_status" -eq 0 @@ -2472,6 +2479,7 @@ jobs: test "$service_calls_status" -eq 0 test "$schedule_catalog_status" -eq 0 test "$namespace_delete_payloads_status" -eq 0 + test "$retention_payloads_status" -eq 0 - name: Compare published patch, legacy-marker and range-repair profiles if: ${{ matrix.part == 'restarts' }} env: diff --git a/scripts/conformance/server-parity/native-image-corpus.sh b/scripts/conformance/server-parity/native-image-corpus.sh index 367d0367a..539eb36ef 100755 --- a/scripts/conformance/server-parity/native-image-corpus.sh +++ b/scripts/conformance/server-parity/native-image-corpus.sh @@ -31,10 +31,12 @@ image=$(jq -r '.server.images["linux/amd64"] | sub("^durableworkflow/server@"; " # The frozen PHP reference loses SQLite writes under maintenance contention. # service-calls (#796), schedule-catalog (#1021) and namespace-delete-payloads -# (#1018) record separately with PHP's loop stopped; native maintenance remains enabled. +# (#1018), plus post-prune payload cleanup, record separately with PHP's loop +# stopped; native maintenance remains enabled. selection=(--exclude-fixture tests/Fixtures/ServerParity/service-calls.json --exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json - --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json) + --exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json + --exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json) for pending in patch-insertion-activity-pending patch-insertion-activity-completed patch-created-activity-pending \ patch-created-activity-completed workflow-waiting-history-type workflow-waiting-history-message workflow-task-transient-retry; do selection+=(--include-fixture "tests/Fixtures/ServerParityPending/$pending.json") @@ -127,15 +129,20 @@ namespace_delete_payloads_status=0 bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 namespace-delete-payloads \ || namespace_delete_payloads_status=$? +retention_payloads_status=0 +bash scripts/conformance/server-parity/update-validation-sqlite.sh http://parity-server:8080 http://parity-rust:8080 retention-external-payloads \ + || retention_payloads_status=$? + native_id=$(docker image inspect --format '{{.Id}}' "$native") jq -n --arg native "$native" --arg id "$native_id" --arg php "$image" --arg revision "$DW_PARITY_RUNNER_REVISION" \ --argjson reference "$status" --argjson nativeStatus "$native_status" --argjson compare "$compare_status" \ --argjson serviceCalls "$service_calls_status" \ --argjson scheduleCatalog "$schedule_catalog_status" \ --argjson namespaceDeletePayloads "$namespace_delete_payloads_status" \ + --argjson retentionPayloads "$retention_payloads_status" \ '{schema:"durable-workflow.native-image-corpus.v1", native_image:$native, native_image_id:$id, php_reference:$php, runner_revision:$revision, backend:"sqlite", - status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare, service_calls:$serviceCalls, schedule_catalog:$scheduleCatalog, namespace_delete_payloads:$namespaceDeletePayloads}}' \ + status:{reference_records:$reference, native_record:$nativeStatus, compare:$compare, service_calls:$serviceCalls, schedule_catalog:$scheduleCatalog, namespace_delete_payloads:$namespaceDeletePayloads, retention_payloads:$retentionPayloads}}' \ > parity-evidence/native-image-corpus.json cat parity-evidence/native-image-corpus.json test "$status" -eq 0 @@ -144,3 +151,4 @@ test "$compare_status" -eq 0 test "$service_calls_status" -eq 0 test "$schedule_catalog_status" -eq 0 test "$namespace_delete_payloads_status" -eq 0 +test "$retention_payloads_status" -eq 0 diff --git a/scripts/conformance/server-parity/update-validation-sqlite.sh b/scripts/conformance/server-parity/update-validation-sqlite.sh index 1e6aa2387..0856dc564 100644 --- a/scripts/conformance/server-parity/update-validation-sqlite.sh +++ b/scripts/conformance/server-parity/update-validation-sqlite.sh @@ -7,6 +7,7 @@ # service-calls (#796) or schedule-catalog, whose backfill can lose an # occurrence to PHP's concurrent SQLite maintenance writer (#1021). # namespace-delete-payloads can leave a post-commit deletion pending (#1018). +# retention-external-payloads has the same contention after a prune commits. # These fixtures keep their exact assertions in the same PHP-only window. # # The published PHP record runs with PHP's maintenance loop stopped. On SQLite diff --git a/tests/Unit/NativeImageCorpusTest.mjs b/tests/Unit/NativeImageCorpusTest.mjs index 932ac30c3..6cc62e1bd 100644 --- a/tests/Unit/NativeImageCorpusTest.mjs +++ b/tests/Unit/NativeImageCorpusTest.mjs @@ -71,7 +71,7 @@ esac`, test('native image qualification records every selected fixture once across main and isolated records', () => { const result = run(); assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads']); + assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads', 'retention-external-payloads']); const commands = result.calls.filter(args => args.includes('scripts/conformance/server-parity.mjs')); assert.equal(commands.length, 4, 'three main records and their comparison'); let first; @@ -88,7 +88,7 @@ test('native image qualification records every selected fixture once across main first = all; } assert.deepEqual(result.receipt.status, {reference_records: 0, native_record: 0, compare: 0, - service_calls: 0, schedule_catalog: 0, namespace_delete_payloads: 0}); + service_calls: 0, schedule_catalog: 0, namespace_delete_payloads: 0, retention_payloads: 0}); assert.equal(result.receipt.runner_revision, revision); }); @@ -96,10 +96,11 @@ for (const [stage, field] of [ ['php', 'reference_records'], ['embedded', 'reference_records'], ['rust', 'native_record'], ['compare', 'compare'], ['service-calls', 'service_calls'], ['schedule-catalog', 'schedule_catalog'], ['namespace-delete-payloads', 'namespace_delete_payloads'], + ['retention-external-payloads', 'retention_payloads'], ]) test(`failure in ${stage} is retained in the receipt and fails image qualification`, () => { const result = run(stage); assert.notEqual(result.status, 0, result.stderr); assert.equal(result.receipt.status[field], 17); - assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads'], + assert.deepEqual(result.helpers, ['service-calls', 'schedule-catalog', 'namespace-delete-payloads', 'retention-external-payloads'], 'one failure does not hide the separate checks'); }); diff --git a/tests/Unit/ServerParityFixtureSelectionTest.mjs b/tests/Unit/ServerParityFixtureSelectionTest.mjs index 95d363010..658bce28f 100644 --- a/tests/Unit/ServerParityFixtureSelectionTest.mjs +++ b/tests/Unit/ServerParityFixtureSelectionTest.mjs @@ -57,7 +57,7 @@ function mainCorpusCommands(job) { return job.split(/(?=docker run )/).filter(command => /server-parity\.mjs (record|compare)/.test(command)); } -test('the SQLite job keeps update-validation in its main corpus and isolates the three contention-sensitive fixtures', () => { +test('the SQLite job keeps update-validation in its main corpus and isolates the contention-sensitive fixtures', () => { const sqlite = jobs()['execution-and-codec']; const commands = mainCorpusCommands(sqlite); assert.equal(commands.length, 4, 'php, embedded and rust records plus their comparison'); @@ -66,6 +66,7 @@ test('the SQLite job keeps update-validation in its main corpus and isolates the '--exclude-fixture tests/Fixtures/ServerParity/service-calls.json', '--exclude-fixture tests/Fixtures/ServerParity/schedule-catalog.json', '--exclude-fixture tests/Fixtures/ServerParity/namespace-delete-payloads.json', + '--exclude-fixture tests/Fixtures/ServerParity/retention-external-payloads.json', ]); } assert.ok(corpus.includes('update-validation.json')); @@ -76,6 +77,8 @@ test('the SQLite job keeps update-validation in its main corpus and isolates the assert.match(sqlite, /test "\$schedule_catalog_status" -eq 0/); assert.match(sqlite, /namespace_delete_payloads_status=0\n\s+bash scripts\/conformance\/server-parity\/update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 namespace-delete-payloads \|\| namespace_delete_payloads_status=\$\?/); assert.match(sqlite, /test "\$namespace_delete_payloads_status" -eq 0/); + assert.match(sqlite, /retention_payloads_status=0\n\s+bash scripts\/conformance\/server-parity\/update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 retention-external-payloads \|\| retention_payloads_status=\$\?/); + assert.match(sqlite, /test "\$retention_payloads_status" -eq 0/); assert.doesNotMatch(sqlite, /update-validation-sqlite\.sh http:\/\/parity-server:8080 http:\/\/parity-rust:8080 \|\|/); }); diff --git a/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs b/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs index c7cb1219f..7f163eac7 100644 --- a/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs +++ b/tests/Unit/ServerParityRetentionPayloadsContractTest.mjs @@ -33,6 +33,7 @@ test('the embedded retention payloads are explicitly inapplicable', () => { for (const [label, mutate] of [ ['a skipped run that holds a payload', state => { state.steps['sole pruned'].result = {outcome: 'skipped', reason: 'external_payload_cleanup_unavailable'}; }], ['an uncounted deletion', state => { state.steps['sole pruned'].result.external_payloads_deleted = 0; }], + ['a post-commit deletion still pending', state => { state.steps['sole pruned'].cleanup.pending = 1; }], ['a payload left after its run', state => { state.steps['sole fetched'] = {status: 200, bytes_match: true}; }], ['a held payload deleted', state => { state.steps['shared fetched while held'] = {status: 404, reason: 'external_payload_not_found'}; }], ['a peer-held payload counted', state => { state.steps['peer-held pruned'].result.external_payloads_deleted = 1; }], From 22ef3f86a44b5331056b97aa0e8e3c6627e83240 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:24:24 +0000 Subject: [PATCH 15/15] Keep the second execution shard report in bounded CI evidence --- scripts/conformance/server-parity/artifact-summary.mjs | 2 +- tests/Unit/ServerParityArtifactSummaryTest.mjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/conformance/server-parity/artifact-summary.mjs b/scripts/conformance/server-parity/artifact-summary.mjs index f780fa838..1903a0e73 100644 --- a/scripts/conformance/server-parity/artifact-summary.mjs +++ b/scripts/conformance/server-parity/artifact-summary.mjs @@ -24,7 +24,7 @@ function priority(path, failed) { return null; } -const TEST_SUMMARY = /^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests\.txt$/; +const TEST_SUMMARY = /^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests(-b)?\.txt$/; // A node:test (TAP) report's failing tests: each `not ok` line with its // indented YAML diagnostic block, ending at the block's `...` line. A large diff --git a/tests/Unit/ServerParityArtifactSummaryTest.mjs b/tests/Unit/ServerParityArtifactSummaryTest.mjs index 30b7140d9..e9012bb05 100644 --- a/tests/Unit/ServerParityArtifactSummaryTest.mjs +++ b/tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -43,7 +43,8 @@ test('successful summaries preserve exact reports and omit journals, DBs, payloa test('execution inventories retain exact bytes with explicit truncation and omit unrelated lists', t => { const {source, output} = fixture(t); const inventories = {'execution-listed-tests.txt': 'case_a\ncase_b\n', - 'execution-shard.txt': 'case_a\n', 'execution-shard-b.txt': 'case_b\n'}; + 'execution-shard.txt': 'case_a\n', 'execution-shard-b.txt': 'case_b\n', + 'mysql-execution-tests-b.txt': 'test result: ok. 1 passed; 0 failed;\n'}; for (const [name, content] of Object.entries(inventories)) writeFileSync(join(source, name), content); writeFileSync(join(source, 'execution-unrelated.txt'), 'not a selected-test inventory'); const summary = summarizeEvidence(source, output, {outcome: 'success'});