Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .github/compatibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions .github/workflows/build-push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
69 changes: 69 additions & 0 deletions ci/github/sign_and_attest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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


Expand Down
74 changes: 74 additions & 0 deletions ci/github/test_sign_and_attest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading