diff --git a/.github/actions/build/attest-artifact/action.yml b/.github/actions/build/attest-artifact/action.yml deleted file mode 100644 index 33dc44e..0000000 --- a/.github/actions/build/attest-artifact/action.yml +++ /dev/null @@ -1,83 +0,0 @@ -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 4c031bd..7c3134e 100644 --- a/.github/actions/build/publish-helm-chart/action.yml +++ b/.github/actions/build/publish-helm-chart/action.yml @@ -25,10 +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" +outputs: + image: + description: "Full OCI image reference with digest (e.g. quay.io/strimzi-helm/chart@sha256:abc)" + value: ${{ steps.helm-push.outputs.image }} runs: using: "composite" @@ -55,13 +55,4 @@ runs: 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 + echo "image=${{ inputs.containerRegistry }}/${{ inputs.containerOrg }}/${{ inputs.helmChartName }}@${DIGEST}" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/build/push-containers/action.yml b/.github/actions/build/push-containers/action.yml index f445b71..4f37371 100644 --- a/.github/actions/build/push-containers/action.yml +++ b/.github/actions/build/push-containers/action.yml @@ -33,8 +33,11 @@ inputs: outputs: images: - description: "JSON array of {name, digest} for multi-image attestation via attest-containers.yml" + description: "JSON array of {name, digest, sbom} for multi-image attestation via reusable-attest.yml" value: ${{ steps.discover-images.outputs.images }} + sbomsArtifact: + description: "Name of the uploaded SBOMs artifact" + value: SBOMs-${{ inputs.artifactSuffix }}-${{ inputs.containerTag }}.tar.gz runs: using: "composite" @@ -146,7 +149,7 @@ runs: 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) + name="${{ inputs.containerRegistry }}/${{ inputs.containerOrg }}/${after_org%%/*}" 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}]') diff --git a/.github/actions/build/release-artifacts/action.yml b/.github/actions/build/release-artifacts/action.yml index 20bd6c8..9a615b5 100644 --- a/.github/actions/build/release-artifacts/action.yml +++ b/.github/actions/build/release-artifacts/action.yml @@ -8,11 +8,6 @@ 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" steps: @@ -31,51 +26,13 @@ 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" -o \ - -iname "*.intoto.jsonl" \) \ + -iname "*${{ inputs.releaseVersion }}*.yaml" \) \ -exec tar -rvf release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar {} \; - name: Upload release artifacts diff --git a/.github/workflows/reusable-attest.yml b/.github/workflows/reusable-attest.yml new file mode 100644 index 0000000..0c9ded1 --- /dev/null +++ b/.github/workflows/reusable-attest.yml @@ -0,0 +1,180 @@ +name: Attestation + +on: + workflow_call: + inputs: + containerImages: + description: "JSON array of {name, digest} from push-containers" + required: false + type: string + default: "[]" + artifactSuffix: + description: "Suffix for the release artifact name (e.g. kafka-bridge)" + required: false + type: string + default: "" + releaseVersion: + description: "Release version for finding archives in the release tar" + required: false + type: string + default: "" + helmChartName: + description: "Helm chart name (used for conditional logic, skip when empty or 'none')" + required: false + type: string + default: "" + helmChartImage: + description: "Full OCI reference with digest (e.g. quay.io/strimzi-helm/chart@sha256:abc123)" + required: false + type: string + default: "" + sbomsArtifact: + description: "Name of the SBOMs artifact from push-containers (e.g. SBOMs-kafka-bridge-latest.tar.gz)" + required: false + type: string + default: "" + +permissions: + contents: read + id-token: write + attestations: write + +jobs: + attest-release: + name: Attest Release Archives + if: ${{ inputs.releaseVersion != '' && inputs.releaseVersion != 'none' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download release artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar + + - name: Extract release tar + shell: bash + run: | + mkdir -p release-workdir + tar -xf release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar -C release-workdir + + - name: Download SBOMs artifact + if: ${{ inputs.sbomsArtifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.sbomsArtifact }} + + - name: Copy SBOMs into release workdir + if: ${{ inputs.sbomsArtifact != '' }} + shell: bash + run: cp sbom.tar.gz release-workdir/ + + - name: Find release archives + id: find-archives + shell: bash + run: | + FILES=$(find release-workdir -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 + + - name: Attest release archives + if: ${{ steps.find-archives.outputs.found == 'true' }} + id: attest + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: ${{ steps.find-archives.outputs.files }} + + - name: Add attestation bundle and SBOMs to release tar + if: ${{ steps.find-archives.outputs.found == 'true' }} + shell: bash + run: | + cp "${{ steps.attest.outputs.bundle-path }}" "release-workdir/release-${{ inputs.releaseVersion }}.intoto.jsonl" + rm release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar + TARBALL="../release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar" + cd release-workdir + find . -type f \( -iname "*${{ inputs.releaseVersion }}*.tar.gz" -o \ + -iname "*${{ inputs.releaseVersion }}*.zip" -o \ + -iname "*${{ inputs.releaseVersion }}*.tgz" -o \ + -iname "*${{ inputs.releaseVersion }}*.yaml" -o \ + -iname "*.intoto.jsonl" -o \ + -name "sbom.tar.gz" \) \ + -exec tar -rvf "${TARBALL}" {} \; + + if ! tar -tf "${TARBALL}" | grep -q 'intoto.jsonl'; then + echo "::error::Attestation bundle missing from rebuilt release tar" + exit 1 + fi + + - name: Re-upload release artifact with attestation + if: ${{ steps.find-archives.outputs.found == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar + path: release-${{ inputs.artifactSuffix }}-${{ inputs.releaseVersion }}.tar + overwrite: true + retention-days: 30 + + attest-helm: + name: Attest Helm Chart + if: ${{ inputs.helmChartName != '' && inputs.helmChartName != 'none' && inputs.helmChartImage != '' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Parse Helm chart image reference + id: parse + shell: bash + run: | + IMAGE="${{ inputs.helmChartImage }}" + echo "subject-name=${IMAGE%%@*}" >> "$GITHUB_OUTPUT" + echo "subject-digest=${IMAGE##*@}" >> "$GITHUB_OUTPUT" + + - name: Attest Helm chart + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-name: ${{ steps.parse.outputs.subject-name }} + subject-digest: ${{ steps.parse.outputs.subject-digest }} + + attest-containers: + name: Attest Container Image + if: ${{ inputs.containerImages != '[]' && inputs.containerImages != '' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + image: ${{ fromJSON(inputs.containerImages) }} + steps: + - name: Attest container image - ${{ matrix.image.name }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-name: ${{ matrix.image.name }} + subject-digest: ${{ matrix.image.digest }} + + - name: Download SBOMs artifact + if: ${{ inputs.sbomsArtifact != '' && matrix.image.sbom != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.sbomsArtifact }} + + - name: Extract SBOMs + if: ${{ inputs.sbomsArtifact != '' && matrix.image.sbom != '' }} + shell: bash + run: tar -xzf sbom.tar.gz + + - name: Attest SBOM - ${{ matrix.image.sbom }} + if: ${{ inputs.sbomsArtifact != '' && matrix.image.sbom != '' }} + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-name: ${{ matrix.image.name }} + subject-digest: ${{ matrix.image.digest }} + sbom-path: ${{ matrix.image.sbom }} diff --git a/.github/workflows/reusable-test-integrations.yml b/.github/workflows/reusable-test-integrations.yml index 27be203..261738f 100644 --- a/.github/workflows/reusable-test-integrations.yml +++ b/.github/workflows/reusable-test-integrations.yml @@ -308,6 +308,7 @@ jobs: timeout-minutes: 20 outputs: images: ${{ steps.push.outputs.images }} + sbomsArtifact: ${{ steps.push.outputs.sbomsArtifact }} steps: - name: Checkout ${{ inputs.repo }} uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -411,7 +412,6 @@ jobs: with: artifactSuffix: "${{ inputs.artifactSuffix }}" releaseVersion: ${{ inputs.releaseVersion }} - attestDryRun: "true" - name: Verify release outputs run: | @@ -454,7 +454,8 @@ jobs: needs: test-release-artifacts runs-on: ubuntu-latest timeout-minutes: 15 - + outputs: + helmChartImage: ${{ steps.publish.outputs.image }} steps: - name: Checkout ${{ inputs.repo }} uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -485,6 +486,7 @@ jobs: uses: ./.github/actions/dependencies/install-helm - name: Publish Helm charts using publish-helm action + id: publish uses: ./.github/actions/build/publish-helm-chart with: registryUser: "strimzi" @@ -493,4 +495,3 @@ jobs: helmChartName: ${{ inputs.helmChartName}} releaseVersion: ${{ inputs.releaseVersion }} artifactSuffix: ${{ inputs.artifactSuffix }} - attestDryRun: "true" diff --git a/README.md b/README.md index 5f740b0..a32ffd9 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,7 @@ Actions for building, testing, and releasing Strimzi components. | `build/load-containers` | Loads container images into Kind/Minikube registry | `registry` (required: minikube/kind/external) | | `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) | +| `build/publish-helm-chart` | Publishes Helm Chart as OCI artifact | `releaseVersion` (required), `helmChartName` (required). Outputs: `image` (full OCI ref with digest) | > [!IMPORTANT] > Build actions do **not** install their own dependencies (Java, yq, Helm, Docker, Shellcheck, Syft, etc.). @@ -56,31 +55,23 @@ Actions for building, testing, and releasing Strimzi components. ### 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)). +Artifact attestation is handled by the `reusable-attest.yml` reusable workflow, which 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: +Attestation runs as a **separate workflow job** — if it fails, the release/push/helm artifacts are already published and remain available. -| 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` | +The reusable workflow handles three types of attestation: -#### Built-in attestation +| Job | What it attests | Key inputs | +|-----|----------------|------------| +| `attest-release` | Release archives (.tar.gz, .zip, .tgz) | `artifactSuffix`, `releaseVersion` | +| `attest-helm` | Helm OCI artifact | `helmChartName`, `helmChartImage` (full OCI ref with digest) | +| `attest-containers` | Container images (matrix) + SBOMs | `containerImages` (JSON array), `sbomsArtifact` | -The following actions include attestation automatically (on push events only, skipped on PRs): +The `attest-release` job downloads the release tar, attests the archives, adds the `.intoto.jsonl` provenance bundle and SBOMs to the tar, and re-uploads it (required for [OpenSSF Scorecard Signed-Releases](https://github.com/ossf/scorecard/blob/main/docs/checks.md#signed-releases) check). -- **`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: +**Required permissions** in the calling workflow: ```yaml permissions: @@ -89,44 +80,19 @@ permissions: attestations: write # GitHub Attestation API ``` -**Example** — add to your project's `release.yml` after the push-containers job: +**Example** — add to your project's `release.yml` after the push-containers and publish-helm jobs: ```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 }} + attest: + needs: [push-containers, release-artifacts, publish-helm] + uses: strimzi/github-actions/.github/workflows/reusable-attest.yml@main + with: + containerImages: ${{ needs.push-containers.outputs.images }} + artifactSuffix: "operators" + releaseVersion: ${{ env.RELEASE_VERSION }} + helmChartName: "strimzi-kafka-operator" + helmChartImage: ${{ needs.publish-helm.outputs.image }} + sbomsArtifact: ${{ needs.push-containers.outputs.sbomsArtifact }} ``` #### Verification