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.
# 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.
# 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.
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.
Related Jump to heading
- Commit Verification Gates โ the parent topic.
- Verifying Signed Commits in GitHub Actions โ a fuller workflow built around the same check.
- Keeping Signatures Valid Through Rebase and Squash โ why a range can become unsigned after a rewrite.
- Setting Up a GitHub Merge Queue โ where the merge-queue range comes from.