Keyless-Signing CLI Artifacts with Sigstore cosign — GitHub Actions OIDC, Bundle Verification, and a Tamper Test

Tadashi Shigeoka · Fri, June 26, 2026

When you distribute a CLI out of band, say by handing someone a zip through a Google Drive share link, the recipient has no way to confirm that the zip actually came from your org’s workflow and was not rewritten by anyone in transit. The share link can be swapped, the filename can be faked, and if the bytes are substituted somewhere along the download path, nobody notices. Publishing through the public npm registry attaches provenance, but that protection lives inside the registry path and does not reach a zip distributed outside the registry.

This post fills that gap with Sigstore cosign keyless signing. The material is the sigstore-cosign recipe from cli-distribution-recipes, a public repository collecting one recipe per distribution channel. We sign both distributed artifacts for the sample CLI @codenote-net/hello-cli: the npm .tgz and the Google Drive .zip. The goal looks like this. A recipient verifies successfully with:

cosign verify-blob \
  --bundle codenote-hello-<version>.zip.bundle \
  --certificate-identity "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  codenote-hello-<version>.zip

And an artifact tampered by even a single byte fails verification with that same command. We achieve all of it while holding no long-lived signing key.

This recipe takes the same posture as the tokenless publishing recipe built earlier in the same repository: hold no stored secret, and prove identity with a short-lived OIDC token. Rather than locking a signing key in a vault and guarding it forever, the move is to hold no key worth guarding in the first place.

Why keyless signing

cosign offers two signing modes: traditional key-pair signing, and Sigstore keyless signing. The difference is exactly the structure that tokenless publishing used when it replaced a stored secret with a short-lived proof of identity.

Key-pair signing signs the artifact with a private key generated by cosign generate-key-pair. That private key and its password must be generated, stored, rotated, and protected against leakage. Using it in CI means parking the key in Secrets, which makes the signing key itself a single point of failure.

Keyless signing holds no stored private key. On each signing, the following happens:

  • GitHub Actions mints an OIDC token for that workflow run
  • Sigstore’s certificate authority Fulcio issues a short-lived signing certificate bound to that identity
  • The certificate expires within minutes, and the signing event is recorded in the Rekor transparency log

So the entire practice of “guarding a key” disappears. There is no long-lived key anywhere, so there is nothing to leak. Instead, “who signed” is proven as the OIDC identity of the workflow run, and by pinning that identity the verifier can confirm down to “this repository’s workflow signed it.”

For that reason this recipe makes keyless the default. Key-pair signing is still an option for offline or non-OIDC environments, but that means taking on the key-management burden described above.

Design — sign two artifacts, attach verification material beside them

This recipe signs the two artifacts the repository produces:

  • the npm package tarball, codenote-net-hello-cli-<version>.tgz
  • the Google Drive distribution zip, codenote-hello-<version>.zip

Both are signed with cosign sign-blob, and the signing material is collected into a .bundle file. The bundle contains everything cosign verify-blob needs (the signature, the certificate, and the Rekor entry), so the verifier only needs the bundle and the artifact.

Alongside, the recipe generates a SHA-256 checksum file (.sha256) per artifact. This is a minimal fallback for environments that cannot install cosign. A checksum only proves “not corrupted,” not “who produced it,” and it only carries meaning once the checksum file itself is obtained from a trusted location. Prefer cosign verification whenever possible.

The signed distribution set ends up as:

codenote-net-hello-cli-<version>.tgz
codenote-net-hello-cli-<version>.tgz.bundle
codenote-net-hello-cli-<version>.tgz.sha256
codenote-hello-<version>.zip
codenote-hello-<version>.zip.bundle
codenote-hello-<version>.zip.sha256
VERIFY.md

VERIFY.md is the consumer-facing verification guide for the Google Drive distribution. One design choice matters here: VERIFY.md is published beside the zip, not inside it. Signing runs over the exact bytes of the zip, so adding content to the zip after signing would invalidate the signature. The verification guide therefore sits outside the zip, as a separate file that is not part of the signed payload.

