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.

Trusted-signature coverage through a typical rolloutAn illustrative rollout: a team starts with just over half of recent commits signed by a trusted key. Warn mode and the daily report lift that quickly, but the last stretch depends on fixing bots and pipelines, which is why coverage stalls in week two before reaching full coverage.share of new commits signed by a trusted key (example team)week 058%week 179%week 286%week 3100%week 4100%the plateau in week two is the bots β€” people fix themselves once the report names them

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.

A four-week rolloutWeek zero measures the baseline. The gate then runs in warn mode while stragglers are fixed and bots are given keys. Enforcement starts only after a clean week, and a short grace period follows where the escape hatch is still documented.Baselinemeasure coverageweek 0Warn modereport on every PRweek 1Fix gapspeople, bots, tagsweek 2Clean weekzero findingsweek 3Enforceflip the flagweek 4the clean week is the gate for the gate β€” do not skip it to hit a date

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.

The usual non-human stragglersDependency bots, release pipelines that create tags or version-bump commits, and the forge's own merge commits all appear in a typical backlog. Each needs a different fix, and none of them will read the daily report.Dependency botneeds its own keyor forge-signed APIRelease pipelinetags and bumpssigned in CIForge mergespin the forge keyin every verifierthese three account for most of the last ten percent

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.