Playbook: GitHub PAT Rotation

Service: GitHub (https://github.com) Secrets: GITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKEN SOPS files: secrets/n8n-bms4-gh.env.sops (the three bms-4 GitHub tokens — isolated per-service file), secrets/monitoring.env.sops, secrets/vps-h1.env.sops

App-first (#4068): bms-4 worker consumers of these PATs (git over HTTPS, gh secret set) now mint short-lived GitHub App installation tokens on demand via bin/gh-app-token.sh — see §App-first below. This playbook still governs rotation of the PATs themselves until they are removed from SOPS (gated on rollout + #4180 + telegram-bot). Rotation frequency: 90 days (or immediately on suspected exposure) Last rotated: 2026-06-27 Next due: 2026-09-27


Token inventory

Key nameSOPS file(s)Scope / purpose
GITHUB_PAT_ALL_WRITESn8n-bms4-ghBroad write access — n8n workflows that open issues, PRs, trigger dispatches
GITHUB_PAT_P24_INFRAn8n-bms4-ghScoped to radieu/p24-infra — infra automation, labelling, comments
GH_TOKENn8n-bms4-gh, monitoring, vps-h1General-purpose repo write token — used by scripts and GH Actions
GITHUB_TEST_PAT_08_2026n8n-bms4Standing test/validation fixture — display name on GitHub is test_pat_08_2026, zero repo scope (Metadata: none), fine-grained PAT on radieu account. Not consumed by any real service. Kept intentionally per user decision (2026-08-01) as a reusable target for testing/exercising scripts/rotate/github-pat.js (see #4070’s E2E validation) without needing to create a fresh disposable PAT each time. Safe to regenerate/rotate freely — nothing depends on its current value.

Note: GH_TOKEN appears in 3 SOPS files. All three must be updated together. They may hold the same token value or separate tokens — check lengths after rotation.


What uses these credentials

ConsumerKeyEffect if missing
n8n workflows (bms-4) — issue management, PR comments, dispatch triggersGITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKENn8n GitHub nodes fail with 401
Monitoring scripts on vps-i1GH_TOKENScript-created issues/comments fail
vps-h1 automation scriptsGH_TOKENScript GitHub operations fail

Where stored

LocationKey nameHow to update
secrets/n8n-bms4-gh.env.sopsGITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKENSOPS write pattern
secrets/monitoring.env.sopsGH_TOKENSOPS write pattern
secrets/vps-h1.env.sopsGH_TOKENSOPS write pattern

Login credentials

  • URL: https://github.com/settings/tokens
  • Account: radieu (radieu@gmail.com)
  • Auth: GitHub password + 2FA (TOTP or SMS)

Important: GitHub login requires 2FA. A Playwright agent can handle this only if the 2FA TOTP secret is available (to generate the OTP code). If the TOTP secret is not in .env.local, this must be done manually (Tier 3 fallback for the login step).


Automation status

Tier-2 autonomous (#4069, plan #4065 §3.2/§5.3, closed 2026-08-01). GITHUB_TOTP_SECRET is present in secrets/n8n-bms4.env.sops, and the bms-4 secret-manager worker has Playwright + Chromium provisioned (playwright_enabled: true in ansible/inventory/host_vars/bms-4.yml) plus oathtool for TOTP generation. Rotation runs end-to-end with no human present via scripts/rotate/github-pat.js — see Option A below, which now points at that script instead of a manual agent-prose flow.

Check the prerequisite is met (never prints the value):

sops --decrypt --input-type dotenv --output-type dotenv secrets/n8n-bms4.env.sops \
  | grep -q "^GITHUB_TOTP_SECRET=" && echo present || echo missing

E2E validation status: the mechanical build (script + provisioning + docs) was completed and statically validated (TOTP generation in isolation, --dry-run navigation to github.com/login) in #4069. A live end-to-end run against a real GitHub PAT — full login, 2FA, and an actual “Regenerate token” click — is intentionally not yet performed and is tracked separately as the validation run for #4065 §3.3 (disposable TEST_PAT_ROTATION_4065, then re-dispatch of #4053). Until that run passes, treat this playbook as build-complete but live-unverified.


App-first (#4068) — preferred for consumers

Rotating a PAT is expensive (2FA-gated, Tier 2). The cheaper win is to stop consuming the PAT at all. Plan #4065 Wave A (#4068) moved bms-4 day-to-day git/secret operations onto short-lived GitHub App installation tokens (App 2109526, installation 142989418, 1h TTL, no browser/2FA):

ConsumerWasNow
git clone/push over HTTPS on bms-4GH_TOKEN PAT in the credential helperbms-4/git-credential-gh-token.sh — App-token-first, GH_TOKEN fallback
post-rotation gh secret setGH_TOKEN PATscripts/lib/gh_app_secret_set.sh (mints a secrets:write-scoped App token)
ad-hoc “give me a token”read PAT from envbin/gh-app-token.sh (--check, --repos, --permissions)

Mint a token (never prints to a URL/log):

bin/gh-app-token.sh                                   # default 1h token, cached ~50 min
bin/gh-app-token.sh --repos p24-infra --permissions contents:write   # scoped, uncached
bin/gh-app-token.sh --check                           # validate creds; never prints the token

The App keys (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY_B64, GITHUB_APP_INSTALLATION_ID) live in secrets/n8n-bms4.env.sops → deployed to /opt/p24-infra/bms-4/.env. Permission gap: the App does not hold actions:write (verified 2026-07-20), so consumers that trigger workflow_dispatch must stay on a PAT until the manifest adds it. Rotate the PAT (below) for those; migrate the rest.


Rotation steps

Option A — Autonomous worker flow via scripts/rotate/github-pat.js (preferred)

Runs on the bms-4 secret-manager worker — see infra/agent-prompts/worker-secret-manager.md §Tier 2 — Playwright rotation pattern (GitHub PAT) for the full step-by-step (prerequisite check, sourcing the old value, invoking the script, verify-before-write, SOPS update, distribution). Summary:

  1. Worker confirms GITHUB_TOTP_SECRET is present in secrets/n8n-bms4.env.sops and Chromium is provisioned (playwright_enabled ansible run has completed on this host). If either is missing, abort and fall back to Option B (Tier 3, human-action).
  2. For each PAT to rotate (GITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKEN — one PAT_NAME per run):
    export GITHUB_TOTP_SECRET=$(sops -d --input-type dotenv --output-type dotenv secrets/n8n-bms4.env.sops | grep "^GITHUB_TOTP_SECRET=" | cut -d= -f2-)
    export GITHUB_PASSWORD="…"   # sourced silently per the target's SOPS location
    export PAT_NAME="GITHUB_PAT_ALL_WRITES"   # or the display name shown in the GitHub UI
    export TOKEN_OUT_FILE=$(mktemp -t github-pat-out.XXXXXX); chmod 600 "$TOKEN_OUT_FILE"
    node scripts/rotate/github-pat.js
    unset GITHUB_PASSWORD GITHUB_TOTP_SECRET
  3. Script writes the new value to TOKEN_OUT_FILE, never to stdout. Exit 0 = success, 1 = fatal (selector/DOM changed — fall back to Option B), 2 = 2FA rejected (TOTP seed may be stale — check with the human before retrying).
  4. Verify the new PAT against api.github.com/user before touching SOPS.
  5. Update secrets/n8n-bms4-gh.env.sops (the three bms-4 GitHub PATs live here, not secrets/n8n-bms4.env.sops — that file holds GITHUB_TOTP_SECRET itself). Also update the GH_TOKEN copies in secrets/monitoring.env.sops and secrets/vps-h1.env.sops if those are in scope for this rotation (see §Token inventory note above — check lengths after rotation to confirm which copies share the same value).
  6. GH Secret update + live server .env + affected container restart (same distribution chain as Tier 1).
  7. Revocation of the old PAT is not a separate step — “Regenerate token” invalidates it.
  8. Append to docs/secrets-rotation-log.md:
    | <YYYY-MM-DD HH:MM UTC> | GITHUB_PAT_ALL_WRITES | Rotated | Tier 2 (Playwright+TOTP) autonomous | github-pat.js | SOPS (n8n-bms4-gh + monitoring + vps-h1 as applicable) |
    

DEBUG_SCREENSHOTS=1 re-run with a headful browser + screenshots if a selector fails — GitHub UI changes are the most likely failure mode. Fix selectors in scripts/rotate/github-pat.js, commit, retry.

Option B — Manual (when 2FA blocks Playwright)

Step 1 — Log in to GitHub with 2FA
  URL: https://github.com/settings/tokens?type=beta
  Account: radieu (radieu@gmail.com)
  Complete 2FA with your authenticator app

Step 2 — Regenerate each PAT
  For each token (GITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKEN):
  → Find token by name → Click it → "Regenerate token" → set expiry 90 days → Regenerate
  → Copy new value immediately (shown only once, starts with github_pat_)

Step 3 — Store in .env.local temporarily (to avoid pasting values in chat)
  Add lines to d:\code_2026\p24-infra\.env.local:
    GITHUB_PAT_ALL_WRITES_NEW=<value>
    GITHUB_PAT_P24_INFRA_NEW=<value>
    GH_TOKEN_NEW=<value>
  Then tell Claude "GitHub PATs updated in .env.local"
  Claude will read from .env.local and update SOPS + PR without you pasting values in chat.

Step 4 — Claude handles SOPS update and PR (follow Option A steps 4–8: verify → SOPS update →
  distribution → rotation log)

Verification

# 1. Confirm keys present in SOPS
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
@("GITHUB_PAT_ALL_WRITES","GITHUB_PAT_P24_INFRA","GH_TOKEN") | ForEach-Object {
    $line = sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4-gh.env.sops |
        Select-String "^${_}="
    $len = $line.ToString().Split('=',2)[1].Length
    Write-Host "${_} length: $len"   # expected: > 40 for fine-grained PATs (github_pat_...)
}
 
# 2. Quick API check with GH_TOKEN
$env:T = (sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4-gh.env.sops |
    Select-String "^GH_TOKEN=").ToString().Split('=',2)[1]
$r = Invoke-RestMethod "https://api.github.com/user" -Headers @{ Authorization = "Bearer $env:T" }
Write-Host "GitHub user: $($r.login)"   # expected: radieu or AI-Dev-* depending on token owner
$env:T = ""; $env:SOPS_AGE_KEY_FILE = ""

Escalation

SymptomAction
2FA blocks Playwright automationFall back to Option B (manual login); store new values in .env.local and hand off to Claude for SOPS update
Token shows as Fine-grained token but scopes differCheck each token’s permissions in GitHub Settings — recreate with same resource access if needed
n8n GitHub nodes still 401 after mergesecrets-sync.yml may not have run; check GH Actions or SSH to bms-4 and restart n8n
GH_TOKEN on vps-i1 not picked upSSH to vps-i1: grep GH_TOKEN /opt/p24-infra/monitoring/.env — if stale, manually update

Prevention

  • GitHub fine-grained PATs can be set with expiry — set 90 days at creation; GitHub will email before expiry.
  • The credential-rotation.yml GH Actions workflow (Monday 06:00 UTC) checks next_due and opens a human-action issue.
  • After rotation, update Last rotated and Next due at the top of this playbook.

Audit Log — Log to infra_operations

After this operation completes, log it to the infra_operations audit table.

Python (Linux server — bms-4, vps-i1, vps-h1, or similar):

import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
 
log_op(
    actor="claude",  # "radieu" for manual human ops, "claude" for agent
    op_type="credential_rotation",
    resource="GITHUB_PAT_ALL_WRITES",
    result="success",  # "success" | "failed" | "skipped"
    detail="Scheduled 90d rotation — GITHUB_PAT_ALL_WRITES / GITHUB_PAT_P24_INFRA / GH_TOKEN regenerated",
    env="bms-4",
    gh_issue=2730,
)

PowerShell (Windows dev machine):

$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'GITHUB_PAT_ALL_WRITES', 'success', 'Scheduled 90d rotation — GITHUB_PAT_ALL_WRITES / GITHUB_PAT_P24_INFRA / GH_TOKEN regenerated', 'bms-4')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''