Skip to main content
System.Return('/blog')
3 min read

Build Attestation: One build.json Feeds the Footer, /status, and /api/health

scripts/generate-build-info.mjs hashes every content file into src/generated/build.json before astro build; Footer.astro, status.astro, and api/health.ts all read that one file — counts never drift because there is exactly one place they can come from.

The footer ticker, the /status page, and /api/health all agree with each other on this site. That is not luck. There is exactly one place their numbers can come from, and the build writes it before Astro runs.

The source: scripts/generate-build-info.mjs

Runs via predev, prebuild, and pretest (package.json), so every dev session, build, and test run re-attests. It writes src/generated/build.json:

{
  "commit": "344f80e70c752e5ba4a4edbc98f6e7558d6b47aa",
  "shortSha": "344f80e",
  "buildTime": "2026-10-08T22:43:35.265Z",
  "counts": { "blog": 8, "work": 6, "uses": 11, "now": 1 },
  "contentHash": "7c6134dc…",
  "contentFiles": 29
}

Collections attested (COLLECTIONS = ['blog', 'work', 'uses', 'now']) must match the loaders in src/content.config.ts. The hash is SHA-256 over every .md/.mdx content file. A content edit without a rebuild shows up as a hash mismatch, not a silent stale count.

Only non-secret facts are recorded (SHA, time, counts, hash). Env keys are never read here.

The three readers

1. src/components/Footer.astro (the ticker). Imports build.json, renders {shortSha}, {contentFiles} artifacts, and a /status link titled with the full commit. Build time is formatted en-GB GST. No fetch, no client JS. The numbers are baked at build.

2. src/pages/status.astro (the human page). Same import, full rendering: commit, build time, per-collection counts, content hash.

3. src/pages/api/health.ts (the machine endpoint). Same import again. Compares live collection counts against the attested EXPECTED_COUNTS (read from the same file, never hardcoded), checks the commit is a 40-hex SHA, and reports env keys as presence booleans only:

const PRESENCE_KEYS = ['GROQ_API_KEY', 'RESEND_API_KEY', 'TURNSTILE_SECRET_KEY'] as const;
// values never read, never leave the server — response carries booleans only

prerender = false is required. Static mode rejects server routes. Healthy returns 200 with {status: 'ok', build, content, services}; any mismatch returns 500 with 'degraded'.

Why counts never drift

The failure mode this kills: hardcoded expected counts in the health check that someone forgets to bump when a post ships, so /api/health pages on a correct deploy. Here EXPECTED_COUNTS and the served counts are the same object from the same file. The comparison is counts[k] !== expected where both sides come from build.json. A miscount is impossible by construction; only a stale build.json (skipped prebuild) can disagree, and that fails loudly as degraded.

Honest limitations

  • build.json is generated, not committed as truth. Whatever is on disk at build time wins. A dirty checkout builds a dirty attestation with the right SHA of the wrong tree. The commit field says what HEAD was, not what files were.
  • Health checks itself, not production. /api/health reads the file it shipped with; it cannot detect that Vercel is serving last week’s deploy. Only verify-prod-csp.mjs-style polling against the live URL closes that loop, and there is no equivalent live-content check.
  • Env presence ≠ env validity. A set-but-expired GROQ_API_KEY reports true. The endpoint attests configuration presence, not credential health.

Anyone shipping a content-driven site should care, because the failure this kills is silent and embarrassing: the footer says one count, /status says another, and /api/health pages on a deploy that was actually correct, usually discovered by a reader rather than by you.

Related: the slim-CI post (the pipeline that runs these gates), the uses entry for this stack, and the live surfaces themselves: /status and the footer ticker on every page.

Share this log: Share on X Share on LinkedIn Share on Reddit

Related logs

↑↓ navigate Tab complete Enter open Esc close View all →