commit 367a9af95c46af326b2e8635bce0ad7c400f2d36
parent 44a98e5f715f8745cdb4914e2eec29edd97b5961
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 09:42:09 -0400
publish: a run's no-op build no longer holds its deploy; built.checkedAt; production refuses a null branch; ARCHILYZER_BRANCH/COMMIT win over git (review MEDIUM 1, a LOW, the S4 seam)
needsDeploy counts the bundle as current under builtAfter when it matches the
index this run updated (inputSig / hubSig / indexStampId) and that index ran
at or after builtAfter. A no-op build writes built.checkedAt; changedChannels
and config changes are measured against max(builtAt, checkedAt). A detached
HEAD records branch null, refused for production like any branch but main.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 148 insertions(+), 19 deletions(-)
diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts
@@ -95,17 +95,22 @@ async function git(cwd: string, args: string[]): Promise<string | null> {
}
/**
- * The checkout's commit and branch; where there is no repository (the runtime
- * image), the commit and branch the image was built from; else null.
+ * The commit and branch a stamp records. `ARCHILYZER_COMMIT` /
+ * `ARCHILYZER_BRANCH`, when set, WIN over git, each on its own: the runtime
+ * image bakes them (it has no .git), and a test server sets
+ * `ARCHILYZER_BRANCH=main` because a worktree's branch is never `main`. Else
+ * the checkout's HEAD and branch; a detached HEAD records `branch: null`,
+ * which a production deploy refuses like any branch but `main`.
*/
export async function checkoutInfo(
paths: Pick<Paths, "monorepoRoot">,
env: NodeJS.ProcessEnv = process.env,
): Promise<{ commit: string | null; branch: string | null }> {
- const commit = await git(paths.monorepoRoot, ["rev-parse", "HEAD"]);
- if (commit === null) return imageBuildFacts(env);
+ const facts = imageBuildFacts(env);
+ const commit = facts.commit ?? (await git(paths.monorepoRoot, ["rev-parse", "HEAD"]));
+ if (facts.branch !== null) return { commit, branch: facts.branch };
const branch = await git(paths.monorepoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
- return { commit, branch: branch === "HEAD" ? null : branch };
+ return { commit, branch: branch === null || branch === "HEAD" ? null : branch };
}
/** `main`'s HEAD where a repository is reachable, else null. */
@@ -388,20 +393,37 @@ async function buildOneSite(ctx: StageContext, r: StageRequest, stamp: IndexStam
}
// The sites `_all` builds: each stale one (every one with --force).
-function sitesToBuild(input: NeedsInput, r: StageRequest, onLog: (l: string) => void): string[] {
+// (A fresh one is a no-op build: its `checkedAt` is moved on.)
+async function sitesToBuild(
+ paths: Paths,
+ input: NeedsInput,
+ r: StageRequest,
+ onLog: (l: string) => void,
+): Promise<string[]> {
const ids: string[] = [];
for (const id of Object.keys(input.sites)) {
const f = STAGES["build-site"].needs(input, { ...r, target: id });
- if (f.state === "fresh") onLog(`[publish] ${id}: fresh — skipped\n`);
- else if (f.state === "blocked") onLog(`[publish] ${id}: blocked — ${f.reason}\n`);
+ if (f.state === "fresh") {
+ onLog(`[publish] ${id}: fresh — skipped\n`);
+ await markChecked(paths, input.sites[id].built);
+ } else if (f.state === "blocked") onLog(`[publish] ${id}: blocked — ${f.reason}\n`);
else ids.push(id);
}
return ids;
}
+/**
+ * A no-op build: the bundle still matches its inputs, as of now. Recorded as
+ * `checkedAt`, so the "channels changed" signal (measured against
+ * max(builtAt, checkedAt)) clears, without pretending a build happened.
+ */
+async function markChecked(paths: Paths, built: BuiltStamp | null | undefined): Promise<void> {
+ if (built) await writeBuiltStamp(paths, { ...built, checkedAt: Date.now() });
+}
+
async function buildAllLocal(ctx: StageContext, r: StageRequest, input: NeedsInput): Promise<StageOutcome> {
const stamp = input.index.stamp!;
- const ids = sitesToBuild(input, r, ctx.onLog);
+ const ids = await sitesToBuild(ctx.paths, input, r, ctx.onLog);
const failed: string[] = [];
let built = 0;
for (const [i, id] of ids.entries()) {
@@ -428,7 +450,7 @@ async function buildAllDocker(ctx: StageContext, r: StageRequest, input: NeedsIn
const b = await import("./build");
if (!(await b.dockerAvailable(signal))) throw new StageFailure(NO_ENGINE, 3);
const stamp = input.index.stamp!;
- const ids = sitesToBuild(input, r, onLog);
+ const ids = await sitesToBuild(paths, input, r, onLog);
if (ids.length === 0) return { status: "noop", stamp: stamp.stampId, summary: "every site is fresh" };
if (!r.skipArchives) {
onLog("=== the archive cache (host) ===\n");
@@ -638,6 +660,13 @@ export async function runStageBody(ctx: StageContext, r: StageRequest): Promise<
: r.kind.endsWith("-hub")
? input.hub.built
: input.homepage.built;
+ if (r.kind.startsWith("build-")) {
+ if (r.target === ALL_TARGET) {
+ for (const t of Object.values(input.sites)) await markChecked(paths, t.built);
+ } else {
+ await markChecked(paths, built);
+ }
+ }
return {
status: "noop",
stamp: built?.stampId ?? stampOf,
diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts
@@ -146,6 +146,11 @@ test("a site the index has not seen is blocked; a fresh site's build is a no-op;
assert.equal(fresh.code, 0);
assert.equal(fresh.outcome?.status, "noop");
assert.equal(fresh.outcome?.stamp, "b-jer");
+ // A no-op build records that it checked (checkedAt), and nothing else.
+ const checked = await stamps.readBuiltStamp(paths, "jer");
+ assert.equal(checked?.stampId, "b-jer");
+ assert.equal(typeof checked?.checkedAt, "number");
+ assert.equal(checked?.inputSig, stamp.sites.jer.inputSig);
// A bad preview name is usage (2), before anything else is asked.
assert.equal((await stage("deploy-site", "jer", { preview: "main" })).code, 2);
@@ -237,3 +242,23 @@ test("a lock left by a dead process on this host is taken over", async () => {
assert.ok(!existsSync(publishLockPath(paths)));
assert.equal(readFileSync(path.join(bundleDir(paths, "jer"), "index.html"), "utf8"), "<!doctype html>");
});
+
+test("stamps' commit/branch: ARCHILYZER_COMMIT / ARCHILYZER_BRANCH win over git, each on its own; no repository is null", async () => {
+ const { checkoutInfo } = await import("./stageBodies");
+ const repo = { monorepoRoot: path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..") };
+ const fromGit = await checkoutInfo(repo, {});
+ assert.match(fromGit.commit ?? "", /^[0-9a-f]{40}$/);
+ assert.deepEqual(await checkoutInfo(repo, { ARCHILYZER_BRANCH: "main", ARCHILYZER_COMMIT: "abc" }), {
+ commit: "abc",
+ branch: "main",
+ });
+ const branchOnly = await checkoutInfo(repo, { ARCHILYZER_BRANCH: "main" });
+ assert.equal(branchOnly.branch, "main");
+ assert.equal(branchOnly.commit, fromGit.commit);
+ const noRepo = { monorepoRoot: ROOT };
+ assert.deepEqual(await checkoutInfo(noRepo, {}), { commit: null, branch: null });
+ assert.deepEqual(await checkoutInfo(noRepo, { ARCHILYZER_COMMIT: "c1", ARCHILYZER_BRANCH: "main" }), {
+ commit: "c1",
+ branch: "main",
+ });
+});
diff --git a/common/publish/stages.test.ts b/common/publish/stages.test.ts
@@ -9,6 +9,7 @@ import {
STAGES,
STAGE_FLAGS,
STAGE_KINDS,
+ builtCheckedAt,
deployKindOf,
parseStageArgs,
stageArgv,
@@ -197,7 +198,41 @@ test("deploy-site: production refuses a build made on another branch; a preview
assert.match(reason(needs(off, req("deploy-site", "jer"))), /built from branch "r18\/stage-core"; production ships only a build of main/);
assert.equal(state(needs(off, req("deploy-site", "jer", { preview: "r18" }))), "stale");
const detached = input({ sites: { jer: target({ built: builtStamp({ branch: null }) }) } });
- assert.equal(state(needs(detached, req("deploy-site", "jer"))), "stale", "no branch (a container): allowed");
+ assert.match(
+ reason(needs(detached, req("deploy-site", "jer"))),
+ /built with no branch recorded \(a detached HEAD, or an image built without ARCHILYZER_BRANCH\); production ships only a build of main/,
+ "no branch is refused like another branch",
+ );
+ assert.equal(state(needs(detached, req("deploy-site", "jer", { preview: "r18" }))), "stale");
+});
+
+test("deploy-site under builtAfter: a run's NO-OP build (the bundle still matches the index this run updated) does not hold the deploy", () => {
+ // The run started at 1_500; its index ran at 2_000 (stamp.builtAt); the
+ // site's build was a no-op, so built.builtAt (500) predates the run.
+ const old = builtStamp({ builtAt: 500 });
+ const s = input({ sites: { jer: target({ built: old }) } });
+ assert.equal(state(needs(s, req("deploy-site", "jer", { builtAfter: 1_500 }))), "stale", "same inputs, same bundle");
+ // …but not when the bundle does NOT match the current index (the build failed, say).
+ const drifted = input({ sites: { jer: target({ built: builtStamp({ builtAt: 500, inputSig: "older" }) }) } });
+ assert.match(reason(needs(drifted, req("deploy-site", "jer", { builtAfter: 1_500 }))), /waiting for the build of jer/);
+ // …nor when the index itself has not run since the run began.
+ assert.match(reason(needs(s, req("deploy-site", "jer", { builtAfter: 2_500 }))), /waiting for the build of jer/);
+ // A no-op build's checkedAt counts as well.
+ const checked = input({ sites: { jer: target({ built: builtStamp({ builtAt: 500, checkedAt: 3_000, inputSig: "older" }) }) } });
+ assert.equal(state(needs(checked, req("deploy-site", "jer", { builtAfter: 2_500 }))), "stale");
+ // The hub and the homepage judge "current" by their own signatures.
+ const hub = input({ hub: target({ built: builtStamp({ target: "_hub", kind: "hub", inputSig: "hub-1", builtAt: 500 }) }) });
+ assert.equal(state(needs(hub, req("deploy-hub", "_hub", { builtAfter: 1_500 }))), "stale");
+ const home = input();
+ home.homepage.built = { ...(home.homepage.built as BuiltStamp), builtAt: 500 };
+ assert.equal(state(needs(home, req("deploy-homepage", "_homepage", { builtAfter: 1_500 }))), "stale");
+});
+
+test("changedChannels and config changes are measured against max(builtAt, checkedAt)", () => {
+ assert.equal(builtCheckedAt(builtStamp({ builtAt: 3_000 })), 3_000);
+ assert.equal(builtCheckedAt(builtStamp({ builtAt: 3_000, checkedAt: 9_000 })), 9_000);
+ const cfg = input({ sites: { jer: target({ built: builtStamp({ checkedAt: 9_000 }), configChangedAt: 5_000 }) } });
+ assert.equal(state(needs(cfg, req("build-site", "jer"))), "fresh", "a config change older than the last check");
});
// --- the hub ------------------------------------------------------------------
diff --git a/common/publish/stages.ts b/common/publish/stages.ts
@@ -103,7 +103,8 @@ export type TargetState = {
built: BuiltStamp | null;
deployed: DeployedFile | null;
// Member channels (a site's, or every listed site's for the hub) with an
- // ingest job ended `done` after `built.builtAt`.
+ // ingest job ended `done` after `builtCheckedAt(built)` — the later of
+ // `builtAt` and `checkedAt`, so a no-op build clears the chip.
changedChannels: string[];
// The newest mtime (ms) of a config file this target's build reads (its
// site.json, tags.json, search-aliases.json, duplicates*.json), or null.
@@ -180,13 +181,22 @@ function indexGate(s: NeedsInput, r: StageRequest): Freshness | IndexStamp {
}
// Stale reasons shared by the three builds, after the target's own signature.
+/**
+ * When the bundle was last known to match its inputs: built, or found fresh
+ * by a later no-op build (`checkedAt`). What `changedChannels` and a config
+ * change are measured against.
+ */
+export function builtCheckedAt(built: BuiltStamp): number {
+ return Math.max(built.builtAt, built.checkedAt ?? 0);
+}
+
function builtStale(t: TargetState, sigMatches: boolean, sigReason: string): Freshness {
const built = t.built!;
if (t.changedChannels.length > 0) {
const n = t.changedChannels.length;
return stale(`${n} channel${n === 1 ? "" : "s"} changed (${namesList(t.changedChannels)})`);
}
- if (t.configChangedAt !== null && t.configChangedAt > built.builtAt) return stale("config changed");
+ if (t.configChangedAt !== null && t.configChangedAt > builtCheckedAt(built)) return stale("config changed");
if (!sigMatches) return stale(sigReason);
if (t.bundleProblem) return stale(t.bundleProblem);
return FRESH;
@@ -232,11 +242,25 @@ function needsBuildHomepage(s: NeedsInput, r: StageRequest): Freshness {
return FRESH;
}
+// Is `built` what the CURRENT index would build? (Same inputs, same bundle.)
+function builtFromCurrentIndex(s: NeedsInput, kind: "site" | "hub" | "homepage", id: string): boolean {
+ const stamp = s.index.stamp;
+ const built = kind === "site" ? s.sites[id]?.built : kind === "hub" ? s.hub.built : s.homepage.built;
+ if (!stamp || !built) return false;
+ if (kind === "site") return stamp.sites[id] !== undefined && built.inputSig === stamp.sites[id].inputSig;
+ if (kind === "hub") return built.inputSig === stamp.hubSig;
+ return built.indexStampId === stamp.stampId;
+}
+
function needsDeploy(
t: TargetState | undefined,
name: string,
buildCmd: string,
r: StageRequest,
+ // The bundle matches the current index, and that index ran at or after
+ // `builtAfter` (a run's no-op build: nothing was rebuilt because nothing
+ // needed to be).
+ currentSince: (after: number) => boolean,
): Freshness {
if (!t) return blocked(`no site "${name}"`);
const kind = deployKindOf(r);
@@ -244,13 +268,21 @@ function needsDeploy(
if (never) return blocked(never);
const built = t.built;
if (!built) return blocked(`no build of ${name} — ${buildCmd}`);
- if (r.builtAfter !== undefined && built.builtAt < r.builtAfter) {
+ if (
+ r.builtAfter !== undefined &&
+ builtCheckedAt(built) < r.builtAfter &&
+ !currentSince(r.builtAfter)
+ ) {
return blocked(`waiting for the build of ${name} this run started`);
}
if (t.bundleProblem) return blocked(t.bundleProblem);
- if (kind === "production" && built.branch !== null && built.branch !== "main") {
+ // Production ships only a build of main. A null branch (a detached HEAD, or
+ // an image built without ARCHILYZER_BRANCH) is refused the same way.
+ if (kind === "production" && built.branch !== "main") {
return blocked(
- `${name} was built from branch "${built.branch}"; production ships only a build of main (deploy it as a preview)`,
+ built.branch === null
+ ? `${name} was built with no branch recorded (a detached HEAD, or an image built without ARCHILYZER_BRANCH); production ships only a build of main (deploy it as a preview)`
+ : `${name} was built from branch "${built.branch}"; production ships only a build of main (deploy it as a preview)`,
);
}
if (r.force) return stale("forced");
@@ -369,12 +401,16 @@ export const STAGES: Record<StageKind, Stage> = {
"update-index": stage("update-index", "Update the index", needsIndex),
"build-site": stage("build-site", "Build site", needsBuildSite),
"deploy-site": stage("deploy-site", "Deploy site", (s, r) =>
- needsDeploy(s.sites[r.target], r.target, `archilyzer publish build ${r.target}`, r)),
+ needsDeploy(s.sites[r.target], r.target, `archilyzer publish build ${r.target}`, r, (after) =>
+ builtFromCurrentIndex(s, "site", r.target) && (s.index.stamp?.builtAt ?? 0) >= after)),
"build-hub": stage("build-hub", "Build hub", needsBuildHub),
- "deploy-hub": stage("deploy-hub", "Deploy hub", (s, r) => needsDeploy(s.hub, "the hub", "archilyzer publish hub", r)),
+ "deploy-hub": stage("deploy-hub", "Deploy hub", (s, r) =>
+ needsDeploy(s.hub, "the hub", "archilyzer publish hub", r, (after) =>
+ builtFromCurrentIndex(s, "hub", "_hub") && (s.index.stamp?.builtAt ?? 0) >= after)),
"build-homepage": stage("build-homepage", "Build homepage", needsBuildHomepage),
"deploy-homepage": stage("deploy-homepage", "Deploy homepage", (s, r) =>
- needsDeploy(s.homepage, "the homepage", "archilyzer publish homepage", r)),
+ needsDeploy(s.homepage, "the homepage", "archilyzer publish homepage", r, (after) =>
+ builtFromCurrentIndex(s, "homepage", "_homepage") && (s.index.stamp?.builtAt ?? 0) >= after)),
};
/** The job kind a stage runs as on the editor's `publish` queue. */
diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts
@@ -62,6 +62,9 @@ export type BuiltStamp = {
indexStampId: string | null;
inputSig: string;
builtAt: number;
+ // When a later no-op build last found this bundle still matching its inputs
+ // (absent: never). `changedChannels` is measured against max(builtAt, it).
+ checkedAt?: number;
commit: string | null;
branch: string | null;
runner: Runner;
@@ -193,6 +196,7 @@ export function asBuiltStamp(v: unknown): BuiltStamp | null {
if (!isStrOrNull(v.corpusGeneratedAt)) return null;
if (!isNum(v.files) || !isNum(v.bytes) || !isNum(v.archivesStaged)) return null;
if (v.sourceCommit !== undefined && !isStrOrNull(v.sourceCommit)) return null;
+ if (v.checkedAt !== undefined && !isNum(v.checkedAt)) return null;
return v as unknown as BuiltStamp;
}