Plan: GH Actions fleet → App token + agent gh throttling (rate-limit fix)
Issue: #4245
Type: Code-change-design (plan only)
Status: Draft — iteration 1
Last updated: 2026-07-17
Author: Claude Opus 4.8 (worker session i1-cw-1 on vps-i1)
Related: #4065 (parent App-migration plan) · #4068 (Wave A — bms-4 workers) · #4180 (queue-exporter exhaustion) · #4139 (existing triage backoff)
0. Summary — recommendation
The issue’s premise does not survive measurement, and the recommendation changes as a result.
#4245 is framed as: ~40 workflows drain one 5000/hr PAT bucket → migrate the Actions fleet onto App 2109526 to fix it. Two of those three clauses are wrong:
| Claim in #4245 | Measured | Verdict |
|---|---|---|
| ”~40 workflows … drain that ONE bucket” | 18 workflows reference secrets.GH_TOKEN on a non-comment line; 16 are migratable here | ❌ over-counted ~2.2× |
| “5 high-frequency crons” to prioritise | 3 of the 5 burn the PAT; 2 use the built-in token | ❌ 2 false positives |
Migrating the fleet fixes 0/5000 daily | Fleet core burn ≈ 20–100 req/hr (0.4–2% of budget). queue-exporter alone burns ≈ 21,540 req/hr = 430% of the entire budget | ❌ fixes <2% of the problem |
The rate limit is not an Actions-fleet problem. It is a queue-exporter problem, and it is already owned by #4180. Migrating all 16 workflows while queue-exporter runs unchanged leaves the bucket at ~430% over budget — the 0/5000 symptom would persist unchanged. Conversely, landing #4180 alone drops burn to ~2% of budget and the symptom disappears without migrating a single workflow.
So the recommendation is a re-ordering and a re-justification, not a cancellation:
- Land #4180 first — it is the actual fix. Do not gate it on anything in #4245. Until it lands, every other number here is noise against a 4.3× overrun.
- Keep Move 1 (fleet → App token), but re-justify it under #4065, not capacity. The migration is still worth shipping — it removes 16 consumers from the PAT-rotation surface (#4065’s actual goal), cuts blast radius from a long-lived PAT to a 1h-TTL token, and unblocks PAT deprecation. It should be **re-parented under #4065 as a Wave A sibling of 4068, and explicitly de-scoped from the rate-limit narrative.
- Ship Move 2 (throttling guard) as an extraction, not an invention. A tested bounded-backoff implementation already exists (
scripts/prometheus-alerts-ai-triage.py, added by #4139, tests inscripts/tests/test_triage_rate_limit.py). Extract it toscripts/lib/gh_rate_limit.pyand reuse. Do not write a second one. - Drop Move 2 item 5 (cron back-off) as a rate-limit measure. Halving
*/10→*/30on the two*/10crons saves ~12 req/hr against a 21,540 req/hr overrun. It is a detection-latency regression bought for nothing. If those crons should be slower, argue it on cost/noise grounds in a separate issue.
Decision required from the human reviewer (ARCH GATE, §8): given Move 1 does not fix the rate limit, do we still want it now under the #4065 PAT-elimination banner, or does it defer until after #4180 + #4068 land?
1. Evidence
All counts from branch plan/4245-gh-app-token-fleet-migration at origin/main, 2026-07-17.
1.1 The fleet is 18 references / 16 migratable, not ~40
68 workflows total in .github/workflows/. Token references, after stripping comments:
# true PAT consumers — secrets.GH_TOKEN on a non-comment line
for f in .github/workflows/*.yml; do
hits=$(sed 's/#.*//' "$f" | grep -cE 'secrets\.GH_TOKEN')
[ "$hits" -gt 0 ] && echo "$f"
done | wc -l
# => 18| Bucket | Count | Consumes the PAT 5000/hr? |
|---|---|---|
secrets.GH_TOKEN (the PAT) | 18 | ✅ yes |
| ├─ must stay on the PAT | 1 (gh-pat-expiry-check.yml) | ✅ by design — see §1.4 |
| ├─ not App-compatible, re-route to #4068 | 1 (update-claude-env.yml) | ✅ — see §1.6 |
| └─ migratable here | 16 | ✅ |
secrets.GITHUB_TOKEN only (built-in) | 14 | ❌ separate per-repo bucket |
| Already on App 2109526 | 3 | ❌ App installation bucket |
| No token at all | 33 | ❌ |
The ~18, not ~40 magnitude is independently corroborated by the repo’s own alert text — gh-pat-expiry-check.yml:142 already says “All ~17 GHA workflows using secrets.GH_TOKEN are failing”. The “~40” in #4245 appears to come from grep -l GH_TOKEN matching the env-var name GH_TOKEN: rather than the secret reference secrets.GH_TOKEN — the same conflation that produces the two false positives in §1.2.
1.2 Two of the five “priority” workflows are false positives
atrax-data-freshness.yml and nc-alert-instant-dispatch.yml both do this:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # env var NAMED GH_TOKEN, VALUE = built-in tokenThe env var is named GH_TOKEN (because that is what the gh CLI reads) but its value is secrets.GITHUB_TOKEN — the built-in Actions token, which has its own 1,000/hr per-repository bucket and never touches the user PAT. GITHUB_TOKEN cannot be shadowed by a repo secret (GitHub reserves the GITHUB_ prefix), so this is unambiguous.
Corrected high-frequency PAT burner list — 3, not 5:
| Workflow | Cron | Runs/hr | PAT? |
|---|---|---|---|
dispatch-health-check.yml | */10 | 6 | ✅ |
prometheus-alerts-ai-triage.yml | */15 | 4 | ✅ |
alertmanager-escalation.yml | */15 | 4 | ✅ |
atrax-data-freshness.yml | */10 | 6 | ❌ built-in |
nc-alert-instant-dispatch.yml | */15 | 4 | ❌ built-in |
reregister-ionos-runners.yml is a fourth false positive — it is already migrated; its two secrets.GH_TOKEN hits are comments at lines 67 and 89 describing the pre-migration state.
1.3 The fleet is not the burn — queue-exporter is
First — “the bucket” is not one bucket. gh api rate_limit on this account returns 14 independent resources. The three that matter here:
| Resource | Limit | Who hits it |
|---|---|---|
core | 5,000/hr | POST/PATCH /repos/{r}/issues*, GET /repos/{r}/issues/{n}, /actions/runners |
search | 30/min | /search/issues — the dedup lookups in both triage scripts |
actions_runner_registration | 10,000/hr | provision-new-vps.yml runner tokens |
The 0/5000 symptom is a core exhaustion. The triage scripts’ dedup /search/issues calls do not touch core at all (scripts/prometheus-alerts-ai-triage.py:249,418; scripts/alertmanager-escalation.py:144,288), so they are excluded from the core column below.
Amortised hourly core burn:
| Consumer | Calc | core req/hr |
|---|---|---|
dispatch-health-check | 6 runs/hr × ~3 calls | ~18 |
prometheus-alerts-ai-triage | 4 runs/hr × (1 POST/PATCH per firing alert; searches are not core) | ~0–40 |
alertmanager-escalation | 4 runs/hr × same shape | ~0–40 |
| 13 other PAT workflows | daily / weekly / manual | ~0 amortised |
| Actions fleet subtotal | ~20–100 (0.4–2% of 5,000) | |
queue-exporter | 359 issues × 60 cycles/hr (app.py:476 time.sleep(60)), all GET /repos/{r}/issues/{n} → core | ~21,540 (430%) |
queue-exporter reads the same PAT — monitoring/exporters/queue-exporter/app.py:233 → os.environ.get('GH_TOKEN'), wired in monitoring/docker-compose.yml as GH_TOKEN=${GH_TOKEN} from /opt/p24-infra/monitoring/.env. #4180 evidences it directly: 9,922 × HTTP 403 Forbidden in 2h, and a clean hourly sawtooth on p24_worker_queue_analyst_escalated_stale.
The conclusion is robust to large estimate error. The fleet numbers above are bounded, not exact — the per-run count depends on how many alerts are firing. But even at a 10× over-estimate (~1,000 core req/hr, 20% of budget) the fleet still cannot produce a 0/5000, and queue-exporter still exceeds the entire budget 4.3× on its own. No plausible refinement of the fleet estimate changes the recommendation.
One consumer is ~99% of core burn, and it is not in this issue’s scope.
1.4 gh-pat-expiry-check.yml must NOT be migrated
It uses the PAT as the subject under test, not as a credential of convenience:
HTTP_RESPONSE=$(curl -sI -H "Authorization: Bearer $TOKEN" ...)
EXPIRY_HEADER=$(echo "$HTTP_RESPONSE" | grep -i '^github-authentication-token-expiration:' ...)Swapping $TOKEN to an App token would report the App token’s expiry (always ~1h) instead of the PAT’s, silently destroying the p24_github_pat_expires_days metric and the expiry alert. It already correctly uses secrets.GITHUB_TOKEN for its own gh CLI alerting (line 134). Exclude permanently.
1.5 Agent gh calls only partly share the PAT bucket
This worker’s gh CLI authenticates as AI-Dev-IO1 — a distinct machine account with its own 5000/hr bucket, measured at 5000/5000 remaining mid-session. So “Claude agents’ gh CLI … drain that ONE bucket” is not true for the gh CLI path on vps-i1.
Two caveats keep it partly true:
update-claude-env.ymlwritesexport GITHUB_TOKEN=<the PAT>into a persistent$HOME/.claude-envon each runner (§1.6), and theclaude-env-syncAnsible role does the same across vps-i1 / bms-4 / vps-h1. Any agent code path that reads$GITHUB_TOKEN(rather than shelling togh) does hit the PAT bucket.- The bms-4 telegram-claude-bot authenticates with radieu’s personal owner-level PAT (
priorities.mdP1).
Move 2 is worth shipping as defence-in-depth, but it is not load-bearing for the 0/5000 symptom — §1.3 is.
1.6 update-claude-env.yml cannot take an App token
It is not a gh consumer — it is a PAT distributor:
runs-on: ${{ inputs.runner_label }}
env:
GITHUB_TOKEN_SECRET: ${{ secrets.GH_TOKEN }}
run: |
ENV_FILE="$HOME/.claude-env"
cat > "$ENV_FILE" <<EOF
export GITHUB_TOKEN=${GITHUB_TOKEN_SECRET}
EOF$HOME persists on a self-hosted runner, and agents source this file for the life of the machine. An App installation token has a 1-hour TTL — writing one here produces a credential file that is dead an hour later and silently 401s every agent that sources it. A long-lived credential written to a persistent file is structurally the opposite of what an App token is.
The correct fix is on-demand minting, which is exactly bin/gh-app-token.sh in #4068 §Scope. This workflow therefore belongs to #4068’s agent-credential class, not to this issue’s Actions-fleet class. Re-route, do not migrate. (Same applies to the claude-env-sync Ansible role.)
1.7 What #4245 gets right
- The
owner:/repositories:scoping trap is real.close-merged-pr-issues.ymlanddispatch-to-queue.ymlboth omitowner:/repositories:and therefore silently scope to the current repo only.reregister-ionos-runners.ymlsets them correctly (owner: radieu,repositories: et-operational-platform,amazon-kdp-tango,p24-infra). Any migration must set both. - One App is the right answer. All tokens from one App share one installation bucket, so a 2nd App buys isolation, not capacity. Correct — and given §1.3, capacity was never the constraint.
- Issues stay on GitHub. The rejected Supabase alternative is correctly rejected.
2. Changes required
Move 1 — fleet → App token (16 workflows, re-justified under #4065)
Pattern per workflow — insert a token step, swap the consumer reference:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
owner: radieu # REQUIRED — default scopes to current repo only
repositories: p24-infra # REQUIRED — widen only where the workflow needs it
- name: <existing step>
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }} # was: ${{ secrets.GH_TOKEN }}| Wave | Files | What changes | Why |
|---|---|---|---|
| 1 — high-frequency (3) | dispatch-health-check.yml, prometheus-alerts-ai-triage.yml, alertmanager-escalation.yml | App-token step + GH_TOKEN env swap | Highest run count → fastest signal on regressions |
| 2 — scheduled (5) | db-maintenance.yml, db-maintenance-check.yml, nightly-queue-cleanup.yml, cloudflare-security-check.yml, nightly-devops-triage.yml | same | Daily/weekly; failure is visible next cycle |
| 3 — event/manual (8) | apply-supabase-migrations.yml, auto-fix-gh-actions.yml, deploy-meta-dispatcher.yml, deploy-p24-auth-worker.yml, resource-incident-triage.yml, provision-new-vps.yml, update-vps-h1-sops.yml, wasabi-sops-update.yml | same | Manual trigger → verify on demand |
| excluded (2) | gh-pat-expiry-check.yml | no change | §1.4 — PAT is the subject under test |
update-claude-env.yml | **re-route to 4068 | §1.6 — 1h TTL incompatible with a persistent credential file |
Waves 1+2+3 = 3 + 5 + 8 = 16, plus 2 excluded = 18 — reconciles with §1.1.
Runner risk is already retired. 9 of the 18 run on [self-hosted, bms4]. dispatch-to-queue.yml and close-merged-pr-issues.yml already run create-github-app-token@v1 on that exact runner today — the Node action is proven there. No runner provisioning needed.
update-vps-h1-sops.yml — verify it is still live before migrating. It checks out ref: dev (CLAUDE.md: “dev branch kept for historical reference only — no new work targets it”) and its PR body says “Closes #232”. It looks stale. Confirm it still runs; if dead, delete it rather than migrate it — that is strictly cheaper and removes a PAT consumer outright.
Move 2 — extract the existing backoff guard
| File | What changes | Why |
|---|---|---|
NEW scripts/lib/gh_rate_limit.py | Extract _rate_limit_delay() + RATE_LIMIT_MAX_RETRIES / RATE_LIMIT_MAX_WAIT_S + the bounded-retry wrapper verbatim from scripts/prometheus-alerts-ai-triage.py | #4139 already built and tested this. One implementation, not two. |
scripts/prometheus-alerts-ai-triage.py | Import from the new lib; delete the inlined copy | Behaviour-preserving refactor |
scripts/alertmanager-escalation.py | Adopt the lib in gh_request() | Currently unguarded; same 403 exposure |
scripts/tests/test_triage_rate_limit.py | Re-point at scripts/lib/gh_rate_limit | Existing coverage carries over |
NEW scripts/tests/test_gh_rate_limit.py | Direct unit tests for the extracted lib | Lib is now a shared contract |
Guard semantics (already proven by #4139, preserve exactly): honour Retry-After, else X-RateLimit-Reset when X-RateLimit-Remaining: 0; retry ≤3× with ≤30s wait; on persistent exhaustion skip remaining work and exit 0 — a rate limit is transient and must not mark the workflow failed.
Rejected — the <500 remaining pre-flight check proposed in #4245. A pre-flight gh api rate_limit costs a call to save a call and races the actual request. Reacting to the 403/429 the API already sends is strictly better and is what #4139 shipped.
Move 2 item 4 — per-session caching helper
Defer. No measured evidence of repeat-fetch cost, and §1.5 shows agents on vps-i1 have their own unexhausted bucket. Re-raise with a measurement if it ever matters.
Move 2 item 5 — cron back-off
Drop. */10 → */30 on dispatch-health-check saves ~12 req/hr against a 21,540 req/hr overrun, at the cost of tripling detection latency on the dispatch health path. Not a rate-limit measure. (atrax-data-freshness, the other */10, does not touch the PAT at all — §1.2.)
New files
scripts/lib/gh_rate_limit.py, scripts/tests/test_gh_rate_limit.py.
DB schema changes: no.
3. Prerequisite — App permission audit (inherited from #4065 §2.4)
#4065 §2.4 requires confirming the installation grants every permission its consumers rely on before the migration PR opens. For this fleet specifically:
| Workflow | Needs | Status |
|---|---|---|
| most (issue create/comment/close) | issues: write | ✅ per #4065 §2.4 |
apply-supabase-migrations.yml, deploy-* | contents: write, actions: write | ✅ per #4065 §2.4 |
update-vps-h1-sops.yml, wasabi-sops-update.yml | contents: write + pull_requests: write — they pass the PAT as actions/checkout token: and open a PR | ✅ per #4065 §2.4 |
provision-new-vps.yml | administration: write (runner registration token) | ⚠️ accepted 2026-07-17 (#4238/#4221) — verify live, acceptance ≠ exercised |
Verified: no workflow in this fleet calls gh secret set or the /actions/secrets API, so secrets: write is not required. (An earlier draft of this plan assumed it was — checked and withdrawn.)
Record the audit output in the implementation PR, per #4065 §2.4.
4. Regression risks
| Risk | Why it could break | Mitigation |
|---|---|---|
| Un-scoped App token 403s on foreign repos | Default scope is current-repo-only. This exact trap already hides in close-merged-pr-issues.yml / dispatch-to-queue.yml. | Set owner: radieu + explicit repositories: on every step. Grep the PR for a create-github-app-token block missing either key. |
| 1h token TTL expires mid-run | alertmanager-escalation.yml / prometheus-alerts-ai-triage.yml export the token over SSH into vps-i1 and run a Python script there. | Both scripts start immediately and run ≪1h — safe. Generate the token in the same job that consumes it; never pass one across jobs or persist it. Net security win: blast radius drops from a long-lived PAT to 1h. |
gh-pat-expiry-check.yml migrated by a mechanical sweep | A sed-style bulk swap would silently break the expiry metric (§1.4). | Explicit exclusion list; assert secrets.GH_TOKEN still present in that file as a test. |
administration: write accepted but never exercised | provision-new-vps.yml mints a runner registration token. The scope was accepted 2026-07-17 but acceptance is not proof it works. | Dry-run it in Wave 3 (§5) before removing its PAT path. |
| **Double-migration with 4068 | Both touch the App. update-claude-env.yml is a genuine overlap — it distributes the PAT to agents, which is #4068’s bin/gh-app-token.sh domain, not the Actions-fleet domain (§1.6). | Exclude update-claude-env.yml here and hand it to #4068. secrets-sync.yml is #4068’s and is untouched here (it references no secrets.GH_TOKEN today — verified). No other file is shared. |
update-vps-h1-sops.yml may be dead | Checks out ref: dev (deprecated) and closes an issue from the #232 era. | Confirm liveness; delete rather than migrate if dead. |
| PAT deprecation before consumers are gone | queue-exporter + telegram bot still read the PAT after this lands. | Do not remove GH_TOKEN from SOPS/GH Secrets at the end of this issue. Removal is gated on #4180 + #4068 + telegram-bot resolution. |
5. Manual test checklist
-
gh workflow run dispatch-health-check.yml→ run green, issue create/comment path exercised -
prometheus-alerts-ai-triage.yml+alertmanager-escalation.ymldry-run (-f dry_run=true) → SSH step reaches vps-i1, script authenticates, no 401/403 -
provision-new-vps.ymldry-run → runner registration token still issues (administration: writeconfirmed live, not just accepted) -
gh-pat-expiry-check.ymlunchanged →p24_github_pat_expires_days{token_name="GH_TOKEN"}still populates on the pushgateway -
update-claude-env.ymlunchanged in this PR → agents’$HOME/.claude-envstill sources a working token (§1.6) -
python -m pytest scripts/tests/test_gh_rate_limit.py scripts/tests/test_triage_rate_limit.py -v→ pass -
ruff check scripts/→ clean - Grep guard: every
create-github-app-tokenblock in the PR has bothowner:andrepositories: - No
secrets.GH_TOKENremains outsidegh-pat-expiry-check.ymland comments
6. Dependencies & sequencing
#4180 queue-exporter throttle ──► THE rate-limit fix. Land first, independently.
#4068 Wave A — bms-4 workers ──► owns secrets-sync.yml + git-credential + rotate/*.js
└─ ALSO take update-claude-env.yml + claude-env-sync role (§1.6 — needs
on-demand bin/gh-app-token.sh, not a static token in a file)
#4245 this issue ──► 16 GH Actions workflows + scripts/lib/gh_rate_limit.py
└─ re-parent under #4065 as a Wave A sibling; de-scope from the rate-limit narrative
After moving update-claude-env.yml to #4068, no file is touched by both issues. #4245 does not block #4180 and must not be sequenced ahead of it.
7. Cost/benefit — why still ship Move 1
With the capacity argument gone, Move 1 stands on #4065’s actual goal:
- Removes 16 of the 18
secrets.GH_TOKENcall sites in.github/workflows/→ materially advances #4065’s “cut the PAT-consumer count”. (#4065 §2.3 counts consumer classes, not call sites; the Actions fleet is not itemised there, so this is net-new coverage relative to #4068.) - Replaces a long-lived PAT with 1h-TTL tokens in 16 places → smaller blast radius on the next exposure (cf. 2824, both still open P0).
- App tokens need no browser/2FA → the migrated set becomes Tier 1 autonomous, no
human-actionon rotation. - Mechanical, reversible, per-workflow. Low risk once §3’s audit passes.
It is a security/autonomy win, not a capacity win. Ship it on that basis or not at all.
8. ARCH GATE — human decision required
worker-issue.md §Step 3 flags migrate as an architecture-gate keyword, and this plan’s findings make that gate substantive rather than procedural: the issue’s stated goal (fix the rate limit) is not achieved by its stated method (migrate the fleet).
Decide before any implementation issue opens:
- Accept the re-frame? Move 1 re-justified under #4065 (PAT elimination), explicitly not a rate-limit fix.
- Sequence: land #4180 first (recommended), or run in parallel?
- **Re-parent #4245 under 4065 as a Wave A sibling of #4068, or keep standalone?
- Accept the de-scopes? Move 2 item 4 (caching) deferred, item 5 (cron back-off) dropped.
- Hand
update-claude-env.yml(+claude-env-syncAnsible role) to #4068? §1.6 — it needs on-demand minting, not a token in a file. #4068’s scope grows slightly. update-vps-h1-sops.yml— confirm dead and delete, or keep and migrate?
No workflow YAML changes in this PR — this is the plan artifact only.
9. File-overlap signature
docs/plans/plan-4245-gh-app-token-fleet-migration.md (this PR — plan only).
Implementation issues, once approved, would touch the 16 migratable workflows — .github/workflows/{dispatch-health-check,prometheus-alerts-ai-triage,alertmanager-escalation,db-maintenance,db-maintenance-check,nightly-queue-cleanup,cloudflare-security-check,nightly-devops-triage,apply-supabase-migrations,auto-fix-gh-actions,deploy-meta-dispatcher,deploy-p24-auth-worker,resource-incident-triage,provision-new-vps,update-vps-h1-sops,wasabi-sops-update}.yml — plus scripts/lib/gh_rate_limit.py, scripts/prometheus-alerts-ai-triage.py, scripts/alertmanager-escalation.py, scripts/tests/test_gh_rate_limit.py, scripts/tests/test_triage_rate_limit.py.
Not touched here: gh-pat-expiry-check.yml (§1.4), update-claude-env.yml + ansible/roles/claude-env-sync (§1.6 → #4068), secrets-sync.yml (#4068), monitoring/exporters/queue-exporter/ (#4180).
Auto-generated by Claude Code worker i1-cw-1 (session on vps-i1) for #4245.