Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/publish/stageBodies.ts | 49+++++++++++++++++++++++++++++++++++++++----------
Mcommon/publish/stageRun.test.ts | 25+++++++++++++++++++++++++
Mcommon/publish/stages.test.ts | 37++++++++++++++++++++++++++++++++++++-
Mcommon/publish/stages.ts | 52++++++++++++++++++++++++++++++++++++++++++++--------
Mcommon/publish/stamps.ts | 4++++
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; }