commit 3bd33837f5e37f17bc661e0e9a7f92ead2e5ef4f
parent 8941c1e696360fd4a10a1dfeb107e8955f518350
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 12:47:14 -0400
common: archilyzer doctor checks the site build image — there, and newer than its Dockerfile
A new "build image" block. When the container engine answers `version` (the
same question Build all asks before choosing containers), the doctor inspects
the image the settings name (buildPipeline.dockerImage — the tag is read, not
spelled again) and reports its age and size. It warns when the image is absent
or was created before its Dockerfile's last change — the file's last commit in
a checkout, its mtime when it has uncommitted edits or there is no checkout —
and prints the one command that rebuilds it: `cd <root> && docker build -f
Dockerfile.build -t <tag> .`. A Dockerfile the settings name that is not there
warns too. No engine: one line saying the check was skipped. Never a failure
(Build all rebuilds the image itself before its fan-out), and only a note while
there is no corpus to build, as for a tool nothing needs yet. DOCKER_BIN names
the engine it asks and the command it prints.
build.ts exports the two pieces the doctor needs so neither has a second copy:
dockerBin(env) and buildImageArgs(pipeline), which ensureBuildImage now runs;
its comment says it runs before every fan-out and what a Dockerfile change
costs there. The git calls run with GIT_OPTIONAL_LOCKS=0, so `git status`
refreshes no index: the doctor stays read-only (a test commits into a temp
checkout and compares .git before and after).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 353 insertions(+), 9 deletions(-)
diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts
@@ -8,6 +8,7 @@
// wants. The last assertion of each is the one that matters most: the tree is
// byte-for-byte and mtime-for-mtime what it was — the doctor wrote nothing.
+import { execFileSync } from "node:child_process";
import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
@@ -23,7 +24,14 @@ import {
import os from "node:os";
import path from "node:path";
import type { Paths } from "../lib/paths";
-import { collectDoctorReport, renderDoctorReport, type DoctorReport } from "./doctor";
+import {
+ collectDoctorReport,
+ parseEngineTime,
+ renderDoctorReport,
+ type BuildImageProbe,
+ type DoctorDeps,
+ type DoctorReport,
+} from "./doctor";
const TMP = mkdtempSync(path.join(os.tmpdir(), "doctor-"));
after(() => rmSync(TMP, { recursive: true, force: true }));
@@ -82,7 +90,14 @@ function tree(root: string): string[] {
return out.sort();
}
-async function run(c: ReturnType<typeof checkout>, env: NodeJS.ProcessEnv = {}): Promise<DoctorReport> {
+// No container engine unless a scenario hands one in: a unit test never asks docker.
+const noEngine = async (): Promise<BuildImageProbe> => ({ engine: false });
+
+async function run(
+ c: ReturnType<typeof checkout>,
+ env: NodeJS.ProcessEnv = {},
+ more: Partial<DoctorDeps> = {},
+): Promise<DoctorReport> {
return collectDoctorReport({
env: { PATH: c.bin, ...env },
paths: c.paths,
@@ -90,6 +105,8 @@ async function run(c: ReturnType<typeof checkout>, env: NodeJS.ProcessEnv = {}):
portBlock: async () => null,
portInUse: async () => false,
umtoolTools: async () => null,
+ buildImage: noEngine,
+ ...more,
});
}
@@ -211,6 +228,7 @@ test("node older than next's floor fails", async () => {
portBlock: async () => null,
portInUse: async () => false,
umtoolTools: async () => null,
+ buildImage: noEngine,
});
assert.equal(status(r, "node"), "fail");
});
@@ -224,8 +242,171 @@ test("umtool's table and the port block are reported, never failed", async () =>
portBlock: async () => ({ label: "worktree #3 (offset 300)", ports: { EDITOR_PORT: "3301" } }),
portInUse: async (p) => p === 3301,
umtoolTools: async () => [{ id: "qrencode", bin: "qrencode", neededBy: ["report-video QR"], required: true }],
+ buildImage: noEngine,
});
assert.equal(status(r, "qrencode"), "warn");
assert.match(r.checks.find((x) => x.id === "EDITOR_PORT")!.detail, /^3301 in use/);
assert.equal(r.ok, true);
});
+
+// ── the build image ─────────────────────────────────────────────────────────
+// Build all's per-site containers run the image the settings name. The engine
+// is always handed in (`buildImage`); only the Dockerfile's date is ever read
+// for real, and then from a temp checkout.
+
+// A corpus whose tools all pass, so every warning below is the image's.
+function corpusCheckout(settings: Record<string, unknown> = {}) {
+ const c = checkout();
+ mkdirSync(path.join(c.paths.channelsDir, "chan"), { recursive: true });
+ writeFileSync(path.join(c.paths.channelsDir, "chan", "config.json"), "{}");
+ writeFileSync(c.paths.lmdbPath, "");
+ for (const b of ["yt-dlp", "ffmpeg", "ffprobe"]) fake(c.bin, b);
+ writeFileSync(c.paths.settingsFile, JSON.stringify({ workers: [], ...settings }));
+ writeFileSync(path.join(c.root, "Dockerfile.build"), "FROM scratch\n");
+ return c;
+}
+
+const imageLine = (r: DoctorReport) => r.checks.find((x) => x.id === "build-image")!;
+const JUL = new Date("2026-07-07T16:04:55Z");
+const SEP = new Date("2026-09-28T17:00:00Z");
+const NOW = new Date("2026-09-29T12:00:00Z");
+
+test("the build image: no container engine is one line saying the check was skipped", async () => {
+ const c = corpusCheckout();
+ const r = await run(c);
+ const lines = r.checks.filter((x) => x.section === "build image");
+ assert.equal(lines.length, 1, renderDoctorReport(r));
+ assert.equal(lines[0].id, "build-image");
+ assert.equal(lines[0].status, "info");
+ assert.match(lines[0].detail, /^skipped: `docker version` did not answer/);
+ assert.equal(r.ok, true, renderDoctorReport(r));
+});
+
+test("the build image: absent warns beside a corpus, with the configured tag and the one command — never a failure", async () => {
+ const c = corpusCheckout({ buildPipeline: { dockerImage: "my-build" } });
+ const asked: { bin: string; image: string }[] = [];
+ const before = tree(c.root);
+ const r = await run(c, {}, {
+ buildImage: async (q) => {
+ asked.push(q);
+ return { engine: true, image: null };
+ },
+ });
+ assert.deepEqual(asked, [{ bin: "docker", image: "my-build" }]);
+ const line = imageLine(r);
+ assert.equal(line.status, "warn", renderDoctorReport(r));
+ assert.match(line.detail, /^no image "my-build" — the next Build all builds it first/);
+ assert.ok(
+ line.detail.includes(`\nrebuild it now: cd ${c.root} && docker build -f Dockerfile.build -t my-build .`),
+ line.detail,
+ );
+ assert.equal(r.ok, true, renderDoctorReport(r));
+ assert.deepEqual(tree(c.root), before);
+
+ // No corpus, no site to build: the same words, as a note.
+ const bare = checkout();
+ const r2 = await run(bare, {}, { buildImage: async () => ({ engine: true, image: null }) });
+ assert.equal(status(r2, "build-image"), "info");
+ assert.equal(r2.ok, true);
+});
+
+test("the build image: older than its Dockerfile's last change warns; newer is ok, with its age", async () => {
+ const c = corpusCheckout();
+ const changed = async () => ({ at: SEP, source: "commit" as const });
+ let r = await run(c, {}, {
+ buildImage: async () => ({ engine: true, image: { created: JUL, sizeBytes: 7_720_000_000 } }),
+ dockerfileChanged: changed,
+ now: NOW,
+ });
+ let line = imageLine(r);
+ assert.equal(line.status, "warn", renderDoctorReport(r));
+ assert.match(
+ line.detail,
+ /^"yt-dlp-transcript-browser-build" built 2026-07-07 16:04 \(84 days ago\), 7\.72 GB — before Dockerfile\.build's last change \(2026-09-28 17:00, its last commit\)/,
+ );
+ assert.match(line.detail, /\nrebuild it now: cd \S+ && docker build -f Dockerfile\.build -t yt-dlp-transcript-browser-build \.$/);
+ assert.equal(r.ok, true);
+
+ r = await run(c, {}, {
+ buildImage: async () => ({
+ engine: true,
+ image: { created: new Date("2026-09-28T18:00:00Z"), sizeBytes: 1_673_611_166 },
+ }),
+ dockerfileChanged: changed,
+ now: NOW,
+ });
+ line = imageLine(r);
+ assert.equal(line.status, "ok", renderDoctorReport(r));
+ assert.match(line.detail, /built 2026-09-28 18:00 \(18 hours ago\), 1\.67 GB, after Dockerfile\.build's last change/);
+ assert.doesNotMatch(line.detail, /rebuild/);
+});
+
+test("the build image: DOCKER_BIN names the engine it asks and the command it prints", async () => {
+ const c = corpusCheckout();
+ const asked: string[] = [];
+ const r = await run(c, { DOCKER_BIN: "podman" }, {
+ buildImage: async (q) => {
+ asked.push(q.bin);
+ return { engine: true, image: null };
+ },
+ });
+ assert.deepEqual(asked, ["podman"]);
+ assert.match(imageLine(r).detail, /&& podman build -f Dockerfile\.build -t yt-dlp-transcript-browser-build \.$/);
+});
+
+test("the build image: a Dockerfile the settings name that is not there warns", async () => {
+ const c = corpusCheckout({ buildPipeline: { dockerfile: "docker/nope.Dockerfile" } });
+ const r = await run(c, {}, {
+ buildImage: async () => ({ engine: true, image: { created: JUL, sizeBytes: null } }),
+ now: NOW,
+ });
+ assert.equal(status(r, "dockerfile"), "warn", renderDoctorReport(r));
+ assert.match(r.checks.find((x) => x.id === "dockerfile")!.detail, /^docker\/nope\.Dockerfile is not at /);
+ assert.equal(status(r, "build-image"), "ok"); // here, and nothing to date it against
+});
+
+test("the build image's Dockerfile dates from its last commit, or its mtime once edited — and git writes nothing", async () => {
+ const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
+ const c = corpusCheckout();
+ symlinkSync(git, path.join(c.bin, "git"));
+ const at = "2026-08-01T00:00:00Z";
+ const g = (...args: string[]) =>
+ execFileSync(git, args, {
+ cwd: c.root,
+ env: {
+ ...process.env,
+ GIT_AUTHOR_DATE: at,
+ GIT_COMMITTER_DATE: at,
+ GIT_AUTHOR_NAME: "t",
+ GIT_AUTHOR_EMAIL: "t@example.invalid",
+ GIT_COMMITTER_NAME: "t",
+ GIT_COMMITTER_EMAIL: "t@example.invalid",
+ },
+ });
+ g("init", "-q");
+ g("add", "Dockerfile.build");
+ g("commit", "-q", "-m", "the build image");
+ const image = async (): Promise<BuildImageProbe> => ({
+ engine: true,
+ image: { created: new Date("2026-09-01T00:00:00Z"), sizeBytes: null },
+ });
+ const before = tree(c.root);
+ let r = await run(c, {}, { buildImage: image, now: NOW });
+ assert.equal(imageLine(r).status, "ok", renderDoctorReport(r));
+ assert.match(imageLine(r).detail, /Dockerfile\.build's last change \(2026-08-01 00:00, its last commit\)/);
+ // .git included: `git status` refreshed no index (GIT_OPTIONAL_LOCKS=0).
+ assert.deepEqual(tree(c.root), before);
+
+ // An uncommitted edit is newer than any commit: its mtime (now) decides.
+ writeFileSync(path.join(c.root, "Dockerfile.build"), "FROM scratch\nRUN true\n");
+ r = await run(c, {}, { buildImage: image, now: NOW });
+ assert.equal(imageLine(r).status, "warn", renderDoctorReport(r));
+ assert.match(imageLine(r).detail, /its mtime: uncommitted or no checkout\)/);
+});
+
+test("an engine's image creation time parses in docker's and podman's spelling", () => {
+ assert.equal(parseEngineTime("2026-07-07T12:04:55.302907312-04:00")?.toISOString(), "2026-07-07T16:04:55.302Z");
+ assert.equal(parseEngineTime("2026-07-07 12:04:55.302907312 -0400 EDT")?.toISOString(), "2026-07-07T16:04:55.302Z");
+ assert.equal(parseEngineTime("2026-09-28T16:35:39Z")?.toISOString(), "2026-09-28T16:35:39.000Z");
+ assert.equal(parseEngineTime("<no value>"), null);
+});
diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts
@@ -5,7 +5,11 @@
// this reads and whose probe it shares — lib/toolProbe.mjs) and the port block
// (scripts/worktree.mjs over lib/ports.mjs).
//
-// STRICTLY READ-ONLY. It stats, reads and runs version flags. It never opens
+// It also asks the container engine whether Build all's site build image is
+// there and older than its Dockerfile (common/publish/build.ts).
+//
+// STRICTLY READ-ONLY. It stats, reads and runs version flags, plus the engine's
+// `image inspect` and a lock-free `git status` / `git log`. It never opens
// LMDB (the index is stat'd, not opened), never mkdirs, never writes settings,
// and never binds a port (a port is "in use" when a TCP connect succeeds). The
// one process-state change is a chdir around umtool's table, which resolves a
@@ -70,8 +74,22 @@ export type DoctorDeps = {
portBlock?: () => Promise<{ label: string; ports: Record<string, string> } | null>;
// umtool's report-pipeline table, or null when there is no umtool here.
umtoolTools?: () => Promise<ToolSpec[] | null>;
+ // The container engine and the site build image. Default: `<bin> version`,
+ // then `<bin> image inspect <image>` — both read-only.
+ buildImage?: (q: { bin: string; image: string }) => Promise<BuildImageProbe>;
+ // When the build image's Dockerfile last changed, or null when it is not
+ // there. Default: its last commit in a checkout, its mtime when it has
+ // uncommitted changes or there is no checkout.
+ dockerfileChanged?: (file: string) => Promise<DockerfileChange | null>;
+ now?: Date;
};
+export type BuildImageProbe =
+ | { engine: false }
+ | { engine: true; image: { created: Date | null; sizeBytes: number | null } | null };
+
+export type DockerfileChange = { at: Date; source: "commit" | "mtime" };
+
const MIN_NODE = [20, 9, 0] as const; // next 16's engines field
export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorReport> {
@@ -254,6 +272,49 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
}
}
+ // ── build image ──────────────────────────────────────────────────────────
+ // Build all builds every site in a container whenever the engine answers
+ // `version`, from the image the settings name (common/publish/build.ts). Never
+ // a failure — Build all (re)builds the image itself before its fan-out — and,
+ // like a tool nothing needs yet, only a note while there is no corpus to build.
+ const B = "build image";
+ {
+ const { buildImageArgs, dockerBin } = await import("../publish/build");
+ const { defaultBuildPipeline } = await import("../lib/settingsSchema");
+ const pipeline = settings?.buildPipeline ?? defaultBuildPipeline();
+ const bin = dockerBin(env);
+ const probed = await (deps.buildImage ?? ((q) => probeBuildImage(q, env)))({ bin, image: pipeline.dockerImage });
+ if (!probed.engine) {
+ add(B, "build-image", "info",
+ `skipped: \`${bin} version\` did not answer (DOCKER_BIN names the engine) — Build all builds sites one at a time on the host`);
+ } else {
+ const grade: CheckStatus = hasCorpus ? "warn" : "info";
+ const now = deps.now ?? new Date();
+ const file = path.resolve(root, pipeline.dockerfile);
+ const changed = await (deps.dockerfileChanged ?? ((f) => dockerfileChangedAt(f, root, env)))(file);
+ const rebuild = `rebuild it now: cd ${shellQuote(root)} && ${[bin, ...buildImageArgs(pipeline)].map(shellQuote).join(" ")}`;
+ const img = probed.image;
+ if (!changed) {
+ add(B, "dockerfile", grade, `${pipeline.dockerfile} is not at ${file} — Build all's image build will fail (Settings → Build pipeline names it)`);
+ }
+ if (!img) {
+ add(B, "build-image", grade,
+ `no image "${pipeline.dockerImage}" — the next Build all builds it first, installing every dependency\n${rebuild}`);
+ } else if (!img.created) {
+ add(B, "build-image", grade, `"${pipeline.dockerImage}" is here but its creation time did not parse\n${rebuild}`);
+ } else {
+ const built = `"${pipeline.dockerImage}" built ${stamp(img.created)} (${ago(img.created, now)})${img.sizeBytes ? `, ${gigabytes(img.sizeBytes)}` : ""}`;
+ const last = changed ? `${pipeline.dockerfile}'s last change (${stamp(changed.at)}, ${changed.source === "commit" ? "its last commit" : "its mtime: uncommitted or no checkout"})` : "";
+ if (changed && img.created < changed.at) {
+ add(B, "build-image", grade,
+ `${built} — before ${last}, so the next Build all rebuilds it from the changed step on\n${rebuild}`);
+ } else {
+ add(B, "build-image", "ok", changed ? `${built}, after ${last}` : built);
+ }
+ }
+ }
+ }
+
// ── ports ────────────────────────────────────────────────────────────────
const P = "ports";
const block = await (deps.portBlock ?? (() => worktreePortBlock(root)))();
@@ -396,6 +457,91 @@ async function worktreePortBlock(
}
}
+// The engine answers `version` (what Build all asks before choosing containers),
+// then the image is inspected. Both only read the daemon's state.
+async function probeBuildImage(
+ q: { bin: string; image: string },
+ env: NodeJS.ProcessEnv,
+): Promise<BuildImageProbe> {
+ try {
+ await execFileP(q.bin, ["version"], { env, timeout: 15_000 });
+ } catch {
+ return { engine: false };
+ }
+ try {
+ const { stdout } = await execFileP(q.bin, ["image", "inspect", "--format", "{{.Created}}|{{.Size}}", q.image], {
+ env,
+ timeout: 15_000,
+ });
+ const [created = "", size = ""] = stdout.trim().split("|");
+ const bytes = Number.parseInt(size, 10);
+ return { engine: true, image: { created: parseEngineTime(created), sizeBytes: Number.isFinite(bytes) ? bytes : null } };
+ } catch {
+ return { engine: true, image: null };
+ }
+}
+
+// An image's creation time as an engine prints it: docker's RFC 3339 with
+// nanoseconds (2026-07-07T12:04:55.302907312-04:00), or podman's Go default
+// (2026-07-07 12:04:55.302907312 -0400 EDT). V8 happens to read both, through
+// its implementation-defined fallback; normalized here to the ISO form the
+// spec defines, so the answer does not rest on that.
+export function parseEngineTime(s: string): Date | null {
+ const m = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}:?\d{2})?/.exec(s.trim());
+ if (!m) return null;
+ const frac = (m[3] ?? "").slice(0, 3).padEnd(3, "0");
+ const tz = (m[4] ?? "Z").replace(/^([+-]\d{2})(\d{2})$/, "$1:$2");
+ const d = new Date(`${m[1]}T${m[2]}.${frac}${tz}`);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+// When the Dockerfile last changed. In a checkout that is its last commit — a
+// clone's mtimes are the clone's — unless the file has uncommitted changes, when
+// the file on disk is newer than any commit and its mtime is the answer. No
+// checkout (or no git): the mtime. GIT_OPTIONAL_LOCKS=0 keeps `git status` from
+// refreshing the index, which would be a write.
+async function dockerfileChangedAt(
+ file: string,
+ root: string,
+ env: NodeJS.ProcessEnv,
+): Promise<DockerfileChange | null> {
+ const st = statOrNull(file);
+ if (!st) return null;
+ const byMtime: DockerfileChange = { at: st.mtime, source: "mtime" };
+ const opts = { cwd: root, env: { ...env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000 };
+ const rel = path.relative(root, file);
+ try {
+ const { stdout: dirty } = await execFileP("git", ["status", "--porcelain", "--", rel], opts);
+ if (dirty.trim() !== "") return byMtime;
+ const { stdout } = await execFileP("git", ["log", "-1", "--format=%ct", "--", rel], opts);
+ const secs = Number.parseInt(stdout.trim(), 10);
+ return Number.isFinite(secs) ? { at: new Date(secs * 1000), source: "commit" } : byMtime;
+ } catch {
+ return byMtime;
+ }
+}
+
+function shellQuote(s: string): string {
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
+}
+
+// The same minute-precision UTC stamp the index line uses.
+function stamp(d: Date): string {
+ return d.toISOString().slice(0, 16).replace("T", " ");
+}
+
+function ago(then: Date, now: Date): string {
+ const mins = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000));
+ if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`;
+ const hours = Math.round(mins / 60);
+ if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
+ return `${Math.round(hours / 24)} days ago`;
+}
+
+function gigabytes(bytes: number): string {
+ return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${Math.round(bytes / 1e6)} MB`;
+}
+
// In use = something accepts a TCP connection on 127.0.0.1. Never binds.
function tcpPortInUse(port: number): Promise<boolean> {
return new Promise((resolve) => {
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -388,8 +388,19 @@ export async function runDeployIntoLog(
// The container engine binary. Defaults to `docker`; podman is a CLI drop-in
// (and rootless podman yields host-owned outputs without needing `-u`).
-function dockerBin(): string {
- return process.env.DOCKER_BIN?.trim() || "docker";
+// `archilyzer doctor` asks the same engine, from the environment it was given.
+export function dockerBin(env: NodeJS.ProcessEnv = process.env): string {
+ return env.DOCKER_BIN?.trim() || "docker";
+}
+
+// The build image's `docker build` argv, run from the monorepo root: the one
+// spelling of it. ensureBuildImage runs it; `archilyzer doctor` prints it as the
+// command that rebuilds an absent or stale image.
+export function buildImageArgs(pipeline: {
+ dockerImage: string;
+ dockerfile: string;
+}): string[] {
+ return ["build", "-f", pipeline.dockerfile, "-t", pipeline.dockerImage, "."];
}
export type SiteBuildOutcome = { siteId: string; code: number };
@@ -434,17 +445,23 @@ async function runHostScript(
});
}
-// Build (or reuse cached layers of) the per-site build image.
+// Build (or reuse cached layers of) the per-site build image. Runs before every
+// Phase B, so a fan-out never meets an image older than the checkout: a code
+// change re-runs only `COPY . .` onward, but a Dockerfile or lockfile change
+// re-installs every dependency first, inside this job. `archilyzer doctor`
+// warns ahead of that when the image is absent or older than the Dockerfile.
async function ensureBuildImage(
onLog: (line: string) => void,
signal: AbortSignal,
paths: Paths,
): Promise<number> {
- const { dockerImage, dockerfile } = getSettings().buildPipeline;
- onLog(`[docker] building image "${dockerImage}" from ${dockerfile} (cached layers reused)`);
+ const pipeline = getSettings().buildPipeline;
+ onLog(
+ `[docker] building image "${pipeline.dockerImage}" from ${pipeline.dockerfile} (cached layers reused)`,
+ );
return runChildIntoLog(onLog, signal, {
command: dockerBin(),
- args: ["build", "-f", dockerfile, "-t", dockerImage, "."],
+ args: buildImageArgs(pipeline),
cwd: paths.monorepoRoot,
});
}