Skip to content

Instantly share code, notes, and snippets.

@stephenfeather
Last active June 25, 2026 09:59
Show Gist options
  • Select an option

  • Save stephenfeather/d620c02d5d666ac76159a72946c23928 to your computer and use it in GitHub Desktop.

Select an option

Save stephenfeather/d620c02d5d666ac76159a72946c23928 to your computer and use it in GitHub Desktop.
GitHub App signed commits — server-side commit signing via GraphQL createCommitOnBranch (setup guide + bash scripts for agents/CI)
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export GH_TOKEN="$("$SCRIPT_DIR/github-app-token")"
export GITHUB_TOKEN="$GH_TOKEN"
exec gh "$@"
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage:
github-agent-commit "Commit message"
Creates a GitHub-signed commit from the staged changes on the current branch
using GitHub's GraphQL createCommitOnBranch mutation.
Requirements:
- Run inside a Git repo.
- GH_TOKEN or GITHUB_TOKEN must be set.
- gh, jq, git, and base64 must be available.
- Changes must already be staged with git add / git rm.
- Current branch must not be main or master.
Supported staged changes:
- Added files.
- Modified files.
- Deleted files.
Not yet supported:
- Renames.
- Copies.
- Submodules.
- Intent-to-add empty files.
EOF
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
json_escape() {
jq -Rs .
}
base64_one_line() {
# macOS base64 wraps by default, so strip newlines.
base64 < "$1" | tr -d '\n'
}
parse_github_remote() {
local url="$1"
local path=""
case "$url" in
git@github.com:*)
path="${url#git@github.com:}"
;;
https://github.com/*)
path="${url#https://github.com/}"
;;
*)
return 1
;;
esac
path="${path%.git}"
local owner="${path%%/*}"
local repo="${path#*/}"
[[ -n "$owner" && -n "$repo" && "$owner" != "$repo" ]] || return 1
printf '%s %s\n' "$owner" "$repo"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
[[ $# -ge 1 ]] || {
usage
exit 1
}
COMMIT_MESSAGE="$*"
# Split into a git-style headline + optional body: the first line becomes the
# commit headline; everything after it (minus one blank separator line) becomes
# the body. GitHub's createCommitOnBranch accepts message.headline and an
# optional message.body.
COMMIT_HEADLINE="${COMMIT_MESSAGE%%$'\n'*}"
if [[ "$COMMIT_MESSAGE" == *$'\n'* ]]; then
COMMIT_BODY="${COMMIT_MESSAGE#*$'\n'}"
COMMIT_BODY="${COMMIT_BODY#$'\n'}"
else
COMMIT_BODY=""
fi
require_cmd git
require_cmd gh
require_cmd jq
require_cmd base64
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export GH_TOKEN="$("$SCRIPT_DIR/github-app-token")"
export GITHUB_TOKEN="$GH_TOKEN"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| die "not inside a git repository"
[[ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]] \
|| die "GH_TOKEN or GITHUB_TOKEN must be set"
BRANCH="$(git branch --show-current)"
[[ -n "$BRANCH" ]] || die "detached HEAD is not supported"
case "$BRANCH" in
main|master)
die "refusing to create an agent commit directly on $BRANCH"
;;
esac
if git diff --cached --quiet; then
die "no staged changes found"
fi
REMOTE_URL="$(git config --get remote.origin.url)"
[[ -n "$REMOTE_URL" ]] || die "remote.origin.url is not set"
read -r OWNER REPO < <(parse_github_remote "$REMOTE_URL") \
|| die "could not parse GitHub owner/repo from origin URL: $REMOTE_URL"
[[ -n "$OWNER" && -n "$REPO" ]] \
|| die "could not determine GitHub owner/repo"
# Reject unsupported staged change types for now.
UNSUPPORTED="$(
git diff --cached --name-status \
| awk '$1 ~ /^[RCUTX]/ { print }'
)"
if [[ -n "$UNSUPPORTED" ]]; then
printf 'Unsupported staged changes found:\n%s\n\n' "$UNSUPPORTED" >&2
die "renames/copies/typechanges are not supported yet"
fi
# Get repository node ID.
REPO_ID="$(
gh api graphql \
-f owner="$OWNER" \
-f name="$REPO" \
-f query='
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
}
}
' \
--jq '.data.repository.id'
)"
[[ -n "$REPO_ID" && "$REPO_ID" != "null" ]] \
|| die "could not fetch repository id for $OWNER/$REPO"
# Ensure the remote branch exists. If it does not, create it from local HEAD.
if ! REMOTE_HEAD="$(
gh api "/repos/$OWNER/$REPO/git/ref/heads/$BRANCH" \
--jq '.object.sha' 2>/dev/null
)"; then
LOCAL_HEAD="$(git rev-parse HEAD)"
# Make sure the local HEAD object exists on GitHub.
gh api "/repos/$OWNER/$REPO/commits/$LOCAL_HEAD" >/dev/null \
|| die "remote branch $BRANCH does not exist, and local HEAD is not known to GitHub"
gh api \
--method POST \
"/repos/$OWNER/$REPO/git/refs" \
-f ref="refs/heads/$BRANCH" \
-f sha="$LOCAL_HEAD" >/dev/null
REMOTE_HEAD="$LOCAL_HEAD"
fi
[[ -n "$REMOTE_HEAD" && "$REMOTE_HEAD" != "null" ]] \
|| die "could not determine remote HEAD for branch $BRANCH"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/github-agent-commit.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
ADDITIONS_JSON="$TMP_DIR/additions.json"
DELETIONS_JSON="$TMP_DIR/deletions.json"
printf '[]' > "$ADDITIONS_JSON"
printf '[]' > "$DELETIONS_JSON"
# Added/modified files.
while IFS= read -r path; do
[[ -n "$path" ]] || continue
# The staged version is what we want, not necessarily the working tree.
blob_file="$TMP_DIR/blob"
git show ":$path" > "$blob_file"
# Write base64 to a file and pass via --rawfile; --arg blows past ARG_MAX
# for large blobs (e.g. uv.lock), failing with "Argument list too long".
content_b64_file="$TMP_DIR/contents.b64"
base64_one_line "$blob_file" > "$content_b64_file"
jq \
--arg path "$path" \
--rawfile contents "$content_b64_file" \
'. + [{path: $path, contents: $contents}]' \
"$ADDITIONS_JSON" > "$ADDITIONS_JSON.tmp"
mv "$ADDITIONS_JSON.tmp" "$ADDITIONS_JSON"
done < <(git diff --cached --name-only --diff-filter=AM)
# Deleted files.
while IFS= read -r path; do
[[ -n "$path" ]] || continue
jq \
--arg path "$path" \
'. + [{path: $path}]' \
"$DELETIONS_JSON" > "$DELETIONS_JSON.tmp"
mv "$DELETIONS_JSON.tmp" "$DELETIONS_JSON"
done < <(git diff --cached --name-only --diff-filter=D)
ADDITIONS="$(cat "$ADDITIONS_JSON")"
DELETIONS="$(cat "$DELETIONS_JSON")"
if [[ "$ADDITIONS" == "[]" && "$DELETIONS" == "[]" ]]; then
die "no supported staged changes found"
fi
INPUT_JSON="$TMP_DIR/input.json"
# Read additions/deletions via --slurpfile (file-based); --argjson from a
# shell var blows past ARG_MAX when a blob (e.g. uv.lock) is large.
jq -n \
--arg repositoryId "$REPO_ID" \
--arg branchName "$BRANCH" \
--arg expectedHeadOid "$REMOTE_HEAD" \
--arg messageHeadline "$COMMIT_HEADLINE" \
--arg messageBody "$COMMIT_BODY" \
--slurpfile additions "$ADDITIONS_JSON" \
--slurpfile deletions "$DELETIONS_JSON" \
'{
input: {
branch: {
repositoryNameWithOwner: "'"$OWNER/$REPO"'",
branchName: $branchName
},
message: (
{ headline: $messageHeadline }
+ (if $messageBody == "" then {} else { body: $messageBody } end)
),
expectedHeadOid: $expectedHeadOid,
fileChanges: {
additions: $additions[0],
deletions: $deletions[0]
}
}
}' > "$INPUT_JSON"
MUTATION='
mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
url
messageHeadline
signature {
isValid
state
wasSignedByGitHub
signer {
login
}
}
}
}
}
'
VARS_JSON="$TMP_DIR/vars.json"
# Extract .input to a file and slurp it; command-substituting the payload
# into --argjson blows past ARG_MAX for large blobs (e.g. uv.lock).
jq '.input' "$INPUT_JSON" > "$TMP_DIR/input_inner.json"
jq -n \
--arg query "$MUTATION" \
--slurpfile input "$TMP_DIR/input_inner.json" \
'{
query: $query,
variables: {
input: $input[0]
}
}' > "$VARS_JSON"
RESULT="$(
gh api graphql \
--input "$VARS_JSON"
)"
COMMIT_SHA="$(printf '%s\n' "$RESULT" | jq -r '.data.createCommitOnBranch.commit.oid')"
COMMIT_URL="$(printf '%s\n' "$RESULT" | jq -r '.data.createCommitOnBranch.commit.url')"
SIG_STATE="$(printf '%s\n' "$RESULT" | jq -r '.data.createCommitOnBranch.commit.signature.state // "unknown"')"
SIG_VALID="$(printf '%s\n' "$RESULT" | jq -r '.data.createCommitOnBranch.commit.signature.isValid // false')"
SIGNED_BY_GITHUB="$(printf '%s\n' "$RESULT" | jq -r '.data.createCommitOnBranch.commit.signature.wasSignedByGitHub // false')"
[[ -n "$COMMIT_SHA" && "$COMMIT_SHA" != "null" ]] \
|| {
printf '%s\n' "$RESULT" | jq .
die "commit creation failed"
}
printf 'Created GitHub API commit:\n'
printf ' repo: %s/%s\n' "$OWNER" "$REPO"
printf ' branch: %s\n' "$BRANCH"
printf ' commit: %s\n' "$COMMIT_SHA"
printf ' signature state: %s\n' "$SIG_STATE"
printf ' signature valid: %s\n' "$SIG_VALID"
printf ' signed by GitHub: %s\n' "$SIGNED_BY_GITHUB"
printf ' url: %s\n' "$COMMIT_URL"
# Update local branch to match the remote API-created commit.
git fetch origin "$BRANCH" >/dev/null
git reset --hard "origin/$BRANCH" >/dev/null
printf '\nLocal branch reset to origin/%s.\n' "$BRANCH"

