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

  1. Create a GitHub App with repo permissions (contents, pull requests, metadata)
  2. Store the App ID as an org variable, private key as an org secret
  3. Update custom actions to support both PAT and App auth (backwards compatible)
  4. Update workflows repo by repo
  5. Also migrate Slack webhooks to bot tokens while you’re at it
  6. 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:

PermissionAccessWhy
ContentsRead & writePush commits, create branches
Pull requestsRead & writeCreate/merge PRs, add reviewers
MetadataRead-onlyRequired for API access
WorkflowsRead & writeIf 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:

  1. 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-org
    
  2. Revoke the PAT in the bot user’s settings

  3. Archive or delete the bot user account (reclaim that licence seat!)

  4. 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