The big picture

Here is the flow from signing to verification, shown first.

flowchart TD
  A["Maintainer runs the signing workflow on main"] --> B["Check out the repository"]
  B --> C["Install Node.js, npm, and cosign"]
  C --> D["Build the .tgz with npm pack"]
  C --> E["Build the Google Drive .zip from that same .tgz"]
  D --> F["Generate a SHA-256 per artifact"]
  E --> F
  F --> G["Request a GitHub Actions OIDC token"]
  G --> H["Fulcio issues a short-lived certificate; cosign sign-blob writes a bundle"]
  H --> I["Verify the untouched artifact with the pinned identity and issuer"]
  I --> J["Tamper a copy and confirm verification fails"]
  J --> K["Upload artifact, .bundle, .sha256, and VERIFY.md"]
  K --> L["Recipient downloads all files"]
  L --> M["Recipient checks SHA-256"]
  M --> N["Recipient runs cosign verify-blob"]
  N --> O{"Identity and issuer match?"}
  O -- "No" --> P["Reject the artifact"]
  O -- "Yes" --> Q["Install or redistribute"]

Left to right, the signing side and the verification side sit symmetrically. Let us build out the key pieces in order.

Inside the signing workflow

The signing workflow .github/workflows/sign-hello-cli-artifacts.yml is triggered manually with workflow_dispatch, and its permissions are kept minimal.

on:
  workflow_dispatch:
 
permissions:
  contents: read
  id-token: write
 
env:
  CERTIFICATE_IDENTITY: "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main"
  CERTIFICATE_OIDC_ISSUER: "https://token.actions.githubusercontent.com"

id-token: write is the crux of keyless signing. Without it, GitHub Actions cannot mint an OIDC token and cannot obtain a certificate from Fulcio. Conversely, the only permissions signing needs are this plus contents: read for checkout. No signing key and no cosign password are stored in GitHub Secrets.

At the top of the job, the trigger ref is restricted to main.

      - name: Require main ref
        run: |
          set -euo pipefail
 
          if [ "$GITHUB_REF" != "refs/heads/main" ]; then
            echo "::error::This workflow signs release artifacts only from refs/heads/main. Current ref: $GITHUB_REF"
            exit 1
          fi

Why this matters is the flip side of the certificate identity pin discussed below. The signing identity the verification command expects is fixed to ...sign-hello-cli-artifacts.yml@refs/heads/main, so signing from another branch produces a mismatched identity and the documented verification would not pass. Signing is therefore allowed only from main, rejected before it begins otherwise.

Next come tool installation and the artifact build. Every third-party action is pinned to a commit SHA rather than a tag. Tags are mutable, so a rewritten upstream would run code you did not intend.

      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6
        with:
          cosign-release: "v3.0.6"

The cosign version is pinned to v3.0.6 as well, to avoid behavioral drift against the verifier. The build step produces the .tgz with npm pack, then assembles the Google Drive .zip from that same .tgz.

The signing and verification itself runs in a loop, once per artifact.

      - name: Sign and verify artifacts
        run: |
          set -euo pipefail
 
          cd "$GITHUB_WORKSPACE/recipes/sigstore-cosign/dist"
          shopt -s nullglob
          artifacts=( *.tgz *.zip )
 
          for artifact in "${artifacts[@]}"; do
            sha256sum "$artifact" > "$artifact.sha256"
 
            cosign sign-blob \
              --yes \
              --bundle "$artifact.bundle" \
              "$artifact"
 
            cosign verify-blob \
              --bundle "$artifact.bundle" \
              --certificate-identity "$CERTIFICATE_IDENTITY" \
              --certificate-oidc-issuer "$CERTIFICATE_OIDC_ISSUER" \
              "$artifact"
 
            "$GITHUB_WORKSPACE/recipes/sigstore-cosign/tamper-verify-fails.sh" \
              "$artifact" \
              cosign verify-blob \
              --bundle "$artifact.bundle" \
              --certificate-identity "$CERTIFICATE_IDENTITY" \
              --certificate-oidc-issuer "$CERTIFICATE_OIDC_ISSUER"
          done
 
          sha256sum --check *.sha256

