Verifying signed commits in GitHub Actions Jump to heading
A signed commit is only as valuable as the gate that checks it. Developers configure SSH commit signing, push a branch, and assume the supply chain is now protected — but nothing rejects the one commit that slipped through unsigned after a git commit --amend on a machine without a key, or the commit whose signature was made by a key that has since left the team. GitHub’s green “Verified” badge trusts GitHub’s own key registry, which is not the same as trusting your roster of authorized signers. This page is a concrete recipe within Verifying Signed Commits in CI Pipelines: a GitHub Actions job that fails the moment any commit in a pull request is unsigned or cannot be verified against a public key you control.
When to use this approach Jump to heading
Apply this recipe when:
- You have adopted SSH commit signing across the team and need CI to prove — on every pull request — that the property actually holds, not just that it holds on the tip commit.
- You want verification against an
allowed_signersfile that you maintain, independent of whether a contributor happened to register their key in GitHub’s UI. - Your compliance posture requires an auditable log showing each commit SHA, its signer, and a pass/fail result for provenance evidence.
- You already enforce branch protection commit-verification gates but want a second, self-hosted check that does not depend on GitHub’s trust store.
This recipe assumes SSH signing (gpg.format = ssh), the modern default for most teams — see GPG vs SSH Commit Signing if you are still deciding. The same job shape works for GPG with a keyring import step swapped in; the loop and range logic are identical. It is not the right fit if your team uses keyless signing — for that, see the sibling recipe on Keyless Commit Signing with Sigstore gitsign, where verification runs against Fulcio/Rekor rather than a static key file.
The diagram below shows where the verification job sits between the pull request and the merge gate, and what it inspects.
Step-by-step recipe Jump to heading
Step 1 — Check out full history with fetch-depth 0 Jump to heading
The default actions/checkout performs a shallow clone (fetch-depth: 1) that fetches only the single merge-ref commit. You cannot compute a commit range against a base branch you never fetched, so git rev-list origin/main..HEAD would resolve to nothing — silently passing every commit. Request full history:
# .github/workflows/verify-signatures.yml
name: Verify signed commits
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
verify-signatures:
runs-on: ubuntu-latest
steps:
- name: Check out full history
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — required to resolve the base..HEAD range Verify the checkout actually fetched the base branch before relying on it:
# Should print a commit count > 0, not an error
git rev-list --count origin/main..HEAD Step 2 — Provision the allowed_signers file Jump to heading
git verify-commit for SSH signatures needs an allowed_signers file mapping each authorized principal (an email) to its public signing key. Your laptop’s trust store does not travel to the runner, so you must materialize this file in the job. Commit a tracked roster to the repository — for example .github/allowed_signers — and point Git at it:
- name: Configure allowed signers
run: |
# File format: one line per signer —
# <principal> <key-type> <base64-key>
# e.g. [email protected] ssh-ed25519 AAAAC3NzaC1...
git config gpg.ssh.allowedSignersFile "$GITHUB_WORKSPACE/.github/allowed_signers"
# Tell Git these are SSH signatures, not GPG
git config gpg.format ssh The roster file itself is plain text and safe to commit — public keys are not secret:
# .github/allowed_signers (checked into the repo)
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...alice
[email protected] ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...bob
Verify Git resolves the config on the runner:
# Both must print the expected values, not empty
git config --get gpg.ssh.allowedSignersFile
git config --get gpg.format Step 3 — Enumerate the commit range under review Jump to heading
Compute the exact set of commits the pull request introduces. Use the merge-base range so you inspect only new work, never commits already on main:
# BASE_SHA / HEAD_SHA come from the PR event context (Step 6 wires them in).
# The two-dot range lists commits reachable from HEAD but not from the base.
git rev-list --no-merges "origin/${BASE_REF}..HEAD" The --no-merges flag excludes merge commits, which are handled separately in Step 5. Verify the range is neither empty (nothing to check — usually a fetch-depth bug) nor the entire history (a base-ref resolution bug):
# Sanity check — a normal PR lists a handful of SHAs
git rev-list --no-merges "origin/main..HEAD" | wc -l Step 4 — Verify each commit with git verify-commit Jump to heading
git verify-commit <sha> exits non-zero if the commit is unsigned or its signature does not verify against the allowed_signers roster. Iterate the range and collect every failure rather than bailing on the first, so contributors see all offending commits at once:
#!/usr/bin/env bash
# scripts/verify-commits.sh — portable POSIX-ish loop, runs anywhere Git does
set -euo pipefail
BASE_REF="${BASE_REF:-main}"
FAILED=0
# Read SHAs one per line; --no-merges excludes merge commits (Step 5)
while read -r sha; do
[ -z "$sha" ] && continue
# verify-commit is quiet on success, verbose on failure via stderr.
if git verify-commit "$sha" 2>/dev/null; then
printf ' ok %s %s\n' "${sha:0:12}" "$(git log -1 --format='%an' "$sha")"
else
printf ' FAIL %s %s <unsigned or unverified>\n' \
"${sha:0:12}" "$(git log -1 --format='%an <%ae>' "$sha")"
FAILED=1
fi
done < <(git rev-list --no-merges "origin/${BASE_REF}..HEAD")
exit "$FAILED" Verify the loop against a known-good and a known-bad commit locally before trusting it in CI:
# Should print "ok" for a signed HEAD, non-zero exit for an unsigned one
git verify-commit HEAD && echo "signed" || echo "NOT signed" Step 5 — Handle merge commits and trusted bots Jump to heading
Two categories of commit legitimately lack a signature you control. Merge commits created by GitHub’s “Merge pull request” button are signed by GitHub’s web-flow key, not by a developer key in your roster — --no-merges already excludes them from the range. Bot commits (Dependabot, Renovate) are likewise signed by GitHub’s key. Maintain a small allowlist of trusted author emails and skip verification for those authors instead of weakening the check for everyone:
# Trusted non-human authors whose commits GitHub signs on their behalf
TRUSTED_AUTHORS="dependabot[bot]@users.noreply.github.com renovate[bot]@users.noreply.github.com"
is_trusted() {
local email="$1"
for t in $TRUSTED_AUTHORS; do
[ "$email" = "$t" ] && return 0
done
return 1
}
while read -r sha; do
author_email="$(git log -1 --format='%ae' "$sha")"
if is_trusted "$author_email"; then
printf ' skip %s %s <trusted bot>\n' "${sha:0:12}" "$author_email"
continue
fi
git verify-commit "$sha" 2>/dev/null || { echo "FAIL $sha"; FAILED=1; }
done < <(git rev-list --no-merges "origin/${BASE_REF}..HEAD") SAFETY WARNING: An overly broad trusted-author allowlist is a supply-chain hole — any attacker who can set
user.emailto a listed address bypasses verification entirely. Keep the list to genuine bot addresses under@users.noreply.github.com, review it in the same PR as any change, and never add a human developer’s address to it.
Verify the skip logic fires only for bots:
# List distinct authors in the range and confirm which would be skipped
git log --no-merges --format='%ae' origin/main..HEAD | sort -u Step 6 — Surface a clear failure and wire it into required checks Jump to heading
Assemble the pieces into a job step that passes the PR base ref through the environment and prints an actionable summary. GitHub Actions exposes the base branch as github.event.pull_request.base.ref:
- name: Verify all commits are signed
env:
BASE_REF: $
run: |
git fetch origin "$BASE_REF" --depth=0 2>/dev/null || true
if ! bash scripts/verify-commits.sh; then
echo "::error::One or more commits are unsigned or unverified. \
Sign them with 'git rebase --exec \"git commit --amend --no-edit -S\"' \
and force-push, or see the signing setup guide." >&2
exit 1
fi
echo "All commits verified against allowed_signers." The ::error:: workflow command renders a red annotation on the pull request’s Files-changed and Checks tabs, giving the contributor a direct pointer to the fix. Make the job a required status check so it actually blocks merge — this is coordinated with your overall trigger design in CI/CD Pipeline Trigger Mapping, and complements the server-side rule described in Commit Verification Gates:
# Mark the job's check as required on the protected branch (GitHub CLI)
gh api -X PATCH "repos/:owner/:repo/branches/main/protection" \
--input - <<'JSON'
{ "required_status_checks": { "strict": true, "checks": [ { "context": "verify-signatures" } ] } }
JSON Verify the gate end to end by opening a throwaway PR with one deliberately unsigned commit:
# Create an unsigned commit and confirm the check turns red
git commit --no-gpg-sign --allow-empty -m "test: unsigned commit should fail CI"
git push origin HEAD Validation checklist Jump to heading
Before considering this gate production-ready, confirm each item:
Frequently asked questions Jump to heading
Why does git verify-commit pass locally but fail in GitHub Actions? Jump to heading
Almost always because the runner has no allowed_signers file, or gpg.ssh.allowedSignersFile is not pointed at it. git verify-commit needs the authorized public keys present on the machine doing the verification; the trust store on your laptop does not travel to CI. Provision the roster file in the job and set both gpg.ssh.allowedSignersFile and gpg.format ssh before iterating commits. A secondary cause is a shallow checkout: without fetch-depth: 0 the base ref is absent and the range resolves to nothing, so the loop verifies zero commits and appears to pass.
Should the CI check replace GitHub’s “Require signed commits” branch-protection setting? Jump to heading
No — run both, because they cover different threat models. Branch protection enforces that GitHub itself marks each commit “Verified”, which trusts GitHub’s own key registry and whatever keys contributors happened to upload. The Actions check verifies against your allowed_signers roster, catches signatures made by keys GitHub does not know about or that have left the team, and produces an auditable per-commit log for compliance evidence. Enforcing signed commits at the branch-protection layer is covered in Enforcing Signed Commits with Branch Protection.
How do I handle commits from Dependabot or other bots that GitHub signs? Jump to heading
Bot commits are signed by GitHub’s web-flow key, not by a key in your allowed_signers file, so git verify-commit will reject them. Maintain a short allowlist of trusted bot author emails — typically the [bot]@users.noreply.github.com addresses — and skip verification for those authors. Alternatively, add GitHub’s web-flow public key as a dedicated line in allowed_signers so the bot commits verify legitimately rather than being skipped. Prefer the explicit key approach if your auditors want every accepted commit to have a verified signature rather than a skip.
Related Jump to heading
- Verifying Signed Commits in CI Pipelines — the parent page covering where signature verification belongs across CI systems, the trust-model choices, and how to stage rollout without blocking existing contributors.
- Keyless Commit Signing with Sigstore gitsign — the sibling recipe for teams verifying against Fulcio/Rekor identity certificates instead of a static
allowed_signerskey file. - Commit Verification Gates — the server-side and branch-protection layer that complements this Actions check, enforcing verification at the point of merge rather than in a job.