How to Sign a Container Image With Cosign Keyless in CI
Cosign keyless signing uses the workflow OIDC identity, so there is no signing key to manage or leak.
Install cosign with sigstore/cosign-installer, grant id-token: write, then run cosign sign against the pushed image digest with COSIGN_EXPERIMENTAL keyless mode.
Steps
- Add
permissions: id-token: writeandpackages: write. - Install cosign with
sigstore/cosign-installer. - Run
cosign sign --yes <image>@<digest>after the push.
Workflow
permissions:
id-token: write
packages: write
steps:
- uses: sigstore/cosign-installer@v3
- id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
- run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}Gotchas
- Sign the digest, not a mutable tag, so the signature stays valid.
- Verify with
cosign verify --certificate-identity-regexp ... --certificate-oidc-issuer https://token.actions.githubusercontent.com.
Verify it actually works
A workflow that runs is not a workflow that works. Confirm the behaviour on a real event rather than on a manual dispatch, because trigger conditions, permissions, and context values all differ between the two.
# 1. validate the file before pushing
docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color
# 2. trigger the real event, not workflow_dispatch
git commit --allow-empty -m "ci: verify trigger" && git push
# 3. watch it and read the conclusion, not just the colour
gh run watch
gh run view --log-failedWhat usually goes wrong first
- The workflow file must exist on the default branch before scheduled or dispatch triggers appear at all.
GITHUB_TOKENpermissions default to read-only in many organisations. Declare apermissions:block listing every scope the job needs.- Fork pull requests get a read-only token and no access to secrets, regardless of workflow configuration.
actions/checkoutgives you depth 1 on a detached HEAD, so anything needing history or a branch name needsfetch-depth: 0.