From 0bc80eee5d90cf7bb10a7d8876bc52fcdb2c7504 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 20 Aug 2026 09:59:18 +0200 Subject: [PATCH 1/3] docs(release): pull the candidate before scanning it Renewing an image advisory fingerprint by hand followed the documented commands and still failed the gate, because Syft and Grype only report an image target's architecture and os from a daemon-backed provider. Scanned straight from the registry both fields come back empty and the gate rejects the evidence as not linux/amd64, which reads as an architecture mismatch on an image that is amd64. The candidate workflow never hits this: it runs the image to record --version before it scans, so the daemon already holds it. Signed-off-by: Jeremi Joslin --- release/OPERATIONS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 07a888aa2..eec7390a2 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -273,6 +273,7 @@ candidate_ref="ghcr.io/registrystack/${name}-candidate@${digest}" evidence_dir="advisory-renewal-${name}-${run_id}-${run_attempt}" mkdir -p "${evidence_dir}/rootfs" crane config "${candidate_ref}" > "${evidence_dir}/oci-config.json" +docker pull --platform linux/amd64 "${candidate_ref}" >/dev/null SYFT_FILE_METADATA_SELECTION=all SYFT_FILE_METADATA_DIGESTS=sha256 \ syft "${candidate_ref}" -o syft-json="${evidence_dir}/syft.json" grype "${candidate_ref}" -o json > "${evidence_dir}/grype.json" @@ -281,6 +282,15 @@ crane export "${candidate_ref}" - | tar --extract --file=- \ --no-same-owner --no-same-permissions ``` +The pull is what makes the scan admissible, not a convenience. Syft and Grype +fill the `architecture` and `os` fields of their image target only from a +daemon-backed provider. Resolved straight from the registry they leave both +empty, and `check-advisory-baselines.py` rejects that evidence with +`grype image target must be linux/amd64`. On an `amd64` candidate the message +does not mean the architecture is wrong; it means the scan never went through +the daemon. The candidate workflow satisfies this incidentally, by running the +image once to record its `--version` before it scans. + Select the matching baseline and confirm its pinned base is still the exact prefix of the candidate's authoritative uncompressed DiffIDs: From 46d67dac959d95ef00e3626f26093b2fb4773699 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 20 Aug 2026 10:14:10 +0200 Subject: [PATCH 2/3] test(release): lock the candidate run before its scans The runbook fix documents that a scan is only admissible once the image has gone through the daemon, and notes the candidate workflow satisfies this incidentally by running the image for its --version first. Nothing held that ordering, so removing or reordering the run would reintroduce the same misleading linux/amd64 rejection on an amd64 image. Assert the run precedes both the Syft and the Grype scan. Signed-off-by: Jeremi Joslin --- .../scripts/test_release_workflow_structure.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index f65b4a836..49f258fae 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -772,6 +772,23 @@ def test_only_pre_oidc_reverification_accepts_the_current_run(self) -> None: self.assertEqual(text.count("--allow-current-run-in-progress"), 1) self.assertIn("--allow-current-run-in-progress", reverify) + def test_runs_each_candidate_image_before_it_scans_it(self) -> None: + _, document = workflow("release-candidate.yml") + scan = step_run( + document, + "assemble", + "Verify and scan exact candidate images", + ) + # Syft and Grype fill an image target's architecture and os only from + # a daemon-backed provider, and check-advisory-baselines.py rejects + # evidence that leaves them empty. Running the image is what puts it + # in the daemon, so moving either scan ahead of it fails the gate with + # a linux/amd64 message on an image that is amd64. + run_image = scan.index('"${candidate_ref}" --version') + self.assertIn("docker run --rm", scan[:run_image]) + self.assertLess(run_image, scan.index('syft "${candidate_ref}"')) + self.assertLess(run_image, scan.index("scan_image \\")) + class PublicationWorkflowStructureTest(unittest.TestCase): def test_is_a_manual_main_workflow_with_recoverable_jobs(self) -> None: From 36b354a152b8a207df38caa3bbbc341589ae2844 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 20 Aug 2026 10:18:40 +0200 Subject: [PATCH 3/3] test(release): lock the documented renewal pull ordering The workflow-ordering test covers the candidate run but not the renewal snippet this change actually adds, so removing the documented pull left the operator path uncovered. Assert the snippet pulls the candidate before both scanners. Signed-off-by: Jeremi Joslin --- .../scripts/test_release_workflow_structure.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index 49f258fae..f9dfe7bd2 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -1225,6 +1225,22 @@ def test_operator_docs_match_the_latest_non_prerelease_contract(self) -> None: self.assertIn(".isPrerelease == false", verify) self.assertNotIn(".isPrerelease == true", verify) + def test_advisory_renewal_pulls_the_candidate_before_scanning_it(self) -> None: + operations = (ROOT / "release/OPERATIONS.md").read_text(encoding="utf-8") + renewal = next( + block + for block in operations.split("```") + if 'syft "${candidate_ref}"' in block + ) + # The renewal commands are run by hand, so they carry the daemon-backed + # pull the candidate workflow gets incidentally from running the image. + # Without it Syft and Grype leave the target's architecture and os + # empty and check-advisory-baselines.py rejects the evidence as not + # linux/amd64. + pull = renewal.index('docker pull --platform linux/amd64 "${candidate_ref}"') + self.assertLess(pull, renewal.index('syft "${candidate_ref}"')) + self.assertLess(pull, renewal.index('grype "${candidate_ref}"')) + def test_docs_deploys_main_and_rechecks_latest_docs_release(self) -> None: text, document = workflow("docs-pages.yml") releases_endpoint = '"repos/${GITHUB_REPOSITORY}/releases?per_page=100"'