Verifying a range of commits in CI, not just the tip Jump to heading

The most common signature check in CI is a single line: git verify-commit HEAD. It passes, the badge goes green, and three unsigned commits sit directly underneath the signed tip, merged along with it. Signing is a property of each commit, not of a branch, so a gate has to verify every commit the change introduces. The hard part is not the loop; it is computing the right range for each kind of CI event, on a runner that by default has a shallow clone and no idea what โ€œnewโ€ means. This page works through those ranges and belongs to the set of commit verification gates.

When to use this approach Jump to heading

  • Your forge does not enforce signatures, or enforces them in a way you cannot extend โ€” for example, it cannot check that the signer is a team member.
  • You want the gate to run on pull requests, so contributors find out before review rather than at merge.
  • Your pipeline runs on several event types โ€” pull requests, pushes to the default branch, merge-queue candidates โ€” and each needs a different range.
  • You have a trust source the job can read, such as a pinned allowed signers file.
  • On a self-hosted server that accepts direct pushes, a pre-receive hook is the stronger place for this check.

Step 1 โ€” Fetch enough history to compute a range Jump to heading

Most CI systems check out a single commit with depth one. With no parents present, Git cannot tell which commits are new, and git log base..head either fails or silently returns too little.

# GitHub Actions: fetch full history, or at least enough to reach the merge base
- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Full history is simplest and, on most repositories, fast enough with a cached mirror; the trade-offs are covered in shallow clone vs full history in CI. If you must stay shallow, deepen until the merge base appears.

# Shallow alternative: deepen until the base branch and HEAD share history
git fetch --no-tags origin "$BASE_REF"
until git merge-base "origin/$BASE_REF" HEAD >/dev/null 2>&1; do
  git fetch --deepen=50 origin "$BASE_REF" HEAD
done
# Verification: the merge base exists locally
git merge-base "origin/$BASE_REF" HEAD

Step 2 โ€” Choose the range for each event Jump to heading

The range is always โ€œcommits reachable from the new state but not from the trusted stateโ€. What counts as each depends on the event.

The range to verify for each CI eventFor a pull request the range runs from the merge base with the target branch to the head of the pull request. For a push it runs from the previous tip of the branch to the new tip, unless the branch is new. For a merge-queue candidate it runs from the target branch to the candidate.Trusted sideNew sidepull requestmerge base with targetPR head commitpush to branchbefore (previous tip)after (new tip)new branch pushevery existing refafter (new tip)merge queuetarget branch tipcandidate commitget the trusted side wrong and you either miss commits or re-verify the whole history The range to verify for each CI eventFor a pull request the range runs from the merge base with the target branch to the head of the pull request. For a push it runs from the previous tip of the branch to the new tip, unless the branch is new. For a merge-queue candidate it runs from the target branch to the candidate.Trusted sideNew sidepull requestmerge base with targetPR head commitpush to branchbefore (previous tip)after (new tip)new branch pushevery existing refafter (new tip)merge queuetarget branch tipcandidate commitget the trusted side wrong and you either miss commits or re-verify the whole history
# One script, three event types (GitHub Actions variables shown)
case "$GITHUB_EVENT_NAME" in
  pull_request)  range="$(git merge-base "origin/$GITHUB_BASE_REF" HEAD)..HEAD" ;;
  merge_group)   range="origin/${GITHUB_BASE_REF:-main}..HEAD" ;;
  push)
    if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
      range="HEAD --not --remotes=origin"   # new branch: anything not already on the remote
    else
      range="$BEFORE..HEAD"
    fi ;;
esac
echo "verifying: $range"

BEFORE comes from the event payload (github.event.before). Force-pushes are the edge case: before may no longer be an ancestor of after, and before..after then still lists exactly the commits that are new on the branch, which is what you want.

Step 3 โ€” Verify every commit, not just whether one fails Jump to heading

Loop over the range and collect every failure, so the job output lists all offending commits at once. Use --no-merges only if you deliberately accept unsigned merge commits; most teams should verify them too.