GitHub App Signed Commits — Setup Guide

How to let an agent create GitHub-signed commits and PRs under a bot identity (e.g. opc-dashboard-agent[bot]) instead of using a human account or local unsigned git commit.

Commits are created server-side via GitHub's GraphQL createCommitOnBranch mutation, so GitHub signs them. The local working tree is then reset to match.


How it works

Three scripts in ~/bin (already on PATH, so effectively system-wide):

Script Role
github-app-token Builds an RS256 JWT from the App's ID + private key, exchanges it for a short-lived (~9 min) installation access token.
agent-gh Thin gh wrapper: exports GH_TOKEN from github-app-token, then exec gh "$@". Every call gets a fresh token.
github-agent-commit Stages → builds a createCommitOnBranch GraphQL mutation (base64 file additions/deletions) → GitHub creates and signs the commit → resets local branch to origin/<branch>.

Key constraints baked into github-agent-commit:

  • Refuses to commit on main / master.
  • Requires staged changes (git add / git rm first).
  • Supports added/modified/deleted files. Not renames, copies, typechanges, submodules.
  • Creates the remote branch from local HEAD if it doesn't exist yet.

Auth model — three env vars drive everything:

  • GITHUB_APP_ID
  • GITHUB_APP_INSTALLATION_ID
  • GITHUB_APP_PRIVATE_KEY (path to the .pem file)

