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: diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index f65b4a836..f9dfe7bd2 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: @@ -1208,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"'