Verifying Signed Commits in CI Pipelines Jump to heading
A signature is only worth what your pipeline does with it. Requiring developers to sign commits achieves nothing if the server that assembles your release accepts unsigned or forged history without complaint. This guide sits inside the broader Commit Signing & Git Supply-Chain Security discipline and covers the enforcement half: how a CI pipeline takes a pull-request range, verifies every commit against a controlled set of trusted keys, and fails the job the moment it finds a commit it cannot vouch for. The mechanics are identical whether your team signs with GPG or SSH — what changes is how you distribute the trust material, which we handle explicitly below.
The goal is a job that is deterministic, fast, and impossible to satisfy with an unsigned commit. Verification that “usually passes” trains developers to ignore it; verification that blocks the merge button is a control.
Prerequisites Jump to heading
Before wiring signature verification into CI, confirm the following:
Verification Pipeline Overview Jump to heading
The diagram below shows the shape of a signature-verification job: check out the range, import the trust material, iterate every commit between the base and the head of the pull request, verify each one, and gate the merge on the aggregate result.
Step 1 — Distribute the allowed_signers File to CI Jump to heading
Intent: Give the runner an authoritative, version-controlled list of which keys are permitted to sign commits, so verification is decided by your policy rather than by whatever happens to be in a runner’s keyring.
For SSH signing, Git verifies commits against the file named by gpg.ssh.allowedSignersFile. Each line maps an identity (an email or *) to a public key. Commit this file into the repository so the trust set is reviewed like any other change:
# .github/allowed_signers — one line per authorized signer
# principal keytype base64-key
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...alice
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...bob
# Platform bot key used for web-UI merges and Dependabot commits
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...github On the runner, point Git at this file before any verification runs:
# Bind Git's SSH verification to the committed trust file
git config gpg.ssh.allowedSignersFile "${GITHUB_WORKSPACE}/.github/allowed_signers"
git config gpg.format ssh For GPG signing, distribute an exported public keyring instead and import it:
# Import the organisation's exported public keys (no private material)
gpg --import ./ci/org-public-keys.asc Verify: Confirm Git can see the trust configuration and that the file is non-empty:
git config --get gpg.ssh.allowedSignersFile # prints the resolved path
test -s "$(git config --get gpg.ssh.allowedSignersFile)" && echo "trust file present"
# Expected: the path, then: trust file present SAFETY WARNING: Never place private keys or the CI’s own signing key into the trust file or the runner image. The verification job needs only public material. A leaked private signing key lets an attacker forge trusted history — rotate it immediately per Rotating a Compromised Commit-Signing Key.
Step 2 — Compute the Pull-Request Commit Range Jump to heading
Intent: Verify exactly the commits the pull request introduces — not the entire history, which would be slow and would re-flag old commits signed by keys since rotated out.
The range is base..head: every commit reachable from the PR head but not from the target branch. Resolve both ends explicitly so the job behaves identically on a shallow or full clone:
# Resolve the base and head of the PR range
BASE_SHA="$(git merge-base origin/main HEAD)" # common ancestor
HEAD_SHA="$(git rev-parse HEAD)"
# List the commits the PR adds, oldest first
git rev-list --reverse "${BASE_SHA}..${HEAD_SHA}" Using git merge-base rather than the raw branch tip means a stale base branch does not pull unrelated commits into the range. On GitHub Actions the base and head SHAs are also available as $ and .head.sha, but computing merge-base locally is portable across platforms.
Verify: The range should contain only the PR’s own commits:
git rev-list --count "${BASE_SHA}..${HEAD_SHA}"
# Expected: a small integer equal to the number of commits in the PR Step 3 — Verify Every Commit in the Range Jump to heading
Intent: Check each commit’s signature cryptographically and against the trust file, refusing anything unsigned or signed by a key you do not recognise.
Three commands do overlapping work; know which to reach for:
git verify-commit <sha>— the enforcement primitive. Exits0only for a valid, trusted signature; non-zero for unsigned or untrusted commits. This is what you script against.git log --show-signature— human-readable output for logs and debugging; it annotates each commit withGood "trusted" signatureorNo signature.git merge --verify-signatures— refuses to complete a merge if the tip commit being merged is not validly signed; useful as a belt-and-braces gate at merge time, but it only checks the tip, not the whole range.
The portable POSIX shell loop below is the heart of the job. It iterates the range, verifies each commit, and collects failures rather than bailing on the first one so the log shows every offending commit:
#!/bin/sh
# verify-signatures.sh — POSIX, no bashisms
set -eu
BASE_SHA="$(git merge-base origin/main HEAD)"
HEAD_SHA="$(git rev-parse HEAD)"
# Author emails whose commits are signed by the platform, not a contributor
BOT_ALLOWLIST="[email protected] dependabot[bot]@users.noreply.github.com"
failed=0
for sha in $(git rev-list --reverse "${BASE_SHA}..${HEAD_SHA}"); do
author_email="$(git show -s --format='%ae' "$sha")"
# Skip known automation identities (see Step 6)
case " $BOT_ALLOWLIST " in
*" $author_email "*)
printf 'skip %s (bot: %s)\n' "$sha" "$author_email"
continue
;;
esac
if git verify-commit "$sha" >/dev/null 2>&1; then
printf 'ok %s %s\n' "$sha" "$author_email"
else
printf 'FAIL %s %s (unsigned or untrusted)\n' "$sha" "$author_email"
failed=1
fi
done
exit "$failed" Because git verify-commit inherits the gpg.ssh.allowedSignersFile and gpg.format configured in Step 1, a signature made by a key absent from the trust file fails even though it is cryptographically valid — trust is decided by your file, not by the mere presence of a signature.
Verify: Run the script against a branch with a known-good history and a deliberately unsigned commit:
sh verify-signatures.sh
# Expected on clean history: every line 'ok' or 'skip', exit code 0
# Expected with an unsigned commit: a 'FAIL' line and exit code 1
echo "exit=$?" Step 4 — Fail the Job on Any Unverified Commit Jump to heading
Intent: Wire the verification loop into a CI job whose non-zero exit blocks the merge, and register it as a required status check so the gate cannot be bypassed.
The GitHub Actions job below checks out the full range, configures the trust file, and runs the verification script. A non-zero exit from the script fails the step, which fails the job:
# .github/workflows/verify-signatures.yml
name: Verify signed commits
on:
pull_request:
branches: [main]
jobs:
verify-signatures:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so the range resolves
persist-credentials: false
- name: Configure trust store
run: |
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile \
"${GITHUB_WORKSPACE}/.github/allowed_signers"
- name: Ensure base branch is fetched
run: git fetch --no-tags origin main
- name: Verify every commit in the PR range
run: sh ./ci/verify-signatures.sh # exits non-zero on any failure Making this job authoritative requires one platform step: add verify-signatures to the branch’s required status checks. That is the boundary between a warning and a control, and it is covered in depth under Commit Verification Gates and its walkthrough Enforcing Signed Commits with Branch Protection.
Verify: Open a pull request containing one commit created with git commit --no-gpg-sign. The job should fail and the merge button should be disabled:
git commit --no-gpg-sign --allow-empty -m "test: unsigned commit"
git push origin HEAD
# Expected: the verify-signatures check reports failure on the PR SAFETY WARNING: A verification job that runs but is not a required status check is theatre — the merge button ignores it. Confirm the check is required and that
enforce_adminsis enabled, or an administrator can merge unsigned history straight past the gate.
Step 5 — Cache and Trust the Public Keys Jump to heading
Intent: Keep the trust material fast to load and stable across runs, and make key rotation a reviewable pull request rather than an out-of-band runner change.
Because the SSH trust file lives in the repository, it is already cached implicitly by the checkout — there is no network fetch and no separate keyserver dependency, which is the main operational advantage of SSH-based verification in CI. For GPG, a keyserver round-trip per run is both slow and a supply-chain risk (the keyserver becomes a trusted third party). Pin the keys instead: commit the exported public keyring and import from disk, and cache the imported GPG home directory between runs:
- name: Cache GPG trust store
uses: actions/cache@v4
with:
path: ~/.gnupg
key: gpg-trust-${{ hashFiles('ci/org-public-keys.asc') }}
- name: Import pinned public keys
run: gpg --import ci/org-public-keys.asc The cache key is derived from the hash of the committed keyring, so any change to the trust set invalidates the cache automatically — you never serve a stale trust store after a rotation. Treat every edit to allowed_signers or org-public-keys.asc as a security-relevant change and require review from a code owner, mirroring the discipline in Protecting & Rotating Signing Keys.
Verify: Confirm the trust set loaded and the cache key reflects the current file:
# SSH: count trusted principals
grep -c . "$(git config --get gpg.ssh.allowedSignersFile)"
# GPG: list imported public keys
gpg --list-keys --keyid-format long | grep -c '^pub'
# Expected: a count matching the number of authorised signers SAFETY WARNING: Do not fetch signing keys from a public keyserver at verification time. A poisoned or hijacked keyserver response would let an attacker’s key be trusted. Pin public keys in-repo and rotate them through review.
Step 6 — Handle Bot and CI-Authored Commits Jump to heading
Intent: Verify automation commits without punching a hole in the policy — bots cannot present a contributor’s private key, so they need an explicit, narrow exemption tied to a known identity.
There are two correct approaches, and one dangerous one to avoid.
Trust the platform’s key. GitHub signs the commits it generates — web-UI merges, squash merges, and Dependabot updates — with its own GPG key. Import that public key (or add its SSH equivalent to allowed_signers) and those commits verify like any other. This is the strongest option because the exemption is still cryptographic:
# Fetch and pin GitHub's web-flow public key once, commit it to the repo
curl -s https://github.com/web-flow.gpg -o ci/github-web-flow.asc
gpg --import ci/github-web-flow.asc Allowlist specific bot author emails. When a bot genuinely cannot sign, exempt it by exact author email — never by pattern-matching “bot” loosely, which an attacker could spoof by setting their author email. The loop in Step 3 already implements this via BOT_ALLOWLIST, matched against the full email:
# Exempt only these exact identities; everything else must verify
BOT_ALLOWLIST="dependabot[bot]@users.noreply.github.com" For commits your own pipeline creates — release bumps, changelog commits — have the CI sign them with a dedicated bot key whose public half is in the trust file, so they verify normally. The trigger conditions and identity plumbing for such pipeline-authored commits are mapped in CI/CD Pipeline Trigger Mapping.
Verify: Confirm a bot commit is handled by the intended path (trusted key or allowlist), not silently skipped for everyone:
git log --show-signature -1 <bot-commit-sha>
# Expected: 'Good signature' if key-trusted, or a 'skip … (bot: …)' line in the job log SAFETY WARNING: Do not exempt unsigned commits by matching on the commit message or a loose email pattern. An attacker sets both freely. Scope every exemption to an exact, known automation identity or, better, to a trusted platform key.
Integration with Adjacent Workflows Jump to heading
Signature verification in CI is one control in a layered supply-chain posture, and it interlocks with three neighbours.
Merge-time gates. The CI job gives contributors fast feedback before merge, but the durable enforcement point is the branch’s required status check plus the platform’s own “require signed commits” toggle. Commit Verification Gates covers how these compose, and why running your own allowed_signers verification catches cases the platform’s built-in check misses — notably signatures from keys the platform does not know about.
Signing format choice. Everything in Step 1 branches on whether the team signs with GPG or SSH. If you have not settled that, GPG vs SSH Commit Signing lays out the trade-offs; SSH’s in-repo allowed_signers file is materially simpler to distribute to CI, which is the position most new setups land on.
Trigger and identity plumbing. When your pipeline authors its own commits, or when you scope verification to only the events that matter, the mapping of Git events to CI jobs lives in CI/CD Pipeline Trigger Mapping. Aligning the verification job’s triggers with the rest of your pipeline avoids redundant runs and keeps the required-check list coherent.
The boundary of responsibility: CI verifies and reports; branch protection enforces at merge; the trust file — reviewed like code — decides who is trusted. Keep those three concerns separate and the system stays auditable.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
git verify-commit fails on a commit the author insists is signed | The signer’s key is not in the CI allowed_signers file | Add the public key to the trust file via a reviewed pull request; re-run the job |
Every commit reports “No signature” in --show-signature | gpg.format/gpg.ssh.allowedSignersFile not configured on the runner | Set both in a step before verification (Step 1); confirm with git config --get |
| Range includes unrelated old commits | Base resolved from a stale branch tip instead of the merge base | Use git merge-base origin/main HEAD and git fetch origin main first |
| Job passes locally but the range is empty in CI | Shallow clone truncated history | Set fetch-depth: 0 on checkout, or fetch the base branch explicitly |
| Dependabot PRs always fail verification | Platform bot key not trusted and email not allowlisted | Import GitHub’s web-flow key or add the exact bot email to BOT_ALLOWLIST |
| Verification passes but merge still lands unsigned commits | Job is not a required status check, or enforce_admins is off | Add the check to branch protection and enable admin enforcement |
| Signatures disappear after a rebase-merge | Rewriting commits changes SHAs and can strip signatures | Verify on the PR head before merge; prefer merge-commit strategy where signatures must survive |
Frequently Asked Questions Jump to heading
Does git verify-commit check the GPG or SSH signature against a specific identity? Jump to heading
It checks that the signature is cryptographically valid and made by a key your trust store recognises. For SSH signatures, Git uses gpg.ssh.allowedSignersFile to bind the key to an identity; for GPG it consults the local keyring and the key’s trust level. A cryptographically valid signature from a key that is not in your trust file still exits non-zero, so verification is both a cryptographic check and an identity check.
Why does git verify-commit exit 0 but git log --show-signature print “No signature”? Jump to heading
git verify-commit succeeds only for commits carrying a valid, trusted signature; a commit with no signature makes it exit non-zero. If you see a mismatch, the usual cause is running the two commands with different trust configuration — for example, verify-commit against a runner whose allowed_signers file lacks the signer’s key. Align gpg.format and gpg.ssh.allowedSignersFile before both commands and they agree.
How do I verify commits authored by Dependabot or other bots that git cannot sign? Jump to heading
Platform bots produce commits signed by the platform’s own key — GitHub signs web-UI and Dependabot commits with its internal key. Import that public key into the CI trust store so those commits verify cryptographically, or maintain a narrow allowlist of exact bot author emails that are exempt from the loop. Never blanket-skip unsigned commits; scope every exemption to specific, known automation identities.
Should CI verification replace branch protection’s “require signed commits” setting? Jump to heading
No — they are complementary. Branch protection enforces signing at merge time on the platform but only checks that a signature exists and verifies against keys the platform knows. A CI job lets you enforce your own allowed_signers file, verify against organisation-controlled keys, and run before merge so contributors get feedback early. Run both layers, as described under Commit Verification Gates.
How do I verify a merge commit that GitHub creates during a squash or merge? Jump to heading
GitHub signs the merge and squash commits it generates with its own key, so import GitHub’s public key or exempt the github-actions bot author. If you use rebase-merge, original author signatures are preserved but commit SHAs change, which can strip signatures — verify on the pull-request head before merge rather than after.
Related Jump to heading
- Verifying Signed Commits in GitHub Actions — the platform-specific walkthrough of this job on GitHub Actions, including workflow permissions and required-check wiring
- Keyless Commit Signing with Sigstore gitsign — verifying commits signed with short-lived OIDC identities instead of long-lived keys, and how that changes the trust model
- Commit Verification Gates — composing CI verification with branch protection so unsigned history cannot reach the default branch
- GPG vs SSH Commit Signing — choosing the signing format that dictates how you distribute trust material to CI
- CI/CD Pipeline Trigger Mapping — aligning the verification job’s triggers with the rest of your pipeline and handling pipeline-authored commits