From b6e00eafea3c1ce8791bee6757848c089db8aeac Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 28 Jun 2026 09:27:57 -0700 Subject: [PATCH] ci(release): generate provenance in an isolated reusable workflow (SLSA L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the binary and image provenance attestations out of the build jobs into a new reusable workflow (.github/workflows/attest.yml). A reusable workflow runs in its own execution context with its own OIDC identity, so the signing material is unreachable by the build's user-defined steps — that isolation is the SLSA Build L3 requirement (in-job attestation is only L2). Provenance still lands on GitHub's attestation API, so `gh attestation verify` keeps working; the signer-workflow is now attest.yml. release.yml: binary-release-assets uploads checksums.txt as an artifact (drops its in-job attest + id-token/attestations perms); new attest-binaries and attest-image jobs call attest.yml. container-image-release keeps keyless cosign + the syft SBOM attestation in-job (separate controls) and drops the in-job provenance attest. The ghd signer ripple is repointed: ghd.toml signer_workflow, stage_ghd_release_assets expected_signer (+ its test), and the inspection-summary `gh attestation verify --signer-workflow` all move release.yml → attest.yml (cosign's certificate-identity stays release.yml — the cosign signer is unchanged). The three tag-only-path fixes from the sibling's release shakeout are baked in from the start: the shared attest job declares packages:write so every caller (incl. the binary one) must grant it; apko publish runs `mkdir -p sbom` first (PR2); and attest.yml carries its OWN docker/login-action because the build job's GHCR login does not cross the reusable-workflow boundary. Verified: actionlint clean on release.yml + attest.yml; stage_ghd test green (6/6, now asserting the attest.yml signer); `moon run root:check` green (12 tasks). The tag-only publish→cosign→attest path is exercised by the throwaway-tag rehearsal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/stage_ghd_release_assets.py | 2 +- .../scripts/test_stage_ghd_release_assets.py | 4 +- .github/workflows/attest.yml | 84 +++++++++++++++++++ .github/workflows/release.yml | 61 ++++++++++---- README.md | 5 +- ghd.toml | 2 +- 6 files changed, 137 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/attest.yml diff --git a/.github/scripts/stage_ghd_release_assets.py b/.github/scripts/stage_ghd_release_assets.py index f56a91f..c8de272 100644 --- a/.github/scripts/stage_ghd_release_assets.py +++ b/.github/scripts/stage_ghd_release_assets.py @@ -135,7 +135,7 @@ def validate_ghd_config(config: dict[str, Any], *, binary_name: str, repository: if not isinstance(provenance, dict): raise StageError("ghd.toml must define a [provenance] table") - expected_signer = f"{repository}/.github/workflows/release.yml" + expected_signer = f"{repository}/.github/workflows/attest.yml" actual_signer = provenance.get("signer_workflow") if actual_signer != expected_signer: raise StageError( diff --git a/.github/scripts/test_stage_ghd_release_assets.py b/.github/scripts/test_stage_ghd_release_assets.py index 3c957f1..35ac743 100644 --- a/.github/scripts/test_stage_ghd_release_assets.py +++ b/.github/scripts/test_stage_ghd_release_assets.py @@ -93,7 +93,7 @@ def test_fails_on_checksum_mismatch(self) -> None: self.assertIn("checksum mismatch for template-mcp_1.2.3_linux_amd64", stderr) def test_fails_on_wrong_signer_workflow(self) -> None: - with fixture(signer="other/repo/.github/workflows/release.yml") as root: + with fixture(signer="other/repo/.github/workflows/attest.yml") as root: result, _, stderr = run_script(root) self.assertEqual(result, 1) @@ -126,7 +126,7 @@ def run_script(root: Path) -> tuple[int, str, str]: @contextlib.contextmanager def fixture( *, - signer: str = "meigma/template-mcp/.github/workflows/release.yml", + signer: str = "meigma/template-mcp/.github/workflows/attest.yml", missing_checksum: str | None = None, checksum_override: tuple[str, str] | None = None, omit_artifact: tuple[str, str, str] | None = None, diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml new file mode 100644 index 0000000..affb60a --- /dev/null +++ b/.github/workflows/attest.yml @@ -0,0 +1,84 @@ +# Reusable workflow that generates artifact PROVENANCE in isolation from the build +# jobs that call it. A reusable workflow runs in its own execution context with its +# own OIDC identity — the caller can inject neither steps nor secrets — so the +# signing material is unreachable by the build's user-defined steps. That isolation +# is the SLSA Build L3 requirement; GitHub artifact attestations generated in-job +# are only L2. Provenance is still written to GitHub's attestation API, so +# `gh attestation verify` keeps working — but the signer-workflow is now this file: +# +# gh attestation verify --repo \ +# --signer-workflow /.github/workflows/attest.yml --source-ref refs/tags/ +# +# SLSA note: L3 makes the signed provenance unforgeable; it does not, by itself, +# make the build hermetic. The build job still computes the checksums/digest it +# passes in — L3's guarantee is that the signature cannot be tampered with because +# the key lives only in this isolated workflow. + +name: Attest (reusable) + +on: + workflow_call: + inputs: + checksums-artifact: + description: Name of an uploaded artifact containing checksums.txt (binary provenance). + type: string + required: false + default: '' + subject-name: + description: Fully-qualified image name without tag/digest (container provenance). + type: string + required: false + default: '' + subject-digest: + description: Image digest in sha256:... form (container provenance). + type: string + required: false + default: '' + push-to-registry: + description: Attach the image provenance attestation to the registry. + type: boolean + required: false + default: false + +permissions: {} + +jobs: + attest: + name: Attest + runs-on: ubuntu-24.04 + permissions: + id-token: write # OIDC token minted HERE, isolated from the build job + attestations: write # write provenance to GitHub's attestation API + contents: read + packages: write # used only for the image attestation (push-to-registry) + steps: + - name: Download checksums + if: ${{ inputs.checksums-artifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.checksums-artifact }} + + - name: Attest binary checksums + if: ${{ inputs.checksums-artifact != '' }} + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-checksums: checksums.txt + + # The image provenance is attached to the registry as a referrer, so this + # isolated job needs its own GHCR login — the build job's docker login does + # not carry into the reusable workflow's separate runner. + - name: Log in to GitHub Container Registry + if: ${{ inputs.subject-digest != '' }} + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Attest image provenance + if: ${{ inputs.subject-digest != '' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ inputs.subject-name }} + subject-digest: ${{ inputs.subject-digest }} + push-to-registry: ${{ inputs.push-to-registry }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d14ef5a..e615fc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,8 +78,6 @@ jobs: - resolve-release permissions: contents: write - id-token: write - attestations: write steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -153,10 +151,32 @@ jobs: RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} run: gh release upload "$RELEASE_TAG" dist/release-assets/* --clobber - - name: Attest release checksums - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + - name: Upload checksums for isolated attestation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - subject-checksums: dist/release-assets/checksums.txt + name: release-checksums + path: dist/release-assets/checksums.txt + if-no-files-found: error + retention-days: 1 + + # Generate binary provenance in an ISOLATED reusable workflow (SLSA L3): the + # signing OIDC token is minted in attest.yml, unreachable by the build steps. + attest-binaries: + needs: + - binary-release-assets + permissions: + id-token: write + attestations: write + contents: read + # attest.yml is shared with attest-image, whose attest job declares + # packages: write for the image attestation's push-to-registry. A reusable + # workflow cannot request more permissions than its caller grants, so this + # caller must also grant packages even though the binary attestation never + # pushes to a registry (unused here, but required to satisfy the shared job). + packages: write + uses: ./.github/workflows/attest.yml + with: + checksums-artifact: release-checksums # Build the per-arch signed apk on a native runner (no QEMU). Each runner mints # its own ephemeral melange key (distinct filename) and uploads its apk plus its @@ -368,14 +388,23 @@ jobs: sbom-path: image.spdx.json push-to-registry: true - # apko has no BuildKit provenance, so generate it explicitly (replaces the - # former Dockerfile/buildx `provenance: mode=max`). - - name: Attest container image provenance - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-name: ${{ steps.publish.outputs.name }} - subject-digest: ${{ steps.publish.outputs.digest }} - push-to-registry: true + # Generate container provenance in the ISOLATED reusable workflow (SLSA L3). + # apko has no BuildKit provenance, so attest.yml generates it explicitly with the + # signing key isolated from this build job (replaces the former in-job + # attest-build-provenance / Dockerfile-buildx `provenance: mode=max`). + attest-image: + needs: + - container-image-release + permissions: + id-token: write + attestations: write + packages: write + contents: read + uses: ./.github/workflows/attest.yml + with: + subject-name: ${{ needs.container-image-release.outputs.image-name }} + subject-digest: ${{ needs.container-image-release.outputs.image-digest }} + push-to-registry: true release-inspection-summary: name: Release Inspection Summary @@ -384,6 +413,8 @@ jobs: - resolve-release - binary-release-assets - container-image-release + - attest-binaries + - attest-image permissions: {} steps: - name: Write inspection summary @@ -403,7 +434,7 @@ jobs: echo '```sh' echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets" echo "asset=\"template-mcp_${RELEASE_VERSION}_\$(go env GOOS)_\$(go env GOARCH)\"" - echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" echo "ghd download \"$GITHUB_REPOSITORY/template-mcp@${RELEASE_VERSION}\" --output \"\$(mktemp -d)\"" echo '```' echo @@ -413,7 +444,7 @@ jobs: echo "docker login ghcr.io" echo "docker pull \"${IMAGE_NAME}:${RELEASE_TAG}\"" echo "docker run --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" --version" - echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" echo "cosign verify \"${IMAGE_NAME}@${IMAGE_DIGEST}\" --certificate-identity-regexp \"^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@.*\" --certificate-oidc-issuer https://token.actions.githubusercontent.com" echo '```' echo diff --git a/README.md b/README.md index 1c9e261..502d368 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,9 @@ The release path is: - Release Please creates a draft GitHub release and tag after merge. - Release Dry Run rehearses the GoReleaser binary path and the native-runner melange/apko container build path on pull requests. - GoReleaser builds binaries, checksums, and SBOMs without publishing directly. -- The release workflow uploads assets to the draft release and creates a GitHub-hosted attestation for `checksums.txt`. -- The release workflow builds per-arch signed apks with melange on native GitHub-hosted runners, assembles and publishes `ghcr.io/meigma/template-mcp:vX.Y.Z` as a multi-platform apko manifest, signs it with keyless cosign, and attaches GitHub-native SBOM and provenance attestations for the manifest digest. +- The release workflow uploads assets to the draft release; binary checksum provenance is attested from an isolated reusable workflow (`.github/workflows/attest.yml`). +- The release workflow builds per-arch signed apks with melange on native GitHub-hosted runners, assembles and publishes `ghcr.io/meigma/template-mcp:vX.Y.Z` as a multi-platform apko manifest, signs it with keyless cosign, attaches a GitHub-native SBOM attestation, and attests image provenance from the same isolated `attest.yml` workflow. +- Provenance is generated in `attest.yml` so its signing key is unreachable by the build jobs (SLSA Build L3). Verify with `gh attestation verify --signer-workflow /.github/workflows/attest.yml`; the keyless cosign image signature is still issued by `release.yml`. - A human inspects the draft release before publication. The root `ghd.toml` matches the default GoReleaser output so generated projects can be installed with `ghd` once the release workflow runs. diff --git a/ghd.toml b/ghd.toml index 02d10a8..0e3b4fb 100644 --- a/ghd.toml +++ b/ghd.toml @@ -1,7 +1,7 @@ version = 1 [provenance] -signer_workflow = "meigma/template-mcp/.github/workflows/release.yml" +signer_workflow = "meigma/template-mcp/.github/workflows/attest.yml" [[packages]] name = "template-mcp"