Menu

Our CI builds the site with none of its keys, on purpose

2026-09-23MCP Checkup

For a while our continuous integration ran two checks: type-check everything, run every test. Both green meant merge. Then a test file was accidentally created inside the web app’s pages/ directory. TypeScript was happy; it was valid TypeScript. The test runner was happy; it ran and passed. And the production build would have failed at the prerender step, because the framework treats everything under pages/ as a route and tried to render a test as a page.

Nothing in CI could have caught it, because nothing in CI actually built the site.

The fix is boring: build the site

CI now runs the real build command, from a clean checkout, on every pull request. The build step does not generate a deployment or upload anything, and it is not allowed to fail quietly: its result is the build’s exit code, nothing else. Five known warnings about font assets that “didn’t resolve at build time” are printed and ignored, deliberately, with a TODO pointing at them; we don’t grep the log for “WARN” because a warning grep is the kind of gate that flips from useful to noisy the first time an upstream package changes its wording.

The interesting decision was what the build is not given.

No secrets, no site configuration

CI gives the build none of the site’s keys: no public API key, no secret, nothing; the only site values it can see are the two non-secret ones committed in the Worker’s config file. That is not laziness; it is the enforcement of a rule that had been true in principle and is now enforced by the build: no route that is rendered at build time may read from the database.

The site has two kinds of routes. Prerendered ones are turned into static HTML during the build. Server-rendered ones run per request in a Worker, and on the server side those are the only ones allowed to open a database client, which they do lazily, at request time. (Browser-side islands, such as the account link in the header, open their own client in the visitor’s browser; that is a different client, and it is not what this rule is about.) If any prerendered route were to slip an unguarded database call into its build-time code, the build would fail in CI with a missing-configuration error before anyone saw it in production. A guarded call, one wrapped in the graceful-degradation pattern the rest of the site uses, would build green with an empty section instead, so the absence of credentials is a strong test, not a complete one.

This mattered once already. An earlier version of the build had a pre-step that generated the sitemap by querying the database for every report page. It worked, and it meant the build could not run without production read access, which meant CI either got credentials it had no business holding or skipped the build. We replaced it with a sitemap route that is server-rendered on request, reads the route list the framework itself produces, and fails loudly with a 500 rather than serving an empty sitemap if that list is missing. The CI build lost its only excuse to hold a key.

The deploy build is a different matter. It inlines public values into the browser bundle, among them the Supabase URL and the anonymous key, because the sign-in page needs them and a static bundle cannot receive them at runtime. None is a secret. The deploy script checks that they made it into the bundle before it deploys, and CI, which never deploys, never sees the key.

Run the build, not the deploy script

One more detail that reads as pedantry until it saves you. Our package has a build script and a deploy script, and the deploy script ends with the command that pushes a new Worker version to production. CI invokes the framework’s build command directly, not the package script, so that no future reshuffling of those two scripts can ever make a pull-request check deploy anything. The comment above that line in the workflow file explains this, at some length, because a future reader will be tempted to “simplify” it.

What we got

A green pull request now means: it type-checks, its tests pass, and the site it describes can actually be built into the artefact that gets deployed, without access to anything a public CI runner shouldn’t have. The third clause is the one that was missing, and it is the only one of the three that resembles what happens at deploy time.