List only branches that still exist as spec versions - #250
Conversation
The version picker advertised 22 branches while 6 pull requests were open, 11 of them deleted from the repository months earlier. The page was not stale: a branch pushed minutes earlier already appeared, so ISR and the Vercel deployments API were working. The GitHub lookup was failing, and the caller kept every branch when it did. GITHUB_TOKEN was referenced in exactly one line and documented nowhere, while authentication was optional in the code. Unauthenticated, GitHub allows 60 requests/hour per IP shared across Vercel's serverless egress, which is exhausted continuously, so the lookup failed on every request and the filter never applied. - Query branch existence rather than open pull requests. Existence is what the picker means: a merged branch is normally deleted and its preview is no longer a version of anything, while a branch can legitimately exist with no open PR, which the old predicate hid. Paginated, so a growing repository cannot silently truncate the list. - Require GITHUB_TOKEN and warn when it is missing or the lookup fails. The failure was previously invisible. - Split the two consumers, which need opposite failure behaviour. getSpecVersions() stays permissive: middleware resolves explicit /spec/<slug> URLs with it, and a URL already handed out must keep working during a GitHub outage. getListedSpecVersions() fails closed, showing the archive and develop only when existence cannot be determined — a short list is a smaller lie than a wrong one. - Exclude dependabot/*: a dependency bump is a valid preview deployment but not a version of the specification. - Document the environment in site/.env.example and site/README.md, and ignore tsconfig.tsbuildinfo. Against live data the listing goes from 23 entries to 8 plus the archive, every drop being a deleted branch or a dependency bump. GITHUB_TOKEN still has to be set on the Vercel project; a fine-grained token needs only Metadata: Read-only on EKGF/dprod. Until then the picker shows the archive and develop, and logs why. Resolves: #249
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ently Follow-up on the same issue. GITHUB_TOKEN turned out to be configured on the Vercel project all along, so "token missing" was the wrong explanation: a present-but-rejected token fails identically, and the old code could not tell the two apart because it logged nothing at all. - Log GitHub's own response body on failure. That is what distinguishes the cases that matter: "Bad credentials" (expired or revoked), a rate-limit message (running unauthenticated), and "Resource not accessible" (token lacks Metadata: Read-only). Fine-grained tokens expire, so this will recur and should be self-diagnosing. - Send an explicit User-Agent. GitHub rejects requests without one with 403, and runtimes differ in whether they supply a default; not worth depending on the platform for. - Report completeness from getListedSpecVersions() and render a "Branch list unavailable" notice. Failing closed without saying so replaces a silently long list with a silently short one. - Correct the documentation, which asserted the token was unset. Resolves: #249
|
The preview deployment settles it: the token is present but not being accepted.
and lists exactly two entries — That combination is conclusive:
By elimination, the token is reaching GitHub and being rejected: expired, revoked, or lacking Two things worth drawing out of this: The behaviour on display in that screenshot is the fix working as intended. Two honest entries plus a notice explaining why, instead of 22 entries of which 11 were branches deleted months ago. It stays that way until the token is rotated, and then the full list returns automatically. And it makes the real failure plain: the token has been configured since April, so it broke sometime after that, silently, and nothing anywhere reported it. A credential that expires on a schedule with no alarm attached will do this again — which is why the logging and the on-page notice matter more here than the token rotation itself. |
List branches requires "Contents" repository permissions (read), not Metadata. Metadata: Read-only is mandatory and added automatically by GitHub once any repository permission is selected, so it is not something to pick. Also record why an authenticated request matters even though the repository is public: the unauthenticated path carries the 60-per-hour per-IP limit that caused this issue in the first place. Resolves: #249
|
Token permission correction (details on #249): the fine-grained token needs |
Vercel binds environment variables to a deployment at build time, so the existing preview build still carried the expired token. No code change.
Including ballot/*, whose branches have all been deleted from origin. Their preview deployments survive and /spec/<slug> still resolves, so published URLs keep working; they are simply no longer advertised. Deliberate: the listing answers "which versions exist now?" and a deleted branch does not. Noted so the disappearance of ballot/5 is not later rediscovered as a regression. Resolves: #249
|
Decision on ballot branches: leave as is. A branch deleted from All three ballot branches ( Recorded in Verified on the same data at the same moment: production ( |
Fixes #249.
The page was never stale
A branch pushed minutes earlier already appeared on the live page while
develophad not been redeployed, so ISR and the Vercel deployments API were both working.ekgf-websiteis not involved either: itssrc/middleware.tsrewrites/dprod/*verbatim to the dprod zone and holds no version logic.What failed was the GitHub lookup — and the caller was written to keep every branch when it did:
GITHUB_TOKENis configured on the Vercel project (added Apr 13) — I initially inferred it was missing, which was wrong. It makes no difference to the diagnosis, because a present-but-rejected token fails on the identical code path:All four collapse into
if (!res.ok) return nullwith nothing logged, so they are indistinguishable from outside. That is the defect: not which one it is.Changes
Branch existence instead of open PRs. Existence is what the picker means. A merged branch is normally deleted and its preview is no longer a version of anything; conversely
issue/241-doc-metadata-driftexists upstream with no open PR, and the old predicate would have hidden it even with a working token. Paginated, so a growing repository cannot silently truncate.The failure is now audible.
console.warnon a missing token and on a failed lookup, visible in the Vercel runtime logs. Previously there was no signal of any kind, which is why this ran broken indefinitely.Two consumers, opposite failure behaviour. This is the part worth reviewing:
getSpecVersions()stays permissive — middleware resolves explicit/spec/<slug>URLs with it, and a preview URL someone already holds must keep working during a GitHub outage.getListedSpecVersions()fails closed — the picker shows only the archive anddevelopwhen existence cannot be determined.Failing closed everywhere would break routing during an outage; failing open everywhere is what produced this bug.
dependabot/*excluded from the picker. A dependency bump produces a valid preview deployment but is not a version of the specification. Flagging this as the one judgement call here — trivially reverted by droppingEXCLUDED_BRANCH_PREFIXESif you disagree.The failure now diagnoses itself. GitHub's own response body is logged, which separates
Bad credentialsfrom a rate limit from a missing scope. An explicitUser-Agentis sent, eliminating one of the four candidates outright — GitHub returns 403 without one, and runtimes differ in whether they supply a default.The degraded state is visible.
getListedSpecVersions()reports completeness and the page renders a "Branch list unavailable" notice. Failing closed without saying so would only trade a silently long list for a silently short one.Documented in
site/.env.exampleand a newsite/README.md.Verification
tsc --noEmitclean,pnpm buildclean. Simulated against live data:Action still required
The token exists but is not being accepted. The leading candidate is expiry — added Apr 13, ~4 months ago, and fine-grained tokens commonly carry 30/60/90-day lifetimes — but rather than guess a third time, this PR makes the system say so itself.
To get the answer: open this PR's preview deployment at
/spec-versionsand check its runtime logs for[spec-versions]. If it reportsBad credentials, rotate the token;Metadata: Read-onlyonEKGF/dprodis the only permission required.Note for the record: the token has been configured since April, so whatever broke it happened after that, silently, and stayed broken with no signal anywhere.
Note
pnpm lintfails ondeveloptoo: there is noeslint.config.js, and ESLint 9 no longer reads.eslintrc.*. Pre-existing and untouched here, but worth its own issue.🤖 Generated with Claude Code
https://claude.ai/code/session_01VHbek1ZojYktiTaeFqChqA