Signing bot commits made by CI workflows Jump to heading

Pipelines commit more than people expect. A release job bumps a version file. A nightly job regenerates API clients or documentation. A formatting bot pushes fixes back to a pull request branch. Each of those commits arrives at your signature gate unsigned, and the gate does the right thing and rejects it — so somebody adds an exemption, then another, and soon the gate trusts anything with “bot” in the email. The durable fix is to give automation a real signing identity with the same properties as a person’s: its own key, its own principal, registered and verifiable. This page builds that for a CI workflow, as part of signed commits in CI pipelines.

When to use this approach Jump to heading

Step 1 — Create one identity per bot role Jump to heading

Give each kind of automated change its own identity, rather than one “ci-bot” for everything. Distinct principals let your gates and audits distinguish a release bump from a formatting fix, and let you revoke one without touching the other.

for role in release-bot format-bot codegen-bot; do
  ssh-keygen -t ed25519 -N '' -C "signing:$role@example.com:$(date +%Y-%m)" -f "$role"
done
ls *.pub
One identity per kind of automated changeA release bot that bumps versions, a formatting bot that pushes style fixes and a code-generation bot that refreshes generated clients each get their own key and principal, so each can be scoped, audited and revoked independently.release-botversion bumpsrelease branch onlyformat-botstyle fixesPR branchescodegen-botgenerated clientsscheduleda single all-purpose bot identity is an exemption with a key attached

Step 2 — Register the keys and trust them Jump to heading

Add each public key to the allowed signers file with its own principal. If the forge should show the commits as verified too, register each key as a signing key on the bot’s machine account, and verify the bot’s email on that account.

# allowed_signers — automation block, maintained by hand
[email protected] namespaces="git" ssh-ed25519 AAAA...  signing:release-bot:2026-10
[email protected]  namespaces="git" ssh-ed25519 AAAA...  signing:format-bot:2026-10
[email protected] namespaces="git" ssh-ed25519 AAAA...  signing:codegen-bot:2026-10

Store each private key as a secret scoped to the workflows that need it, then delete the local copies.

gh secret set RELEASE_BOT_KEY --env release-signing < release-bot
gh secret set FORMAT_BOT_KEY < format-bot
shred -u release-bot format-bot codegen-bot

Step 3 — Configure Git in the job and commit with signing Jump to heading

The job configures identity and signing for its own checkout only — --global on a shared runner would leak into other jobs. The key goes into an in-memory agent.

#!/bin/sh
# ci/bot-commit.sh "<message>" — signs and commits whatever is staged
set -eu
eval "$(ssh-agent -s)" >/dev/null; trap 'ssh-agent -k >/dev/null' EXIT
printf '%s\n' "$BOT_KEY" | ssh-add - >/dev/null; unset BOT_KEY

git config user.name  "$BOT_NAME"
git config user.email "$BOT_EMAIL"
git config gpg.format ssh
git config user.signingKey "key::$(ssh-add -L | head -1)"
git config commit.gpgSign true

git diff --cached --quiet && { echo "nothing to commit"; exit 0; }
git commit -m "$1"
git log -1 --format='%h %G? %ce'
A signed bot commit inside a workflowThe workflow generates its change, stages it, loads the bot's key into a temporary agent, commits with signing enabled for this checkout only, and pushes. The signature gate then verifies the commit against the bot's principal like any other.Generatenpm version, codegenStagegit add -ASignagent + key::Pushto branchGateprincipal matchesno exemption anywhere — the gate cannot tell a bot commit from a human one except by name
# Workflow step using it
- run: |
    npm version patch --no-git-tag-version
    git add package.json package-lock.json
    ./ci/bot-commit.sh "chore(release): bump version to $(node -p "require('./package.json').version")"
    git push origin HEAD:main
  env:
    BOT_KEY: ${{ secrets.RELEASE_BOT_KEY }}
    BOT_NAME: release-bot
    BOT_EMAIL: release-[email protected]

Step 4 — Constrain what each bot may change Jump to heading

A signing identity proves who made the commit; it does not limit what the commit contains. Pair each bot identity with a check that its commits touch only the paths it is supposed to.

# In the gate, after signature verification
case "$(git log -1 --format='%GS' "$c")" in
  [email protected])
    git diff-tree --no-commit-id --name-only -r "$c" | grep -vE '\.(js|ts|css|md)$' && {
      echo "$c: format-bot touched non-formattable files"; exit 1; } ;;
  [email protected])
    git diff-tree --no-commit-id --name-only -r "$c" | grep -vxE 'package(-lock)?\.json|CHANGELOG\.md' && {
      echo "$c: release-bot touched unexpected files"; exit 1; } ;;
esac
Signature plus scope, per botThe signature establishes which bot made a commit. A path check then confirms the commit only contains the kind of change that bot exists to make, so a stolen formatting key cannot be used to slip in a code change.Allowed pathsRejected examplerelease-botpackage.json, CHANGELOGsrc/auth.tsformat-bot*.js *.ts *.css *.mdDockerfilecodegen-botclients/generated/**clients/hand-written/**a narrow scope makes a leaked bot key far less useful to an attacker

Step 5 — Avoid the loop: bot commits triggering bot workflows Jump to heading

A workflow that commits and pushes can trigger itself. Forges usually suppress workflow triggers for pushes made with the default job token, but pushes made with a personal access token or app token do trigger. Guard against loops explicitly.

on:
  push:
    branches: [main]
jobs:
  bump:
    if: github.actor != 'release-bot' && !contains(github.event.head_commit.message, 'chore(release)')
# Verification: the bot's own push does not start another run
gh run list --workflow bump.yml --limit 5 --json event,headSha,conclusion

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can the bot sign with the forge’s own key instead? Jump to heading

Only for commits created through the forge’s API, where the forge signs on the bot’s behalf. Commits made with git commit on a runner are signed by whatever key the runner holds. If API-created commits fit your use case, they avoid managing a key at all.

Should bot commits go through pull requests? Jump to heading

For anything that changes behaviour — code generation, dependency changes — yes. For a version bump immediately before tagging a release, pushing directly with a narrowly scoped identity is common and reasonable, as long as the gate’s path check is in place.

What if the bot needs to commit to a pull request branch from a fork? Jump to heading

It cannot, safely. Workflows triggered by fork pull requests should not receive signing secrets at all. Have the bot suggest the change as a review comment or a patch artefact instead.