From 80e3c6344912250ca2d20286bdae692f753b2cca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:30:34 +0000 Subject: [PATCH 1/5] Initial plan From 20c6d525f29deb9830055a602843d34ae24a5ff6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:38:58 +0000 Subject: [PATCH 2/5] feat: add Docker attest-images action for build provenance attestation --- .github/workflows/docker-build-images.md | 10 + .github/workflows/docker-build-images.yml | 35 ++- README.md | 2 + actions/docker/attest-images/README.md | 249 ++++++++++++++++++++++ actions/docker/attest-images/action.yml | 160 ++++++++++++++ 5 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 actions/docker/attest-images/README.md create mode 100644 actions/docker/attest-images/action.yml 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..b85aeae3 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,28 @@ 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(); + 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 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItc2hpZWxkIiBjb2xvcj0iYmx1ZSI+PHBhdGggZD0iTTEyIDIycy04LTQtOC0xMFY1bDgtMy04IDN2N2M4IDYgOCAxMCA4IDEwWiI+PC9wYXRoPjwvc3ZnPg==) GitHub Action: Docker - Attest images + +
+ Docker - Attest images +
+ +--- + + + + + +[![Release](https://img.shields.io/github/v/release/hoverkraft-tech/ci-github-container)](https://github.com/hoverkraft-tech/ci-github-container/releases) +[![License](https://img.shields.io/github/license/hoverkraft-tech/ci-github-container)](http://choosealicense.com/licenses/mit/) +[![Stars](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-container?style=social)](https://img.shields.io/github/stars/hoverkraft-tech/ci-github-container?style=social) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/hoverkraft-tech/ci-github-container/blob/main/CONTRIBUTING.md) +![GitHub Verified Creator](https://img.shields.io/badge/GitHub-Verified%20Creator-4493F8?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJyZ2IoNjgsIDE0NywgMjQ4KSI+CiAgPHBhdGggZD0ibTkuNTg1LjUyLjkyOS42OGMuMTUzLjExMi4zMzEuMTg2LjUxOC4yMTVsMS4xMzguMTc1YTIuNjc4IDIuNjc4IDAgMCAxIDIuMjQgMi4yNGwuMTc0IDEuMTM5Yy4wMjkuMTg3LjEwMy4zNjUuMjE1LjUxOGwuNjguOTI4YTIuNjc3IDIuNjc3IDAgMCAxIDAgMy4xN2wtLjY4LjkyOGExLjE3NCAxLjE3NCAwIDAgMC0uMjE1LjUxOGwtLjE3NSAxLjEzOGEyLjY3OCAyLjY3OCAwIDAgMS0yLjI0MSAyLjI0MWwtMS4xMzguMTc1YTEuMTcgMS4xNyAwIDAgMC0uNTE4LjIxNWwtLjkyOC42OGEyLjY3NyAyLjY3NyAwIDAgMS0zLjE3IDBsLS45MjgtLjY4YTEuMTc0IDEuMTc0IDAgMCAwLS41MTgtLjIxNUwzLjgzIDE0LjQxYTIuNjc4IDIuNjc4IDAgMCAxLTIuMjQtMi4yNGwtLjE3NS0xLjEzOGExLjE3IDEuMTcgMCAwIDAtLjIxNS0uNTE4bC0uNjgtLjkyOGEyLjY3NyAyLjY3NyAwIDAgMSAwLTMuMTdsLjY4LS45MjhjLjExMi0uMTUzLjE4Ni0uMzMxLjIxNS0uNTE4bC4xNzUtMS4xNGEyLjY3OCAyLjY3OCAwIDAgMSAyLjI0LTIuMjRsMS4xMzktLjE3NWMuMTg3LS4wMjkuMzY1LS4xMDMuNTE4LS4yMTVsLjkyOC0uNjhhMi42NzcgMi42NzcgMCAwIDEgMy4xNyAwWk03LjMwMyAxLjcyOGwtLjkyNy42OGEyLjY3IDIuNjcgMCAwIDEtMS4xOC40ODlsLTEuMTM3LjE3NGExLjE3OSAxLjE3OSAwIDAgMC0uOTg3Ljk4N2wtLjE3NCAxLjEzNmEyLjY3NyAyLjY3NyAwIDAgMS0uNDg5IDEuMThsLS42OC45MjhhMS4xOCAxLjE4IDAgMCAwIDAgMS4zOTRsLjY4LjkyN2MuMjU2LjM0OC40MjQuNzUzLjQ4OSAxLjE4bC4xNzQgMS4xMzdjLjA3OC41MDkuNDc4LjkwOS45ODcuOTg3bDEuMTM2LjE3NGEyLjY3IDIuNjcgMCAwIDEgMS4xOC40ODlsLjkyOC42OGMuNDE0LjMwNS45NzkuMzA1IDEuMzk0IDBsLjkyNy0uNjhhMi42NyAyLjY3IDAgMCAxIDEuMTgtLjQ4OWwxLjEzNy0uMTc0YTEuMTggMS4xOCAwIDAgMCAuOTg3LS45ODdsLjE3NC0xLjEzNmEyLjY3IDIuNjcgMCAwIDEgLjQ4OS0xLjE4bC42OC0uOTI4YTEuMTc2IDEuMTc2IDAgMCAwIDAtMS4zOTRsLS42OC0uOTI3YTIuNjg2IDIuNjg2IDAgMCAxLS40ODktMS4xOGwtLjE3NC0xLjEzN2ExLjE3OSAxLjE3OSAwIDAgMC0uOTg3LS45ODdsLTEuMTM2LS4xNzRhMi42NzcgMi42NzcgMCAwIDEtMS4xOC0uNDg5bC0uOTI4LS42OGExLjE3NiAxLjE3NiAwIDAgMC0xLjM5NCAwWk0xMS4yOCA2Ljc4bC0zLjc1IDMuNzVhLjc1Ljc1IDAgMCAxLTEuMDYgMEw0LjcyIDguNzhhLjc1MS43NTEgMCAwIDEgLjAxOC0xLjA0Mi43NTEuNzUxIDAgMCAxIDEuMDQyLS4wMThMNyA4Ljk0bDMuMjItMy4yMmEuNzUxLjc1MSAwIDAgMSAxLjA0Mi4wMTguNzUxLjc1MSAwIDAgMSAuMDE4IDEuMDQyWiI+PC9wYXRoPgo8L3N2Zz4K) + + + + + +## Overview + +Action to generate build provenance attestations for OCI images. +It uses [actions/attest](https://github.com/actions/attest) to create SLSA provenance attestations +that establish a verifiable record of how and where the image was built. + +Attestations are published to the GitHub Attestations API and optionally pushed to the image registry. + +### Signing vs. Attestation + +This action is **complementary** to, and not a replacement for, image signing with `cosign`: + +| Control | Action | What it proves | +|---|---|---| +| **Signature** | [`sign-images`](../sign-images/README.md) | The image was signed by a trusted identity (registry-native) | +| **Provenance attestation** | `attest-images` (this action) | The image was built by a specific workflow on a specific commit | + +A secure container pipeline should use **both**: + +1. Build image +2. Push image +3. Resolve immutable digest +4. **Sign image** with `sign-images` (cosign) +5. **Attest provenance** with `attest-images` (this action) + +### Why `actions/attest` and not `actions/attest-build-provenance`? + +GitHub explicitly states that new implementations should use +[`actions/attest`](https://github.com/actions/attest) instead of +[`actions/attest-build-provenance`](https://github.com/actions/attest-build-provenance), +which is now a thin wrapper over `actions/attest`. + +### Deduplication + +Images sharing the same registry/name and digest are deduplicated automatically — +only one attestation is created per unique subject. This means multiple image tags +that point to the same digest (e.g. `:main` and `:latest`) generate a single attestation. + +This action supports up to **5** unique image subjects per call. For workflows with more +unique images, call the action once per image. + + + +## Permissions + +If the default GitHub token is used, the following permissions are required: + +```yml +permissions: + id-token: write + attestations: write +``` + +When `push-to-registry` is `true` (the default), the token also needs write access to the registry: + +```yml +permissions: + id-token: write + attestations: write + packages: write # for ghcr.io +``` + + + +## Usage + +````yaml +- uses: hoverkraft-tech/ci-github-container/actions/docker/attest-images@main + with: + # 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" + # ] + # ``` + # + # This input is required. + images: "" + + # Whether to push the attestation to the image registry. + # When enabled, the attestation is stored as an OCI artifact alongside the image. + # + # Default: `true` + push-to-registry: "true" + + # GitHub Token used to create attestations. + # Permissions: + # - `id-token`: `write` + # - `attestations`: `write` + # + # Default: `${{ github.token }}` + github-token: ${{ github.token }} +```` + + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | --------------------- | +| **`images`** | Images to attest. | **true** | - | +| | 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: | | | +| | | | | +| |
[
 "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`(?[a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*(?::[0-9]+)?)`; + const repositoryPart = String.raw`(?[a-z0-9._\/-]+)`; + const tagPart = String.raw`(?[a-zA-Z0-9._-]+)`; + const digestPart = String.raw`(?sha256:[a-f0-9]{64})`; + const imageRegex = new RegExp(`^${registryPart}/${repositoryPart}:${tagPart}@${digestPart}$`); + + const MAX_SUBJECTS = 5; + const subjectMap = new Map(); + + for (const image of images) { + if (typeof image !== 'string') { + return core.setFailed(`Invalid image format: ${image}. Expected a string.`); + } + + const match = image.match(imageRegex); + if (!match || !match.groups) { + return core.setFailed(`Invalid image format: ${image}. Expected format: registry/name:tag@digest`); + } + + const { registry, repository, digest } = match.groups; + const subjectName = `${registry}/${repository}`; + const key = `${subjectName}@${digest}`; + + if (!subjectMap.has(key)) { + subjectMap.set(key, { subjectName, subjectDigest: digest }); + } + } + + const subjects = Array.from(subjectMap.values()); + + if (subjects.length > MAX_SUBJECTS) { + core.warning( + `Found ${subjects.length} unique image subjects but only ${MAX_SUBJECTS} will be attested. ` + + `Consider calling this action separately for each image.` + ); + } + + const attested = subjects.slice(0, MAX_SUBJECTS); + attested.forEach((subject, index) => { + core.setOutput(`subject-${index}-name`, subject.subjectName); + core.setOutput(`subject-${index}-digest`, subject.subjectDigest); + }); + + core.setOutput('subject-count', String(attested.length)); + core.debug(`Extracted ${attested.length} unique subject(s) for attestation`); + + - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + if: steps.extract-subjects.outputs['subject-0-name'] != '' + with: + subject-name: ${{ steps.extract-subjects.outputs['subject-0-name'] }} + subject-digest: ${{ steps.extract-subjects.outputs['subject-0-digest'] }} + push-to-registry: ${{ inputs.push-to-registry }} + github-token: ${{ inputs.github-token }} + + - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + if: steps.extract-subjects.outputs['subject-1-name'] != '' + with: + subject-name: ${{ steps.extract-subjects.outputs['subject-1-name'] }} + subject-digest: ${{ steps.extract-subjects.outputs['subject-1-digest'] }} + push-to-registry: ${{ inputs.push-to-registry }} + github-token: ${{ inputs.github-token }} + + - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + if: steps.extract-subjects.outputs['subject-2-name'] != '' + with: + subject-name: ${{ steps.extract-subjects.outputs['subject-2-name'] }} + subject-digest: ${{ steps.extract-subjects.outputs['subject-2-digest'] }} + push-to-registry: ${{ inputs.push-to-registry }} + github-token: ${{ inputs.github-token }} + + - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + if: steps.extract-subjects.outputs['subject-3-name'] != '' + with: + subject-name: ${{ steps.extract-subjects.outputs['subject-3-name'] }} + subject-digest: ${{ steps.extract-subjects.outputs['subject-3-digest'] }} + push-to-registry: ${{ inputs.push-to-registry }} + github-token: ${{ inputs.github-token }} + + - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + if: steps.extract-subjects.outputs['subject-4-name'] != '' + with: + subject-name: ${{ steps.extract-subjects.outputs['subject-4-name'] }} + subject-digest: ${{ steps.extract-subjects.outputs['subject-4-digest'] }} + push-to-registry: ${{ inputs.push-to-registry }} + github-token: ${{ inputs.github-token }} From 1a6e98acbe78f5fad7dc2fe1fac38dcbd05fc7fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:40:11 +0000 Subject: [PATCH 3/5] fix: only set images-to-attest output when there are images to attest --- .github/workflows/docker-build-images.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-build-images.yml b/.github/workflows/docker-build-images.yml index b85aeae3..36f3bf7d 100644 --- a/.github/workflows/docker-build-images.yml +++ b/.github/workflows/docker-build-images.yml @@ -568,7 +568,9 @@ jobs: // Get images to attest const imagesToAttest = Object.values(builtImages).map(image => image.images).flat(); - core.setOutput('images-to-attest', JSON.stringify(imagesToAttest)); + 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 From 92b26ee6c8bba52da430e3395777909bf399b94a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:49:56 +0000 Subject: [PATCH 4/5] feat: add CI tests for docker-build-images attestation --- .github/workflows/__main-ci.yml | 1 + .github/workflows/__pull-request-ci.yml | 1 + .github/workflows/__shared-ci.yml | 14 ++ ...rkflow-docker-build-images-attestation.yml | 179 ++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 .github/workflows/__test-workflow-docker-build-images-attestation.yml 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..211b6f49 --- /dev/null +++ b/.github/workflows/__test-workflow-docker-build-images-attestation.yml @@ -0,0 +1,179 @@ +--- +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 + exit $? + fi + + 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}" + + 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 From f0ad2958075f37e40667da9f772340b025ecdafc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:50:57 +0000 Subject: [PATCH 5/5] fix: use if/else in attestation assertion step instead of exit $? --- ...rkflow-docker-build-images-attestation.yml | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/__test-workflow-docker-build-images-attestation.yml b/.github/workflows/__test-workflow-docker-build-images-attestation.yml index 211b6f49..ae76dbe7 100644 --- a/.github/workflows/__test-workflow-docker-build-images-attestation.yml +++ b/.github/workflows/__test-workflow-docker-build-images-attestation.yml @@ -137,19 +137,17 @@ jobs: gh attestation verify \ oci://ghcr.io/hoverkraft-tech/ci-github-container/"${IMAGE_NAME}" \ --repo hoverkraft-tech/ci-github-container - exit $? - fi - - 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 + 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 - echo "Attestation verification failed as expected for non-attested image ${IMAGE_NAME}" - cleanup: name: Cleanup ephemeral test packages runs-on: ubuntu-latest