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.

CI signed-commit verification pipelineA pipeline flowing left to right: checkout the PR range, import the allowed_signers trust file, iterate each commit in git rev-list base..head, run git verify-commit on each, then a gate that passes if all commits verify or fails the job on any unverified commit.checkoutfetch-depth:0import trustallowed_signersiteraterev-list base..headverify eachverify-commitnext commitgateexit 0 — mergeall verifiedall okexit 1 — block mergeany unsigned or untrustedany fail

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. Exits 0 only 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 with Good "trusted" signature or No 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_admins is 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

SymptomLikely causeFix
git verify-commit fails on a commit the author insists is signedThe signer’s key is not in the CI allowed_signers fileAdd the public key to the trust file via a reviewed pull request; re-run the job
Every commit reports “No signature” in --show-signaturegpg.format/gpg.ssh.allowedSignersFile not configured on the runnerSet both in a step before verification (Step 1); confirm with git config --get
Range includes unrelated old commitsBase resolved from a stale branch tip instead of the merge baseUse git merge-base origin/main HEAD and git fetch origin main first
Job passes locally but the range is empty in CIShallow clone truncated historySet fetch-depth: 0 on checkout, or fetch the base branch explicitly
Dependabot PRs always fail verificationPlatform bot key not trusted and email not allowlistedImport GitHub’s web-flow key or add the exact bot email to BOT_ALLOWLIST
Verification passes but merge still lands unsigned commitsJob is not a required status check, or enforce_admins is offAdd the check to branch protection and enable admin enforcement
Signatures disappear after a rebase-mergeRewriting commits changes SHAs and can strip signaturesVerify 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.