Part 1 — First-time machine setup (from scratch)

Do this once per machine / GitHub account.

Throughout this doc, opc-dashboard-agent is just the example App name — substitute your own (e.g. my-repo-agent) wherever it appears.

1.1 Create the GitHub App

  1. GitHub → Settings → Developer settings → GitHub Apps → New GitHub App.
  2. Name it (e.g. opc-dashboard-agent). Homepage URL can be a placeholder.
  3. Permissions — at minimum:
    • Repository → Contents: Read and write
    • Repository → Pull requests: Read and write
    • Repository → Metadata: Read-only (auto)
  4. Uncheck "Webhook → Active" (not needed).
  5. Where can it be installed: "Only on this account".
  6. Create the App. Note the App ID.

1.2 Generate a private key

On the App's settings page → Private keys → Generate a private key. A .pem downloads. Store it somewhere stable and private, e.g.:

~/.config/github-apps/opc-dashboard-agent/private-key.pem
chmod 600 ~/.config/github-apps/opc-dashboard-agent/private-key.pem

Never commit this file. Never print it.

1.3 Install the App

On the App page → Install App → choose your account → select the repos it should act on. After installing, the URL contains the installation ID: https://github.com/settings/installations/<INSTALLATION_ID>.

You can also list installations with the helper:

