From 2ccad5c33b07c4508d6178a77079b816a27e7b1a Mon Sep 17 00:00:00 2001 From: hanthor Date: Sun, 9 Aug 2026 03:39:26 +0000 Subject: [PATCH 1/3] fix(sign-and-publish): pin cosign to legacy .sig format for podman/bootc cosign 3.x flipped --new-bundle-format to default true, which writes the signature as an OCI 1.1 referrer under a sha256- tag instead of the legacy sha256-.sig tag. containers/image -- the library behind podman, skopeo and `bootc switch` -- discovers signatures only via the .sig tag, and GHCR does not implement the referrers API at all (404). So every image built since the cosign-installer v3->v4 bump on 2026-06-04 is signed in a way podman cannot see: `cosign verify` passes and a policy.json sigstoreSigned entry still rejects the image. Verified against ghcr.io/projectbluefin/common@sha256:85b9270d...: sha256-85b9270d....sig -> 404 (legacy, what podman reads) sha256-85b9270d... -> 200 (new format, what cosign 3 wrote) And reproduced against a local registry:2 with cosign v3.0.6, where only the flag differs: cosign sign -y -> sha256- cosign sign -y --new-bundle-format=false -> sha256-.sig Pass --new-bundle-format on all four sign invocations via a new input that defaults to "false". Because `cosign verify` accepts both formats, the existing verification step cannot detect this class of regression -- it passed throughout. Add an explicit registry check for the .sig tag so a future default flip fails the build instead of silently shipping unverifiable images. Refs projectbluefin/common#933 --- bootc-build/sign-and-publish/action.yml | 48 +++++++++++++++++-- .../composite-actions/action-reference.md | 16 +++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/bootc-build/sign-and-publish/action.yml b/bootc-build/sign-and-publish/action.yml index 009b354b..02132b1b 100644 --- a/bootc-build/sign-and-publish/action.yml +++ b/bootc-build/sign-and-publish/action.yml @@ -30,6 +30,17 @@ inputs: image-name: description: "Short image name for SBOM file path (e.g. bluefin)" default: "" + new-bundle-format: + description: > + Whether cosign writes signatures in the Sigstore "new bundle format" (OCI 1.1 + referrers) instead of the legacy `sha256-.sig` tag. + + MUST stay "false" for bootc/podman consumers. containers/image — which backs + podman, skopeo and `bootc switch` — only discovers signatures via the legacy + `.sig` tag, so a policy.json `sigstoreSigned` entry cannot see new-format + signatures. cosign 3.x flipped this default to true, which silently produced + images that `cosign verify` accepts but podman rejects. See projectbluefin/common#XXX. + default: "false" certificate-identity-regexp: description: > Regexp for cosign verify --certificate-identity-regexp. Defaults to known signing @@ -80,18 +91,20 @@ runs: if: inputs.signing-mode == 'keyless' uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 env: + NEW_BUNDLE_FORMAT: ${{ inputs.new-bundle-format }} IMAGE: ${{ inputs.image }} DIGEST: ${{ inputs.digest }} with: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y "${IMAGE}@${DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" "${IMAGE}@${DIGEST}" - name: Sign container image (key-based) if: inputs.signing-mode == 'key' uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 env: + NEW_BUNDLE_FORMAT: ${{ inputs.new-bundle-format }} IMAGE: ${{ inputs.image }} DIGEST: ${{ inputs.digest }} COSIGN_PRIVATE_KEY: ${{ inputs.signing-key }} @@ -99,7 +112,7 @@ runs: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${DIGEST}" - name: Verify signature if: inputs.signing-mode == 'keyless' @@ -115,6 +128,31 @@ runs: --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ "${IMAGE}@${DIGEST}" + # `cosign verify` succeeds against BOTH the legacy `.sig` tag and the new + # OCI 1.1 bundle format, so it cannot detect a format regression on its own — + # that is exactly how cosign 3.x's default flip shipped months of images that + # verified cleanly but that podman/bootc could not validate. + # containers/image (podman, skopeo, `bootc switch`) resolves signatures ONLY + # via `sha256-.sig`, so assert that tag exists in the registry. + - name: Assert legacy .sig tag exists (podman/bootc compatibility) + if: inputs.new-bundle-format == 'false' + shell: bash + env: + IMAGE: ${{ inputs.image }} + DIGEST: ${{ inputs.digest }} + run: | + set -euo pipefail + sig_tag="${DIGEST/:/-}.sig" + if ! cosign manifest download "${IMAGE}:${sig_tag}" >/dev/null 2>&1; then + echo "::error::No legacy signature tag ${IMAGE}:${sig_tag} in the registry." + echo "::error::cosign verify passed, but containers/image (podman, skopeo, bootc)" + echo "::error::only discovers signatures via the .sig tag, so this image would be" + echo "::error::rejected by a policy.json sigstoreSigned entry. Check that" + echo "::error::--new-bundle-format=false reached the cosign sign invocation." + exit 1 + fi + echo "Legacy signature tag present: ${IMAGE}:${sig_tag}" + - name: Install Syft if: inputs.generate-sbom == 'true' id: setup-syft @@ -193,18 +231,20 @@ runs: if: inputs.generate-sbom == 'true' && inputs.signing-mode == 'keyless' uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 env: + NEW_BUNDLE_FORMAT: ${{ inputs.new-bundle-format }} IMAGE: ${{ inputs.image }} SBOM_DIGEST: ${{ steps.upload-sbom.outputs.sbom-digest }} with: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y "${IMAGE}@${SBOM_DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" "${IMAGE}@${SBOM_DIGEST}" - name: Sign SBOM artifact (key-based) if: inputs.generate-sbom == 'true' && inputs.signing-mode == 'key' uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 env: + NEW_BUNDLE_FORMAT: ${{ inputs.new-bundle-format }} IMAGE: ${{ inputs.image }} SBOM_DIGEST: ${{ steps.upload-sbom.outputs.sbom-digest }} COSIGN_PRIVATE_KEY: ${{ inputs.signing-key }} @@ -212,7 +252,7 @@ runs: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${SBOM_DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${SBOM_DIGEST}" - name: GitHub Build Provenance Attestation if: inputs.push-attestation == 'true' diff --git a/docs/skills/composite-actions/action-reference.md b/docs/skills/composite-actions/action-reference.md index da7e2c04..99a885be 100644 --- a/docs/skills/composite-actions/action-reference.md +++ b/docs/skills/composite-actions/action-reference.md @@ -166,6 +166,22 @@ Two signing modes: - `keyless` (default): OIDC/Fulcio via `cosign sign -y`. **Requires** `id-token: write` in the calling job. Validated early — fails immediately if `ACTIONS_ID_TOKEN_REQUEST_URL` is unset. - `key`: `cosign sign -y --key env://COSIGN_PRIVATE_KEY`. Requires `inputs.signing-key` to be set. +**Signature format — `new-bundle-format` (default `"false"`). Do not change this.** + +All four `cosign sign` invocations pass `--new-bundle-format=false`. cosign 3.x flipped +this default to `true`, which writes the signature as an OCI 1.1 referrer under a +`sha256-` tag instead of the legacy `sha256-.sig` tag. + +`containers/image` — the library behind podman, skopeo and `bootc switch` — discovers +signatures **only** via the `.sig` tag. A new-format signature is therefore invisible to +a `policy.json` `sigstoreSigned` entry: the image is signed, `cosign verify` passes, and +podman still rejects it. Because `cosign verify` accepts both formats, nothing in CI +catches the regression, which is why the `Assert legacy .sig tag exists` step exists — +it queries the registry directly for the `.sig` tag and fails the build if it is absent. + +Only set `new-bundle-format: "true"` if every consumer of the image verifies with cosign +rather than with podman/bootc policy. + **Step order (important):** gen-sbom → GitHub SBOM attestation → ORAS attach → sign SBOM artifact → SLSA provenance attestation. **SBOM flow** (when `generate-sbom: true`): Syft generates SPDX JSON → `actions/attest` with `sbom-path` creates a GitHub-native SBOM attestation in the attestation store → ORAS attaches the same SPDX JSON as an OCI referrer artifact → cosign signs the ORAS artifact digest. Both are needed: ORAS serves OCI-native consumers; the GitHub attestation store serves `gh attestation verify` and GitHub-native consumers. From 4ff0ae076877f290faffb4e4c9c30a961f59ee30 Mon Sep 17 00:00:00 2001 From: hanthor Date: Sun, 9 Aug 2026 03:43:57 +0000 Subject: [PATCH 2/3] docs: link the tracking issue in the new-bundle-format input description --- bootc-build/sign-and-publish/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootc-build/sign-and-publish/action.yml b/bootc-build/sign-and-publish/action.yml index 02132b1b..d8a77c2b 100644 --- a/bootc-build/sign-and-publish/action.yml +++ b/bootc-build/sign-and-publish/action.yml @@ -39,7 +39,7 @@ inputs: podman, skopeo and `bootc switch` — only discovers signatures via the legacy `.sig` tag, so a policy.json `sigstoreSigned` entry cannot see new-format signatures. cosign 3.x flipped this default to true, which silently produced - images that `cosign verify` accepts but podman rejects. See projectbluefin/common#XXX. + images that `cosign verify` accepts but podman rejects. See projectbluefin/common#977. default: "false" certificate-identity-regexp: description: > From d4f5f2a726c82ebe32311dc005d69aabcb6c7d91 Mon Sep 17 00:00:00 2001 From: hanthor Date: Sun, 9 Aug 2026 03:47:28 +0000 Subject: [PATCH 3/3] fix(sign-and-publish): add --use-signing-config and use a real download subcommand Two defects in the previous commit, both of which would have failed every consumer build on merge. Caught by testing the exact production invocation against a local registry:2 with cosign v3.0.6 rather than only the variant I had already proven. 1. cosign rejects --new-bundle-format=false while --use-signing-config is at its default of true: $ cosign sign -y --new-bundle-format=false --key cosign.key Error: must provide --new-bundle-format or --bundle where applicable with --signing-config or --use-signing-config The two flags must agree, so pass --use-signing-config="${NEW_BUNDLE_FORMAT}". Legacy mode gets both false; a consumer opting into the new format gets both true, which is cosign's own default. Verified: with the flag added, signing succeeds and writes sha256-.sig. 2. `cosign manifest download` is not a subcommand -- `cosign manifest` only provides `verify`. The guard would have exited non-zero unconditionally and failed the build even when the .sig tag was present. Use `cosign download signature`, verified in both directions: exit 0 against a signed image, non-zero against an unsigned one. --- bootc-build/sign-and-publish/action.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bootc-build/sign-and-publish/action.yml b/bootc-build/sign-and-publish/action.yml index d8a77c2b..660fe693 100644 --- a/bootc-build/sign-and-publish/action.yml +++ b/bootc-build/sign-and-publish/action.yml @@ -35,6 +35,11 @@ inputs: Whether cosign writes signatures in the Sigstore "new bundle format" (OCI 1.1 referrers) instead of the legacy `sha256-.sig` tag. + Also controls `--use-signing-config`, which cosign requires to agree with it: + `--new-bundle-format=false` with the default `--use-signing-config=true` is + rejected outright ("must provide --new-bundle-format or --bundle where + applicable with --signing-config or --use-signing-config"). + MUST stay "false" for bootc/podman consumers. containers/image — which backs podman, skopeo and `bootc switch` — only discovers signatures via the legacy `.sig` tag, so a policy.json `sigstoreSigned` entry cannot see new-format @@ -98,7 +103,7 @@ runs: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" "${IMAGE}@${DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --use-signing-config="${NEW_BUNDLE_FORMAT}" "${IMAGE}@${DIGEST}" - name: Sign container image (key-based) if: inputs.signing-mode == 'key' @@ -112,7 +117,7 @@ runs: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --use-signing-config="${NEW_BUNDLE_FORMAT}" --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${DIGEST}" - name: Verify signature if: inputs.signing-mode == 'keyless' @@ -143,7 +148,7 @@ runs: run: | set -euo pipefail sig_tag="${DIGEST/:/-}.sig" - if ! cosign manifest download "${IMAGE}:${sig_tag}" >/dev/null 2>&1; then + if ! cosign download signature "${IMAGE}@${DIGEST}" >/dev/null 2>&1; then echo "::error::No legacy signature tag ${IMAGE}:${sig_tag} in the registry." echo "::error::cosign verify passed, but containers/image (podman, skopeo, bootc)" echo "::error::only discovers signatures via the .sig tag, so this image would be" @@ -238,7 +243,7 @@ runs: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" "${IMAGE}@${SBOM_DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --use-signing-config="${NEW_BUNDLE_FORMAT}" "${IMAGE}@${SBOM_DIGEST}" - name: Sign SBOM artifact (key-based) if: inputs.generate-sbom == 'true' && inputs.signing-mode == 'key' @@ -252,7 +257,7 @@ runs: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 - command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${SBOM_DIGEST}" + command: cosign sign -y --new-bundle-format="${NEW_BUNDLE_FORMAT}" --use-signing-config="${NEW_BUNDLE_FORMAT}" --key env://COSIGN_PRIVATE_KEY "${IMAGE}@${SBOM_DIGEST}" - name: GitHub Build Provenance Attestation if: inputs.push-attestation == 'true'