fix(github): evict a rejected installation token on PR-mutating calls - #2406
Conversation
mergePullRequest, closePullRequest, createPullRequestReview, updatePullRequestBranch, createIssueComment, and getLastCloserLogin all called createInstallationToken directly instead of routing through withInstallationTokenRetry — the only caller of expireCachedInstallationToken. Only the check-run creator and the comment poster in comments.ts got evict-and-retry. An installation suspended (or an App private key rotated) while a cached token was still within its ~1h TTL meant every merge/close/review/update-branch call kept reusing the same now-invalid token and re-failing with 401 until the cache entry expired on its own — no self-heal, unlike the paths that already had the retry wiring. Route all six through withInstallationTokenRetry, mirroring the existing comments.ts pattern: on a 401, evict the cached token and retry once with a freshly-minted one. createPullRequestReviewComments and createInstallationToken's other caller are unaffected — not in scope here.
|
Warning 🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨 ⏸️ Gittensory review result - manual review recommendedReview updated: 2026-07-02 00:45:13 UTC
⏸️ Suggested Action - Manual Review
Review summary Nits — 7 non-blocking
Review context
Contributor next steps
Signal definitions
🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2406 +/- ##
=======================================
Coverage 95.90% 95.90%
=======================================
Files 224 224
Lines 25217 25217
Branches 9169 9169
=======================================
Hits 24185 24185
Misses 419 419
Partials 613 613
🚀 New features to boost your workflow:
|
…oken-retry-wiring # Conflicts: # src/github/pr-actions.ts # test/unit/github-pr-actions.test.ts
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
gittensory-ui | 54c2275 | Commit Preview URL Branch Preview URL |
Jul 02 2026, 12:37 AM |
createPullRequestReviewComments and dismissLatestBotApproval still called createInstallationToken directly, so a rejected cached token would keep dropping inline review comments and skip stale-bot-approval cleanup until the cache's own TTL lapsed -- the same failure mode the other six functions in this file were just fixed for. Route both through withInstallationTokenRetry, mirroring the existing pattern exactly.
…oken-minting poisoning createAppJwt caches the App-level JWT for ~8 minutes with no eviction path on rejection, unlike installation tokens (evicted + retried once by withInstallationTokenRetry since #2406). A single GitHub-side rejection of the currently-cached App JWT — a transient validation hiccup, a clock-skew edge case, or a brief App suspend/reinstate — kept getting reused by every subsequent installation-token mint attempt across EVERY installation on the instance, since mintInstallationToken threw straight through on the first non-ok response with no retry at all. Add expireCachedAppJwt (mirroring expireCachedInstallationToken) and have mintInstallationToken evict + re-sign + retry once on a 401 from the access-tokens endpoint, bounded the same way withInstallationTokenRetry already bounds installation-token retries. Fixes #2453
…oken-minting poisoning createAppJwt caches the App-level JWT for ~8 minutes with no eviction path on rejection, unlike installation tokens (evicted + retried once by withInstallationTokenRetry since #2406). A single GitHub-side rejection of the currently-cached App JWT — a transient validation hiccup, a clock-skew edge case, or a brief App suspend/reinstate — kept getting reused by every subsequent installation-token mint attempt across EVERY installation on the instance, since mintInstallationToken threw straight through on the first non-ok response with no retry at all. Add expireCachedAppJwt (mirroring expireCachedInstallationToken) and have mintInstallationToken evict + re-sign + retry once on a 401 from the access-tokens endpoint, bounded the same way withInstallationTokenRetry already bounds installation-token retries. Fixes #2453
…oken-minting poisoning (#2480) * fix(github): evict a cached App JWT on rejection to stop fleet-wide token-minting poisoning createAppJwt caches the App-level JWT for ~8 minutes with no eviction path on rejection, unlike installation tokens (evicted + retried once by withInstallationTokenRetry since #2406). A single GitHub-side rejection of the currently-cached App JWT — a transient validation hiccup, a clock-skew edge case, or a brief App suspend/reinstate — kept getting reused by every subsequent installation-token mint attempt across EVERY installation on the instance, since mintInstallationToken threw straight through on the first non-ok response with no retry at all. Add expireCachedAppJwt (mirroring expireCachedInstallationToken) and have mintInstallationToken evict + re-sign + retry once on a 401 from the access-tokens endpoint, bounded the same way withInstallationTokenRetry already bounds installation-token retries. Fixes #2453 * fix(github): evict the App JWT again when the retried mint is also rejected createAppJwt caches optimistically before GitHub validates the JWT. The first-401 retry path evicted and re-signed once, but if that retried JWT was ALSO rejected, the just-rejected JWT stayed cached and kept poisoning every installation-token mint fleet-wide for up to APP_JWT_REUSE_MS -- the exact failure mode the retry was meant to fix (flagged by the gate's own review). Evict again on a second 401 before throwing, so the next mint attempt signs fresh instead of replaying the poisoned JWT.
What
mergePullRequest,closePullRequest,createPullRequestReview,updatePullRequestBranch,createIssueComment, andgetLastCloserLogininsrc/github/pr-actions.tsall calledcreateInstallationTokendirectly and never routed throughwithInstallationTokenRetry(src/github/app.ts) — the only caller ofexpireCachedInstallationToken. Only the check-run creator and the comment poster insrc/github/comments.tsgot evict-and-retry.An installation suspended, or the App private key rotated, while a cached token (kept up to ~1h) is still in the cache meant every merge/close/review/update-branch call for that installation kept reusing the same now-invalid token and re-failing with 401 each time — no self-heal, unlike the paths that already route through the retry helper. It would just keep failing until the cache's own TTL lapsed on its own.
Fix
Routed all six through
withInstallationTokenRetry, mirroring the existingcomments.tspattern exactly:return withInstallationTokenRetry(env, installationId, async (token) => { ...the existing octokit call, unchanged... });. On a 401 (isGitHubBadCredentialsError), the helper evicts the cached token and retries once with a freshly-minted one — a suspended installation or rotated App key now self-heals on the very next attempt instead of failing repeatedly against a known-bad cached token for up to an hour.getLastCloserLoginkeeps its existing outertry/catch(fail-closed to{ login: null, coveredAllPages: false }on any unrecoverable error) — the retry now happens inside that try, so a persistent 401 after the retry still degrades the same way it always has.Deliberately out of scope:
createPullRequestReviewComments(not in the issue's list — it takes amode: AgentActionModeparameter and is used for lower-stakes inline review notes) and any other caller ofcreateInstallationTokenelsewhere in the codebase.createInstallationTokenitself is still imported/used for that one function, unchanged.Tests
closePullRequestreceives a 401 on the first attempt, succeeds on the retry with a freshly-minted token — asserts exactly 2 close attempts and 2 token mints (proving the stale token was evicted, not reused).updatePullRequestBranch'sexpectedHeadShaternary TRUE branch;getLastCloserLogin'slastPage <= 1branch, both directions of its?? nullfallback).npx tsc --noEmitclean.github-pr-actions.test.ts— 22 passed.agent-action-executor.test.ts,agent-approval-queue.test.ts,routes-agent-approval.test.ts(direct importers), fullqueue.test.ts+inline-comments.test.ts— 317 passed combined.src/github/pr-actions.ts: fully covered.npm run test:coverage: 5634 passed, 4 skipped (pre-existing/unrelated), 0 failed.npm audit --audit-level=moderate: 0 vulnerabilities.Advances #1936. Closes #2263.