From d61cdd81dc2b835c90bc7d42c2c9b60eb5e74af0 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Wed, 6 May 2026 13:46:24 +1000 Subject: [PATCH 01/15] UID2-6764: emit SLSA provenance for non-Java docker images Co-Authored-By: Claude Sonnet 4.6 --- actions/shared_publish_to_docker/action.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/actions/shared_publish_to_docker/action.yaml b/actions/shared_publish_to_docker/action.yaml index 295ee6d9..d24dae83 100644 --- a/actions/shared_publish_to_docker/action.yaml +++ b/actions/shared_publish_to_docker/action.yaml @@ -90,6 +90,7 @@ runs: scan_type: ${{ inputs.scan_type }} - name: Push to Docker + id: push uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: ${{ inputs.docker_context }} @@ -100,3 +101,13 @@ runs: build-args: | JAR_VERSION=${{ inputs.new_version }} IMAGE_VERSION=${{ inputs.new_version }} + + - name: Attest build provenance + if: ${{ inputs.not_snapshot == 'true' }} + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + env: + NODE_OPTIONS: --max-http-header-size=32768 + with: + subject-name: ${{ inputs.docker_registry }}/${{ inputs.docker_image_name }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true From c8fbbaf2b585916b00630f2eea1c9710a7a85dec Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Wed, 6 May 2026 13:47:24 +1000 Subject: [PATCH 02/15] UID2-6764: grant id-token and attestations write to non-Java publish workflow --- .github/workflows/shared-publish-to-docker-versioned.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/shared-publish-to-docker-versioned.yaml b/.github/workflows/shared-publish-to-docker-versioned.yaml index 942b4953..18e88698 100644 --- a/.github/workflows/shared-publish-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-to-docker-versioned.yaml @@ -51,6 +51,8 @@ jobs: security-events: write packages: write pull-requests: write + id-token: write + attestations: write steps: - name: Setup id: setup From 11649c6f25fdd903a59ad6d5833ef65717e0d8cb Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Wed, 6 May 2026 13:48:37 +1000 Subject: [PATCH 03/15] UID2-6764: emit SLSA provenance for Java docker images --- .../shared-publish-java-to-docker-versioned.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/shared-publish-java-to-docker-versioned.yaml b/.github/workflows/shared-publish-java-to-docker-versioned.yaml index 36bad1ec..9f3989fd 100644 --- a/.github/workflows/shared-publish-java-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-java-to-docker-versioned.yaml @@ -66,6 +66,8 @@ jobs: security-events: write packages: write pull-requests: write + id-token: write + attestations: write outputs: jar_version: ${{ steps.version.outputs.new_version }} image_tag: ${{ steps.updatePom.outputs.image_tag }} @@ -203,6 +205,7 @@ jobs: scan_type: image - name: Push to Docker + id: push uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: ${{inputs.working_dir}} @@ -213,6 +216,16 @@ jobs: JAR_VERSION=${{ steps.version.outputs.new_version }} IMAGE_VERSION=${{ steps.version.outputs.new_version }} + - name: Attest build provenance + if: ${{ steps.checkRelease.outputs.not_snapshot == 'true' }} + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + env: + NODE_OPTIONS: --max-http-header-size=32768 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ inputs.append_image_name }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + - name: Build Changelog id: github_release if: ${{ steps.checkRelease.outputs.is_release == 'true' }} From 80a5560c68dd667db3a028cc0795a1c58895ea8f Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 13:59:58 +1000 Subject: [PATCH 04/15] UID2-6764: don't fail release on attestation error Attestation runs after the docker push but before the changelog/release steps. Without continue-on-error, an attest failure leaves a half-finished release: image pushed, no GitHub Release created. Tolerate attest failures during the v3 rollout so consumers aren't stuck mid-release if attestation breaks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/shared-publish-java-to-docker-versioned.yaml | 1 + actions/shared_publish_to_docker/action.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/shared-publish-java-to-docker-versioned.yaml b/.github/workflows/shared-publish-java-to-docker-versioned.yaml index 9f3989fd..c44277fa 100644 --- a/.github/workflows/shared-publish-java-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-java-to-docker-versioned.yaml @@ -219,6 +219,7 @@ jobs: - name: Attest build provenance if: ${{ steps.checkRelease.outputs.not_snapshot == 'true' }} uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + continue-on-error: true env: NODE_OPTIONS: --max-http-header-size=32768 with: diff --git a/actions/shared_publish_to_docker/action.yaml b/actions/shared_publish_to_docker/action.yaml index d24dae83..9384e112 100644 --- a/actions/shared_publish_to_docker/action.yaml +++ b/actions/shared_publish_to_docker/action.yaml @@ -105,6 +105,7 @@ runs: - name: Attest build provenance if: ${{ inputs.not_snapshot == 'true' }} uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + continue-on-error: true env: NODE_OPTIONS: --max-http-header-size=32768 with: From 7a55c464970937225340620e87cdbf86ec5b7ba5 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 14:11:33 +1000 Subject: [PATCH 05/15] Revert "UID2-6764: don't fail release on attestation error" This reverts commit 80a5560c68dd667db3a028cc0795a1c58895ea8f. --- .github/workflows/shared-publish-java-to-docker-versioned.yaml | 1 - actions/shared_publish_to_docker/action.yaml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/shared-publish-java-to-docker-versioned.yaml b/.github/workflows/shared-publish-java-to-docker-versioned.yaml index c44277fa..9f3989fd 100644 --- a/.github/workflows/shared-publish-java-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-java-to-docker-versioned.yaml @@ -219,7 +219,6 @@ jobs: - name: Attest build provenance if: ${{ steps.checkRelease.outputs.not_snapshot == 'true' }} uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - continue-on-error: true env: NODE_OPTIONS: --max-http-header-size=32768 with: diff --git a/actions/shared_publish_to_docker/action.yaml b/actions/shared_publish_to_docker/action.yaml index 9384e112..d24dae83 100644 --- a/actions/shared_publish_to_docker/action.yaml +++ b/actions/shared_publish_to_docker/action.yaml @@ -105,7 +105,6 @@ runs: - name: Attest build provenance if: ${{ inputs.not_snapshot == 'true' }} uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - continue-on-error: true env: NODE_OPTIONS: --max-http-header-size=32768 with: From 9c2bb4dc4a28aad0bf9dd6f2bb9d80d2842c8025 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 17:19:54 +1000 Subject: [PATCH 06/15] UID2-6764: extract attest_image action; add verify step + smoke test Addresses jon8787's review comments on PR #228: - #2 verify step: attest_image now calls 'gh attestation verify' immediately after signing so misconfigured signatures fail at build time, not consumer pull time. - #3 case sensitivity: lowercase the image ref once and reuse it for both signing and verifying. actions/attest@v4 already lowercases subject-name internally when push-to-registry is true (verified at the pinned commit 59d8942 in src/main.ts and src/subject.ts), but 'gh attestation verify' does NOT lowercase the OCI URI we pass it; doing it ourselves keeps the signed name and the verified URI byte-identical. - #4 NODE_OPTIONS comment: brief comment explaining why we mirror actions/attest-build-provenance's defensive HTTP header bump. - #5 extract: pulled the attest+verify pair into a single composite action so the Java workflow and the non-Java composite action share one implementation. Adds .github/workflows/test-attest-image.yaml: a manually-dispatched smoke test that builds a throwaway image and exercises the full attest+verify path. Use this whenever attest_image or actions/attest@v4 changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ared-publish-java-to-docker-versioned.yaml | 9 +-- .github/workflows/test-attest-image.yaml | 67 +++++++++++++++++++ actions/attest_image/action.yaml | 49 ++++++++++++++ actions/shared_publish_to_docker/action.yaml | 9 +-- 4 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/test-attest-image.yaml create mode 100644 actions/attest_image/action.yaml diff --git a/.github/workflows/shared-publish-java-to-docker-versioned.yaml b/.github/workflows/shared-publish-java-to-docker-versioned.yaml index 9f3989fd..ee148820 100644 --- a/.github/workflows/shared-publish-java-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-java-to-docker-versioned.yaml @@ -218,13 +218,10 @@ jobs: - name: Attest build provenance if: ${{ steps.checkRelease.outputs.not_snapshot == 'true' }} - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - env: - NODE_OPTIONS: --max-http-header-size=32768 + uses: IABTechLab/uid2-shared-actions/actions/attest_image@v3 with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ inputs.append_image_name }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true + subject_name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ inputs.append_image_name }} + subject_digest: ${{ steps.push.outputs.digest }} - name: Build Changelog id: github_release diff --git a/.github/workflows/test-attest-image.yaml b/.github/workflows/test-attest-image.yaml new file mode 100644 index 00000000..d8b327af --- /dev/null +++ b/.github/workflows/test-attest-image.yaml @@ -0,0 +1,67 @@ +name: Test attest_image composite action + +# Manually-dispatched smoke test that builds a tiny throwaway image, runs the +# `actions/attest_image` composite action, and verifies the resulting attestation. +# Validates UID2-6764 end-to-end without depending on a real consumer publish. +# Re-run this whenever `actions/attest@v4.x` is bumped or attest_image is changed. + +on: + workflow_dispatch: + +env: + TEST_IMAGE: ghcr.io/${{ github.repository }}/test-attest + +jobs: + smoke: + name: Smoke-test attest_image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Write throwaway Dockerfile + run: | + cat > Dockerfile.test-attest <<'DOCKERFILE' + FROM alpine:3.20 + RUN echo "uid2-6764 attest_image smoke test, run ${GITHUB_RUN_ID:-local}" > /uid2-6764.txt + DOCKERFILE + + - name: Build and push test image + id: push + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: Dockerfile.test-attest + push: true + tags: ${{ env.TEST_IMAGE }}:run-${{ github.run_id }} + + - name: Attest image (the system under test) + uses: ./actions/attest_image + with: + subject_name: ${{ env.TEST_IMAGE }} + subject_digest: ${{ steps.push.outputs.digest }} + + - name: Independent re-verify + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "Verifying ${TEST_IMAGE}@${{ steps.push.outputs.digest }} ..." + gh attestation verify \ + "oci://${TEST_IMAGE}@${{ steps.push.outputs.digest }}" \ + --owner "${{ github.repository_owner }}" \ + --format json | jq '.[0].verificationResult.signature.certificate | {issuer, subjectAlternativeName, runnerEnvironment}' diff --git a/actions/attest_image/action.yaml b/actions/attest_image/action.yaml new file mode 100644 index 00000000..f76de7b8 --- /dev/null +++ b/actions/attest_image/action.yaml @@ -0,0 +1,49 @@ +name: Attest container image +description: | + Generates a SLSA build-provenance attestation for an OCI image, pushes the + signed bundle to the registry alongside the image, and verifies it via + `gh attestation verify` so a misconfigured signature fails the build rather + than the consumer pull. + + actions/attest already lowercases subject-name when push-to-registry is true + (see github.com/actions/attest src/main.ts and src/subject.ts), but + `gh attestation verify` does not lowercase the OCI URI we pass to it. To + keep the signed name and the verified URI byte-identical, we lowercase + once here and reuse the value in both steps. + +inputs: + subject_name: + description: Fully qualified image reference (registry/owner/repo[+suffix]). Mixed case is fine — this action lowercases it for OCI compliance. + required: true + subject_digest: + description: OCI manifest digest (sha256:...) emitted by docker/build-push-action. + required: true + +runs: + using: composite + steps: + - name: Lowercase image reference + id: ref + shell: bash + run: | + value="$(printf '%s' '${{ inputs.subject_name }}' | tr '[:upper:]' '[:lower:]')" + echo "value=${value}" >> "$GITHUB_OUTPUT" + + - name: Attest build provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + env: + # Mirrors actions/attest-build-provenance, prevents oversized OCI registry auth-challenge headers triggering HPE_HEADER_OVERFLOW. + NODE_OPTIONS: --max-http-header-size=32768 + with: + subject-name: ${{ steps.ref.outputs.value }} + subject-digest: ${{ inputs.subject_digest }} + push-to-registry: true + + - name: Verify attestation + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh attestation verify \ + "oci://${{ steps.ref.outputs.value }}@${{ inputs.subject_digest }}" \ + --owner "${{ github.repository_owner }}" diff --git a/actions/shared_publish_to_docker/action.yaml b/actions/shared_publish_to_docker/action.yaml index d24dae83..096acd5f 100644 --- a/actions/shared_publish_to_docker/action.yaml +++ b/actions/shared_publish_to_docker/action.yaml @@ -104,10 +104,7 @@ runs: - name: Attest build provenance if: ${{ inputs.not_snapshot == 'true' }} - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - env: - NODE_OPTIONS: --max-http-header-size=32768 + uses: IABTechLab/uid2-shared-actions/actions/attest_image@v3 with: - subject-name: ${{ inputs.docker_registry }}/${{ inputs.docker_image_name }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true + subject_name: ${{ inputs.docker_registry }}/${{ inputs.docker_image_name }} + subject_digest: ${{ steps.push.outputs.digest }} From 95b7c529a77f642bdc191e20e64533fa8370c89e Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 17:21:03 +1000 Subject: [PATCH 07/15] UID2-6764: trigger smoke test on push to feature branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop after merge — only here so the smoke test can run before the workflow file lands on main (gh workflow run / API dispatch require the file to exist on the default branch). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test-attest-image.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test-attest-image.yaml b/.github/workflows/test-attest-image.yaml index d8b327af..ced3c038 100644 --- a/.github/workflows/test-attest-image.yaml +++ b/.github/workflows/test-attest-image.yaml @@ -7,6 +7,11 @@ name: Test attest_image composite action on: workflow_dispatch: + push: + branches: [bmz-UID2-6764-artifact-attestation] + paths: + - .github/workflows/test-attest-image.yaml + - actions/attest_image/** env: TEST_IMAGE: ghcr.io/${{ github.repository }}/test-attest From 46592c9e797d7b9893f6a7cd8107a3a8d4361dee Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 17:22:14 +1000 Subject: [PATCH 08/15] UID2-6764: lowercase the smoke test image ref before docker push github.repository is mixed case; docker rejects mixed-case tags at push time. Compute a lowercased ref once and reuse it for the push tag, the attest_image input, and the independent re-verify command. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test-attest-image.yaml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-attest-image.yaml b/.github/workflows/test-attest-image.yaml index ced3c038..7735b127 100644 --- a/.github/workflows/test-attest-image.yaml +++ b/.github/workflows/test-attest-image.yaml @@ -13,9 +13,6 @@ on: - .github/workflows/test-attest-image.yaml - actions/attest_image/** -env: - TEST_IMAGE: ghcr.io/${{ github.repository }}/test-attest - jobs: smoke: name: Smoke-test attest_image @@ -29,6 +26,10 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Compute lowercased test image ref + id: image + run: echo "ref=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')/test-attest" >> "$GITHUB_OUTPUT" + - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: @@ -53,20 +54,22 @@ jobs: context: . file: Dockerfile.test-attest push: true - tags: ${{ env.TEST_IMAGE }}:run-${{ github.run_id }} + tags: ${{ steps.image.outputs.ref }}:run-${{ github.run_id }} - name: Attest image (the system under test) uses: ./actions/attest_image with: - subject_name: ${{ env.TEST_IMAGE }} + subject_name: ${{ steps.image.outputs.ref }} subject_digest: ${{ steps.push.outputs.digest }} - name: Independent re-verify env: GH_TOKEN: ${{ github.token }} + IMAGE_REF: ${{ steps.image.outputs.ref }} + IMAGE_DIGEST: ${{ steps.push.outputs.digest }} run: | - echo "Verifying ${TEST_IMAGE}@${{ steps.push.outputs.digest }} ..." + echo "Verifying ${IMAGE_REF}@${IMAGE_DIGEST} ..." gh attestation verify \ - "oci://${TEST_IMAGE}@${{ steps.push.outputs.digest }}" \ + "oci://${IMAGE_REF}@${IMAGE_DIGEST}" \ --owner "${{ github.repository_owner }}" \ --format json | jq '.[0].verificationResult.signature.certificate | {issuer, subjectAlternativeName, runnerEnvironment}' From 688a8186961109d921213d93aea55335326c81d4 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 17:26:01 +1000 Subject: [PATCH 09/15] UID2-6764: drop push trigger from test-attest-image now that smoke test is green Run 25542801315 verified the attest+verify path end-to-end. Reverting to workflow_dispatch only so the test stops auto-firing and remains as an on-demand regression check after merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test-attest-image.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/test-attest-image.yaml b/.github/workflows/test-attest-image.yaml index 7735b127..a9e7670c 100644 --- a/.github/workflows/test-attest-image.yaml +++ b/.github/workflows/test-attest-image.yaml @@ -7,11 +7,6 @@ name: Test attest_image composite action on: workflow_dispatch: - push: - branches: [bmz-UID2-6764-artifact-attestation] - paths: - - .github/workflows/test-attest-image.yaml - - actions/attest_image/** jobs: smoke: From 3954ca4de2043d89d115f7ec3e021997be15cb97 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Fri, 8 May 2026 18:32:44 +1000 Subject: [PATCH 10/15] UID2-6764: drop the smoke-test workflow now that it's served its purpose Run 25542801315 captured the verified attestation evidence on PR #228; keeping the workflow would just push throwaway test images on every manual dispatch. The composite action lives at actions/attest_image and can be re-tested in any future change by re-adding this workflow file ad-hoc. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test-attest-image.yaml | 70 ------------------------ 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/test-attest-image.yaml diff --git a/.github/workflows/test-attest-image.yaml b/.github/workflows/test-attest-image.yaml deleted file mode 100644 index a9e7670c..00000000 --- a/.github/workflows/test-attest-image.yaml +++ /dev/null @@ -1,70 +0,0 @@ -name: Test attest_image composite action - -# Manually-dispatched smoke test that builds a tiny throwaway image, runs the -# `actions/attest_image` composite action, and verifies the resulting attestation. -# Validates UID2-6764 end-to-end without depending on a real consumer publish. -# Re-run this whenever `actions/attest@v4.x` is bumped or attest_image is changed. - -on: - workflow_dispatch: - -jobs: - smoke: - name: Smoke-test attest_image - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - id-token: write - attestations: write - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Compute lowercased test image ref - id: image - run: echo "ref=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')/test-attest" >> "$GITHUB_OUTPUT" - - - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - - name: Write throwaway Dockerfile - run: | - cat > Dockerfile.test-attest <<'DOCKERFILE' - FROM alpine:3.20 - RUN echo "uid2-6764 attest_image smoke test, run ${GITHUB_RUN_ID:-local}" > /uid2-6764.txt - DOCKERFILE - - - name: Build and push test image - id: push - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 - with: - context: . - file: Dockerfile.test-attest - push: true - tags: ${{ steps.image.outputs.ref }}:run-${{ github.run_id }} - - - name: Attest image (the system under test) - uses: ./actions/attest_image - with: - subject_name: ${{ steps.image.outputs.ref }} - subject_digest: ${{ steps.push.outputs.digest }} - - - name: Independent re-verify - env: - GH_TOKEN: ${{ github.token }} - IMAGE_REF: ${{ steps.image.outputs.ref }} - IMAGE_DIGEST: ${{ steps.push.outputs.digest }} - run: | - echo "Verifying ${IMAGE_REF}@${IMAGE_DIGEST} ..." - gh attestation verify \ - "oci://${IMAGE_REF}@${IMAGE_DIGEST}" \ - --owner "${{ github.repository_owner }}" \ - --format json | jq '.[0].verificationResult.signature.certificate | {issuer, subjectAlternativeName, runnerEnvironment}' From 5152af346773e9b19061d5e3e98e3739bcba99ca Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Mon, 11 May 2026 09:30:01 +1000 Subject: [PATCH 11/15] Update action.yaml to simplify descriptions --- actions/attest_image/action.yaml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/actions/attest_image/action.yaml b/actions/attest_image/action.yaml index f76de7b8..50ee80a0 100644 --- a/actions/attest_image/action.yaml +++ b/actions/attest_image/action.yaml @@ -2,18 +2,11 @@ name: Attest container image description: | Generates a SLSA build-provenance attestation for an OCI image, pushes the signed bundle to the registry alongside the image, and verifies it via - `gh attestation verify` so a misconfigured signature fails the build rather - than the consumer pull. - - actions/attest already lowercases subject-name when push-to-registry is true - (see github.com/actions/attest src/main.ts and src/subject.ts), but - `gh attestation verify` does not lowercase the OCI URI we pass to it. To - keep the signed name and the verified URI byte-identical, we lowercase - once here and reuse the value in both steps. + `gh attestation verify`. inputs: subject_name: - description: Fully qualified image reference (registry/owner/repo[+suffix]). Mixed case is fine — this action lowercases it for OCI compliance. + description: Fully qualified image reference (registry/owner/repo[+suffix]). required: true subject_digest: description: OCI manifest digest (sha256:...) emitted by docker/build-push-action. From fc2bf95be83a88f35a335e1b24b670feba9b5f65 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Mon, 11 May 2026 10:05:43 +1000 Subject: [PATCH 12/15] UID2-6764: demote verify failure to warning on private repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end smoke against private UnifiedID2/uid2-test-source surfaced a real gh CLI limitation: attestations signed by GitHub's internal Sigstore instance (used for private repos) fail verification with 'Error: verifying with issuer "GitHub, Inc."'. Tried --no-public-good, --bundle-from-oci, --cert-oidc-issuer combinations; same result. Signing and upload still succeed (bundle lands in both the attestations API and the OCI registry), so external verifiers remain authoritative. Demote the in-CI verify failure to a warning for private repos only; public repos still hard-fail on verify mismatch as Jon's review #2 intended. Evidence: UnifiedID2/uid2-test-source actions run 25643422322 — full shared-publish-to-docker-versioned.yaml chain green (setup → buildx → vulnerability_scan → push → attest_image sign+upload → shared_create_releases draft), attestation signed for ghcr.io/.../uid2-6764-smoke@sha256:05058e77... Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/attest_image/action.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/actions/attest_image/action.yaml b/actions/attest_image/action.yaml index 50ee80a0..6d5117c8 100644 --- a/actions/attest_image/action.yaml +++ b/actions/attest_image/action.yaml @@ -37,6 +37,20 @@ runs: env: GH_TOKEN: ${{ github.token }} run: | + set +e gh attestation verify \ "oci://${{ steps.ref.outputs.value }}@${{ inputs.subject_digest }}" \ --owner "${{ github.repository_owner }}" + rc=$? + if [ "$rc" -ne 0 ] && [ "${{ github.event.repository.private }}" = "true" ]; then + # Private-repo attestations are signed by GitHub's internal Sigstore + # instance ("GitHub, Inc." CA). gh CLI <= 2.92 rejects this cert + # chain even with --no-public-good / --bundle-from-oci. Signing and + # upload succeed (bundle is in both the attestations API and the + # OCI registry); only the in-CI verify is brittle. Demote to a + # warning so private consumers don't hard-fail; external verifiers + # remain authoritative. + echo "::warning::gh attestation verify failed; private-repo verification is a known gh CLI limitation. Attestation was signed and uploaded successfully." + exit 0 + fi + exit "$rc" From d9ea44806a5bbda8c187e2d7562961d38be61ed5 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Mon, 11 May 2026 10:16:04 +1000 Subject: [PATCH 13/15] UID2-6764: grant artifact-metadata:write to publish jobs actions/attest@v4.1.0 sets create-storage-record:true by default, which calls GitHub's artifact-metadata API to cross-link the signed attestation to the build artifact. Without artifact-metadata:write the call returns 403 and the run logs 'Failed to persist storage record'. The storage record itself is independent of the signature/upload chain (those still succeed), but it powers the "Attestations" tab in the GitHub UI and surfaces attestations for org-wide policy/discovery. Required on the callee's job permissions block too; reusable workflows take the intersection of caller and callee permissions. Consumer rollout PRs receive a matching grant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/shared-publish-java-to-docker-versioned.yaml | 1 + .github/workflows/shared-publish-to-docker-versioned.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/shared-publish-java-to-docker-versioned.yaml b/.github/workflows/shared-publish-java-to-docker-versioned.yaml index ee148820..813ef6cc 100644 --- a/.github/workflows/shared-publish-java-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-java-to-docker-versioned.yaml @@ -68,6 +68,7 @@ jobs: pull-requests: write id-token: write attestations: write + artifact-metadata: write outputs: jar_version: ${{ steps.version.outputs.new_version }} image_tag: ${{ steps.updatePom.outputs.image_tag }} diff --git a/.github/workflows/shared-publish-to-docker-versioned.yaml b/.github/workflows/shared-publish-to-docker-versioned.yaml index 18e88698..559b1d94 100644 --- a/.github/workflows/shared-publish-to-docker-versioned.yaml +++ b/.github/workflows/shared-publish-to-docker-versioned.yaml @@ -53,6 +53,7 @@ jobs: pull-requests: write id-token: write attestations: write + artifact-metadata: write steps: - name: Setup id: setup From 72a3a111725ed0e2d697253b984dab7de513efb0 Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Mon, 11 May 2026 11:06:50 +1000 Subject: [PATCH 14/15] UID2-6764: fix verify SAN policy; drop private-repo warning fallback The previous "private-repo verify caveat" misdiagnosed cli/cli#9045: the failure isn't about the GitHub-internal Sigstore CA, it's that the cert SAN of a reusable-workflow signer doesn't match the default --owner regex. Add --cert-identity-regex to accept either signer pattern (reusable workflow under IABTechLab/uid2-shared-actions, or composite-action caller under ${{ github.repository }}), and remove the warning demotion so verify hard-fails uniformly. Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/attest_image/action.yaml | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/actions/attest_image/action.yaml b/actions/attest_image/action.yaml index 6d5117c8..0da393df 100644 --- a/actions/attest_image/action.yaml +++ b/actions/attest_image/action.yaml @@ -37,20 +37,22 @@ runs: env: GH_TOKEN: ${{ github.token }} run: | - set +e + # gh attestation verify enforces both a source-repo-owner policy (via + # --owner) and a signer-identity policy (the cert SAN regex). With + # only --owner, the SAN regex defaults to ^https://github.com//, + # which breaks every reusable-workflow caller because the signer SAN + # is the reusable workflow's own URL, not the caller's repo (see + # cli/cli#9045 and `gh attestation verify --help`). + # + # attest_image gets signed under one of two SANs: + # - From a reusable workflow under IABTechLab/uid2-shared-actions + # (shared-publish-{,java-}to-docker-versioned.yaml) -- the + # reusable workflow is itself the signer. + # - From the composite action actions/shared_publish_to_docker + # invoked directly by a caller -- the caller's workflow is the + # signer (path under ${{ github.repository }}). + # Accept either pattern explicitly. gh attestation verify \ "oci://${{ steps.ref.outputs.value }}@${{ inputs.subject_digest }}" \ - --owner "${{ github.repository_owner }}" - rc=$? - if [ "$rc" -ne 0 ] && [ "${{ github.event.repository.private }}" = "true" ]; then - # Private-repo attestations are signed by GitHub's internal Sigstore - # instance ("GitHub, Inc." CA). gh CLI <= 2.92 rejects this cert - # chain even with --no-public-good / --bundle-from-oci. Signing and - # upload succeed (bundle is in both the attestations API and the - # OCI registry); only the in-CI verify is brittle. Demote to a - # warning so private consumers don't hard-fail; external verifiers - # remain authoritative. - echo "::warning::gh attestation verify failed; private-repo verification is a known gh CLI limitation. Attestation was signed and uploaded successfully." - exit 0 - fi - exit "$rc" + --owner "${{ github.repository_owner }}" \ + --cert-identity-regex "^https://github\.com/(IABTechLab/uid2-shared-actions|${{ github.repository }})/" From c3cb8b46bd6f76468e8887c0a862f80e7d3f12fb Mon Sep 17 00:00:00 2001 From: Behnam Mozafari Date: Mon, 11 May 2026 11:32:07 +1000 Subject: [PATCH 15/15] UID2-6764: shorten verify comment Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/attest_image/action.yaml | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/actions/attest_image/action.yaml b/actions/attest_image/action.yaml index 0da393df..dad23376 100644 --- a/actions/attest_image/action.yaml +++ b/actions/attest_image/action.yaml @@ -37,21 +37,7 @@ runs: env: GH_TOKEN: ${{ github.token }} run: | - # gh attestation verify enforces both a source-repo-owner policy (via - # --owner) and a signer-identity policy (the cert SAN regex). With - # only --owner, the SAN regex defaults to ^https://github.com//, - # which breaks every reusable-workflow caller because the signer SAN - # is the reusable workflow's own URL, not the caller's repo (see - # cli/cli#9045 and `gh attestation verify --help`). - # - # attest_image gets signed under one of two SANs: - # - From a reusable workflow under IABTechLab/uid2-shared-actions - # (shared-publish-{,java-}to-docker-versioned.yaml) -- the - # reusable workflow is itself the signer. - # - From the composite action actions/shared_publish_to_docker - # invoked directly by a caller -- the caller's workflow is the - # signer (path under ${{ github.repository }}). - # Accept either pattern explicitly. + # Accept either signer SAN: the reusable workflow under IABTechLab/uid2-shared-actions, or the caller's workflow via the composite path. --owner alone would reject the former (see https://github.com/cli/cli/issues/9045). gh attestation verify \ "oci://${{ steps.ref.outputs.value }}@${{ inputs.subject_digest }}" \ --owner "${{ github.repository_owner }}" \