#!/bin/sh
# ci/verify-signatures.sh <range...>
set -eu
git config gpg.ssh.allowedSignersFile "$SIGNERS_FILE"
fail=0
for c in $(git rev-list $range); do
  st=$(git log -1 --format='%G?' "$c")
  if [ "$st" != G ]; then
    printf '%s %s %s\n' "$(git log -1 --format='%h' "$c")" "$st" "$(git log -1 --format='%ce' "$c")"
    fail=1
  fi
done
[ "$fail" -eq 0 ] || { echo "Unsigned or untrusted commits found"; exit 1; }

$range is unquoted on purpose: for the new-branch case it expands to several arguments. Everything else stays quoted.

What a tip-only check missesA pull request contains four commits. The tip is signed, so a check of HEAD passes, but the two commits beneath it are unsigned. Verifying the full range from the merge base flags both.tip-only check: pass โ€” range check: two failuresmainM1M2signedC1unsignedC2C3signed tipC4signing is per commit โ€” a green tip says nothing about the commits beneath it What a tip-only check missesA pull request contains four commits. The tip is signed, so a check of HEAD passes, but the two commits beneath it are unsigned. Verifying the full range from the merge base flags both.tip-only check: pass โ€” range check: two failuresmainM1M2signedC1unsignedC2C3signed tipC4signing is per commit โ€” a green tip says nothing about the commits beneath it
# Verification: plant an unsigned commit below a signed tip and confirm the job fails
git commit --allow-empty --no-gpg-sign -m "unsigned probe"
git commit --allow-empty -S -m "signed tip"
range="HEAD~2..HEAD" sh ci/verify-signatures.sh; echo "exit=$?"   # expect exit=1

Step 4 โ€” Read the trust file from a pinned source Jump to heading

The job must not read the trust file from the pull requestโ€™s own tree. If it did, a contributor could add their own key in the same pull request that relies on it. Read it from the base branch, or from a separate repository at a pinned revision.

# Read allowed_signers as it exists on the target branch, not in the PR
git show "origin/$GITHUB_BASE_REF:.github/allowed_signers" > "$RUNNER_TEMP/allowed_signers"
export SIGNERS_FILE="$RUNNER_TEMP/allowed_signers"

If you trust forge-made merge commits, import the forgeโ€™s pinned key here too, as described in verifying signatures on merge commits made by the forge.

Where the trust file may come fromReading the allowed signers file from the pull request tree lets a contributor trust themselves. Reading it from the base branch or from a pinned revision of a separate repository keeps the trust decision outside the change being judged.the judge must not be supplied by the defendantPR head treecontributor can add their own keyBase branch treechanged only through reviewed mergesPinned external repositoryowned by a security groupgit show origin/base:path reads the file without checking out the base branch Where the trust file may come fromReading the allowed signers file from the pull request tree lets a contributor trust themselves. Reading it from the base branch or from a pinned revision of a separate repository keeps the trust decision outside the change being judged.the judge must not be supplied by the defendantPR head treecontributor can add their own keyBase branch treechanged only through reviewed mergesPinned external repositoryowned by a security groupgit show origin/base:path reads the file without checking out the base branch

Step 5 โ€” Make the check required and fast Jump to heading

A range check on a typical pull request takes well under a second once history is present. Make the job required on protected branches, and keep it in its own lightweight job so it reports quickly rather than waiting behind the test suite.

jobs:
  signatures:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: ./ci/verify-signatures.sh
        env:
          BEFORE: ${{ github.event.before }}

If the team is not ready for it to block, run it in report mode first, as described in rolling out signature enforcement in warn-only mode.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not use git log --show-signature and grep the output? Jump to heading

Its output format is meant for humans and changes between GPG and SSH and between Git versions. The %G? placeholder gives a single stable character per commit, which is what a script should compare.

Does the range for a pull request change after the base branch moves? Jump to heading

The merge base can move when the pull request is updated, but the commits in the range are still exactly the pull requestโ€™s own commits. Commits that arrived on the base branch in the meantime are excluded, because they are reachable from the base side.

Should the check run again in the merge queue? Jump to heading

Yes, if your queue builds candidate commits. A candidate can contain commits from several pull requests plus a queue-made merge, and the queue run is the last point where the exact set about to land is checked.