The point is that CI confirms three things in sequence. First, it signs the artifact and creates a bundle. Second, it verifies that bundle with the pinned identity and issuer, confirming an untouched artifact passes. Third, it confirms that a tampered copy fails verification. CI itself proves, before anything ships, both that the signature verifies and that it rejects tampering.

Wiring the tamper test into CI

That third check, “rejects tampering,” is essential to a signature. A signature that only passes verification could be satisfied by verification logic that always returns success, a sieve that lets everything through. Only by confirming that a tampered artifact reliably fails can you say the signature is actually working.

tamper-verify-fails.sh appends a few bytes to a copy of the artifact and expects the given verification command to fail. Because it expects failure, the script itself errors out if verification unexpectedly succeeds.

#!/usr/bin/env sh
set -eu
 
ARTIFACT=$1
shift
TAMPERED_ARTIFACT="$ARTIFACT.tampered"
 
cp "$ARTIFACT" "$TAMPERED_ARTIFACT"
printf '\ntampered\n' >> "$TAMPERED_ARTIFACT"
 
if "$@" "$TAMPERED_ARTIFACT" >/dev/null 2>&1; then
  printf 'Tampered artifact unexpectedly passed verification: %s\n' "$ARTIFACT" >&2
  exit 1
fi
 
printf 'tamper failure ok: %s\n' "$ARTIFACT"

To confirm the same thing by hand, do this. Both should exit non-zero.

cp codenote-hello-<version>.zip tampered.zip
printf '\ntampered\n' >> tampered.zip
cosign verify-blob \
  --bundle codenote-hello-<version>.zip.bundle \
  --certificate-identity "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  tampered.zip

Integrating with Google Drive distribution

This recipe adds a signed distribution set as a separate recipe without breaking the existing Google Drive distribution recipe. The unsigned, plain Google Drive recipe stays in place at recipes/google-drive/, and the signed variant sits beside it at recipes/google-drive-with-cosign/.

When distributing the signed set, always upload these four files together to Google Drive and share them with the same audience:

codenote-hello-<version>.zip
codenote-hello-<version>.zip.bundle
codenote-hello-<version>.zip.sha256
VERIFY.md

Do not publish the zip without its .bundle, .sha256, and VERIFY.md. A zip missing its verification material is just as unverifiable as the unsigned recipe. As noted, VERIFY.md sits beside the zip rather than inside it, because changing the zip after signing invalidates the signature.

Verification — pin the identity and issuer

The recipient downloads all four files into one directory and follows VERIFY.md. It is two steps. First, check the checksum:

shasum -a 256 -c codenote-hello-<version>.zip.sha256

Then verify the keyless signature:

cosign verify-blob \
  --bundle codenote-hello-<version>.zip.bundle \
  --certificate-identity "https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  codenote-hello-<version>.zip

What you must never drop here are the two pins, --certificate-identity and --certificate-oidc-issuer. Drop them, and verification only proves that “some trusted identity signed it.” Fulcio issues a certificate to anyone authenticated via OIDC, so without pinning the identity, an artifact signed by an entirely different person using their own GitHub account would still pass. To say “this repository’s workflow signed it,” you have to fix the identity and issuer exactly.

Only once both commands succeed do you proceed to install.

