From 2df98a4c8c9609f9ef6eca635af7fb8e4052ee24 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sat, 27 Jun 2026 17:00:42 -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-checksums and container-image PROVENANCE attestations out of the build jobs into a new reusable workflow (attest.yml, workflow_call). A reusable workflow runs in its own context with its own OIDC identity that the build steps cannot reach — the SLSA Build L3 isolation requirement (in-job artifact attestations are L2). Provenance stays on GitHub's attestation API, so `gh attestation verify` still works; the signer-workflow is now attest.yml. - attest.yml: actions/attest (binary checksums, via an uploaded artifact) + attest-build-provenance (image, by name+digest), keyed on inputs; reuses the action SHAs already pinned in the repo. - release.yml: binary-release-assets drops in-job attest and uploads checksums.txt as an artifact; container-image-release drops in-job attest-build-provenance (keeps keyless cosign sign + syft SBOM attestation); new attest-binaries + attest-image jobs call attest.yml; the summary depends on them and points gh-attestation verify at attest.yml (cosign verify still points at release.yml, since the cosign signer is unchanged). - ghd.toml signer_workflow, stage_ghd_release_assets.py expected_signer (+ its test fixture), and release-dry-run.yml expected_signer follow the new provenance signer so ghd verification stays consistent. - README/DELETE_ME prose updated (also fixes a stale "BuildKit provenance" line left over from the melange/apko migration). SBOM attestation and the cosign image signature stay in the build job — they are separate controls, not the SLSA provenance. The tag-triggered attest path runs only on a real tag; validate with a throwaway prerelease tag before a release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/stage_ghd_release_assets.py | 2 +- .../scripts/test_stage_ghd_release_assets.py | 2 +- .github/workflows/attest.yml | 73 +++++++++++++++++++ .github/workflows/release-dry-run.yml | 2 +- .github/workflows/release.yml | 52 +++++++++---- DELETE_ME.md | 2 +- README.md | 7 +- ghd.toml | 2 +- 8 files changed, 120 insertions(+), 22 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 0af20da..64e6210 100644 --- a/.github/scripts/stage_ghd_release_assets.py +++ b/.github/scripts/stage_ghd_release_assets.py @@ -127,7 +127,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 f7597fe..d938ced 100644 --- a/.github/scripts/test_stage_ghd_release_assets.py +++ b/.github/scripts/test_stage_ghd_release_assets.py @@ -126,7 +126,7 @@ def run_script(root: Path) -> tuple[int, str, str]: @contextlib.contextmanager def fixture( *, - signer: str = "meigma/template-go-api/.github/workflows/release.yml", + signer: str = "meigma/template-go-api/.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..4ed09c8 --- /dev/null +++ b/.github/workflows/attest.yml @@ -0,0 +1,73 @@ +# 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 + + - 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-dry-run.yml b/.github/workflows/release-dry-run.yml index 57cec87..6c78978 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -62,7 +62,7 @@ jobs: version="0.0.0-dryrun.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" binary_name="template-go-api" asset_prefix="${binary_name}_" - expected_signer="${GITHUB_REPOSITORY}/.github/workflows/release.yml" + expected_signer="${GITHUB_REPOSITORY}/.github/workflows/attest.yml" actual_signer="$(awk -F'"' '/signer_workflow/ { print $2; exit }' ghd.toml)" if [ "$actual_signer" != "$expected_signer" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 908bc5a..7efafc4 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -148,10 +146,26 @@ 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 + 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 @@ -346,13 +360,6 @@ jobs: IMAGE_REF: ${{ steps.publish.outputs.ref }} run: cosign sign --yes "$IMAGE_REF" - - 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 - - name: Generate image SBOM (Go module granularity) env: IMAGE_REF: ${{ steps.publish.outputs.ref }} @@ -366,6 +373,21 @@ jobs: sbom-path: image.spdx.json push-to-registry: true + # Generate container provenance in the ISOLATED reusable workflow (SLSA L3). + 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 runs-on: ubuntu-24.04 @@ -373,6 +395,8 @@ jobs: - resolve-release - binary-release-assets - container-image-release + - attest-binaries + - attest-image permissions: {} steps: - name: Write inspection summary @@ -392,7 +416,7 @@ jobs: echo '```sh' echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets" echo "asset=\"template-go-api_${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-go-api@${RELEASE_VERSION}\" --output \"\$(mktemp -d)\"" echo '```' echo @@ -402,7 +426,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/DELETE_ME.md b/DELETE_ME.md index 55f988d..cd86669 100644 --- a/DELETE_ME.md +++ b/DELETE_ME.md @@ -155,7 +155,7 @@ The nominal generated-project path is an HTTP service with both a downloadable b For library-only projects: - Keep Release Please if version tags and changelogs are useful. - - Delete `.github/workflows/release.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/security-scan.yml`, `.goreleaser.yaml`, `ghd.toml`, `melange.yaml`, and `apko.yaml` unless the library publishes some other artifact. + - Delete `.github/workflows/release.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/attest.yml`, `.github/workflows/security-scan.yml`, `.goreleaser.yaml`, `ghd.toml`, `melange.yaml`, and `apko.yaml` unless the library publishes some other artifact. - Remove release dry-run checks from `.github/repository-settings.toml`. - If the library should not create releases at all, delete `.github/workflows/release-please.yml`, `release-please-config.json`, `.release-please-manifest.json`, and `CHANGELOG.md`. diff --git a/README.md b/README.md index cf0f1cf..1119770 100644 --- a/README.md +++ b/README.md @@ -706,10 +706,11 @@ The release path is: - Release Please opens and maintains the release PR. - Release Please creates a draft GitHub release and tag after merge. -- Release Dry Run rehearses the GoReleaser binary path and native-runner Docker container build path on pull requests. +- 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 amd64 and arm64 container images on native GitHub-hosted runners, publishes `ghcr.io/meigma/template-go-api:vX.Y.Z` as a multi-platform manifest, attaches BuildKit provenance and SBOM metadata, and creates a GitHub-native attestation for the manifest digest. +- The release workflow uploads assets to the draft release; a separate, isolated reusable workflow (`attest.yml`) generates the GitHub-hosted provenance attestation for the binary checksums. +- The release workflow builds amd64 and arm64 apks with melange on native GitHub-hosted runners, assembles and publishes `ghcr.io/meigma/template-go-api:vX.Y.Z` as a multi-platform manifest with apko, signs it with keyless cosign, and attaches a syft SBOM attestation; the isolated `attest.yml` workflow then creates the GitHub-native provenance attestation for the manifest digest. +- Generating both provenance attestations in the isolated `attest.yml` reusable workflow (not in the build job) keeps the signing identity unreachable by build steps — the SLSA Build L3 isolation requirement — while staying on GitHub's attestation API (verify with `gh attestation verify --signer-workflow …/attest.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 0a67f5e..881ac4b 100644 --- a/ghd.toml +++ b/ghd.toml @@ -1,7 +1,7 @@ version = 1 [provenance] -signer_workflow = "meigma/template-go-api/.github/workflows/release.yml" +signer_workflow = "meigma/template-go-api/.github/workflows/attest.yml" [[packages]] name = "template-go-api"