diff --git a/.github/workflows/attest-release-files.yml b/.github/workflows/attest-release-files.yml new file mode 100644 index 0000000..e056f69 --- /dev/null +++ b/.github/workflows/attest-release-files.yml @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Netresearch DTT GmbH +# +# Reusable "Attest release files" — emits a GitHub-issued, Sigstore-backed +# SLSA build-provenance attestation (actions/attest-build-provenance) for +# the files a python-release.yml run built and attached to its GitHub +# Release, taken from that run's `dist` artifact. +# +# WHY THIS IS A SEPARATE REUSABLE, not an `attest` input on python-release.yml +# =========================================================================== +# attest-build-provenance requires `attestations: write`. A called workflow's +# job permissions are validated STATICALLY at workflow startup — before any +# job-level `if:` is evaluated — so declaring that scope on any +# python-release.yml job would make every existing caller (which grants only +# `contents: write` + `id-token: write`) fail with `startup_failure` until it +# granted the new scope. Keeping attestation in its own reusable means only +# the repos that want provenance grant the extra scope; existing +# python-release.yml callers are unaffected. Same rationale as +# attest-image.yml and attest-sbom.yml. +# +# Consequence: the attestation is recorded right after the release job, not +# before the Release is published. +# +# Caller pattern (pairs with python-release.yml): +# +# jobs: +# release: +# uses: netresearch/.github/.github/workflows/python-release.yml@main +# permissions: +# contents: write +# id-token: write +# with: +# publish-pypi: false +# release-files: 'dist/*' +# +# attest: +# needs: release +# uses: netresearch/.github/.github/workflows/attest-release-files.yml@main +# permissions: +# id-token: write # Sigstore OIDC identity +# attestations: write # write the attestation to GitHub +# with: +# subject-path: 'dist/*' # same value as release-files +# +# Verify afterwards with: +# gh attestation verify --repo / +# +# SECURITY: pinned action SHAs, harden-runner, least-privilege job +# permissions. `subject-path` reaches the run block only through `env:` and +# is word-split into argv, never evaluated; the resolved file list is handed +# to the attest action via `with:`. + +name: Attest release files (reusable) + +on: + workflow_call: + inputs: + subject-path: + description: >- + Glob (or several, whitespace-separated) of files to attest, + resolved against the downloaded artifact (e.g. `dist/*`). Pass the + same value as python-release.yml's `release-files` so the attested + set equals the attached set. Every pattern must match at least one + file, otherwise the job fails. + required: true + type: string + artifact-name: + description: "Name of the workflow artifact holding the files. python-release.yml uploads its build as `dist`." + required: false + type: string + default: "dist" + +# CALLER REQUIREMENTS +# =================== +# The calling job MUST grant every scope below, or the run fails with +# `startup_failure` before any job executes: +# +# id-token: write — Sigstore OIDC identity +# attestations: write — write the attestation to GitHub +permissions: {} + +jobs: + attest: + name: Attest release files + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + id-token: write + attestations: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + + - name: Download distribution artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact-name }} + path: dist/ + + - name: Resolve attestation subjects + id: resolve + env: + SUBJECT_PATH: ${{ inputs.subject-path }} + run: | + set -euo pipefail + + if [ -z "${SUBJECT_PATH//[[:space:]]/}" ]; then + echo "::error title=No subject-path::subject-path is empty; pass the glob of files to attest (e.g. 'dist/*')." + exit 1 + fi + + # Word-split each pattern into argv so a caller cannot inject shell + # metacharacters; the shell still expands the glob against dist/. + # Globbing is off while splitting so each entry stays a pattern and + # the error below can name the one that matched nothing. + FILES=() + set -f + # shellcheck disable=SC2206 + PATTERNS=( $SUBJECT_PATH ) + # Subjects must be regular files inside the downloaded artifact: an + # absolute or ../ pattern must not reach other runner files, and a + # directory match would be dropped silently by the attest action. + DIST="$(realpath -e dist)" + for pattern in "${PATTERNS[@]}"; do + set +f + # shellcheck disable=SC2206 + matches=( $pattern ) + set -f + if [ ! -e "${matches[0]}" ]; then + echo "::error title=No subject files::subject-path pattern '${pattern}' matched nothing." + exit 1 + fi + for match in "${matches[@]}"; do + real="$(realpath -e -- "$match")" + case "$real" in + "$DIST"/*) ;; + *) + echo "::error title=Subject outside artifact::'${match}' resolves to '${real}', outside ${DIST}." + exit 1 + ;; + esac + if [ ! -f "$real" ]; then + echo "::error title=Subject not a file::'${match}' is not a regular file." + exit 1 + fi + FILES+=( "$match" ) + done + done + set +f + + echo "Attesting ${#FILES[@]} file(s):" + printf ' %s\n' "${FILES[@]}" + { + echo 'files<> "$GITHUB_OUTPUT" + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ steps.resolve.outputs.files }} diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 0715f9f..d8b5484 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -44,6 +44,31 @@ # with: # publish-pypi: false # +# Caller pattern (SLSA build provenance for the attached release files): +# +# jobs: +# release: +# uses: netresearch/.github/.github/workflows/python-release.yml@main +# permissions: +# contents: write +# id-token: write +# with: +# release-files: 'dist/*' +# attest: +# needs: release +# uses: netresearch/.github/.github/workflows/attest-release-files.yml@main +# permissions: +# id-token: write +# attestations: write +# with: +# subject-path: 'dist/*' +# +# Provenance lives in the paired attest-release-files.yml, not in an input +# here: it needs `attestations: write`, and a called workflow's job +# permissions are validated at startup before any `if:`, so declaring that +# scope in this workflow would startup_failure every existing caller. The +# attestation is therefore recorded right after the release is created. +# # SECURITY: pinned action SHAs, harden-runner, least-privilege per-job # permissions, `persist-credentials: false` on checkout. Caller-supplied # commands are routed through `env:` and executed with `bash -c "$VAR"`; diff --git a/docs/reusable-workflow-permissions.md b/docs/reusable-workflow-permissions.md index 7582001..c011e53 100644 --- a/docs/reusable-workflow-permissions.md +++ b/docs/reusable-workflow-permissions.md @@ -69,6 +69,7 @@ truth; this table is the index.) | `node-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write` | | `python-app-ci.yml` | `actions: read`, `contents: read`, `security-events: write`, `pull-requests: write`, `id-token: write` | | `python-release.yml` | `contents: write`, `id-token: write` | +| `attest-release-files.yml` | `id-token: write`, `attestations: write` | | `docker-image-ci.yml` | `contents: read`, `packages: write`, `security-events: write`, `id-token: write`, `attestations: write`, `actions: read`, `pull-requests: write` | | `lint-*.yml` / `php-ci.yml` / `python-ci.yml` / `python-build.yml` / `python-audit.yml` / `ansible-lint.yml` / `ansible-molecule.yml` / `ts-check.yml` / `node-audit.yml` / `node-test.yml` / `node-build.yml` / `lint-compose.yml` / `sonarqube.yml` / `smoke-test-container.yml` / `lint-container.yml` / `check-template-drift.yml` | `contents: read` |