GITHUB_APP_ID=<id> \
GITHUB_APP_PRIVATE_KEY=~/.config/github-apps/opc-dashboard-agent/private-key.pem \
github-app-token --installation-id

This prints id <tab> account <tab> repository_selection.

1.4 Install the three scripts

If ~/bin/github-app-token, ~/bin/agent-gh, ~/bin/github-agent-commit don't already exist, create them (see Appendix — Script sources below) and:

chmod +x ~/bin/github-app-token ~/bin/agent-gh ~/bin/github-agent-commit

Ensure ~/bin is on PATH. Dependencies: gh, jq, git, base64, openssl, curl.

1.5 Export the env vars

Add to your shell profile (~/.zshrc) — or better, a sourced secrets file:

export GITHUB_APP_ID="123456"
export GITHUB_APP_INSTALLATION_ID="78901234"
export GITHUB_APP_PRIVATE_KEY="$HOME/.config/github-apps/opc-dashboard-agent/private-key.pem"

1.6 Verify

echo "$(github-app-token)" | cut -c1-12   # should print a token prefix like ghs_xxxxxxxx
agent-gh auth status

Part 2 — Onboard another repo

The scripts and machine env are already done. For each new repo:

2.1 Install the App on the repo

GitHub → App settings → Install App → edit repo access → add the new repo. (If the App is org-wide with "All repositories", nothing to do.)

If it's a different account/org, you'll get a new installation ID — update GITHUB_APP_INSTALLATION_ID accordingly (or switch per-repo via a direnv .envrc).

2.2 Protect main

Repo → Settings → Branches → Add branch protection rule for main:

  • Require a pull request before merging.
  • (Optional) Require status checks. This enforces the "no direct commits to main" rule.

2.3 Add agent rules to the repo's CLAUDE.md

Paste the following block into the repo's CLAUDE.md, replacing <repo>-agent[bot] with this repo's App identity. These rules tell the agent to use github-agent-commit / agent-gh and forbid the unsigned-local-commit path.

## Project GitHub Agent Rules

This repository is configured for GitHub App based agent work.

The agent identity is:

    <repo>-agent[bot]

The default branch is protected. Direct commits and direct pushes to `main` are forbidden.

All agent-created mergeable commits must be created through GitHub's API so GitHub signs the commit as the GitHub App.

---

## Required Workflow

Always work on a feature branch.

    git switch -c agent-short-description

