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 @@
+
+
+#  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`(?