#!/usr/bin/env node /** * check-published-matches-build — what readers are served must be what we built. * * THE DEFECT THIS CATCHES, and it has cost more agent-hours than any bug in the tree. * Every gate in this repo measures the LOCAL build. Nothing measured the PUBLISHED one. So the * whole board can be green for weeks while readers are served a months-old bundle, and the only * detector is a human noticing something looks wrong. * * MEASURED 2026-09-24: live my-upg-100-list.js was 485,665 bytes against 678,837 built — 193KB and * many merges behind. Live LACKED the legend tab labels, LACKED the a11y keys, LACKED * v3.24.0/mapbox-gl.css and still HAD v3.3.0. Every other bundle was stale too (country-posters * -194KB, research-dashboard -98KB, gospel-resources larger than local, i.e. not even the same * lineage). * * ★ THE EXPENSIVE PART IS NOT THE STALENESS, IT IS THE PHANTOM BUGS. A stale publish manufactures * bug reports that cannot be reproduced. Three separate reports this week — print buttons * truncated to "Pr...", the legend showing "Generation 1/2/3" instead of the real tab names, the * pin hint saying "red dot" instead of "coloured dot" — were ALL fixed in main and ALL still * visible live. Each read as a regression against a card marked done. Two of them consumed most of * a tick to diagnose, and the diagnosis was the same sentence every time. * * ⚠ IT COMPARES THE SRI HASH, NOT THE BYTE SIZE. manifest.json already carries a sha384 per * bundle, which is the identity the browser itself enforces; two different builds can coincide in * length, and a size check would call that a match. Size is reported only to make a mismatch * legible ("193KB behind" reads better than two hashes). * * ⚠ IT IS A NETWORK GATE, so it SKIPs rather than fails when the published host is unreachable. * A gate that goes red on someone's flaky wifi teaches people to ignore it. Absence of evidence is * reported as a SKIP with the reason. * * Usage: node internal/check-published-matches-build.mjs [bundle ...] * PUBLISHED_BASE= where the published tree lives * Defaults to every bundle in the local manifest. */ import { readFileSync, existsSync, statSync } from 'node:fs' import { createHash } from 'node:crypto' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { TREE_NAME } from './_tree-name.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const REPO = process.env.DOXA_REPO || resolve(HERE, '..') const PUBLISHED_BASE = (process.env.PUBLISHED_BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}`).replace(/\/$/, '') const TREE = resolve(REPO, `public/${TREE_NAME}`) const MANIFEST = join(TREE, 'manifest.json') if (!existsSync(MANIFEST)) { console.log(`PUBLISHED-MATCHES-BUILD: SKIP — no local manifest at ${MANIFEST}.`) console.log(' Build the tree first (bun run tree). Absence is not a pass.') process.exit(0) } const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) const all = (manifest.bundles || []).filter((b) => b.bundle && b.output) const picked = process.argv.slice(2).length ? all.filter((b) => process.argv.slice(2).includes(b.bundle)) : all if (!picked.length) { console.log('PUBLISHED-MATCHES-BUILD: SKIP — no bundles selected from the manifest.') process.exit(0) } const sri = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64') const kb = (n) => (n / 1024).toFixed(0) + 'KB' // One reachability probe before the sweep. If the host is down we SKIP once, rather than // reporting every bundle as a separate failure of the same cause. let hostUp = false try { const r = await fetch(PUBLISHED_BASE + '/manifest.json', { signal: AbortSignal.timeout(15000) }) hostUp = r.ok || r.status === 404 // 404 still proves the host answers } catch { /* handled below */ } if (!hostUp) { console.log(`PUBLISHED-MATCHES-BUILD: SKIP — ${PUBLISHED_BASE} is not answering.`) console.log(' This gate needs the network. Unreachable is NOT a pass: it means the published') console.log(' tree was never compared, so a stale publish would go unnoticed right now.') process.exit(0) } console.log('PUBLISHED-MATCHES-BUILD') console.log(` published : ${PUBLISHED_BASE}`) console.log(` built : ${TREE}`) const stale = [] const unreachable = [] let matched = 0 for (const b of picked) { const localPath = resolve(REPO, 'public', b.output) if (!existsSync(localPath)) { unreachable.push(`${b.bundle}: no local build at ${b.output}`); continue } const localBuf = readFileSync(localPath) const localHash = sri(localBuf) const url = `${PUBLISHED_BASE}/${b.bundle}/${b.bundle}.js` let liveBuf = null try { const r = await fetch(url, { signal: AbortSignal.timeout(30000) }) if (!r.ok) { unreachable.push(`${b.bundle}: published copy returned HTTP ${r.status}`); continue } liveBuf = Buffer.from(await r.arrayBuffer()) } catch (e) { unreachable.push(`${b.bundle}: fetch failed — ${String(e.message).slice(0, 60)}`) continue } const liveHash = sri(liveBuf) if (liveHash === localHash) { matched++ console.log(` ✓ ${b.bundle} — published copy is the built copy (${kb(localBuf.length)})`) } else { const delta = liveBuf.length - localBuf.length console.log(` ✗ ${b.bundle} — published ${kb(liveBuf.length)} vs built ${kb(localBuf.length)}` + ` (${delta > 0 ? '+' : ''}${kb(delta)})`) stale.push({ bundle: b.bundle, live: liveBuf.length, local: localBuf.length, delta }) } } console.log(`\n ${matched} of ${picked.length} bundle(s) match; ${stale.length} stale; ` + `${unreachable.length} could not be compared`) for (const u of unreachable) console.log(` · ${u}`) if (stale.length) { console.error(`\nPUBLISHED-MATCHES-BUILD: FAIL — ${stale.length} published bundle(s) are NOT what we built.`) for (const s of stale) { console.error(` ✗ ${s.bundle}: readers get ${kb(s.live)}, we built ${kb(s.local)}` + ` (${s.delta > 0 ? '+' : ''}${kb(s.delta)})`) } console.error('\n Every other gate in this repo measures the LOCAL tree, so they stay green while') console.error(' readers are served something else. The expensive part is not the staleness: it is') console.error(' that a stale publish MANUFACTURES BUG REPORTS THAT CANNOT BE REPRODUCED. Three') console.error(' reports this week (truncated print buttons, "Generation 1/2/3" legend tabs, the') console.error(' "red dot" pin hint) were all fixed in main and all still visible live, and each') console.error(' one read as a regression against a card marked done.') console.error('\n FIX: publish. That is a COORDINATOR action — deploy:dev runs a full build whose') console.error(` first step deletes public/${TREE_NAME}/ and tears the tree out from under every`) console.error(' other agent, which is why the build guard refuses it without BUILD_ALLOW=1.') process.exit(1) } console.log('\nPUBLISHED-MATCHES-BUILD: PASS — every compared bundle is byte-identical to the build.')