Make changes locally, then inspect them:

    git status --short
    git diff

Stage the intended changes:

    git add path/to/file

For deletions:

    git rm path/to/file

Before creating a commit, show the staged summary:

    git status --short
    git diff --cached --stat

Create commits only with:

    github-agent-commit "Descriptive commit message"

Open pull requests with:

    agent-gh pr create --base main --head "$(git branch --show-current)" --title "PR title" --body "PR body"

Use `agent-gh` instead of plain `gh` for GitHub CLI operations so a fresh GitHub App token is minted automatically.

---

## Forbidden Commands

Never run:

    git commit
    git commit -m
    git commit -S
    git commit --amend
    git commit-tree
    git merge --commit
    git cherry-pick
    git rebase
    git am
    git push origin main
    gh pr merge --admin

Do not create local Git commits. Local commits will not be signed by GitHub as the app and may fail repository signature requirements.

Do not push directly to `main`.

Do not use the human account credentials or personal access tokens.

Do not print secrets, tokens, private keys, or environment dumps.

Avoid commands such as:

    echo "$GH_TOKEN"
    echo "$GITHUB_TOKEN"
    env
    printenv
    cat ~/.config/github-apps/<repo>-agent/private-key.pem

Safe token checks may show only a short prefix:

    echo "$GH_TOKEN" | cut -c1-12

---

## Allowed Git Commands

These commands are allowed when needed:

    git status
    git status --short
    git diff
    git diff --cached
    git diff --cached --stat
    git add
    git rm
    git restore
    git restore --staged
    git switch
    git branch
    git fetch
    git pull --ff-only
    git log
    git show
    git reset --hard origin/branch-name

Use caution with destructive commands. Do not discard user work unless explicitly instructed.

---

## GitHub CLI Rules

Prefer:

    agent-gh

instead of:

    gh

Examples:

    agent-gh auth status
    agent-gh pr create --base main --head "$(git branch --show-current)"
    agent-gh pr view
    agent-gh api /repos/<owner>/<repo>

Reason: `agent-gh` mints a fresh short-lived GitHub App installation token for each command.

---

## Commit Rules

Use:

    github-agent-commit "Message"

This command reads staged changes and creates a GitHub API commit on the current branch.

Expected result:

    signature state:   VALID
    signature valid:   true
    signed by GitHub:  true

If `github-agent-commit` fails, do not fall back to `git commit`. Report the error and stop.

---

## Branch Rules

Never work directly on `main`.

Before editing, check the current branch:

    git branch --show-current

If on `main`, create a feature branch:

    git switch -c agent-short-description

Branch names should be short and descriptive, for example:

    agent-fix-login-state
    agent-add-dashboard-filter
    agent-update-readme

---

## Pull Request Rules

Open PRs against `main`.

Use:

    agent-gh pr create \
      --base main \
      --head "$(git branch --show-current)" \
      --title "Clear title" \
      --body "Summary of changes and testing performed."

---

## Testing Expectations

Before creating a commit, run relevant checks when available.

At minimum, inspect the diff:

    git diff --cached

If the project has known test/lint commands, prefer those before committing.

Do not invent successful test results. If a test was not run, say so.

---

## Token and Credential Model

The shell uses GitHub App credentials:

    GITHUB_APP_ID
    GITHUB_APP_INSTALLATION_ID
    GITHUB_APP_PRIVATE_KEY

Short-lived GitHub tokens are minted on demand.

Do not try to fix authentication by switching to the human account.

Do not run:

    gh auth login
    gh auth refresh
    gh auth switch

If GitHub authentication fails, prefer:

    agent-gh auth status

and report the error.

---

## Summary

The correct agent workflow is:

    git switch -c agent-branch-name
    # edit files
    git status --short
    git diff
    git add path/to/files
    git diff --cached --stat
    github-agent-commit "Commit message"
    agent-gh pr create --base main --head "$(git branch --show-current)" --title "Title" --body "Body"

