Rolling out signature enforcement in warn-only mode Jump to heading
The quickest way to make a team hate commit signing is to enable enforcement on a Monday morning. Half the engineers have never configured a signing key, two bots push unsigned commits, and the release pipeline creates a tag with a key nobody registered. Every one of those failures blocks real work, and the rule gets switched off by lunchtime. A staged rollout avoids that: run the exact same check in a mode that reports instead of rejects, measure how close the team is to compliance, fix the gaps one by one, and flip to enforcement only when the report has been clean for a while. This page describes that staging for any of the commit verification gates.
When to use this approach Jump to heading
- More than a handful of people or automated systems push to the repositories you want to protect.
- You do not yet know what fraction of recent commits are signed by a trusted key.
- Your gate can run in a non-blocking mode β a CI job allowed to fail, a pre-receive hook that prints instead of exiting, or a scheduled audit.
- You have a trust source ready, such as an allowed signers file built from team membership.
- If only two or three people push and all already sign, skip the staging and enforce directly.
Step 1 β Measure todayβs baseline Jump to heading
Before changing anything, find out where you stand. Run the verification over recent history on every protected branch and record the result as a number.
# Signature status of every commit on main in the last 60 days
git log --since=60.days --format='%G? %ce' main > baseline.txt
total=$(wc -l < baseline.txt)
good=$(grep -c '^G ' baseline.txt || true)
echo "signed and trusted: $good / $total"
# Who is producing the rest?
grep -v '^G ' baseline.txt | awk '{print $2}' | sort | uniq -c | sort -rn | head -20 The second list is your rollout backlog. Expect it to contain people, bots, and at least one surprise β usually the forgeβs own merge commits, covered in verifying signatures on merge commits made by the forge.
Step 2 β Run the gate in warn mode Jump to heading
Give the gate a mode switch, defaulting to warn. In warn mode it runs every check and prints every finding, but exits zero. Nothing else about it changes, so the transition to enforcement later is a one-word configuration change rather than a new piece of code.
# Inside the gate script
mode=${SIGNING_MODE:-warn}
if [ -s "$failures" ]; then
echo "::warning::$(wc -l < "$failures") commit(s) not signed by a trusted key"
cat "$failures"
[ "$mode" = enforce ] && exit 1
fi
exit 0 # CI: the job runs on every pull request but cannot block merging yet
jobs:
signatures:
runs-on: ubuntu-latest
env: { SIGNING_MODE: warn }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: ./ci/verify-signatures.sh "origin/${{ github.base_ref }}" HEAD Do not add the job to the branchβs required checks yet. Required plus warn mode is harmless, but forgetting to remove the warn flag later leaves a gate that looks enforced and is not.
Step 3 β Publish the report where people see it Jump to heading
A warning nobody reads is not a rollout. Turn the findings into a short daily report grouped by person, and send it somewhere the team already looks.
# Daily summary from the last day of pull-request gate runs
gh run list --workflow verify-signatures.yml --created ">=$(date -d yesterday +%F)" \
--json databaseId --jq '.[].databaseId' |
while read -r id; do gh run view "$id" --log | grep -E '^[0-9a-f]{7,} '; done |
awk '{print $NF}' | sort | uniq -c | sort -rn Pair the report with a one-page setup guide linked from the warning text itself, so the person who sees the warning is one click from fixing it. For most engineers that is setting up SSH commit signing from scratch.
Step 4 β Fix the non-human signers Jump to heading
People respond to a report; bots do not. Go through every automated identity in the backlog and give each one a signing path, or route its changes through a path that signs.
Commits created through the forgeβs API by an app identity are usually signed by the forge automatically, which is often the simplest fix for bots; see verified bot commits with a GitHub App identity. Release tooling that commits from a runner needs a key of its own, covered in signing bot commits made by CI workflows.
# Verification: no automated identity remains in the last week of findings
git log --since=7.days --format='%G? %ce' main | grep -v '^G ' | grep -iE 'bot|ci|noreply' Step 5 β Flip to enforcement and keep an escape hatch Jump to heading
When the warn-mode report has been empty for a full week, change the mode and add the job to the required checks in the same change. Announce it a few days ahead, with the date and the setup link.
env: { SIGNING_MODE: enforce } # Make the check required on main (classic branch protection shown)
gh api -X PATCH "repos/$OWNER/$REPO/branches/main/protection/required_status_checks" \
-f 'contexts[]=signatures' -F strict=true Keep a documented escape hatch for the first fortnight: a named person who can temporarily set the mode back to warn for one repository if something unforeseen blocks a release. Documented and owned, it gets used once and then removed. Undocumented, someone disables the check entirely during an incident and nobody turns it back on.
β οΈ SAFETY WARNING: Flipping a server-side hook to enforce mode blocks every push that fails, including pushes from release automation in the middle of a deploy. Schedule the flip outside a release window, and keep the previous hook version alongside the new one so you can restore it in seconds:
cp hooks/pre-receive.warn hooks/pre-receive.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
How long should warn mode last? Jump to heading
As long as it takes for the report to be empty for a week, and rarely less than two weeks in total. A team of twenty usually gets there in three to four weeks. If it drags past two months, the blocker is almost always an automated system nobody owns, not the people.
Should warn mode also check old commits? Jump to heading
No. The gate checks commits being introduced, in warn mode as in enforce mode. Historical commits are a separate audit question; a gate that complains about history nobody can change trains people to ignore it.
What do we do about long-lived branches full of unsigned commits? Jump to heading
Let them merge with a merge commit before enforcement, or have their owners re-sign them with a rebase while the branch is still private. After enforcement, an unsigned branch must be re-signed before it can merge, which is far more disruptive.
Related Jump to heading
- Commit Verification Gates β the parent topic and the gates this staging applies to.
- Verifying a Range of Commits in CI, Not Just the Tip β the CI check you run in warn mode first.
- Migrating a Team from GPG to SSH Commit Signing β the same staged approach applied to a signing-format change.
- Choosing Required Status Checks That Actually Gate β making sure the enforced check is really required.