commit 8885166c47ff136c00b360cd66a1469aac04ede2
parent 8d46c5578545b91b181d8d919a3e0b99cfc419a2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 09:58:51 -0400
doctor: publish-lock, index-stamp, export-builds from built.json bytes, and node against the pinned wrangler's floor
- publish/publish-lock (S1's stageLock.ts): free ok; a live holder on this host
a note; a holder the lock's own rule (holderIsGone) calls gone, or a lock that
has not parsed past the torn grace, a warning with the rm; another host's
lock named, never judged. Never cleared here.
- publish/index-stamp (S1's stamps.ts): the stamp's id, age and generation;
the built targets whose indexStampId is older; no stamp = "update the index
first" (a warning once anything is built or configured).
- export-builds sums each bundle's built.json `bytes`; a bundle with no stamp
is still walked.
- workspace/node also grades against wrangler's engines.node when wrangler is
installed in common/node_modules (a warning below it: every deploy refuses).
- stamps.ts re-exports imageBuildFacts from lib/envVars (the one definition).
4 tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 247 insertions(+), 25 deletions(-)
diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts
@@ -69,6 +69,7 @@ function checkout(): { root: string; bin: string; paths: Paths } {
parakeetCliBin: "parakeet-cli",
configDir: path.join(root, ".config"),
exportBuildsDir: path.join(root, "export", ".export-builds"),
+ exportIndexDir: path.join(root, "export", ".export-index"),
sourceScrubFile: path.join(root, ".config", "source-scrub.txt"),
sourceDenylistFile: path.join(root, ".config", "source-denylist.txt"),
// Outside the checkout, as the XDG cache is: the tests that compare the
@@ -809,3 +810,116 @@ test("source publish: source-repo — the variable naming nothing fails, a reada
assert.match(find(r, "source publish", "source-repo")!.detail, /^\/data\/source\.git \(ARCHILYZER_SOURCE_REPO\): main does not read — fatal: detected dubious ownership$/);
assert.deepEqual(tree(c.root), before);
});
+
+// ── release 18, second half: the lock, the index stamp, the wrangler floor ─
+
+function indexStampJson(stampId: string, builtAt: number) {
+ return {
+ v: 1, stampId, generation: 7, scannedAt: builtAt - 60_000, builtAt, templatesAt: builtAt, commit: null,
+ index: { shortCircuited: false, added: 1, changed: 0, removed: 0, heldChannels: [] },
+ stats: { shortCircuited: false, notIndexedYet: 0, notIndexable: 0 },
+ sites: {}, hubSig: "h",
+ };
+}
+
+function builtJson(target: string, indexStampId: string | null, bytes: number) {
+ return {
+ v: 1, stampId: `b-${target}`, target, kind: target === "_hub" ? "hub" : "site", indexStampId, inputSig: "s",
+ builtAt: Date.UTC(2026, 9, 6, 10), commit: null, branch: null, runner: "local", audience: "public",
+ corpusGeneratedAt: null, files: 3, bytes, archivesStaged: 0,
+ };
+}
+
+test("publish: publish-lock — free is ok, a running holder a note, a dead one stale with the rm, another host's named and never stale", async () => {
+ const c = checkout();
+ const h = home(c);
+ const builds = c.paths.exportBuildsDir;
+ mkdirSync(builds, { recursive: true });
+ const file = path.join(builds, ".publish.lock");
+ let r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "publish-lock")?.status, "ok");
+ const { pidStartOf } = await import("../publish/stageLock");
+ const hold = (holder: Record<string, unknown>) => writeFileSync(file, JSON.stringify(holder));
+ // This process holds it: alive, same host.
+ hold({ pid: process.pid, host: "doctor-host", kind: "build-site", target: "alpha", since: Date.now() - 5_000, pidStart: pidStartOf(process.pid) });
+ r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "doctor-host" });
+ assert.equal(find(r, "publish", "publish-lock")?.status, "info");
+ assert.match(find(r, "publish", "publish-lock")!.detail, /^held: build-site alpha \(pid \d+ on doctor-host/);
+ // A pid that is not running, same host: stale.
+ hold({ pid: 2 ** 22 - 3, host: "doctor-host", kind: "deploy-site", target: "alpha", since: Date.now() - 60_000 });
+ r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "doctor-host" });
+ assert.equal(find(r, "publish", "publish-lock")?.status, "warn");
+ assert.match(find(r, "publish", "publish-lock")!.detail, new RegExp(`^stale: deploy-site alpha .* the next stage takes it over, or clear it, when nothing is publishing: rm ${file.replace(/[.]/g, "\\.")}$`));
+ // The same dead pid on ANOTHER host: named, never judged.
+ r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "this-host" });
+ assert.equal(find(r, "publish", "publish-lock")?.status, "warn");
+ assert.match(find(r, "publish", "publish-lock")!.detail, /^held by ANOTHER host: deploy-site alpha \(pid \d+ on doctor-host.*this host is "this-host"/);
+ // A lock that does not parse: being written, then (past the grace) torn.
+ writeFileSync(file, "{");
+ r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "publish-lock")?.status, "info");
+ const before = tree(c.root);
+ r = await run(c, { HOME: h }, { now: new Date(Date.now() + 10 * 60_000) });
+ assert.equal(find(r, "publish", "publish-lock")?.status, "warn");
+ assert.match(find(r, "publish", "publish-lock")!.detail, /does not parse.*a taker died writing it/);
+ assert.equal(r.ok, true, "a warning, never a failure");
+ assert.deepEqual(tree(c.root), before, "the doctor never clears a lock");
+});
+
+test("publish: index-stamp — none is a note (a warning once something is built); its age; targets built from an older stamp; export-builds counts built.json bytes", async () => {
+ const c = checkout();
+ const h = home(c);
+ const builds = c.paths.exportBuildsDir;
+ let r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "index-stamp")?.status, "info");
+ assert.match(find(r, "publish", "index-stamp")!.detail, /no index stamp at .*stamp\.json — update the index first: archilyzer publish index$/);
+ // Built bundles with no stamp: a warning.
+ for (const [target, id, bytes] of [["alpha", "old-stamp", 7_000_000], ["_hub", "cur-stamp", 2_000_000]] as const) {
+ mkdirSync(path.join(builds, target, "out"), { recursive: true });
+ writeFileSync(path.join(builds, target, "out", "index.html"), "x");
+ writeFileSync(path.join(builds, target, "built.json"), JSON.stringify(builtJson(target, id, bytes)));
+ }
+ r = await run(c, { HOME: h }, { freeBytes: () => 5e9 });
+ assert.equal(find(r, "publish", "index-stamp")?.status, "warn");
+ // export-builds sums the stamps' bytes, not the one-byte files on disk.
+ assert.match(find(r, "publish", "export-builds")!.detail, /: 2 bundles, 9 MB; 5\.00 GB free$/);
+ // A stamp: alpha was built from an older one.
+ const now = Date.UTC(2026, 9, 6, 12);
+ mkdirSync(c.paths.exportIndexDir, { recursive: true });
+ writeFileSync(path.join(c.paths.exportIndexDir, "stamp.json"), JSON.stringify(indexStampJson("cur-stamp", now - 3 * 3_600_000)));
+ const before = tree(c.root);
+ r = await run(c, { HOME: h }, { now: new Date(now) });
+ assert.equal(find(r, "publish", "index-stamp")?.status, "warn");
+ assert.match(find(r, "publish", "index-stamp")!.detail, /^cur-stamp, built 2026-10-06 09:00 \(3 hours ago\), generation 7; built from an older stamp: alpha — archilyzer publish build <id> rebuilds each$/);
+ // Everything from the current stamp: ok.
+ writeFileSync(path.join(builds, "alpha", "built.json"), JSON.stringify(builtJson("alpha", "cur-stamp", 7_000_000)));
+ const before2 = tree(c.root);
+ r = await run(c, { HOME: h }, { now: new Date(now) });
+ assert.equal(find(r, "publish", "index-stamp")?.status, "ok");
+ assert.match(find(r, "publish", "index-stamp")!.detail, /generation 7; 2 bundles built from it$/);
+ assert.equal(r.ok, true);
+ assert.notDeepEqual(before, before2);
+ assert.deepEqual(tree(c.root), before2);
+});
+
+test("workspace: node is graded against the pinned wrangler's engines floor when wrangler is installed — a warning below it, never a failure", async () => {
+ const c = checkout();
+ // No wrangler installed: next's floor only.
+ let r = await run(c, {}, { nodeVersion: "20.11.0" });
+ assert.equal(find(r, "workspace", "node")?.status, "ok");
+ assert.equal(find(r, "workspace", "node")!.detail, "v20.11.0 (needs >= 20.9.0)");
+ const pkg = path.join(c.root, "common", "node_modules", "wrangler");
+ mkdirSync(pkg, { recursive: true });
+ writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ name: "wrangler", version: "4.147.0", engines: { node: ">=22.0.0" } }));
+ const before = tree(c.root);
+ r = await run(c, {}, { nodeVersion: "20.11.0" });
+ assert.equal(find(r, "workspace", "node")?.status, "warn");
+ assert.match(find(r, "workspace", "node")!.detail, /^v20\.11\.0 — runs the apps .* the pinned wrangler 4\.147\.0 needs node >=22\.0\.0: every deploy refuses; use Node 22$/);
+ assert.equal(r.ok, true);
+ r = await run(c, {}, { nodeVersion: "22.23.3" });
+ assert.equal(find(r, "workspace", "node")?.status, "ok");
+ assert.equal(find(r, "workspace", "node")!.detail, "v22.23.3 (needs >= 20.9.0; deploys: >= 22.0.0, wrangler 4.147.0)");
+ r = await run(c, {}, { nodeVersion: "18.20.0" });
+ assert.equal(find(r, "workspace", "node")?.status, "fail");
+ assert.deepEqual(tree(c.root), before);
+});
diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts
@@ -9,7 +9,9 @@
// there and older than its Dockerfile (common/publish/build.ts), and what a
// publish needs from this machine (release 18): which yt-dlp (the image's or
// an override), whether deploy credentials are SET (never their values), room
-// for the bundles, and the repository the source mirror reads.
+// for the bundles, the publish lock (free, held, stale — never cleared here),
+// the index stamp and what was built from an older one, the repository the
+// source mirror reads, and node against the pinned wrangler's floor.
//
// STRICTLY READ-ONLY. It stats, reads and runs version flags, plus the engine's
// `image inspect`, a lock-free `git status` / `git log` and a `git rev-parse`
@@ -134,9 +136,21 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
// ── workspace ────────────────────────────────────────────────────────────
const W = "workspace";
const nodeV = deps.nodeVersion ?? process.versions.node;
- add(W, "node", versionAtLeast(nodeV, MIN_NODE) ? "ok" : "fail",
- `v${nodeV} (needs >= ${MIN_NODE.join(".")})`);
const root = paths.monorepoRoot;
+ // Two floors: next's (a failure — nothing runs below it) and the pinned
+ // wrangler's engines (a warning — every deploy refuses below it; wrangler 4
+ // wants 22). The wrangler floor is read from its package.json when it is
+ // installed, as the image's drift test reads it.
+ const wranglerFloor = wranglerNodeFloor(root);
+ if (!versionAtLeast(nodeV, MIN_NODE)) {
+ add(W, "node", "fail", `v${nodeV} (needs >= ${MIN_NODE.join(".")})`);
+ } else if (wranglerFloor && !versionAtLeast(nodeV, wranglerFloor.min)) {
+ add(W, "node", "warn",
+ `v${nodeV} — runs the apps (>= ${MIN_NODE.join(".")}), but the pinned wrangler ${wranglerFloor.version} needs node ${wranglerFloor.range}: every deploy refuses; use Node ${wranglerFloor.min[0]}`);
+ } else {
+ add(W, "node", "ok",
+ `v${nodeV} (needs >= ${MIN_NODE.join(".")}${wranglerFloor ? `; deploys: >= ${wranglerFloor.min.join(".")}, wrangler ${wranglerFloor.version}` : ""})`);
+ }
if (existsSync(path.join(root, "pnpm-workspace.yaml"))) {
add(W, "checkout", "ok", root);
} else {
@@ -509,7 +523,7 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
} else if (!writable(builds)) {
add(PB, "export-builds", "warn", `${builds} is not writable — every site build fails (${freeText})`);
} else {
- const bundles = await bundleBytes(builds);
+ const bundles = await bundleBytes(paths);
const need = Math.ceil(bundles.bytes * 1.5);
const what = `${builds}: ${bundles.count} bundle${bundles.count === 1 ? "" : "s"}, ${gigabytes(bundles.bytes)}; ${freeText}`;
if (free !== null && bundles.count > 0 && free < need) {
@@ -520,6 +534,63 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
}
}
}
+
+ // The publish lock (common/publish/stageLock.ts): free, held by a stage
+ // that is running, or left by one that is gone — judged by the lock's own
+ // rule (holderIsGone), never removed here.
+ if (builds) {
+ const lock = await import("../publish/stageLock");
+ const file = lock.publishLockPath(paths);
+ const st = statOrNull(file);
+ const clear = `clear it, when nothing is publishing: rm ${file}`;
+ if (!st) {
+ add(PB, "publish-lock", "ok", "free — no stage is publishing");
+ } else {
+ const holder = lock.parseLockHolder(readOrNull(file) ?? "");
+ const host = lock.lockHostId(env);
+ const nowMs = (deps.now ?? new Date()).getTime();
+ if (!holder) {
+ const torn = nowMs - st.mtime.getTime() > lock.LOCK_TORN_GRACE_MS;
+ add(PB, "publish-lock", torn ? "warn" : "info",
+ torn
+ ? `${file} does not parse, and has not for ${ago(st.mtime, new Date(nowMs)).replace(/ ago$/, "")} — a taker died writing it; the next stage takes it over, or ${clear}`
+ : `${file} is being written — a stage is taking the lock`);
+ } else if (holder.host !== host) {
+ add(PB, "publish-lock", "warn",
+ `held by ANOTHER host: ${lock.describeHolder(holder)} — never taken over from here (this host is "${host}"); if that host is gone, ${clear}`);
+ } else if (lock.holderIsGone(holder, { host })) {
+ add(PB, "publish-lock", "warn",
+ `stale: ${lock.describeHolder(holder)} — its process is gone; the next stage takes it over, or ${clear}`);
+ } else {
+ add(PB, "publish-lock", "info", `held: ${lock.describeHolder(holder)} — a stage is running`);
+ }
+ }
+ }
+
+ // The index stamp (common/publish/stamps.ts): how old, and which built
+ // targets came from an older one (they rebuild on their next build).
+ if (paths.exportIndexDir) {
+ const stamps = await import("../publish/stamps");
+ const idx = await stamps.readIndexStamp(paths);
+ const built = builds ? await builtStamps(paths) : [];
+ const now = deps.now ?? new Date();
+ if (!idx) {
+ const something = built.length > 0 || (await configuredSiteIds(paths)).length > 0;
+ add(PB, "index-stamp", something ? "warn" : "info",
+ `no index stamp at ${stamps.indexStampPath(paths)} — update the index first: archilyzer publish index`);
+ } else {
+ const at = new Date(idx.builtAt);
+ const head = `${idx.stampId}, built ${stamp(at)} (${ago(at, now)}), generation ${idx.generation}`;
+ const older = built.filter((b) => b.indexStampId !== idx.stampId).map((b) => b.target);
+ if (older.length > 0) {
+ add(PB, "index-stamp", "warn",
+ `${head}; built from an older stamp: ${older.join(", ")} — archilyzer publish build <id> rebuilds each`);
+ } else {
+ add(PB, "index-stamp", "ok",
+ `${head}${built.length > 0 ? `; ${built.length} bundle${built.length === 1 ? "" : "s"} built from it` : "; nothing built yet"}`);
+ }
+ }
+ }
}
// ── source publish ───────────────────────────────────────────────────────
@@ -983,9 +1054,12 @@ function statfsFree(dir: string): number | null {
}
}
-// Every `<builds>/<target>/out` bundle and their bytes, by stat (links not
-// followed). A bundle's own size is what a rebuild writes again beside it.
-async function bundleBytes(buildsDir: string): Promise<{ count: number; bytes: number }> {
+// Every `<builds>/<target>/out` bundle and its bytes: the `bytes` its
+// built.json recorded (common/publish/stamps.ts), else — a bundle no stamp
+// describes, from before the stages — a stat walk (links not followed). A
+// bundle's own size is what a rebuild writes again beside it.
+async function bundleBytes(paths: Paths): Promise<{ count: number; bytes: number }> {
+ const { readBuiltStamp } = await import("../publish/stamps");
let count = 0;
let bytes = 0;
const walk = async (d: string): Promise<void> => {
@@ -995,16 +1069,61 @@ async function bundleBytes(buildsDir: string): Promise<{ count: number; bytes: n
else if (ent.isFile()) bytes += statOrNull(p)?.size ?? 0;
}
};
- for (const ent of await readdir(buildsDir, { withFileTypes: true }).catch(() => [])) {
- if (!ent.isDirectory()) continue;
- const out = path.join(buildsDir, ent.name, "out");
+ for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) {
+ if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
+ const out = path.join(paths.exportBuildsDir, ent.name, "out");
if (!statOrNull(out)?.isDirectory()) continue;
count += 1;
- await walk(out);
+ const built = await readBuiltStamp(paths, ent.name);
+ if (built) bytes += built.bytes;
+ else await walk(out);
}
return { count, bytes };
}
+// Every built.json under the builds dir (sites, `_hub`, `_homepage`), by
+// target name. Read-only; an unreadable stamp is skipped.
+async function builtStamps(paths: Paths): Promise<{ target: string; indexStampId: string | null }[]> {
+ const { readBuiltStamp } = await import("../publish/stamps");
+ const out: { target: string; indexStampId: string | null }[] = [];
+ for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) {
+ if (!ent.isDirectory() || ent.name.startsWith(".")) continue;
+ const built = await readBuiltStamp(paths, ent.name);
+ if (built) out.push({ target: built.target, indexStampId: built.indexStampId });
+ }
+ return out.sort((x, y) => x.target.localeCompare(y.target));
+}
+
+// The configured site ids (a sites/<id>/site.json), `_`-dirs excluded.
+async function configuredSiteIds(paths: Paths): Promise<string[]> {
+ if (!paths.sitesDir) return [];
+ const out: string[] = [];
+ for (const e of await readdir(paths.sitesDir, { withFileTypes: true }).catch(() => [])) {
+ if (e.isDirectory() && !e.name.startsWith("_") && existsSync(path.join(paths.sitesDir, e.name, "site.json"))) out.push(e.name);
+ }
+ return out.sort();
+}
+
+// The pinned wrangler's Node floor, from its package.json `engines.node`
+// (">=22.0.0"), when wrangler is installed in common/node_modules; else null.
+function wranglerNodeFloor(root: string): { version: string; range: string; min: [number, number, number] } | null {
+ const text = readOrNull(path.join(root, "common", "node_modules", "wrangler", "package.json"));
+ if (text === null) return null;
+ try {
+ const pkg = JSON.parse(text) as { version?: unknown; engines?: { node?: unknown } };
+ const range = typeof pkg.engines?.node === "string" ? pkg.engines.node : "";
+ const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(range);
+ if (!m) return null;
+ return {
+ version: typeof pkg.version === "string" ? pkg.version : "?",
+ range,
+ min: [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)],
+ };
+ } catch {
+ return null;
+ }
+}
+
// The sites whose site.json names a Cloudflare Pages project — what "this
// machine is configured to deploy" means. Read-only; an unreadable file is
// skipped.
diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts
@@ -139,20 +139,9 @@ export function deployedPath(paths: Pick<Paths, "exportBuildsDir">, target: stri
}
// The runtime image's build facts (Dockerfile build args → ENV): the stamps'
-// `commit` / `branch` where there is no .git to ask. Release 18 S5 declares
-// the two names and exports the same helper (`imageBuildFacts`,
-// lib/envVars.ts); this local one is swapped for it in one line once both
-// slices are merged. Empty = null.
-const IMAGE_COMMIT_NAME = "ARCHILYZER_COMMIT";
-const IMAGE_BRANCH_NAME = "ARCHILYZER_BRANCH";
-
-export function imageBuildFacts(env: NodeJS.ProcessEnv = process.env): {
- commit: string | null;
- branch: string | null;
-} {
- const v = (k: string) => env[k]?.trim() || null;
- return { commit: v(IMAGE_COMMIT_NAME), branch: v(IMAGE_BRANCH_NAME) };
-}
+// `commit` / `branch` where there is no checkout to ask. Declared once, beside
+// the two names, in lib/envVars.ts (release 18 S5); re-exported for the stages.
+export { imageBuildFacts } from "../lib/envVars";
/** A fresh, sortable, unique stamp id. */
export function newStampId(now = Date.now()): string {