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 viabin/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 name | SOPS file(s) | Scope / purpose |
|---|---|---|
GITHUB_PAT_ALL_WRITES | n8n-bms4-gh | Broad write access — n8n workflows that open issues, PRs, trigger dispatches |
GITHUB_PAT_P24_INFRA | n8n-bms4-gh | Scoped to radieu/p24-infra — infra automation, labelling, comments |
GH_TOKEN | n8n-bms4-gh, monitoring, vps-h1 | General-purpose repo write token — used by scripts and GH Actions |
GITHUB_TEST_PAT_08_2026 | n8n-bms4 | Standing 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_TOKENappears 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
| Consumer | Key | Effect if missing |
|---|---|---|
| n8n workflows (bms-4) — issue management, PR comments, dispatch triggers | GITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKEN | n8n GitHub nodes fail with 401 |
| Monitoring scripts on vps-i1 | GH_TOKEN | Script-created issues/comments fail |
| vps-h1 automation scripts | GH_TOKEN | Script GitHub operations fail |
Where stored
| Location | Key name | How to update |
|---|---|---|
secrets/n8n-bms4-gh.env.sops | GITHUB_PAT_ALL_WRITES, GITHUB_PAT_P24_INFRA, GH_TOKEN | SOPS write pattern |
secrets/monitoring.env.sops | GH_TOKEN | SOPS write pattern |
secrets/vps-h1.env.sops | GH_TOKEN | SOPS 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 missingE2E validation status: the mechanical build (script + provisioning + docs) was completed and statically validated (TOTP generation in isolation,
--dry-runnavigation togithub.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 (disposableTEST_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):
| Consumer | Was | Now |
|---|---|---|
| git clone/push over HTTPS on bms-4 | GH_TOKEN PAT in the credential helper | bms-4/git-credential-gh-token.sh — App-token-first, GH_TOKEN fallback |
post-rotation gh secret set | GH_TOKEN PAT | scripts/lib/gh_app_secret_set.sh (mints a secrets:write-scoped App token) |
| ad-hoc “give me a token” | read PAT from env | bin/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 tokenThe 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:
- Worker confirms
GITHUB_TOTP_SECRETis present insecrets/n8n-bms4.env.sopsand Chromium is provisioned (playwright_enabledansible run has completed on this host). If either is missing, abort and fall back to Option B (Tier 3, human-action). - For each PAT to rotate (
GITHUB_PAT_ALL_WRITES,GITHUB_PAT_P24_INFRA,GH_TOKEN— onePAT_NAMEper 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 - 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). - Verify the new PAT against
api.github.com/userbefore touching SOPS. - Update
secrets/n8n-bms4-gh.env.sops(the three bms-4 GitHub PATs live here, notsecrets/n8n-bms4.env.sops— that file holdsGITHUB_TOTP_SECRETitself). Also update theGH_TOKENcopies insecrets/monitoring.env.sopsandsecrets/vps-h1.env.sopsif those are in scope for this rotation (see §Token inventory note above — check lengths after rotation to confirm which copies share the same value). - GH Secret update + live server
.env+ affected container restart (same distribution chain as Tier 1). - Revocation of the old PAT is not a separate step — “Regenerate token” invalidates it.
- 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
| Symptom | Action |
|---|---|
| 2FA blocks Playwright automation | Fall 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 differ | Check each token’s permissions in GitHub Settings — recreate with same resource access if needed |
| n8n GitHub nodes still 401 after merge | secrets-sync.yml may not have run; check GH Actions or SSH to bms-4 and restart n8n |
| GH_TOKEN on vps-i1 not picked up | SSH 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.ymlGH Actions workflow (Monday 06:00 UTC) checksnext_dueand opens ahuman-actionissue. - 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 = ''