diff --git a/.github/workflows/__main-ci.yml b/.github/workflows/__main-ci.yml index 3c88c525..e17e2c92 100644 --- a/.github/workflows/__main-ci.yml +++ b/.github/workflows/__main-ci.yml @@ -22,6 +22,7 @@ jobs: uses: ./.github/workflows/__shared-ci.yml permissions: actions: read + attestations: write contents: read id-token: write issues: write diff --git a/.github/workflows/__pull-request-ci.yml b/.github/workflows/__pull-request-ci.yml index 1200302a..570ae0ff 100644 --- a/.github/workflows/__pull-request-ci.yml +++ b/.github/workflows/__pull-request-ci.yml @@ -17,6 +17,7 @@ jobs: uses: ./.github/workflows/__shared-ci.yml permissions: actions: read + attestations: write contents: read id-token: write issues: write diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index 7f637a3c..fc0f5f65 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -141,3 +141,17 @@ jobs: pull-requests: read secrets: CI_BOT_APP_PRIVATE_KEY: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} + + test-workflow-docker-build-images-attestation: + name: Test docker build images - Attestation + needs: linter + uses: ./.github/workflows/__test-workflow-docker-build-images-attestation.yml + permissions: + attestations: write + contents: read + id-token: write + issues: read + packages: write + pull-requests: read + secrets: + CI_BOT_APP_PRIVATE_KEY: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/__test-workflow-docker-build-images-attestation.yml b/.github/workflows/__test-workflow-docker-build-images-attestation.yml new file mode 100644 index 00000000..ae76dbe7 --- /dev/null +++ b/.github/workflows/__test-workflow-docker-build-images-attestation.yml @@ -0,0 +1,177 @@ +--- +name: Test for "docker-build-images" workflow - Attestation +run-name: Test for "docker-build-images" workflow - Attestation + +on: # yamllint disable-line rule:truthy + workflow_call: + secrets: + CI_BOT_APP_PRIVATE_KEY: + required: false + +permissions: {} + +# jscpd:ignore-start +jobs: + arrange: + name: Arrange + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.define-matrix.outputs.result }} + steps: + - run: | + if [ -z "${{ secrets.GITHUB_TOKEN }}" ]; then + echo "GitHub token secret is not set" + exit 1 + fi + + - id: define-matrix + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + result-encoding: json + script: | + let imageNamePrefix; + + if (context.eventName === "pull_request") { + const pullRequestNumber = context.payload.pull_request?.number; + if (!pullRequestNumber) { + throw new Error("Pull request number is not available in the event payload"); + } + imageNamePrefix = `pr-${pullRequestNumber}`; + } else { + imageNamePrefix = process.env.GITHUB_REF_NAME; + if (!imageNamePrefix) { + throw new Error("GITHUB_REF_NAME environment variable is not set"); + } + } + + imageNamePrefix = `test-${imageNamePrefix}-${context.runNumber}`.replace(/\//g, "-"); + + const tag = '0.1.0'; + + const matrix = { + include: [ + { + name: "mono-arch - attested", + "image-name": `${imageNamePrefix}-mono-arch-attested`, + platforms: '["linux/amd64"]', + attest: true, + tag + }, + { + name: "multi-arch - attested", + "image-name": `${imageNamePrefix}-multi-arch-attested`, + platforms: '["linux/amd64","linux/arm64"]', + attest: true, + tag + }, + { + name: "mono-arch - not attested", + "image-name": `${imageNamePrefix}-mono-arch-not-attested`, + platforms: '["linux/amd64"]', + attest: false, + tag + } + ] + }; + return matrix; + + act-build-images: + name: Act - Build images (${{ matrix.name }}) + needs: arrange + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.arrange.outputs.matrix) }} + uses: ./.github/workflows/docker-build-images.yml + permissions: + attestations: write + contents: read + id-token: write + issues: read + packages: write + pull-requests: read + secrets: + oci-registry-password: ${{ secrets.GITHUB_TOKEN }} + build-secret-github-app-key: ${{ secrets.CI_BOT_APP_PRIVATE_KEY }} + with: + attest: ${{ matrix.attest }} + sign: false + images: | + [ + { + "name": "${{ matrix.image-name }}", + "context": ".", + "dockerfile": "./tests/application/Dockerfile", + "build-args": { "BUILD_RUN_ID": "${{ github.run_id }}" }, + "target": "base", + "platforms": ${{ matrix.platforms }}, + "tag": "${{ matrix.tag }}" + } + ] + + assert-images-attestation: + name: Assert - Images attestation (${{ matrix.name }}) + needs: [arrange, act-build-images] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.arrange.outputs.matrix) }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: ./actions/docker/setup + with: + oci-registry: ghcr.io + oci-registry-username: ${{ github.repository_owner }} + oci-registry-password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify image attestation + env: + SHOULD_HAVE_ATTESTATION: ${{ matrix.attest }} + IMAGE_NAME: "${{ matrix.image-name }}:${{ matrix.tag }}" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${SHOULD_HAVE_ATTESTATION}" = "true" ]; then + echo "Verifying attestation for image: ${IMAGE_NAME}" + gh attestation verify \ + oci://ghcr.io/hoverkraft-tech/ci-github-container/"${IMAGE_NAME}" \ + --repo hoverkraft-tech/ci-github-container + else + echo "Image ${IMAGE_NAME} should not have an attestation" + if gh attestation verify \ + oci://ghcr.io/hoverkraft-tech/ci-github-container/"${IMAGE_NAME}" \ + --repo hoverkraft-tech/ci-github-container; then + echo "::error::Image ${IMAGE_NAME} should not be attested but attestation verification succeeded" + exit 1 + fi + echo "Attestation verification failed as expected for non-attested image ${IMAGE_NAME}" + fi + + cleanup: + name: Cleanup ephemeral test packages + runs-on: ubuntu-latest + if: always() + needs: + - arrange + - assert-images-attestation + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.arrange.outputs.matrix) }} + permissions: + packages: write + steps: + - name: Delete test packages + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + IMAGE_NAME: ${{ matrix.image-name }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.packages.deletePackageForOrg({ + package_type: 'container', + package_name: `ci-github-container/${process.env.IMAGE_NAME}`, + org: 'hoverkraft-tech', + }); + +# jscpd:ignore-end diff --git a/.github/workflows/docker-build-images.md b/.github/workflows/docker-build-images.md index 3aeb30cf..b2377343 100644 --- a/.github/workflows/docker-build-images.md +++ b/.github/workflows/docker-build-images.md @@ -35,6 +35,7 @@ This includes [multi-platform](https://docs.docker.com/build/building/multi-plat - **`issues`**: `read` - **`packages`**: `write` - **`pull-requests`**: `read` +- **`attestations`**: `write` (only required when `attest: true`) @@ -58,6 +59,7 @@ jobs: issues: read packages: write pull-requests: read + attestations: write # only required when `attest: true` secrets: # Password or GitHub token (`packages:read` and `packages:write` scopes) configuration used to log against OCI registries. # Accepts either a single password/token string (default format) or a JSON object using the same keys as `oci-registry`. @@ -178,6 +180,12 @@ jobs: # # Default: `true` sign: true + + # Generate build provenance attestations for built images. + # See [attest-images](../../actions/docker/attest-images/README.md). + # + # Default: `false` + attest: false ```` @@ -229,6 +237,8 @@ jobs: | |
[registry."my-registry.local:5000"]
http = true
insecure = true | | | |
| **`sign`** | Sign built images. | **false** | **boolean** | `true` |
| | See [sign-images](../../actions/docker/sign-images/README.md). | | | |
+| **`attest`** | Generate build provenance attestations for built images. | **false** | **boolean** | `false` |
+| | See [attest-images](../../actions/docker/attest-images/README.md). | | | |
diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml
index 7f0a9c0f..36f3bf7d 100644
--- a/.github/workflows/docker-build-images.yml
+++ b/.github/workflows/docker-build-images.yml
@@ -123,6 +123,13 @@ on: # yamllint disable-line rule:truthy
type: boolean
default: true
required: false
+ attest:
+ description: |
+ Generate build provenance attestations for built images.
+ See [attest-images](../../actions/docker/attest-images/README.md).
+ type: boolean
+ default: false
+ required: false
secrets:
oci-registry-password:
description: |
@@ -461,7 +468,8 @@ jobs:
permissions:
contents: read
packages: write
- id-token: write # Needed for signing images
+ id-token: write # Needed for signing and attesting images
+ attestations: write # Needed for attesting images
needs: [prepare-variables, build-images]
runs-on: ${{ fromJson(inputs.runs-on) }}
outputs:
@@ -542,3 +550,30 @@ jobs:
with:
images: ${{ steps.get-images-to-sign.outputs.images-to-sign }}
github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - id: get-images-to-attest
+ if: inputs.attest
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ BUILT_IMAGES_INPUT: ${{ steps.create-images-manifests.outputs.built-images }}
+ with:
+ script: |
+ const builtImagesInput = process.env.BUILT_IMAGES_INPUT;
+ let builtImages = null;
+ try {
+ builtImages = JSON.parse(builtImagesInput);
+ } catch (error) {
+ throw new Error(`"built-images" input is not a valid JSON: ${error}`);
+ }
+
+ // Get images to attest
+ const imagesToAttest = Object.values(builtImages).map(image => image.images).flat();
+ if (imagesToAttest.length > 0) {
+ core.setOutput('images-to-attest', JSON.stringify(imagesToAttest));
+ }
+
+ - uses: ./../self-workflow/actions/docker/attest-images
+ if: steps.get-images-to-attest.outputs.images-to-attest
+ with:
+ images: ${{ steps.get-images-to-attest.outputs.images-to-attest }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 3f28bd95..1e29ae2c 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,8 @@ _Actions that operate on OCI images across their build, metadata, and lifecycle
#### - [Setup](actions/docker/setup/README.md)
+#### - [Attest images](actions/docker/attest-images/README.md)
+
#### - [Sign images](actions/docker/sign-images/README.md)
### Helm
diff --git a/actions/docker/attest-images/README.md b/actions/docker/attest-images/README.md
new file mode 100644
index 00000000..04b087ee
--- /dev/null
+++ b/actions/docker/attest-images/README.md
@@ -0,0 +1,249 @@
+
+
+#  GitHub Action: Docker - Attest images
+
+[
"ghcr.io/my-org/my-repo/application:pr-63-5222075@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d",
"ghcr.io/my-org/my-repo/application:pr-63@sha256:0f5aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f402"
] | | |
+| | Multiple images sharing the same `registry/name` and digest are deduplicated. | | |
+| **`push-to-registry`** | Whether to push the attestation to the image registry. | **false** | `true` |
+| | When enabled, the attestation is stored as an OCI artifact alongside the image. | | |
+| **`github-token`** | GitHub Token used to create attestations. | **false** | `${{ github.token }}` |
+| | Permissions: | | |
+| | - `id-token`: `write` | | |
+| | - `attestations`: `write` | | |
+
+
+
+
+
+
+
+
+
+
+
+## Examples
+
+### Full supply-chain pipeline
+
+```yaml
+name: Build and attest
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ packages: write
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ # 1. Build and push image (produces immutable digest refs)
+ - id: build
+ uses: hoverkraft-tech/ci-github-container/.github/workflows/docker-build-images.yml@main
+ with:
+ images: '[{"name":"application","platforms":["linux/amd64"]}]'
+ secrets:
+ oci-registry-password: ${{ secrets.GITHUB_TOKEN }}
+
+ # 2. Sign image with cosign (registry-native signature)
+ - uses: hoverkraft-tech/ci-github-container/actions/docker/sign-images@main
+ with:
+ images: ${{ steps.build.outputs.built-images }}
+
+ # 3. Attest build provenance (GitHub-native attestation)
+ - uses: hoverkraft-tech/ci-github-container/actions/docker/attest-images@main
+ with:
+ images: ${{ steps.build.outputs.built-images }}
+```
+
+### Verifying attestations
+
+Attestations created by this action can be verified with the `gh` CLI:
+
+```bash
+gh attestation verify oci://ghcr.io/my-org/my-repo/application:main \
+ --owner my-org
+```
+
+
+
+
+
+
+
+## Contributing
+
+Contributions are welcome! Please see the [contributing guidelines](https://github.com/hoverkraft-tech/ci-github-container/blob/main/CONTRIBUTING.md) for more details.
+
+
+
+
+
+
+
+
+## License
+
+This project is licensed under the MIT License.
+
+SPDX-License-Identifier: MIT
+
+Copyright © 2026 hoverkraft
+
+For more details, see the [license](http://choosealicense.com/licenses/mit/).
+
+
+
+
+
+---
+
+This documentation was automatically generated by [CI Dokumentor](https://github.com/hoverkraft-tech/ci-dokumentor).
+
+
+
+
diff --git a/actions/docker/attest-images/action.yml b/actions/docker/attest-images/action.yml
new file mode 100644
index 00000000..7a1341f2
--- /dev/null
+++ b/actions/docker/attest-images/action.yml
@@ -0,0 +1,160 @@
+---
+name: "Docker - Attest images"
+description: |
+ Action to generate build provenance attestations for OCI images.
+ It uses [actions/attest](https://github.com/actions/attest) to create SLSA provenance attestations.
+ Attestations are published to the GitHub Attestations API and optionally pushed to the image registry.
+ The images must be identified by immutable digest references in the format `registry/name:tag@digest`.
+ Images sharing the same `registry/name` and digest are deduplicated — only one attestation is
+ created per unique subject.
+
+ Note: this action complements, and does not replace, image signing with cosign.
+ See [sign-images](../sign-images/README.md) for registry-native image signing.
+
+author: hoverkraft
+branding:
+ icon: shield
+ color: blue
+
+inputs:
+ images:
+ description: |
+ Images to attest.
+ Can be a single image or a list of images separated by commas or newlines or spaces.
+ The images should be in the format `registry/name:tag@digest`.
+ It can also be a list of images in JSON format.
+ Example:
+
+ ```json
+ [
+ "ghcr.io/my-org/my-repo/application:pr-63-5222075@sha256:d31aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f49d",
+ "ghcr.io/my-org/my-repo/application:pr-63@sha256:0f5aa93410434ac9dcfc9179cac2cb1fd4d7c27f11527addc40299c7c675f402"
+ ]
+ ```
+
+ Multiple images sharing the same `registry/name` and digest are deduplicated —
+ only one attestation is created per unique subject (up to 5 unique subjects).
+ required: true
+ push-to-registry:
+ description: |
+ Whether to push the attestation to the image registry.
+ When enabled, the attestation is stored as an OCI artifact alongside the image.
+ Requires the registry to support OCI artifact storage.
+ default: "true"
+ github-token:
+ description: |
+ GitHub Token used to create attestations.
+ Permissions:
+ - `id-token`: `write`
+ - `attestations`: `write`
+ default: ${{ github.token }}
+
+runs:
+ using: "composite"
+ steps:
+ - id: extract-subjects
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ INPUT_IMAGES: ${{ inputs.images }}
+ with:
+ script: |
+ const imagesInput = process.env.INPUT_IMAGES || "";
+ let images = null;
+ try {
+ // Try to parse the input as JSON
+ images = JSON.parse(imagesInput);
+ } catch (error) {
+ // If it fails, split the input by commas, newlines or spaces
+ images = imagesInput.split(/[\s,]+/).filter(image => image.trim() !== "");
+ }
+
+ if (!Array.isArray(images) || images.length === 0) {
+ return core.setFailed(`"images" input is not a valid JSON array or a non-empty string: ${images}`);
+ }
+
+ // Ensure images are in the correct format
+ const registryPart = String.raw`(?