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_signers file 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.

Signed-commit verification position in the pull request flowDiagram showing a pull request feeding a GitHub Actions job that checks out full history, loads an allowed_signers file, iterates the commit range with git verify-commit, and either fails the job to block merge or passes to the required-check merge gate.Pull requestN commitsverify jobfetch-depth: 0load allowed_signersrev-list base..HEADgit verify-commitexit 1 — unsignedmerge blockedfailpassrequired checkmerge gatemainsigned only

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.email to 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.

  • 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_signers key 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.