catalog-currency: a bundle missing from the catalog now fails instead of passing
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
Card 1 Step 1 asks that the merge lose nothing. No gate owned that. A local bundle that 404s live was counted "unreachable" and skipped, and unreachable never gated the exit, so a catalog with an entire plugin deleted passed: CATALOG-CURRENCY: 17 current - 0 STALE - 1 unreachable (of 18 local bundles) CATALOG-CURRENCY: PASS <- exit 0 Proven by deleting one bundle from a copy of the catalog and pointing the gate at it. The hole fell between two checks that each answered their own question correctly: identity proves the live bytes are self-consistent, and currency proves they are the same build; neither asked whether anything was absent. The tell was in the PASS text itself -- "every REACHABLE live bundle". A qualifier inside a success message describes what a check does not cover. MISSING is now distinguished from UNREACHABLE. A 4xx is the server stating the file is not there, which is a publish defect and fails. A connection error or timeout is an environment condition and still counts as unreachable, so an offline box still SKIPs and never fails a build. Negative-tested four ways: the real catalog still PASSES (18/18, 0 MISSING); a deleted bundle now FAILS exit 1 naming the exact URL probed; a tampered bundle still FAILS as STALE; offline still SKIPs. Also fixed a reporting bug introduced en route -- the failure printer rebuilt a .js path from the entry name, mangling leaf URLs, and a wrong URL in an error message sends the reader hunting for a file that was never requested.
This commit is contained in:
parent
190d89846b
commit
2a1e55a0e8
1 changed files with 68 additions and 10 deletions
|
|
@ -15,12 +15,19 @@
|
|||
*
|
||||
* check:catalog-identity is the live catalog consistent WITH ITSELF? (tamper / corruption)
|
||||
* check:catalog-currency is the live catalog the SAME BUILD as ours? (staleness)
|
||||
* …and, since 2026-09-22, is anything we built ABSENT from it? (loss)
|
||||
*
|
||||
* Neither subsumes the other. A tampered catalog fails identity and passes currency; a stale one
|
||||
* passes identity and fails currency.
|
||||
* Neither of the first two subsumes the other. A tampered catalog fails identity and passes
|
||||
* currency; a stale one passes identity and fails currency.
|
||||
*
|
||||
* SKIPs (exit 0) when the catalog is unreachable — an offline box must not fail a build. Only
|
||||
* "reached it and it is behind" is a failure.
|
||||
* ★ THE THIRD QUESTION USED TO BELONG TO NOBODY. A bundle that 404s live was counted "unreachable"
|
||||
* and skipped, and unreachable never gated the exit — so a catalog with an ENTIRE PLUGIN DELETED
|
||||
* passed with exit 0 (proven by experiment; see the MISSING ≠ UNREACHABLE note below). That is the
|
||||
* exact guarantee Card 1 Step 1 asks for — "merge all bundles so nothing is LOST" — and it fell
|
||||
* between two gates that each answered their own question correctly.
|
||||
*
|
||||
* SKIPs (exit 0) only when the catalog could not be REACHED AT ALL — an offline box must not fail
|
||||
* a build. A 4xx is not an offline condition: it is the server telling us the file is not there.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
|
@ -65,16 +72,43 @@ const fetchBuf = async (url) => {
|
|||
} finally { clearTimeout(t) }
|
||||
}
|
||||
|
||||
const stale = []; const matched = []; let unreachable = 0
|
||||
// ── MISSING ≠ UNREACHABLE ─────────────────────────────────────────────────────────────────────
|
||||
// ★ THE HOLE THIS CLOSES (measured 2026-09-22). Every local bundle that failed to fetch used to be
|
||||
// counted `unreachable` and skipped, and `unreachable` never gated the exit. PROVEN by deleting one
|
||||
// whole bundle from a copy of the catalog and pointing this gate at it:
|
||||
// CATALOG-CURRENCY: 17 current · 0 STALE · 1 unreachable (of 18 local bundles)
|
||||
// CATALOG-CURRENCY: PASS ← exit 0, with an entire plugin missing from the catalog
|
||||
// That is precisely the failure Card 1 Step 1 exists to prevent ("merge all bundles so nothing is
|
||||
// LOST"), and no gate owned it: catalog-identity proves the live bytes are self-consistent, this
|
||||
// check proves they are the same BUILD — neither asked "is anything absent?". The tell was in the
|
||||
// PASS text itself: "every REACHABLE live bundle". A qualifier inside a success message is a
|
||||
// description of what the check does not cover.
|
||||
//
|
||||
// A 404 is a STATEMENT FROM THE SERVER that the bundle is not there — that is a publish defect, not
|
||||
// a network condition, so it FAILS. A connection error / timeout / DNS failure is an environment
|
||||
// condition and still counts as unreachable, preserving the offline SKIP below (an offline box must
|
||||
// never fail a build).
|
||||
const stale = []; const matched = []; const missing = []; let unreachable = 0
|
||||
for (const [bundle, wantIntegrity] of localBundles) {
|
||||
const { buf, err } = await fetchBuf(`${BASE}/${bundle}/${bundle}.js`)
|
||||
if (err) { unreachable++; continue }
|
||||
if (err) {
|
||||
// Record the URL that was actually probed. An earlier version stored only a name and let the
|
||||
// printer rebuild the path, which produced a mangled URL for leaf entries
|
||||
// (…/index.html/…/index.html.js) — a wrong URL in a failure message sends the reader hunting
|
||||
// for a file that was never requested.
|
||||
if (/^HTTP 4\d\d/.test(err)) missing.push({ bundle, url: `${BASE}/${bundle}/${bundle}.js`, err })
|
||||
else unreachable++ // the network says it couldn't ask
|
||||
continue
|
||||
}
|
||||
const got = sha384(buf)
|
||||
if (got === wantIntegrity) matched.push(bundle)
|
||||
else stale.push({ bundle, want: wantIntegrity.slice(0, 22), got: got.slice(0, 22) })
|
||||
}
|
||||
|
||||
if (unreachable === localBundles.size) {
|
||||
// OFFLINE SKIP. Only when NOTHING could be asked — i.e. every bundle failed at the network layer
|
||||
// and none came back as a 4xx. A single 404 means the server IS answering, so we are online and a
|
||||
// missing bundle is a real finding, not an environment condition.
|
||||
if (unreachable === localBundles.size && missing.length === 0) {
|
||||
console.log(`CATALOG-CURRENCY: SKIP — catalog unreachable at ${BASE} (${unreachable} bundle(s)). Offline is not a failure.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
|
@ -94,7 +128,13 @@ for (const b of local.bundles || []) {
|
|||
const localPath = join(ROOT, 'public', 'doxa-maps', rel)
|
||||
if (!existsSync(localPath)) continue
|
||||
const { buf, err } = await fetchBuf(`${BASE}/${rel}`)
|
||||
if (err) { leafUnreachable++; continue }
|
||||
// Same MISSING-vs-UNREACHABLE split as the bundle loop above: a 4xx is the server telling us the
|
||||
// leaf page is not published, which is a loss; anything else is an environment condition.
|
||||
if (err) {
|
||||
if (/^HTTP 4\d\d/.test(err)) missing.push({ bundle: rel, url: `${BASE}/${rel}`, err })
|
||||
else leafUnreachable++
|
||||
continue
|
||||
}
|
||||
const want = sha384(readFileSync(localPath))
|
||||
const got = sha384(buf)
|
||||
if (want === got) leafMatched++
|
||||
|
|
@ -104,7 +144,25 @@ console.log(`CATALOG-CURRENCY: leaves — ${leafMatched} current · ${leafStale.
|
|||
for (const s of leafStale) console.error(` ✗ ${s.rel}\n local ${s.want}…\n live ${s.got}…`)
|
||||
stale.push(...leafStale.map(s => ({ bundle: s.rel, want: s.want, got: s.got })))
|
||||
|
||||
console.log(`CATALOG-CURRENCY: ${matched.length} current · ${stale.length} STALE · ${unreachable} unreachable (of ${localBundles.size} local bundles)`)
|
||||
console.log(`CATALOG-CURRENCY: ${matched.length} current · ${stale.length} STALE · ` +
|
||||
`${missing.length} MISSING · ${unreachable} unreachable (of ${localBundles.size} local bundles)`)
|
||||
|
||||
// MISSING is reported before STALE because it is the worse finding: a stale bundle serves old code,
|
||||
// an absent one serves nothing at all, and the remedy differs (re-publish vs. work out what dropped
|
||||
// it from the merge).
|
||||
if (missing.length) {
|
||||
console.error(`\nCATALOG-CURRENCY: FAIL — ${missing.length} locally-built bundle(s) are ABSENT from the live catalog:`)
|
||||
for (const m of missing) console.error(` ✗ ${m.bundle}\n ${m.url} → ${m.err}`)
|
||||
console.error(
|
||||
'\n Something was LOST between the build and the catalog — the merge dropped it, or the publish\n' +
|
||||
' never included it. This is the guarantee Card 1 Step 1 asks for ("merge all bundles so nothing\n' +
|
||||
' is lost"). Re-run the publish and assert the resulting bundle SET, not just the changed files:\n' +
|
||||
' rsync -a --delete public/doxa-maps/ <catalog>/app/doxa-maps/ # --delete mirrors removals\n' +
|
||||
' then confirm every local bundle name appears live before pushing.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (stale.length) {
|
||||
for (const s of stale) console.error(` ✗ ${s.bundle}\n local ${s.want}…\n live ${s.got}…`)
|
||||
console.error('\n The live catalog is BEHIND this build. Publish per')
|
||||
|
|
@ -112,4 +170,4 @@ if (stale.length) {
|
|||
console.error(' (rsync public/doxa-maps/ into the catalog, then push with -c http.postBuffer=524288000).')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('CATALOG-CURRENCY: PASS — every reachable live bundle matches this build.')
|
||||
console.log('CATALOG-CURRENCY: PASS — every local bundle is present live and matches this build.')
|
||||
|
|
|
|||
Loading…
Reference in a new issue