The arms report their verdict in the exit code, and run on a staged tree
The reviewer's findings on cf5625d, with the two that could have shipped a broken launch first.
An arm's shutdown put process.exit(code) inside an unref'd timer, so a run whose event loop emptied before the timer fired exited 0 with FAIL lines on the screen. The live check after the publish is exactly such a run, and a gate reading its exit code would have called a red site green. Every arm now sets process.exitCode as the first thing shutdown does. Proved both ways: the arm from cf5625d, given a planted failure, prints "1 failed" and exits 0; the same plant against the fixed arm exits 1. test/test-arm-exit.mjs is that plant, kept.
publish-web only checked the build's stamp when CRASH_WEB_DIR named the tree, and the deploy is tagged with HEAD either way, so the default path could publish yesterday's bytes under today's commit. The dirty and not-HEAD refusals now apply to both paths; it refuses build/web here today, which is correct, since the tree moved after that build.
verify.mjs could not run against a staged tree since the wasm moved to R2: the imports leg read the file beside the page. It now falls back to w/<sha16>/crash-the-stack.wasm, the path stage-web writes and the page asks for. The screens leg also insisted the top screen had no NEWS row, which is true of build/web and false of the staged tree and the deploy, where the feed sits beside the game; both shapes pass now.
The smaller ones: ?news= took a feed from any origin, so a shared link could have put another site's titles and links in the game's menu, and now only this origin is read (the arm's fixture is same-origin); the tracker page links its manual; the tracker manual's tune list gains fault-line; and NOTICE says what it covers, since it lists what the binaries link rather than what sigil.lock resolves, which is why sigil-sqlite is absent (no sqlite symbol in either binary).
NOTICE | 12 ++++++++----
docs/tracker.md | 2 +-
index.template.html | 11 ++++++++++-
scripts/publish-web | 18 +++++++++---------
test/test-arm-exit.mjs | 40 ++++++++++++++++++++++++++++++++++++++++
tracker.template.html | 1 +
verify-cards.mjs | 3 +++
verify-field.mjs | 3 +++
verify-guard.mjs | 3 +++
verify-site.mjs | 3 +++
verify.mjs | 22 +++++++++++++++++++---
11 files changed, 100 insertions(+), 18 deletions(-)NOTICEmodified
(assets/LICENSE) except the reserved marks in assets/RESERVED.md.The shipped binaries (the native builds and the web build) contain thefollowing third-party software. Each entry names the library, itslicence, the copyright holders as their own files state them, and whereit enters the build. The licence texts are in the named dependencies'checkouts (sigil deps install fetches them; the paths are relative tofollowing third-party software. It lists what is linked in, not whatsigil.lock resolves: the lock names every package the toolchain canreach, and some are never linked. sigil-sqlite is the example (no sqlitesymbol appears in either binary), so it is not listed here.Each entry names the library, its licence, the copyright holders astheir own files state them, and where it enters the build. The licencetexts are in the named dependencies' checkouts (sigil deps install fetches them; the paths are relative toeach dependency's root).== Sigil and its packages ==docs/tracker.mdmodified
`groove`, `breaker`, `black-glass`, `black-glass-title`,`basement-circuit`, `blind-spot`, `breach-vector`, `clock-edge`,`closed-loop`, `cold-boot`, `dead-sector`, `dirty-cache`,`glass-current`, `obsidian-index`, `quiet-array`, `relay-ghost`,`fault-line`, `glass-current`, `obsidian-index`, `quiet-array`, `relay-ghost`,`sector-drift` and `shadow-protocol`, with `demo-columns`, which touchesevery column, every effect and every instrument.index.template.htmlmodified
// layout (the game under /jack-in/); a dev host serving build/web at // the root has none, and a fetch there would only log a 404 var beside = /\/jack-in\/$/.test(location.pathname.replace(/index\.html$/, "")); var url = params.get("news") || (beside ? "../devlog/feed.json" : null); // ?news= is the arm's door, so it may only name this origin: a shared // link that pointed the menu's NEWS at another site's feed would put // that site's titles and links in the game (reviewer, 2026-09-23) var asked = params.get("news"); if (asked) { var sameOrigin = false; try { sameOrigin = new URL(asked, location.href).origin === location.origin; } catch (e) { sameOrigin = false; } if (!sameOrigin) { console.log("crash: news refused " + asked); return; } } var url = asked || (beside ? "../devlog/feed.json" : null); if (!url) { console.log("crash: news none"); return; } fetch(url, { cache: "no-cache" }).then(function (r) { if (!r.ok) throw new Error("news " + r.status);scripts/publish-webmodified
[ -f "$WEB/index.html" ] || { echo "publish-web: $WEB/index.html missing; build --config web (or point CRASH_WEB_DIR at a built tree)"; exit 1; }STAMP="$(grep -o 'version: "[^"]*"' "$WEB/index.html" | head -1 | cut -d'"' -f2)"HEAD_SHA="$(git rev-parse --short=12 HEAD)"if [ -n "${CRASH_WEB_DIR:-}" ]; then case "$STAMP" in *-dirty-*) echo "publish-web: $WEB was built from a dirty tree (stamp $STAMP); publish a tree built from a commit"; exit 1 ;; esac [ "$STAMP" = "$HEAD_SHA" ] || { echo "publish-web: $WEB was built at $STAMP, HEAD is $HEAD_SHA; build the commit you are publishing"; exit 1; } echo "publish-web: source tree $WEB, built at $STAMP (= HEAD)"else echo "publish-web: source tree $WEB, built at $STAMP (HEAD $HEAD_SHA)"fi# Whichever tree it is: a deploy is tagged with HEAD, so a tree built from# a dirty working copy, or built at another commit, would publish bytes the# tag does not describe. The default path is the one that needs this most:# build/web is the directory someone reaches for in a hurry.case "$STAMP" in *-dirty-*) echo "publish-web: $WEB was built from a dirty tree (stamp $STAMP); publish a tree built from a commit"; exit 1 ;;esac[ "$STAMP" = "$HEAD_SHA" ] || { echo "publish-web: $WEB was built at $STAMP, HEAD is $HEAD_SHA; build the commit you are publishing"; exit 1; }echo "publish-web: source tree $WEB, built at $STAMP (= HEAD)"STAGE="$(mktemp -d /tmp/crash-publish-XXXXXX)"trap 'rm -rf "$STAGE"' EXITtest/test-arm-exit.mjsadded
// The arms must exit non-zero when a leg fails, or a gate that runs them// reports success on a red. verify-site's shutdown put process.exit(code)// inside an unref'd timer, so a run whose event loop emptied first exited// 0 with FAIL lines on screen (found in review, 2026-09-23; five of five// runs). Every arm now sets process.exitCode before that timer.//// node test/test-arm-exit.mjs [STAGED]//// It stages a copy of the tree (default build/hosted-site), plants one// failure a leg is certain to catch, runs verify-site against the copy and// asserts a non-zero exit with that leg's FAIL line. A planted failure is// the only honest way to test this: a green run exits 0 either way.import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process";const STAGED = process.argv[2] || "build/hosted-site";const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");const staged = path.resolve(ROOT, STAGED);if (!fs.existsSync(path.join(staged, "soundtrack", "style.css"))) { console.log(`SETUP-FAILED: ${staged} is not a staged tree (scripts/stage-web builds one)`); process.exit(2);}const tmp = fs.mkdtempSync("/tmp/crash-arm-exit-");const copy = path.join(tmp, "staged");fs.cpSync(staged, copy, { recursive: true });// the plant: the soundtrack leg asserts the site's palette in the page's stylesheetconst css = path.join(copy, "soundtrack", "style.css");fs.writeFileSync(css, fs.readFileSync(css, "utf8").replace("--bg: #0d0a1a", "--bg: #ffffff"));const run = spawnSync("node", ["verify-site.mjs", copy, "--port", "8175", "--cdp", "9275"], { cwd: ROOT, encoding: "utf8", timeout: 900000 });fs.rmSync(tmp, { recursive: true, force: true });const out = (run.stdout || "") + (run.stderr || "");const failed = /^FAIL soundtrack: /m.test(out);const result = /^RESULT: \d+ passed, (\d+) failed/m.exec(out);let detail = "";if (!result) detail = "the arm printed no RESULT line";else if (result[1] === "0") detail = `the planted failure was not caught: ${result[0]}`;else if (!failed) detail = "a leg failed, but not the soundtrack leg the plant targets";else if (run.status === 0) detail = `the arm reported ${result[0]} and still exited 0`;else if (run.status !== 1) detail = `the arm exited ${run.status}, not 1`;if (detail) { console.log(`FAIL arm-exit: ${detail}`); process.exit(1); }console.log(`PASS arm-exit: a planted failure gives "${result[0]}" and exit ${run.status}`);tracker.template.htmlmodified
mount and script tag at the marker and stamps __VERSION__. --> <link rel="icon" href="../assets/icon-192.png"> <link rel="stylesheet" href="styles.css"> <link rel="help" href="../docs/tracker/"> <style>#stage:focus { outline: none; }</style></head><body>verify-cards.mjsmodified
let exiting = false;function shutdown(code) { if (exiting) return; exiting = true; // the verdict first: the exit below sits in an unref'd timer, so a run whose // loop empties before it fires exits naturally, and without this it exits 0 process.exitCode = code; try { server.close(); } catch { /* not listening */ } killChromeGroup("SIGTERM"); let tries = 0;verify-field.mjsmodified
let exiting = false;function shutdown(code) { if (exiting) return; exiting = true; // the verdict first: the exit below sits in an unref'd timer, so a run whose // loop empties before it fires exits naturally, and without this it exits 0 process.exitCode = code; try { server.close(); } catch { /* not listening */ } killChromeGroup("SIGTERM"); let tries = 0;verify-guard.mjsmodified
let exiting = false;function shutdown(code) { if (exiting) return; exiting = true; // the verdict first: the exit below sits in an unref'd timer, so a run whose // loop empties before it fires exits naturally, and without this it exits 0 process.exitCode = code; try { server.close(); } catch { /* not listening */ } killChromeGroup("SIGTERM"); let tries = 0;verify-site.mjsmodified
let exiting = false;function shutdown(code) { if (exiting) return; exiting = true; // the verdict first: the exit below sits in an unref'd timer, so a run whose // loop empties before it fires exits naturally, and without this it exits 0 process.exitCode = code; try { server.close(); } catch { /* not listening */ } killChromeGroup("SIGTERM"); setTimeout(() => { killChromeGroup("SIGKILL"); try { fs.rmSync(udd, { recursive: true, force: true }); } catch { /* scratch */ } process.exit(code); }, 1500).unref();verify.mjsmodified
// ---- 1. imports, from the file on disk (no browser needed) -----------------{ const wasmPath = path.join(GAME, "crash-the-stack.wasm"); if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: ${wasmPath} missing`); process.exit(2); } // build/web keeps the wasm beside the page; a staged tree has it at // w/<sha16>/crash-the-stack.wasm, where scripts/wasm-to-r2 put it and the // page's data-wasm points (the deploy serves that path from R2). Read // whichever is there, so this arm runs on either tree. let wasmPath = path.join(GAME, "crash-the-stack.wasm"); if (!fs.existsSync(wasmPath)) { const hashed = path.join(GAME, "w"); const dir = fs.existsSync(hashed) ? fs.readdirSync(hashed).find((h) => fs.existsSync(path.join(hashed, h, "crash-the-stack.wasm"))) : null; if (dir) wasmPath = path.join(hashed, dir, "crash-the-stack.wasm"); } if (!fs.existsSync(wasmPath)) { console.log(`SETUP-FAILED imports: no crash-the-stack.wasm under ${GAME} (beside the page, or at w/<sha16>/)`); process.exit(2); } const mod = new WebAssembly.Module(fs.readFileSync(wasmPath)); const byModule = {}; for (const imp of WebAssembly.Module.imports(mod)) (byModule[imp.module] = byModule[imp.module] || []).push(imp.name);let exiting = false;function shutdown(code) { if (exiting) return; exiting = true; // the verdict first: the exit below sits in an unref'd timer, so a run whose // loop empties before it fires exits naturally, and without this it exits 0 process.exitCode = code; try { server.close(); } catch { /* not listening */ } killChromeGroup("SIGTERM"); let tries = 0; "free-cards": "new-board daily-board version back", settings: "music sfx volume scanlines veil background back", credits: "", scores: "back", code: "back" }; // the credits crawl has no rows: Escape or a tap leaves if (r.top.order.join(" ") !== "continue jack-in free-play settings credits") detail = `the pause menu's rows are ${r.top.order.join(" ")}`; // NEWS joins the top screen wherever a feed sits beside the game, which // is the staged tree and the deploy but not build/web (index.template's // fetch is scoped to /jack-in/): both shapes are right, so allow it const topRows = r.top.order.join(" "); if (topRows !== "continue jack-in free-play settings credits" && topRows !== "continue jack-in free-play settings credits news") detail = `the pause menu's rows are ${topRows}`; // the sub-screens' first row sits where the main menu's does (David, 2026-09-22), pulled up only when the rows would cross the band const rowsFrom = (s) => s.rows[s.order[0]].y - 14; // the row's center less half its height const rowsWant = (s) => Math.max(124, Math.min(168, 380 - s.order.length * 32));