diff --git a/.github/actions/build/attest-artifact/action.yml b/.github/actions/build/attest-artifact/action.yml new file mode 100644 index 0000000..33dc44e --- /dev/null +++ b/.github/actions/build/attest-artifact/action.yml @@ -0,0 +1,83 @@ +name: "Attest Artifact" +description: "Creates provenance or SBOM attestation for file or OCI artifacts using actions/attest" + +inputs: + mode: + description: "Attestation mode: blob (file artifacts), oci (container/Helm provenance), or sbom (SBOM attestation for OCI image)" + required: true + subjectPath: + description: "Path/glob to file artifacts (blob mode only)" + required: false + subjectPrefix: + description: "Registry/org prefix for OCI artifacts, e.g. quay.io/strimzi (oci and sbom modes)" + required: false + imageName: + description: "Image name, e.g. operator (oci and sbom modes)" + required: false + subjectDigest: + description: "Image digest, e.g. sha256:abc123 (oci and sbom modes)" + required: false + sbomPath: + description: "Path to SPDX/CycloneDX SBOM JSON file (sbom mode only)" + required: false + dryRun: + description: "Log what would be attested without creating real attestations. This should be changed only in case of testing." + required: false + default: "false" + +outputs: + bundlePath: + description: "Path to the generated attestation bundle" + value: ${{ steps.attest-blob.outputs.bundle-path || steps.attest-oci.outputs.bundle-path || steps.attest-sbom.outputs.bundle-path }} + +runs: + using: "composite" + steps: + - name: Attest file artifact + id: attest-blob + if: ${{ inputs.mode == 'blob' && inputs.dryRun != 'true' }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: ${{ inputs.subjectPath }} + + - name: Attest OCI artifact provenance + id: attest-oci + if: ${{ inputs.mode == 'oci' && inputs.dryRun != 'true' }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-name: ${{ inputs.subjectPrefix }}/${{ inputs.imageName }} + subject-digest: ${{ inputs.subjectDigest }} + + - name: Attest SBOM for OCI artifact + id: attest-sbom + if: ${{ inputs.mode == 'sbom' && inputs.dryRun != 'true' }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-name: ${{ inputs.subjectPrefix }}/${{ inputs.imageName }} + subject-digest: ${{ inputs.subjectDigest }} + sbom-path: ${{ inputs.sbomPath }} + + - name: Attestation summary + if: ${{ inputs.dryRun != 'true' }} + shell: bash + run: | + echo "::group::Attestation Summary" + echo "Mode: ${{ inputs.mode }}" + if [ "${{ inputs.mode }}" = "blob" ]; then + echo "Subject: ${{ inputs.subjectPath }}" + else + echo "Subject: ${{ inputs.subjectPrefix }}/${{ inputs.imageName }}@${{ inputs.subjectDigest }}" + fi + if [ "${{ inputs.mode }}" = "sbom" ]; then + echo "SBOM: ${{ inputs.sbomPath }}" + fi + URL="${{ steps.attest-blob.outputs.attestation-url }}${{ steps.attest-oci.outputs.attestation-url }}${{ steps.attest-sbom.outputs.attestation-url }}" + echo "Attestation URL: ${URL}" + echo "" + echo "To verify this attestation, run:" + if [ "${{ inputs.mode }}" = "blob" ]; then + echo " gh attestation verify --repo ${{ github.repository }}" + else + echo " gh attestation verify oci://${{ inputs.subjectPrefix }}/${{ inputs.imageName }}@${{ inputs.subjectDigest }} --repo ${{ github.repository }}" + fi + echo "::endgroup::" diff --git a/.github/actions/build/publish-helm-chart/action.yml b/.github/actions/build/publish-helm-chart/action.yml index 5085265..4c031bd 100644 --- a/.github/actions/build/publish-helm-chart/action.yml +++ b/.github/actions/build/publish-helm-chart/action.yml @@ -25,6 +25,10 @@ inputs: registryPassword: description: "Container registry password" required: true + attestDryRun: + description: "Run attestation in dry-run mode (log only, no real attestation)" + required: false + default: "false" runs: using: "composite" @@ -44,5 +48,20 @@ runs: run: helm registry login -u ${{ inputs.registryUser }} -p ${{ inputs.registryPassword }} ${{ inputs.containerRegistry }} - name: Push Helm Chart to OCI registry + id: helm-push shell: bash - run: helm push ${{ inputs.helmChartName }}-${{ inputs.releaseVersion }}.tgz oci://${{ inputs.containerRegistry }}/${{ inputs.containerOrg }} \ No newline at end of file + run: | + PUSH_OUTPUT=$(helm push ${{ inputs.helmChartName }}-${{ inputs.releaseVersion }}.tgz oci://${{ inputs.containerRegistry }}/${{ inputs.containerOrg }} 2>&1) + echo "$PUSH_OUTPUT" + DIGEST=$(echo "$PUSH_OUTPUT" | grep "Digest:" | awk '{print $2}') + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + + - name: Attest Helm chart + if: ${{ github.event_name != 'pull_request' }} + uses: ./.github/actions/build/attest-artifact + with: + mode: oci + subjectPrefix: ${{ inputs.containerRegistry }}/${{ inputs.containerOrg }} + imageName: ${{ inputs.helmChartName }} + subjectDigest: ${{ steps.helm-push.outputs.digest }} + dryRun: ${{ inputs.attestDryRun }} \ No newline at end of file diff --git a/.github/actions/build/push-containers/action.yml b/.github/actions/build/push-containers/action.yml index 1d47db1..f445b71 100644 --- a/.github/actions/build/push-containers/action.yml +++ b/.github/actions/build/push-containers/action.yml @@ -31,6 +31,11 @@ inputs: description: "Suffix of archive with images" required: true +outputs: + images: + description: "JSON array of {name, digest} for multi-image attestation via attest-containers.yml" + value: ${{ steps.discover-images.outputs.images }} + runs: using: "composite" steps: @@ -131,6 +136,26 @@ runs: DOCKER_ORG: ${{ inputs.containerOrg }} DOCKER_TAG: ${{ inputs.containerTag }} + # Generates a list of images and it's digest for attestation + - name: Discover container images for attestation + id: discover-images + shell: bash + run: | + images="[]" + if [ -d "./sbom" ]; then + for sbom_file in $(find ./sbom -name "*.json" -type f | sort); do + relative="${sbom_file#./sbom/}" + after_org="${relative#*${{ inputs.containerRegistry }}/${{ inputs.containerOrg }}/}" + name=$(echo "$after_org" | cut -d'/' -f1) + digest=$(basename "$sbom_file" .json) + images=$(echo "$images" | jq -c --arg n "$name" --arg d "$digest" --arg s "$relative" \ + '. + [{"name": $n, "digest": $d, "sbom": $s}]') + echo "Found image: ${name} @ ${digest} (${relative})" + done + fi + echo "images=${images}" >> "$GITHUB_OUTPUT" + echo "Discovered $(echo "$images" | jq 'length') image(s) for attestation" + - name: Create SBOM archive # This condition is required for properly running the tests # The keyless signing doesn't work on pull_requests events so this part will be tested only during push events diff --git a/.github/actions/build/release-artifacts/action.yml b/.github/actions/build/release-artifacts/action.yml index 2207cae..20bd6c8 100644 --- a/.github/actions/build/release-artifacts/action.yml +++ b/.github/actions/build/release-artifacts/action.yml @@ -8,6 +8,10 @@ inputs: artifactSuffix: description: "Suffix of archive with images" required: true + attestDryRun: + description: "Run attestation in dry-run mode (log only, no real attestation)" + required: false + default: "false" runs: using: "composite" @@ -27,13 +31,51 @@ runs: RELEASE_VERSION: ${{ inputs.releaseVersion }} MVN_ARGS: '-B -DskipTests' + # Find release archives using case-insensitive search (Maven may change case, e.g. rc1 -> RC1) + - name: Find release archives for attestation + id: find-archives + shell: bash + run: | + FILES=$(find . -type f \( -iname "*${{ inputs.releaseVersion }}*.tar.gz" -o \ + -iname "*${{ inputs.releaseVersion }}*.zip" -o \ + -iname "*${{ inputs.releaseVersion }}*.tgz" \)) + if [ -n "$FILES" ]; then + echo "found=true" >> "$GITHUB_OUTPUT" + { + echo "files<> "$GITHUB_OUTPUT" + echo "Found $(echo "$FILES" | wc -l | tr -d ' ') release archive(s) for attestation" + else + echo "found=false" >> "$GITHUB_OUTPUT" + echo "No release archives found for attestation" + fi + + # Creates attestation for release artifacts + - name: Attest release archives + if: ${{ github.event_name != 'pull_request' && steps.find-archives.outputs.found == 'true' }} + id: attest + uses: ./.github/actions/build/attest-artifact + with: + mode: blob + subjectPath: ${{ steps.find-archives.outputs.files }} + dryRun: ${{ inputs.attestDryRun }} + + # Copy attestation provenance file in .intoto.jsonl format to add it into release tarball + - name: Copy attestation bundle + if: ${{ github.event_name != 'pull_request' && steps.find-archives.outputs.found == 'true' && inputs.attestDryRun != 'true' }} + shell: bash + run: cp "${{ steps.attest.outputs.bundlePath }}" "./release-${{ inputs.releaseVersion }}.intoto.jsonl" + - name: Create release tarball shell: bash run: | find . -type f \( -iname "*${{ inputs.releaseVersion }}*.tar.gz" -o \ -iname "*${{ inputs.releaseVersion }}*.zip" -o \ -iname "*${{ inputs.releaseVersion }}*.tgz" -o \ - -iname "*${{ inputs.releaseVersion }}*.yaml" \) \ + -iname "*${{ inputs.releaseVersion }}*.yaml" -o \ + -iname "*.intoto.jsonl" \) \ -exec tar -rvf release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar {} \; - name: Upload release artifacts diff --git a/.github/workflows/reusable-test-integrations.yml b/.github/workflows/reusable-test-integrations.yml index f4b220f..27be203 100644 --- a/.github/workflows/reusable-test-integrations.yml +++ b/.github/workflows/reusable-test-integrations.yml @@ -70,7 +70,8 @@ on: # Declare default permissions as read only permissions: contents: read - id-token: write # Required for OIDC keyless signing + id-token: write # Required for OIDC keyless signing + attestations: write # Required for actions/attest provenance jobs: test-build-binaries: @@ -305,6 +306,8 @@ jobs: - test-build-containers runs-on: ubuntu-latest timeout-minutes: 20 + outputs: + images: ${{ steps.push.outputs.images }} steps: - name: Checkout ${{ inputs.repo }} uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -341,6 +344,7 @@ jobs: uses: ./.github/actions/dependencies/install-syft - name: Push containers using push-containers action + id: push uses: ./.github/actions/build/push-containers with: registryUser: "strimzi" @@ -349,6 +353,18 @@ jobs: architectures: "amd64" artifactSuffix: ${{ inputs.artifactSuffix }} + - name: Verify images discovery output + if: ${{ github.event_name != 'pull_request' }} + run: | + IMAGES='${{ steps.push.outputs.images }}' + COUNT=$(echo "$IMAGES" | jq 'length') + if [ "$COUNT" -eq 0 ]; then + echo "❌ No images discovered for attestation" + exit 1 + fi + echo "✓ Discovered $COUNT image(s) for attestation:" + echo "$IMAGES" | jq '.' + test-release-artifacts: name: Release Artifacts needs: @@ -395,6 +411,7 @@ jobs: with: artifactSuffix: "${{ inputs.artifactSuffix }}" releaseVersion: ${{ inputs.releaseVersion }} + attestDryRun: "true" - name: Verify release outputs run: | @@ -476,3 +493,4 @@ jobs: helmChartName: ${{ inputs.helmChartName}} releaseVersion: ${{ inputs.releaseVersion }} artifactSuffix: ${{ inputs.artifactSuffix }} + attestDryRun: "true" diff --git a/.github/workflows/test-integrations.yml b/.github/workflows/test-integrations.yml index 6bc71e1..e97a41b 100644 --- a/.github/workflows/test-integrations.yml +++ b/.github/workflows/test-integrations.yml @@ -12,7 +12,8 @@ on: # Declare default permissions as read only permissions: contents: read - id-token: write # Required for OIDC keyless signing + id-token: write # Required for OIDC keyless signing + attestations: write # Required for actions/attest provenance # Cancel previous builds on the same branch/PR concurrency: diff --git a/README.md b/README.md index c325c90..5f740b0 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Actions for building, testing, and releasing Strimzi components. | `build/deploy-java` | Deploys Java artifacts to Maven Central | `projects` (required), `settingsPath` (required) | | `build/release-artifacts` | Builds release artifacts using Makefile | `releaseVersion` (required), `artifactSuffix` (required) | | `build/publish-helm-chart` | Publishes Helm Chart as OCI artifact | `releaseVersion` (required), `helmChartName` (required) | +| `build/attest-artifact` | Creates provenance or SBOM attestation for artifacts | `mode` (required: blob/oci/sbom), see [Artifact Attestation](#artifact-attestation) | > [!IMPORTANT] > Build actions do **not** install their own dependencies (Java, yq, Helm, Docker, Shellcheck, Syft, etc.). @@ -53,6 +54,94 @@ Actions for building, testing, and releasing Strimzi components. > The `build-binaries` action supports an `clusterOperatorBuild` input (default `false`) that enables Strimzi Kafka Operator specific build steps — Helm chart generation, CRD distribution, dashboard setup, documentation checks, and uncommitted changes verification. > Other repositories should leave this disabled. +### Artifact Attestation + +The `attest-artifact` action creates [SLSA build provenance](https://slsa.dev/provenance/v1) and [SBOM attestations](https://spdx.dev/Document/v2.3) using GitHub's Attestation API ([`actions/attest`](https://github.com/actions/attest)). +Attestations are signed with Sigstore (keyless, via GitHub OIDC) and stored in the GitHub Attestation API. +Consumers verify with `gh attestation verify`. + +The action supports three modes: + +| Mode | Use case | Key inputs | +|--------|-------------------------------------|---------------------------------------------------------| +| `blob` | Release archives (.tar.gz, .zip) | `subjectPath` (glob) | +| `oci` | Container/Helm image provenance | `subjectPrefix`, `imageName`, `subjectDigest` | +| `sbom` | SBOM attestation for OCI image | `subjectPrefix`, `imageName`, `subjectDigest`, `sbomPath` | + +#### Built-in attestation + +The following actions include attestation automatically (on push events only, skipped on PRs): + +- **`release-artifacts`** — attests release archives in `blob` mode, includes `.intoto.jsonl` provenance bundle in the release tarball (required for [OpenSSF Scorecard Signed-Releases](https://github.com/ossf/scorecard/blob/main/docs/checks.md#signed-releases) check) +- **`publish-helm-chart`** — attests the Helm OCI artifact in `oci` mode after `helm push` + +#### Container image attestation + +Container images require a separate attestation job because each image needs its own `actions/attest` call. +The `push-containers` action discovers images from the SBOM directory and outputs a JSON array for use in a matrix job. + +**Required permissions** in the release workflow: + +```yaml +permissions: + contents: read + id-token: write # OIDC token for Sigstore signing + attestations: write # GitHub Attestation API +``` + +**Example** — add to your project's `release.yml` after the push-containers job: + +```yaml + attest-containers: + needs: push-containers + runs-on: ubuntu-latest + permissions: + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.push-containers.outputs.images) }} + steps: + - name: Download SBOM artifact + uses: actions/download-artifact@v4 + with: + name: SBOMs-operators-${{ env.RELEASE_VERSION }}.tar.gz + + - name: Extract SBOMs + run: tar -xzf sbom.tar.gz + + - name: Attest container provenance + uses: ./.github/actions/build/attest-artifact + with: + mode: oci + subjectPrefix: quay.io/strimzi + imageName: ${{ matrix.image.name }} + subjectDigest: ${{ matrix.image.digest }} + + - name: Attest container SBOM + uses: ./.github/actions/build/attest-artifact + with: + mode: sbom + subjectPrefix: quay.io/strimzi + imageName: ${{ matrix.image.name }} + subjectDigest: ${{ matrix.image.digest }} + sbomPath: ./${{ matrix.image.sbom }} +``` + +#### Verification + +```bash +# Release archives +gh attestation verify --repo strimzi/ + +# Container images +gh attestation verify oci://quay.io/strimzi/@ --repo strimzi/ + +# Helm charts +gh attestation verify oci://quay.io/strimzi-helm/@ --repo strimzi/ +``` + ### Security Actions Actions for security scanning of dependencies and container images. @@ -142,6 +231,7 @@ on: permissions: contents: read id-token: write + attestations: write concurrency: group: ${{ github.workflow }}-${{ github.ref }}