The most important rule:

    Never use git commit. Use github-agent-commit.

2.4 Verify in the new repo

cd /path/to/new-repo
git switch -c agent-test-signing
echo "test" > .agent-signing-test
git add .agent-signing-test
github-agent-commit "Test: verify GitHub App signed commits"

Expected output includes:

signature state:   VALID
signature valid:   true
signed by GitHub:  true

Then clean up the test branch.


Daily workflow (the actual usage)

git switch -c agent-short-description
# edit files
git status --short
git diff
git add path/to/file          # git rm path/to/file for deletions
git diff --cached --stat
github-agent-commit "Descriptive commit message"
agent-gh pr create --base main --head "$(git branch --show-current)" \
  --title "PR title" --body "PR body"

Never use git commit — it produces an unsigned local commit that won't carry the App signature and may be rejected by branch protection.


Troubleshooting

Symptom Cause / fix
GITHUB_APP_ID is required Env vars not exported in this shell.
could not fetch repository id App not installed on this repo, or wrong installation ID.
refusing to create an agent commit directly on main Switch to a feature branch first.
no staged changes found Run git add before github-agent-commit.
renames/copies/typechanges are not supported Stage the change as separate delete + add.
remote branch ... does not exist, and local HEAD is not known to GitHub Push the parent commit first, or branch from an already-pushed commit.
Token works but gh fails Use agent-gh, not plain gh — plain gh uses your human credentials.

Appendix — Script sources

The three scripts currently installed at ~/bin/. Reproduce them on a new machine.

github-app-token

Builds an RS256 JWT (iat = now-60s, exp = now+540s) signed with the App private key via openssl dgst -sha256 -sign, then POSTs to /app/installations/<id>/access_tokens to get the installation token. With --installation-id it instead lists all installations.

agent-gh

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export GH_TOKEN="$("$SCRIPT_DIR/github-app-token")"
export GITHUB_TOKEN="$GH_TOKEN"
exec gh "$@"

github-agent-commit

Parses origin URL → owner/repo, fetches the repo node ID and remote branch HEAD, base64-encodes each staged file into a GraphQL additions/deletions payload, runs the createCommitOnBranch mutation, prints the signature status, then git fetch + git reset --hard origin/<branch> to sync the local tree. Resolves its sibling github-app-token the same way agent-gh does ($(dirname "${BASH_SOURCE[0]}")), so the three scripts are portable as a set — drop them in any directory on PATH and they work without edits.

The canonical, full-length copies live at ~/bin/. Reproduce verbatim on a new machine; the scripts resolve each other relatively, so no path edits are needed.

#!/usr/bin/env bash
set -euo pipefail
: "${GITHUB_APP_ID:?GITHUB_APP_ID is required.}"
: "${GITHUB_APP_PRIVATE_KEY:?GITHUB_APP_PRIVATE_KEY is required.}"
now="$(date +%s)"
iat="$((now - 60))"
exp="$((now + 540))"
b64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
header="$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)"
payload="$(printf '{"iat":%s,"exp":%s,"iss":"%s"}' "$iat" "$exp" "$GITHUB_APP_ID" | b64url)"
signature="$(
printf '%s.%s' "$header" "$payload" |
openssl dgst -sha256 -sign "$GITHUB_APP_PRIVATE_KEY" |
b64url
)"
jwt="${header}.${payload}.${signature}"
if [[ "${1:-}" == "--installation-id" ]]; then
curl -fsSL \
-H "Authorization: Bearer ${jwt}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/app/installations |
jq -r '.[] | "\(.id)\t\(.account.login)\t\(.repository_selection)"'
exit 0
fi
: "${GITHUB_APP_INSTALLATION_ID:?GITHUB_APP_INSTALLATION_ID is required.}"
curl -fsSL \
-X POST \
-H "Authorization: Bearer ${jwt}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/app/installations/${GITHUB_APP_INSTALLATION_ID}/access_tokens" |
jq -r '.token'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment