We had a dedicated “bot” user account for GitHub automation - creating PRs, pushing commits, merging branches. It worked, but it always felt like a hack. The account consumed a licence seat, used a long-lived PAT that someone had to rotate manually, and the audit trail was confusing because it looked like a real person.
I finally migrated everything to a GitHub App. This touched 30+ repositories and 6 custom GitHub Actions across the org. Here’s the full process.
TLDR Link to heading
- Create a GitHub App with repo permissions (contents, pull requests, metadata)
- Store the App ID as an org variable, private key as an org secret
- Update custom actions to support both PAT and App auth (backwards compatible)
- Update workflows repo by repo
- Also migrate Slack webhooks to bot tokens while you’re at it
- Migrate branch protection to rulesets via Terraform for proper App bypass
Why GitHub Apps are better Link to heading
- Short-lived tokens - 1 hour expiry vs PATs that live forever (and inevitably get leaked)
- Fine-grained permissions - per-repo and per-permission control
- No licence seat - Apps don’t consume org seats
- Better rate limits - 5,000 requests/hour per installation vs 5,000/hour shared across the whole PAT
- Cleaner audit trail - Actions clearly attributed to the App, not a fake user
- No password/2FA to manage - One less account to secure
Creating the App Link to heading
In your org settings: Settings > Developer Settings > GitHub Apps > New GitHub App.
Permissions needed for typical CI/CD:
| Permission | Access | Why |
|---|---|---|
| Contents | Read & write | Push commits, create branches |
| Pull requests | Read & write | Create/merge PRs, add reviewers |
| Metadata | Read-only | Required for API access |
| Workflows | Read & write | If you modify workflow files |
Important settings:
- Webhook: Disable unless you need it (you probably don’t for CI/CD)
- Installation: “Only on this account” for org-internal use
After creating, generate a private key (downloads a .pem file) and note the App ID from the settings page.
Setting up org-wide credentials Link to heading
Store at the org level so all repos inherit them:
# As an org admin
gh secret set APP_PRIVATE_KEY --org your-org < app-private-key.pem
gh variable set APP_ID --org your-org --body "123456"
The App ID is a variable (not secret) because it’s not sensitive and you’ll want to see it in workflow logs for debugging.
Finding repos that need updating Link to heading
Before diving in, I needed to know the scope. This one-liner finds all repos using the old bot PAT:
gh search code "BOT_PERSONAL_ACCESS_TOKEN" \
--owner your-org \
-L 500 \
--json repository \
| jq -r '.[].repository.fullName' \
| sort -u
I found ~30 repos. Also check your custom actions:
gh search code "personal-access-token" \
--owner your-org \
--filename action.yml \
--json repository,path
Phase 1: Update custom actions Link to heading
We had 6 custom actions that accepted a PAT. The key is making them backwards compatible so you can migrate repos gradually.
Before (action.yml):
inputs:
personal-access-token:
description: 'GitHub PAT for API operations'
required: true
After:
inputs:
personal-access-token:
description: 'GitHub PAT (deprecated, use app-id and app-private-key)'
required: false
app-id:
description: 'GitHub App ID'
required: false
app-private-key:
description: 'GitHub App private key'
required: false
Then in the action’s workflow, generate the token conditionally:
- name: Generate GitHub App token
if: ${{ inputs.app-id && inputs.app-private-key }}
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.app-private-key }}
owner: ${{ github.repository_owner }}
- name: Set auth token
id: auth
shell: bash
run: |
if [ -n "${{ steps.app-token.outputs.token }}" ]; then
echo "token=${{ steps.app-token.outputs.token }}" >> $GITHUB_OUTPUT
elif [ -n "${{ inputs.personal-access-token }}" ]; then
echo "token=${{ inputs.personal-access-token }}" >> $GITHUB_OUTPUT
else
echo "::error::Either app-id/app-private-key or personal-access-token must be provided"
exit 1
fi
Now use ${{ steps.auth.outputs.token }} throughout the action.
Git identity for commits Link to heading
When the action pushes commits, configure git with the App’s identity:
- name: Configure git
run: |
git config user.name "your-app[bot]"
git config user.email "123456+your-app[bot]@users.noreply.github.com"
The email format is <app-id>+<app-name>[bot]@users.noreply.github.com. The square brackets are literal - this is GitHub’s convention for bot accounts.
Cross-repo tokens Link to heading
If your action needs to access other repos (e.g., checking out a private action repo, or creating PRs across repos), add the owner parameter:
- uses: actions/create-github-app-token@v1
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.app-private-key }}
owner: ${{ github.repository_owner }} # Access all repos in the org
Without owner, the token only works for the current repository.
Phase 2: Update repository workflows Link to heading
With actions updated, migrate repos one by one.
Typical deploy workflow - before:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: your-org/deploy-action@main
with:
personal-access-token: ${{ secrets.BOT_PERSONAL_ACCESS_TOKEN }}
slack-webhook: ${{ secrets.SLACK_WEBHOOK_ALERTS }}
After:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: your-org/deploy-action@main
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
Slack migration side-quest Link to heading
While updating workflows, I also migrated from Slack incoming webhooks to a Slack bot token. Webhooks are fire-and-forget; bot tokens let you post to any channel and update messages. If you’re touching every workflow anyway, might as well do both.
# Old: hardcoded webhook per channel
slack-webhook: ${{ github.ref_name == 'main' && secrets.SLACK_WEBHOOK_PROD || secrets.SLACK_WEBHOOK_DEV }}
# New: single bot token, channel specified in action
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
slack-channel: ${{ github.ref_name == 'main' && '#alerts-prod' || '#alerts-dev' }}
The actor identity gotcha Link to heading
This one bit me. GitHub Apps have a specific identity format: app-name[bot]. The square brackets are literal.
If you have workflows that skip jobs when the bot triggers them (to avoid infinite loops), update the condition:
# Before - checking for the bot user
if: github.actor != 'bot-user'
# After - checking for the App
if: github.actor != 'your-app[bot]'
I kept getting infinite trigger loops until I spotted this. The App would push a commit, trigger the workflow, push another commit, etc.
Release workflows are especially prone Link to heading
Release workflows that bump versions and push tags need this check:
jobs:
release:
runs-on: ubuntu-latest
# Skip if triggered by the App (prevents infinite loops)
if: github.actor != 'your-app[bot]'
steps:
- uses: your-org/release-action@main
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}
Auto-merge jobs Link to heading
We have repos where staging is the default branch but we still need to merge main back regularly. The bot user handled this; now the App does:
merge-main:
runs-on: ubuntu-latest
if: github.ref_name == 'main'
steps:
- name: Merge or create PR
uses: your-org/merge-or-pr-action@main
with:
app-id: ${{ vars.APP_ID }}
app-private-key: ${{ secrets.APP_PRIVATE_KEY }}
source-ref: main
target-ref: staging
reviewer: your-org/team-name
Timing matters: Don’t wait for deployment to complete before merging back. I originally had needs: [deploy] but that meant staging could diverge significantly during long deploys. Changed to run immediately:
merge-main:
needs: setup # Just needs the initial checkout, not the full deploy
if: github.ref_name == 'main'
Phase 3: Branch protection bypass Link to heading
This is where it gets interesting. Classic branch protection has poor support for GitHub Apps. You can add the App to “Restrict who can push to matching branches” but it’s clunky and doesn’t work well with required reviews.
Querying existing protection Link to heading
First, audit what you have:
#!/bin/bash
for repo in $(gh repo list your-org --json name -q '.[].name' -L 500); do
protection=$(gh api "repos/your-org/$repo/branches/main/protection" 2>/dev/null)
if [ -n "$protection" ]; then
echo "=== $repo ==="
echo "$protection" | jq '{
required_reviews: .required_pull_request_reviews.required_approving_review_count,
dismiss_stale: .required_pull_request_reviews.dismiss_stale_reviews,
bypass_users: [.restrictions.users[].login],
bypass_apps: [.restrictions.apps[].slug]
}'
fi
done
I found ~13 repos with classic branch protection, each configured slightly differently.
Migrate to repository rulesets Link to heading
Rulesets are the modern replacement. They have first-class support for App bypass:
resource "github_repository_ruleset" "branch_protection" {
name = "protect-default-branches"
repository = github_repository.repo.name
target = "branch"
enforcement = "active"
conditions {
ref_name {
include = ["~DEFAULT_BRANCH", "refs/heads/staging", "refs/heads/release"]
exclude = []
}
}
# Let the App bypass for automated operations
bypass_actors {
actor_id = var.github_app_id # The numeric App ID
actor_type = "Integration"
bypass_mode = "always"
}
# Org admins can also bypass
bypass_actors {
actor_id = 1
actor_type = "OrganizationAdmin"
bypass_mode = "always"
}
rules {
# Require PRs
pull_request {
required_approving_review_count = 1
dismiss_stale_reviews_on_push = true
require_last_push_approval = false
}
# Prevent force pushes and deletions
non_fast_forward = true
deletion = true
}
}
Key insight: actor_type = "Integration" with the App’s numeric ID. Not the slug, not the name - the ID. Find it in the App’s settings URL or via API:
gh api /orgs/your-org/installations --jq '.installations[] | select(.app_slug == "your-app") | .app_id'
Terraform module approach Link to heading
For consistency, I created a module that handles this per-repo:
# modules/github-repo/variables.tf
variable "github_app_id" {
description = "GitHub App ID for bypass rules"
type = number
}
variable "protected_branches" {
description = "Branches to protect"
type = list(string)
default = ["~DEFAULT_BRANCH"]
}
variable "required_approvals" {
description = "Number of required approvals"
type = number
default = 1
}
Then in the root module:
module "api_repo" {
source = "./modules/github-repo"
name = "api"
github_app_id = 123456
required_approvals = 2
protected_branches = ["~DEFAULT_BRANCH", "refs/heads/staging", "refs/heads/release"]
}
Importing existing rulesets Link to heading
Some repos already had rulesets (created manually). Import them:
# Find the ruleset ID
gh api /repos/your-org/repo-name/rulesets --jq '.[].id'
# Import into Terraform
terraform import 'module.repo_name.github_repository_ruleset.branch_protection' repo-name:123456
Delete classic protection after migration Link to heading
Once rulesets are active, remove the legacy protection:
gh api -X DELETE repos/your-org/repo-name/branches/main/protection
Phase 4: Cleanup Link to heading
After all PRs are merged:
Remove old secrets:
gh secret delete BOT_PERSONAL_ACCESS_TOKEN --org your-org gh secret delete SLACK_WEBHOOK_ALERTS_PROD --org your-org gh secret delete SLACK_WEBHOOK_ALERTS_DEV --org your-orgRevoke the PAT in the bot user’s settings
Archive or delete the bot user account (reclaim that licence seat!)
Update documentation - any runbooks referencing the old bot
Lessons learned Link to heading
Do actions first. Updating the shared actions to support both auth methods meant I could migrate repos at my own pace without coordination.
Grep the org. gh search code is invaluable for finding all the places you need to update. I kept finding edge cases in repos I’d forgotten about.
Test the actor check. The [bot] suffix in actor names is easy to miss. Test with a dummy workflow that just logs ${{ github.actor }} to see exactly what you’re matching against.
Rulesets > classic protection. The migration is worth it just for the cleaner Terraform and proper App support. Classic branch protection feels increasingly legacy.
Batch the PRs. I created all ~30 PRs in one go, then worked through reviews. Easier than context-switching between migration and reviews.
Further reading Link to heading
- GitHub Apps documentation
- actions/create-github-app-token
- Repository rulesets
- Terraform github_repository_ruleset
- gh search code for finding what needs updating