diff --git a/.github/compatibility/README.md b/.github/compatibility/README.md index 08b5e98..a7987e5 100644 --- a/.github/compatibility/README.md +++ b/.github/compatibility/README.md @@ -52,7 +52,26 @@ changes therefore cannot drift independently. evidence. These are accountable roles, not long-lived signing keys. The promoted-manifest -signer must use a short-lived GitHub Actions OIDC identity bound to a reviewed, -SHA-pinned workflow. A candidate manifest is not promoted merely because it -validates. Until that signer and its verification receipt are present, retain -the manifest as `candidate`; do not represent it as an immutable signed release. +signer must use a short-lived GitHub Actions OIDC identity bound to the reviewed +managed workflow ref and record the exact resolved workflow commit. A candidate +manifest is not promoted merely because it validates. Until that signer and its +verification receipt are present, retain the manifest as `candidate`; do not +represent it as an immutable signed release. + +## Publication evidence input + +Every signed invocation of the shared `build-push` workflow uploads a +`publication-record-...` artifact for 90 days and exposes its artifact ID, +GitHub-reported artifact digest, and authenticated URL as reusable-workflow +outputs. The JSON record contains the exact caller source commit, managed +publisher identity, resolved publisher commit, caller workflow, hosted run, +native image digests, final tag-plus-digest references, and verified SBOM and +provenance evidence for every registry alias. + +The record is generated only after every final image has the same digest and +its signature and attestations verify. Manifest producers should consume these +records, add component-owned contract evidence, and validate the assembled +candidate. Do not copy the resolved publisher commit back into a caller's +`uses:` reference: LibOps callers stay on the managed `@main` channel, while +the generated record preserves the immutable identity used for release and +rollback evidence. diff --git a/.github/workflows/build-push.yaml b/.github/workflows/build-push.yaml index 319edc8..44bbf3b 100644 --- a/.github/workflows/build-push.yaml +++ b/.github/workflows/build-push.yaml @@ -88,6 +88,16 @@ on: DOCKERHUB_PASSWORD: required: false description: "Docker Hub token, required only when Docker Hub is the primary registry" + outputs: + publication-record-artifact-id: + description: "Artifact ID for the verified publication record; empty when signing is disabled" + value: ${{ jobs.merge.outputs.publication-record-artifact-id }} + publication-record-artifact-digest: + description: "SHA-256 digest reported by GitHub for the publication-record artifact" + value: ${{ jobs.merge.outputs.publication-record-artifact-digest }} + publication-record-artifact-url: + description: "Authenticated GitHub URL for the publication-record artifact" + value: ${{ jobs.merge.outputs.publication-record-artifact-url }} jobs: build: strategy: @@ -490,6 +500,10 @@ jobs: timeout-minutes: 30 needs: - build + outputs: + publication-record-artifact-id: ${{ steps.publication-record.outputs.artifact-id }} + publication-record-artifact-digest: ${{ steps.publication-record.outputs.artifact-digest }} + publication-record-artifact-url: ${{ steps.publication-record.outputs.artifact-url }} steps: - name: Resolve registry host id: registry @@ -757,11 +771,22 @@ jobs: PRIMARY_IMAGE: ${{ steps.metadata.outputs.primary-image }} PRIMARY_REGISTRY: ${{ inputs.docker-registry }} PUBLICATION_TAG: ${{ steps.metadata.outputs.tag }} + PUBLICATION_RECORD_PATH: ${{ runner.temp }}/publication-record.json SBOM_AMD64_PATH: ${{ runner.temp }}/final-image.linux-amd64.spdx.json SBOM_ARM64_PATH: ${{ runner.temp }}/final-image.linux-arm64.spdx.json SLSA_PROVENANCE_PATH: ${{ runner.temp }}/final-image.slsa-provenance.json run: python3 gha/ci/github/sign_and_attest.py + - name: Preserve verified publication record + id: publication-record + if: inputs.sign + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: publication-record-${{ steps.metadata.outputs.artifact-name }} + path: ${{ runner.temp }}/publication-record.json + if-no-files-found: error + retention-days: 90 + cleanup: name: cleanup-staging-tags runs-on: ubuntu-24.04 diff --git a/README.md b/README.md index afa6a6f..eabc71e 100644 --- a/README.md +++ b/README.md @@ -256,3 +256,11 @@ cosign verify-attestation --type https://slsa.dev/provenance/v1 \ Promotion must retain these verification results in the platform compatibility manifest. Producing attestations does not replace application, vulnerability, hosted-canary, or rollback evidence. + +Each signed invocation also retains a machine-readable publication record for +90 days. Reusable-workflow outputs expose the record's artifact ID, GitHub +artifact digest, and authenticated URL. The record contains the exact source, +resolved builder, caller workflow, native and final image digests, and the +verified attestation evidence needed by a platform-release candidate. It is a +generated evidence artifact, not a source pin: LibOps callers continue to use +the managed `build-push.yaml@main` channel. diff --git a/ci/github/sign_and_attest.py b/ci/github/sign_and_attest.py index 5d68e59..9a551d5 100644 --- a/ci/github/sign_and_attest.py +++ b/ci/github/sign_and_attest.py @@ -488,6 +488,71 @@ def publication_images(config: Configuration) -> tuple[str, ...]: return tuple(images) +def build_publication_record( + config: Configuration, + native_digests: Mapping[str, str], + final_digest: str, +) -> Mapping[str, object]: + source_commit = config.caller_ref or config.github_sha + if not COMMIT_PATTERN.fullmatch(source_commit): + raise ValueError("cannot record publication from a non-immutable source ref") + validated_native_digests = { + f"linux/{architecture}": validate_digest(native_digests[architecture]) + for architecture in ("amd64", "arm64") + } + validated_final_digest = validate_digest(final_digest) + builder_commit = config.resolved_builder_identity.rsplit("@", 1)[1] + source = { + "repository": f"https://github.com/{config.github_repository}", + "commit": source_commit.lower(), + } + publisher = { + "certificateIdentity": config.certificate_identity, + "builderCommit": builder_commit, + "callerWorkflowRef": config.caller_workflow_ref, + } + publication_run = ( + f"https://github.com/{config.github_repository}/actions/runs/" + f"{config.github_run_id}" + ) + attestations = { + **publisher, + "sbom": { + "predicateType": "https://spdx.dev/Document", + "platforms": ["linux/amd64", "linux/arm64"], + "verificationRun": publication_run, + }, + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1", + "verificationRun": publication_run, + }, + } + return { + "schemaVersion": 1, + "source": source, + "publisher": publisher, + "publicationRun": publication_run, + "nativeDigests": validated_native_digests, + "images": [ + { + "reference": ( + f"{image}:{config.publication_tag}@{validated_final_digest}" + ), + "source": source, + "attestations": attestations, + } + for image in publication_images(config) + ], + } + + +def write_publication_record( + path: Path, + record: Mapping[str, object], +) -> None: + path.write_text(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + + def main() -> int: config = Configuration.from_environment(os.environ) validate_oidc_claims(config, request_oidc_claims(config)) @@ -499,6 +564,10 @@ def main() -> int: if image_digest != final_digest: raise ValueError(f"{image} does not match the verified primary manifest") sign_attest_and_verify(config, native_digests, image, image_digest) + write_publication_record( + Path(required(os.environ, "PUBLICATION_RECORD_PATH")), + build_publication_record(config, native_digests, final_digest), + ) return 0 diff --git a/ci/github/test_sign_and_attest.py b/ci/github/test_sign_and_attest.py index 18da111..aac131f 100644 --- a/ci/github/test_sign_and_attest.py +++ b/ci/github/test_sign_and_attest.py @@ -270,6 +270,80 @@ def test_publication_images_include_primary_mirror_and_aliases(self) -> None: ), ) + def test_publication_record_retains_resolved_release_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = configuration(Path(directory)) + record = sign_and_attest.build_publication_record( + config, + {"amd64": AMD64_DIGEST, "arm64": ARM64_DIGEST}, + FINAL_DIGEST, + ) + + self.assertEqual(record["schemaVersion"], 1) + self.assertEqual( + record["source"], + { + "repository": "https://github.com/libops/example", + "commit": SHA, + }, + ) + self.assertEqual( + record["publisher"], + { + "certificateIdentity": config.certificate_identity, + "builderCommit": SHA, + "callerWorkflowRef": config.caller_workflow_ref, + }, + ) + self.assertEqual( + record["publicationRun"], + "https://github.com/libops/example/actions/runs/42", + ) + self.assertEqual( + record["nativeDigests"], + { + "linux/amd64": AMD64_DIGEST, + "linux/arm64": ARM64_DIGEST, + }, + ) + self.assertEqual( + [image["reference"] for image in record["images"]], + [ + f"ghcr.io/libops/example:main@{FINAL_DIGEST}", + f"us-docker.pkg.dev/libops/primary:main@{FINAL_DIGEST}", + f"ghcr.io/libops/alias:main@{FINAL_DIGEST}", + f"us-docker.pkg.dev/libops/alias:main@{FINAL_DIGEST}", + ], + ) + for image in record["images"]: + self.assertEqual(image["source"], record["source"]) + self.assertEqual( + image["attestations"], + { + **record["publisher"], + "sbom": { + "predicateType": "https://spdx.dev/Document", + "platforms": ["linux/amd64", "linux/arm64"], + "verificationRun": record["publicationRun"], + }, + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1", + "verificationRun": record["publicationRun"], + }, + }, + ) + + mutable_builder = dataclasses.replace( + config, + job_workflow_sha="refs/heads/main", + ) + with self.assertRaisesRegex(ValueError, "exact commit"): + sign_and_attest.build_publication_record( + mutable_builder, + {"amd64": AMD64_DIGEST, "arm64": ARM64_DIGEST}, + FINAL_DIGEST, + ) + if __name__ == "__main__": unittest.main()