Verified bot commits with a GitHub App identity Jump to heading
Managing a signing key for every bot is real work: generation, storage, rotation, revocation. There is a shortcut on GitHub that many teams use without realising it. When a GitHub App creates a commit through the REST or GraphQL API — rather than pushing one created with git commit — GitHub signs it with its own key and marks it verified, attributed to the app. No key ever exists on the runner. The trade-off is that the signature now says “GitHub created this on the app’s behalf”, which is a different claim from “this key-holder created it”. This page shows how to do it and where that difference matters, within signed commits in CI pipelines.
When to use this approach Jump to heading
- Your automation runs on GitHub and creates small commits — version bumps, generated files, lockfile refreshes.
- You want those commits verified on the forge without storing a signing key anywhere.
- Your signature gates either run on the forge or can trust the forge’s signing key, as described in verifying signatures on merge commits made by the forge.
- If your verifiers must not depend on the forge’s key, sign on the runner instead with signing bot commits made by CI workflows.
Step 1 — Create a narrowly scoped app Jump to heading
Create an app owned by the organisation, grant it only contents: write (and pull_requests: write if it opens pull requests), and install it only on the repositories where it commits. Store its app ID and private key as secrets. The app’s private key authenticates the app; it is not a commit-signing key.
# Verification: the installation is limited to the expected repositories
gh api /orgs/$ORG/installations --jq '.installations[] | {app_slug, repository_selection}' Step 2 — Get a short-lived installation token in the workflow Jump to heading
Exchange the app credentials for an installation token at the start of the job. The token lasts an hour and carries only the app’s permissions.
steps:
- uses: actions/create-github-app-token@v1
id: app
with:
app-id: ${{ vars.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
with: { token: "${{ steps.app.outputs.token }}" } Step 3 — Create the commit through the API, not with git push Jump to heading
A commit made locally and pushed with the app token is still unsigned: the token authenticates the push but does not sign anything. The commit must be created by the API. GraphQL’s createCommitOnBranch takes file additions and deletions and an expected head, which also protects against races.
#!/bin/sh
# ci/api-commit.sh <branch> <message> <file>...
set -eu
branch=$1 msg=$2; shift 2
head=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$branch" --jq .object.sha)
adds=$(for f in "$@"; do
jq -n --arg p "$f" --arg c "$(base64 -w0 < "$f")" '{path:$p, contents:$c}'
done | jq -s .)
gh api graphql -f query='
mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid url } }
}' -F input="$(jq -n --arg repo "$GITHUB_REPOSITORY" --arg b "$branch" \
--arg h "$head" --arg m "$msg" --argjson adds "$adds" '{
branch: {repositoryNameWithOwner: $repo, branchName: $b},
expectedHeadOid: $h,
message: {headline: $m},
fileChanges: {additions: $adds}
}')" # Verification: the new commit is verified and attributed to the app
gh api "repos/$GITHUB_REPOSITORY/commits/$branch" \
--jq '{author: .commit.author.name, verified: .commit.verification.verified, reason: .commit.verification.reason}' Step 4 — Make your own verifiers agree with the forge Jump to heading
The forge shows these commits as verified. Your own gates — a CI range check or an audit script — see a signature from GitHub’s web-flow key and need to trust it. Import the pinned key, and narrow that trust to commits whose author is the app, so the forge key does not become a blanket pass.
# In the gate: accept the forge signature only for the app's commits
case "$(git log -1 --format='%ae' "$c")" in
*"[bot]@users.noreply.github.com")
[ "$(git log -1 --format=%G? "$c")" = G ] || fail "$c: app commit not forge-signed" ;;
*) verify_human_signature "$c" ;;
esac Step 5 — Know the limits of the claim Jump to heading
A forge signature on an app commit proves that the app, authenticated with its credentials, asked GitHub to create this exact commit. It does not prove anything about what code produced the change, and anyone with the app’s private key can produce such commits. Protect the app key like a signing key, and pair it with the same path restrictions you would put on any bot.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does this work for large or binary changes? Jump to heading
The API accepts base64 file contents, which is fine for version files and generated code but awkward for large binaries. For those, sign on the runner with a dedicated key instead, or store the binary elsewhere and commit a reference.
Do commits created through the API trigger workflows? Jump to heading
Yes, when made with an app token. That is useful for running checks on the bot’s change, but it means you need the same loop guard as for any bot that commits.
Can the app create a commit on a protected branch? Jump to heading
Only if branch protection allows the app to bypass pull requests or push directly. Prefer having the app open a pull request and letting normal review and the merge queue apply.
Related Jump to heading
- Signed Commits in CI Pipelines — the parent topic.
- Signing Bot Commits Made by CI Workflows — the runner-side alternative with your own key.
- Scoping Deploy Keys and Tokens — keeping the app’s reach narrow.
- Bulk Updating Repository Settings with the API — more ways automation can act through the forge API.