Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions site/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Environment for the DPROD marketing/spec site (site/).
#
# All four are read at request time by src/lib/spec-versions.ts. Set them on
# the Vercel project (Settings -> Environment Variables), not in the repo.

# --- Vercel API: which branches have a READY deployment ---------------------
# A Vercel access token with read access to the dprod project.
VERCEL_TOKEN=
# Both are also injected automatically by Vercel at build time, but the
# spec-versions lookup runs per-request, so they must exist as real
# environment variables too.
VERCEL_PROJECT_ID=
VERCEL_ORG_ID=

# --- GitHub API: which of those branches still exist ------------------------
# REQUIRED, and it must be VALID: an expired or revoked token fails exactly
# like a missing one. Without a working token the lookup either runs
# unauthenticated (60 requests/hour per IP, shared across all of Vercel's
# egress, so exhausted continuously) or is rejected outright, and the version
# picker degrades to the archive and develop.
#
# Fine-grained token setup:
# Resource owner EKGF
# Repository access Only select repositories -> EKGF/dprod
# Permissions Contents: Read-only
#
# "Contents (read)" is what GET /repos/{owner}/{repo}/branches requires; see
# https://docs.github.com/en/rest/branches/branches. GitHub adds the mandatory
# "Metadata: Read-only" automatically once any repository permission is
# selected. No write permissions, and no Pull requests permission — the branch
# lookup does not touch that endpoint.
#
# A classic token needs `public_repo` instead.
#
# Fine-grained tokens expire. When the picker degrades, check the deployment
# logs: the failure is logged with GitHub's own message, which distinguishes
# "Bad credentials" (expired/revoked) from a rate limit or a missing scope.
GITHUB_TOKEN=
3 changes: 3 additions & 0 deletions site/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
.vercel

# TypeScript incremental build info
tsconfig.tsbuildinfo
65 changes: 65 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# DPROD site

The Next.js app served at `https://ekgf.org/dprod` (proxied verbatim from the
`ekgf-website` zone) and at `https://dprod.ekgf.vercel.app`.

## Spec version discovery

`src/lib/spec-versions.ts` builds the list behind `/spec-versions` and the
`/spec/<slug>` routing in `middleware.ts`. It combines two sources:

| Source | Question it answers | Env vars |
|---|---|---|
| Vercel deployments API | Which branches have a READY deployment? | `VERCEL_TOKEN`, `VERCEL_PROJECT_ID`, `VERCEL_ORG_ID` |
| GitHub branches API | Which of those branches still exist? | `GITHUB_TOKEN` |

Both are cached for 60 seconds via `next: { revalidate: 60 }`, so a new branch
appears without redeploying `develop`.

A branch deleted from `origin` drops off the listing, **including `ballot/*`**.
Its Vercel deployment survives and `/spec/<slug>` still resolves, so any URL
already published keeps working — it is simply no longer advertised. This is
deliberate (issue #249): the listing answers "which versions exist now?", and
a deleted branch does not. If a ballot needs to stay listed after its branch is
gone, keep the branch on `origin` rather than special-casing it here.

There are two entry points, and the difference matters:

- **`getSpecVersions()`** — everything routable. Permissive on purpose: the
middleware resolves explicit `/spec/<slug>` URLs with it, and a URL someone
already holds should keep working even when GitHub is unreachable.
- **`getListedSpecVersions()`** — what a reader is shown. Fails **closed**: if
branch existence cannot be determined, it lists only the archive and
`develop`.

## `GITHUB_TOKEN`

Required, and it must be **valid** — an expired or revoked token behaves
exactly like a missing one. Fine-grained tokens expire, so this will recur.

A fine-grained token needs **`Contents: Read-only`** on `EKGF/dprod` — that is
the permission `GET /repos/{owner}/{repo}/branches`
[requires](https://docs.github.com/en/rest/branches/branches). GitHub adds the
mandatory `Metadata: Read-only` automatically. No write permissions, and no
Pull requests permission: the lookup does not use that endpoint.

The repository is public, so the endpoint would also answer an unauthenticated
request — but that path carries GitHub's 60-requests-per-hour-per-IP limit,
shared across Vercel's egress, which is exactly the failure mode being avoided.
Granting `Contents: Read-only` is what makes the request authenticated, at
5,000 requests per hour.

See `.env.example`.

When the branch lookup fails, two things happen and neither is silent:

- the reason is logged with `console.warn`, including GitHub's own message —
`Bad credentials` for an expired or revoked token, a rate-limit message when
running unauthenticated, `Resource not accessible` for a missing scope;
- the page renders a "Branch list unavailable" notice.

This matters because issue #249 was caused by the *absence* of both. The
picker advertised 22 branches, 11 of them deleted months earlier, while only 6
pull requests were open — and nothing anywhere reported that the filter had
stopped working. The token was configured the whole time; it simply was not
being accepted, which the old code could not distinguish from success.
29 changes: 24 additions & 5 deletions site/src/app/spec-versions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight, CheckCircle2, Clock, Archive } from "lucide-react";
import { getSpecVersions, type SpecVersion } from "@/lib/spec-versions";
import { getListedSpecVersions, type SpecVersion } from "@/lib/spec-versions";

export const metadata: Metadata = {
title: "Spec Versions — DPROD",
Expand Down Expand Up @@ -48,7 +48,10 @@ function BadgeFor({ version }: { version: SpecVersion }) {
}

export default async function SpecVersionsPage() {
const versions = await getSpecVersions();
// Deliberately the *listed* set, not the routable one: a branch that no
// longer exists must not be advertised, even though its preview URL still
// resolves. See issue #249.
const { versions, complete } = await getListedSpecVersions();

return (
<div className="flex flex-col">
Expand All @@ -74,6 +77,20 @@ export default async function SpecVersionsPage() {
<section>
<div className="container py-20">
<div className="mx-auto max-w-4xl">
{!complete && (
<div className="mb-6 rounded-lg border border-[#ff6f00]/40 bg-[#ff6f00]/5 p-4 text-sm">
<p className="font-semibold text-foreground">
Branch list unavailable
</p>
<p className="mt-1 text-muted-foreground">
The GitHub lookup that checks which branches still exist did
not succeed, so only the archive and the production draft are
listed. In-flight preview branches are hidden rather than
shown unverified. The deployment logs record the reason.
</p>
</div>
)}

<div className="space-y-4">
{versions.map((version) => {
const href =
Expand Down Expand Up @@ -119,9 +136,11 @@ export default async function SpecVersionsPage() {
Every branch in the repository automatically gets its own
Vercel preview deployment. This page queries the Vercel API at
request time (cached for 60&nbsp;seconds) so new branches show
up without needing to redeploy <code>develop</code>. Each
version link routes through Next.js middleware to the correct
deployment&apos;s own <code>/spec/</code> page.
up without needing to redeploy <code>develop</code>, and
cross-checks GitHub so that branches which have since been
deleted drop off the list. Each version link routes through
Next.js middleware to the correct deployment&apos;s own{" "}
<code>/spec/</code> page.
</p>
</div>
</div>
Expand Down
Loading