Skip to content

List only branches that still exist as spec versions - #250

Merged
jgeluk merged 5 commits into
developfrom
issue/249-spec-versions-branch-filter
Aug 21, 2026
Merged

List only branches that still exist as spec versions#250
jgeluk merged 5 commits into
developfrom
issue/249-spec-versions-branch-filter

Conversation

@jgeluk

@jgeluk jgeluk commented Aug 21, 2026

Copy link
Copy Markdown
Member

Fixes #249.

The page was never stale

A branch pushed minutes earlier already appeared on the live page while develop had not been redeployed, so ISR and the Vercel deployments API were both working. ekgf-website is not involved either: its src/middleware.ts rewrites /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:

// Fail open: when the GitHub lookup failed, keep every branch.
if (activeBranches && !activeBranches.has(branch)) continue;

GITHUB_TOKEN is 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:

missing token      -> unauthenticated -> 403 rate limit -> null
expired/revoked    -> 401 Bad credentials               -> null
missing scope      -> 403 Resource not accessible       -> null
no User-Agent      -> 403                               -> null

All four collapse into if (!res.ok) return null with 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-drift exists 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.warn on 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 and develop when 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 dropping EXCLUDED_BRANCH_PREFIXES if you disagree.

The failure now diagnoses itself. GitHub's own response body is logged, which separates Bad credentials from a rate limit from a missing scope. An explicit User-Agent is 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.example and a new site/README.md.

Verification

tsc --noEmit clean, pnpm build clean. Simulated against live data:

BEFORE (live page): 23 branches
AFTER  (new logic):  8 branches + archive

LISTED:  develop, fix/retire-github-pages-urls, issue/184-schedule-model,
         issue/198-reuse-odrl-collections, issue/238-unified-evaluation-context,
         issue/239-duty-only-properties, issue/241-doc-metadata-drift,
         issue/246-jsonld-context

DROPPED: 11 branches deleted upstream, 4 dependabot bumps

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-versions and check its runtime logs for [spec-versions]. If it reports Bad credentials, rotate the token; Metadata: Read-only on EKGF/dprod is 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 lint fails on develop too: there is no eslint.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

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
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dprod Ready Ready Preview Aug 21, 2026 12:24pm

…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
@jgeluk

jgeluk commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

The preview deployment settles it: the token is present but not being accepted.

https://issue-249-spec-versions-branch-filter.dprod-preview.ekgf.org/dprod/spec-versions/ renders:

Branch list unavailable

and lists exactly two entries — DPROD 1.0 (Beta) and Develop (latest).

That combination is conclusive:

  • The Vercel API works. Develop (latest) only appears because fetchDeployments() returned a READY deployment for the develop branch. If the Vercel credentials were the problem, the archive would be the only entry.
  • The GitHub lookup fails. Every branch came back with existsUpstream === null, which is why the notice renders and the fail-closed path applies.
  • GITHUB_TOKEN is available to this deployment — it is set for All Environments, including previews.
  • The missing User-Agent hypothesis is eliminated. This build sends User-Agent: ekgf-dprod-site explicitly, and the lookup still fails.

By elimination, the token is reaching GitHub and being rejected: expired, revoked, or lacking Metadata: Read-only on EKGF/dprod. Given it was added Apr 13 and fine-grained tokens commonly carry 30/60/90-day lifetimes, expiry is the likely one — but the runtime log now carries GitHub's own message, so it does not need to be guessed. Look for [spec-versions] GitHub branch lookup failed: in this preview's logs; Bad credentials means rotate, Resource not accessible means fix the scope.

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
@jgeluk

jgeluk commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Token permission correction (details on #249): the fine-grained token needs Contents: Read-only on EKGF/dprod, not Metadata as I said earlier — that is what List branches requires. Metadata: Read-only is added automatically by GitHub and is not a choice. site/.env.example and site/README.md are corrected in the latest commit.

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
@jgeluk

jgeluk commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Decision on ballot branches: leave as is. A branch deleted from origin drops off the listing, ballot/* included.

All three ballot branches (ballot/3, ballot/4, ballot/5) have been deleted from origin and survive only in local clones, so ballot/5 disappears from the picker with this change. Their Vercel deployments still exist and /spec/ballot-5 still resolves — any already-published URL keeps working, it is simply no longer advertised.

Recorded in site/README.md so the disappearance is not later rediscovered as a regression. If a ballot ever needs to stay listed, the fix is to keep its branch on origin rather than special-casing it in code.

Verified on the same data at the same moment: production (develop) lists 25 branch entries, this PR's preview lists 9.

@jgeluk
jgeluk merged commit 9446903 into develop Aug 21, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spec versions page lists deleted branches: GitHub filter fails open

1 participant