From f8b269621ca6bd37b12b878cc8025476c9fe5564 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 28 Jun 2026 14:48:38 -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 artifact provenance out of the build jobs into a reusable workflow (.github/workflows/attest.yml). A reusable workflow runs with its own OIDC identity that the calling build steps cannot reach, which is the SLSA Build L3 requirement; in-job attestations are only L2. Provenance still lives on GitHub's attestation API (gh attestation verify keeps working) — the signer-workflow is now attest.yml. - attest.yml (new, workflow_call): one isolated `attest` job that does binary checksums (actions/attest --subject-checksums) and OCI provenance (actions/attest-build-provenance, used for both the image and the chart). - release.yml: drop the three inline `actions/attest` provenance steps; add attest-binaries / attest-image / attest-chart caller jobs (the user's Option A: the Helm chart provenance is isolated too). binary-release-assets now uploads checksums.txt as an artifact for the binary caller; keyless cosign + the syft image SBOM attestation stay inline (separate controls, not the SLSA provenance). - Every caller grants packages: write — a reusable workflow cannot request more than its caller, and the shared attest job declares it for the image/chart push-to-registry; attest.yml carries its own GHCR login since the build job's login does not cross the reusable-workflow boundary. - charts/template-k8s/values.yaml: flip the Kyverno trusted signer attestor.subjectRegExp from release.yml to attest.yml, in lockstep with where provenance is now signed. attest-build-provenance emits the same slsa.dev/provenance/v1 predicate + actions/buildtypes/workflow/v1 buildType the policy already requires, so only the signer identity changes. The chart-render test (test/chart/rbac_test.go) is updated to expect the attest.yml signer. - Release inspection summary: gh attestation verify now points at attest.yml for binaries/image/chart; cosign verify stays release.yml (cosign signer unchanged). - DELETE_ME: document attest.yml as the provenance signer and the Kyverno coupling. The attest.yml path is unreachable by the dry-run (which never attests) — only a real release tag exercises it, so a throwaway-tag rehearsal is the final check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/attest.yml | 84 +++++++++++++++++++++++++++++++ .github/workflows/release.yml | 89 +++++++++++++++++++++++---------- DELETE_ME.md | 12 +++-- charts/template-k8s/values.yaml | 2 +- test/chart/rbac_test.go | 2 +- 5 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/attest.yml diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml new file mode 100644 index 0000000..ed1adf1 --- /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/chart name without tag/digest (OCI provenance). + type: string + required: false + default: '' + subject-digest: + description: Image/chart digest in sha256:... form (OCI provenance). + type: string + required: false + default: '' + push-to-registry: + description: Attach the OCI 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 OCI 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 OCI 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Attest OCI 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 a29a909..6ba533a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,8 +80,6 @@ jobs: - resolve-release permissions: contents: write - id-token: write - attestations: write steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -151,10 +149,31 @@ 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's shared job declares packages: write (for the OCI + # attestation's push-to-registry). A reusable workflow cannot request more + # permissions than its caller grants, so every caller must grant it — even + # the binary one, whose attestation never pushes to a registry. + 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 @@ -359,15 +378,22 @@ jobs: sbom-path: image.spdx.json push-to-registry: true - # SLSA provenance (GitHub artifact attestation). This is the attestation the - # chart's optional Kyverno policy verifies; the signer stays release.yml here. - # Moving it into an isolated reusable workflow (SLSA L3) is a later change. - - name: Attest container image - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: - subject-name: ${{ steps.manifest.outputs.name }} - subject-digest: ${{ steps.manifest.outputs.digest }} - push-to-registry: true + # Generate container provenance in the ISOLATED reusable workflow (SLSA L3). This + # is the attestation the chart's optional Kyverno policy verifies — its signer is + # now attest.yml, which the chart's Kyverno defaults trust. + 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 helm-chart-release: name: Helm Chart Release @@ -378,10 +404,8 @@ jobs: permissions: contents: read packages: write - id-token: write - attestations: write - artifact-metadata: write outputs: + chart-name: ${{ steps.push-chart.outputs.name }} chart-digest: ${{ steps.push-chart.outputs.digest }} steps: - name: Check out repository @@ -486,14 +510,23 @@ jobs: exit "$push_rc" fi + echo "name=$CHART_NAME" >> "$GITHUB_OUTPUT" echo "digest=$digest" >> "$GITHUB_OUTPUT" - - name: Attest Helm chart - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: - subject-name: ${{ env.CHART_NAME }} - subject-digest: ${{ steps.push-chart.outputs.digest }} - push-to-registry: true + # Generate Helm chart provenance in the ISOLATED reusable workflow (SLSA L3). + attest-chart: + needs: + - helm-chart-release + permissions: + id-token: write + attestations: write + packages: write + contents: read + uses: ./.github/workflows/attest.yml + with: + subject-name: ${{ needs.helm-chart-release.outputs.chart-name }} + subject-digest: ${{ needs.helm-chart-release.outputs.chart-digest }} + push-to-registry: true release-inspection-summary: name: Release Inspection Summary @@ -503,6 +536,9 @@ jobs: - binary-release-assets - container-image-release - helm-chart-release + - attest-binaries + - attest-image + - attest-chart permissions: {} steps: - name: Write inspection summary @@ -523,7 +559,7 @@ jobs: echo '```sh' echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets" echo "asset=\"template-k8s_${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 '```' echo echo "Container verification commands:" @@ -532,7 +568,8 @@ jobs: echo "docker login ghcr.io" echo "docker pull \"${IMAGE_NAME}:${RELEASE_TAG}\"" echo "docker run --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" --help" - 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 echo "Helm chart verification commands:" @@ -541,7 +578,7 @@ jobs: echo "helm show chart \"${CHART_REF}\" --version \"${RELEASE_VERSION}\"" echo "helm pull \"${CHART_REF}\" --version \"${RELEASE_VERSION}\"" echo "helm install template-k8s \"${CHART_REF}\" --version \"${RELEASE_VERSION}\" --namespace template-k8s-system --create-namespace" - echo "gh attestation verify \"${CHART_REF}@${CHART_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 \"${CHART_REF}@${CHART_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" echo '```' echo echo "Publish or reject the draft release manually after inspection. The container image and Helm chart are already available in GHCR because GHCR does not have a draft release state." diff --git a/DELETE_ME.md b/DELETE_ME.md index 9ddab1f..ec09b7f 100644 --- a/DELETE_ME.md +++ b/DELETE_ME.md @@ -220,8 +220,8 @@ that shape, trim the release files before the first release. - `charts/template-k8s/values.yaml` - Update `image.repository`. - Update `kyverno.imageVerification.attestor.subjectRegExp` so optional - Kyverno image verification trusts the generated repository's release - workflow. + Kyverno image verification trusts the generated repository's provenance + signer, the reusable `.github/workflows/attest.yml` (not `release.yml`). - Add, remove, or rename values for real controller runtime options. - Keep fixed image tags or digests; do not default to `latest`. @@ -243,7 +243,7 @@ that shape, trim the release files before the first release. - `charts/template-k8s/templates/kyverno-image-policy.yaml` - Update the policy name helper and default attestor subject if the chart or - release workflow identity changes. + the provenance signer workflow (`attest.yml`) identity changes. - Keep it optional unless Kyverno is a hard prerequisite for the generated repository. @@ -290,6 +290,12 @@ that shape, trim the release files before the first release. - Update Helm chart paths, rendered-output assertions, install examples, and release inspection summary commands. +- `.github/workflows/attest.yml` + - The reusable workflow that signs binary/image/chart provenance in isolation + (SLSA Build L3). It has no project-specific identifiers, but it IS the signer + identity the Kyverno policy and the release inspection summary trust — keep it + in sync with `kyverno.imageVerification.attestor.subjectRegExp`. + - `.github/workflows/release-dry-run.yml` - Update image and chart refs. - Update binary validation names, dry-run image names, OCI archive names, diff --git a/charts/template-k8s/values.yaml b/charts/template-k8s/values.yaml index ac4e70f..0ad1e5d 100644 --- a/charts/template-k8s/values.yaml +++ b/charts/template-k8s/values.yaml @@ -23,7 +23,7 @@ kyverno: attestor: issuer: https://token.actions.githubusercontent.com subject: "" - subjectRegExp: ^https://github\.com/meigma/template-k8s/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ + subjectRegExp: ^https://github\.com/meigma/template-k8s/\.github/workflows/attest\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ rekor: url: https://rekor.sigstore.dev attestation: diff --git a/test/chart/rbac_test.go b/test/chart/rbac_test.go index 038b270..0d629e1 100644 --- a/test/chart/rbac_test.go +++ b/test/chart/rbac_test.go @@ -101,7 +101,7 @@ func TestKyvernoImageVerificationPolicyRendersGitHubAttestationPolicy(t *testing requireNestedString( t, keyless, - "^https://github\\.com/meigma/template-k8s/\\.github/workflows/release\\.yml@refs/tags/"+ + "^https://github\\.com/meigma/template-k8s/\\.github/workflows/attest\\.yml@refs/tags/"+ "v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$", "subjectRegExp", )