unzip codenote-hello-<version>.zip -d codenote-hello
cd codenote-hello
npm install -g ./*.tgz
codenote-hello
Ohayou gozaimasu, Konnichiwa, Konbanwa!

Note that if you change the workflow filename, the repository, or the signing ref, you must update the workflow’s CERTIFICATE_IDENTITY, the recipe README, and VERIFY.md in the same change. The recipe ships check-certificate-identity.sh to catch that drift in CI, reconciling the identity the workflow declares against the identity in the docs before signing. If the pinned string diverges between the docs and CI, the verification procedure itself breaks.

Verification checklists for humans and agents

Verification is not only done by humans. An AI agent that fetches and runs the CLI should verify with the same discipline. The recipe ships checklists for both.

For humans:

  1. Download the artifact, its .bundle, and its .sha256.
  2. Run shasum -a 256 -c <artifact>.sha256.
  3. Run cosign verify-blob with the pinned certificate identity and issuer.
  4. Install or redistribute only if both succeed.

For agents, spelled out one notch more explicitly:

  1. Refuse to install if the .bundle or .sha256 is missing.
  2. Run checksum verification.
  3. Run cosign verify-blob.
  4. Require the certificate identity to match this string exactly:
https://github.com/codenote-net/cli-distribution-recipes/.github/workflows/sign-hello-cli-artifacts.yml@refs/heads/main
  1. Require the OIDC issuer to match https://token.actions.githubusercontent.com exactly.
  2. Treat any verification error or identity mismatch as a hard failure.

The key point for an agent is to not swallow a verification error as a warning. If the identity differs even slightly, it is someone else’s signature, and it should be rejected unconditionally.

Local smoke test

The keyless certificate identity is only issued inside the signing workflow. Still, before opening a PR, you want to confirm the parts that do not need OIDC (build, sign, verify, tamper, checksum) pass locally. The recipe ships a smoke test for that.

recipes/sigstore-cosign/local-smoke-test.sh

The script uses mise to fetch sigstore/cosign@3.0.6 via aqua, creates a local key pair in a temporary directory, and runs the .tgz and .zip through signing, verification, tampering, and checksum checks. It is strictly a local cosign smoke test and does not verify the GitHub Actions keyless certificate identity, because that identity is only issued inside the signing workflow. That is the boundary between local and workflow responsibilities.

Limitations and known constraints

This recipe is not a silver bullet either. Before distributing, hold these constraints explicitly.

  • The signing event is public: keyless signing is recorded in Rekor, a public transparency log. That is fine for a public repository, but be aware the fact of signing becomes public.
  • It proves origin and integrity, not full build integrity: the signature only says “the signed bytes came from this identity and were not modified.” It does not guarantee the integrity of the build environment or every dependency and runner input. For that, you need isolated builds (SLSA Build L3 or higher) separately. This is the same caveat as npm provenance.
  • Verification requires installing cosign: a tooling dependency. The SHA-256 checksum is a minimal fallback, but it does not prove origin.
  • Production signing runs from main: the certificate identity is pinned to refs/heads/main, so the signing workflow must run from main. If you change the identity, update the workflow, README, and VERIFY.md together.
  • The transport is still Google Drive: who can download is decided by Google Drive’s sharing controls. The signature only adds “authenticity of the contents”; access control is a separate layer.

These are design trade-offs rather than defects. When your requirements outgrow these assumptions, take it as the signal to step up to OS-level code signing (macOS notarization, Windows Authenticode) or stricter build integrity.

Conclusion

Add authenticity and integrity to out-of-band artifacts while holding no stored key. What this post assembled is the minimal shape of that. To recap:

  • Sign with cosign keyless signing using GitHub Actions OIDC, holding zero long-lived signing keys. Fulcio issues a short-lived certificate, and the signing event lands in Rekor.
  • Sign both the npm .tgz and the Google Drive .zip, collecting the signing material into a .bundle. Attach SHA-256 as a fallback for environments without cosign.
  • Always pin --certificate-identity and --certificate-oidc-issuer during verification. Without the pins you can only say “someone signed it,” not “this workflow signed it.”
  • Wire the tamper test into CI, proving both that the untouched artifact passes and the tampered one fails before anything ships.
  • Keep the VERIFY.md guide beside the zip, not inside it, because changing the zip after signing invalidates the signature.
  • Keyless proves origin and integrity but not build-time integrity. If you need that, move on to isolated builds.

Hold no key, and you cannot leak a key. Replace the operational burden of guarding a signing-key vault with verification of a short-lived OIDC identity. If you are distributing artifacts outside a registry in 2026, this level of authenticity is worth making the default.

That is the design for keyless-signing distributed CLI artifacts with cosign, sent from the field.

References