diff --git a/.github/workflows/attest-provenance.yml b/.github/workflows/attest-provenance.yml new file mode 100644 index 0000000..5fd0757 --- /dev/null +++ b/.github/workflows/attest-provenance.yml @@ -0,0 +1,84 @@ +# Reusable: the SLSA build provenance of the files a caller's release built, +# signed apart from the build (SLSA Build Level 3). +# +# The job runs none of the caller's code: it takes the caller's artifact as +# data, on a runner of its own, and attests it with the steps below. Its +# Sigstore certificate names this workflow as the signer, which only a job of +# this file can get. A build step of the caller, even one that holds +# id-token: write for something else, gets a certificate naming the caller's +# own workflow, so a check that pins this workflow refuses what it signed: +# +# gh attestation verify --repo / \ +# --signer-workflow IronFlock/workflows/.github/workflows/attest-provenance.yml \ +# --source-ref refs/heads/main +# +# Everything in the provenance except the files' names and digests comes from +# the job's OIDC token; SLSA lets the build supply those two. Call it after the +# last step that changes the files, and before they are published. +# +# Container images have their own provenance (release-image.yml). +# +# Callers grant +# permissions: { contents: read, id-token: write, attestations: write } +name: attest-provenance + +on: + workflow_call: + inputs: + artifact: + description: Name of the artifact of this workflow run that holds the files. + type: string + required: true + subject-path: + description: >- + The files to attest, one glob per line, relative to the root of the + artifact. Every line must match at least one file. + type: string + required: true + dry-run: + description: Check that the files are there and attest nothing (release pull requests). + type: boolean + default: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + attest: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ inputs.artifact }} + - name: every pattern matches a file + env: + PATTERNS: ${{ inputs.subject-path }} + run: | + shopt -s nullglob + fail=0 + while IFS= read -r pattern; do + [ -n "$pattern" ] || continue + # shellcheck disable=SC2206 # the pattern is there to be expanded + files=($pattern) + if [ "${#files[@]}" = 0 ]; then + echo "::error::no file matches $pattern" + fail=1 + continue + fi + sha256sum -- "${files[@]}" + done <<<"$PATTERNS" + exit "$fail" + - name: attest the build provenance + if: ${{ !inputs.dry-run }} + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ inputs.subject-path }} diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 5f5c3dc..c433ddf 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -659,6 +659,33 @@ jobs: echo "environment claim: '$environment'" [ "$environment" = image-provenance ] || { echo "::error::no environment claim image-provenance with deployment: false; release-image.yml could not sign"; exit 1; } + # attest-provenance.yml as a dry run: a real one would write to the public + # transparency log on every pull request. + attest-fixture: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - run: mkdir -p out/one && echo image > out/one/one.img.gz && echo bundle > out/one/one.raucb + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: selftest-attest + path: out + retention-days: 1 + + attest-dry-run: + needs: [attest-fixture] + uses: ./.github/workflows/attest-provenance.yml + permissions: + contents: read + id-token: write + attestations: write + with: + artifact: selftest-attest + subject-path: | + */*.img.gz + */*.raucb + dry-run: true + assert-planted: needs: [security-planted, security-helm, security-rootfs, lint-planted] # Also when a scanner job broke: the counts then show what is missing. @@ -716,7 +743,7 @@ jobs: # above succeeded (skipped or cancelled counts as failed). selftest-ok: if: ${{ always() }} - needs: [lint-self, lint-templates, lint-planted, scripts, gate-pass, gate-fail, node-gate-npm, node-gate-npm-tools, node-gate-bun, node-gate-fail, python-gate-pass, python-gate-fail, shell-gate-pass, shell-gate-artifact, shell-gate-fail, shell-gate-arm64, tools-arm64, security-clean, security-clean-node, security-planted, config-scan-skipped-chart, security-helm, security-rootfs, rootfs-scan-empty-tree, release-dry-run, release-dry-run-package-json, build-context-artifact, release-dry-run-build-context, release-dry-run-image-tag, assert-image-tag, provenance-sign, provenance-environment-claim, assert-planted] + needs: [lint-self, lint-templates, lint-planted, scripts, gate-pass, gate-fail, node-gate-npm, node-gate-npm-tools, node-gate-bun, node-gate-fail, python-gate-pass, python-gate-fail, shell-gate-pass, shell-gate-artifact, shell-gate-fail, shell-gate-arm64, tools-arm64, security-clean, security-clean-node, security-planted, config-scan-skipped-chart, security-helm, security-rootfs, rootfs-scan-empty-tree, release-dry-run, release-dry-run-package-json, build-context-artifact, release-dry-run-build-context, release-dry-run-image-tag, assert-image-tag, provenance-sign, provenance-environment-claim, attest-fixture, attest-dry-run, assert-planted] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/README.md b/README.md index d8db291..46be758 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The inputs of each workflow are documented in its `on.workflow_call` block. | [release-intent.yml](.github/workflows/release-intent.yml) | `intent` | the version file decides: a rise makes a release PR (title `release ` required), an unchanged version a no-release PR; output `kind` gates the release dry run; listed in the `needs` of `ci-ok` | | [release-image.yml](.github/workflows/release-image.yml) | `verify`, `build`, `publish` | release of a container image on the merge of a release PR into the protected `main`; native multi-arch build, SBOM attached, SLSA provenance signed with a Cloud KMS key and attached (see [Signed image provenance](#signed-image-provenance)), keyless push; on a release PR a dry run (every platform build, no push); re-runs are safe; read rights only; `build-contexts-artifact` hands data an earlier job prepared (with secrets the build must not see) to the build as named contexts | | [release-tag.yml](.github/workflows/release-tag.yml) | `tag` | the annotated git tag of a release, after every image job of the caller; a tag always means "in the registry"; written with a token of the release GitHub App from the caller's `release` environment | +| [attest-provenance.yml](.github/workflows/attest-provenance.yml) | `attest` | SLSA Build Level 3 provenance of files a release built (disk images, bundles): signed by a job that runs none of the caller's code, under this workflow's identity; pin it with `gh attestation verify --signer-workflow` | | [selftest.yml](.github/workflows/selftest.yml) | — | proves the harness against [tests/fixtures](tests/fixtures) | A gate for chart repositories follows the same pattern and is added when the