Archilyzer · Source

archilyzer

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

commit 97a92b16df4db8346cd5d878039a83d94a9c5b0c
parent 9c05b02eff32397f1d0e55b23f0d976ad39e0ee2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 10:51:17 -0400

publish: the local deploy's destination guard resolves symlinks and refuses export/, the builds and the corpus; deploy exit codes agree with needs()

- localDestProblem: realpath of the destination (its nearest existing
  ancestor) and of every protected root; a destination inside the builds
  dir, export/ (so export/out, the link to the last bundle) or the corpus
  is refused, as is one containing them or the checkout. Test:
  ARCHILYZER_SITE_OUT=<export>/out is refused and the bundle untouched.
- A private site, no Pages project, a production build not of main and
  the bundle guards exit 3 (precondition), as needs() does.
- deployStage.ts's header says what landed: S1's stamps and needs() first.
- stageRun.test.ts proves the local copy by files the destination did not
  have (corpus.json, site.json) and the replaced index.html.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/publish/deployStage.test.ts | 51++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/publish/deployStage.ts | 124++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcommon/publish/stageRun.test.ts | 6+++++-
3 files changed, 123 insertions(+), 58 deletions(-)

diff --git a/common/publish/deployStage.test.ts b/common/publish/deployStage.test.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -53,6 +54,8 @@ function fixture(): Fixture { const paths: Paths = { ...getPaths(), monorepoRoot: root, + // Never the checkout's corpus: the local-destination guard resolves it. + transcriptsDir: path.join(root, "transcripts"), exportDir: path.join(root, "export"), exportBuildsDir: path.join(root, "builds"), sitesDir, @@ -304,8 +307,8 @@ test("every refusal leaves deployed.json untouched, and wrangler unspawned", asy ["no credential", { kind: "deploy-site", target: "anilyzer" }, {}, 1, /set CLOUDFLARE_API_TOKEN in \.env/], ["a production branch as preview", { kind: "deploy-site", target: "anilyzer", preview: "main" }, TOKEN, 2, /is the production branch/], ["the hub's target as a site", { kind: "deploy-site", target: "_hub" }, TOKEN, 2, /"_hub" is not a site — deploy it with deploy-hub/], - ["a private site", { kind: "deploy-site", target: "mine" }, TOKEN, 1, /is private \(audience: private\)/], - ["no Pages project", { kind: "deploy-site", target: "noproj" }, TOKEN, 1, /no Cloudflare Pages project configured/], + ["a private site", { kind: "deploy-site", target: "mine" }, TOKEN, 3, /is private \(audience: private\)/], + ["no Pages project", { kind: "deploy-site", target: "noproj" }, TOKEN, 3, /no Cloudflare Pages project configured/], ["never built", { kind: "deploy-site", target: "nobuild" }, TOKEN, 3, /no build of nobuild in .*builds\/nobuild — archilyzer publish build nobuild/], ["local and preview at once", { kind: "deploy-site", target: "anilyzer", to: "local", preview: "p" }, TOKEN, 2, /a local deploy has no preview branch/], ["local with nowhere to copy", { kind: "deploy-site", target: "anilyzer", to: "local" }, TOKEN, 3, /^\[deploy\] REFUSED — --to local needs ARCHILYZER_SITE_OUT/], @@ -362,7 +365,7 @@ test("production ships only a build of main; the same build may go to a preview" const c = ctx(fx, TOKEN); await refused( runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), - 1, + 3, /made from branch "r18\/feature", not main: production ships only a build of main/, ); assert.equal(deployedBytes(fx, "anilyzer"), null); @@ -373,7 +376,7 @@ test("production ships only a build of main; the same build may go to a preview" built(fx, "jasolyzer", { stamp: { branch: null } }); await refused( runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "jasolyzer" }), - 1, + 3, /has no branch recorded .*production ships only a build of main/, ); } finally { @@ -388,14 +391,14 @@ test("the bundle guards: another site's bundle and a private build are refused b built(fx, "anilyzer", { bundleOf: "jeralyzer" }); await refused( runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "anilyzer" }), - 1, + 3, /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)\. Nothing was sent to Cloudflare Pages/, ); site(fx, "bonnellyzer"); built(fx, "bonnellyzer", { corpus: { site: { id: "bonnellyzer", audience: "private" } } }); await refused( runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "bonnellyzer" }), - 1, + 3, /private build of "bonnellyzer"/, ); assert.deepEqual(sidecar(fx, "anilyzer"), []); @@ -431,7 +434,7 @@ test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replac built(fx, "mine"); await refused( runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: dest }), { kind: "deploy-site", target: "mine", to: "local" }), - 1, + 3, /is private/, ); assert.ok(existsSync(path.join(dest, "site.json"))); @@ -453,6 +456,36 @@ test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replac /holds the checkout/, ); assert.ok(existsSync(path.join(fx.root, "sites", "anilyzer", "site.json")), "nothing was emptied"); + + // export/out is a LINK to the bundle built last (S1): resolved, it is the + // bundle itself, inside the builds dir — refused, the bundle untouched. + const bundle = path.join(fx.paths.exportBuildsDir, "anilyzer", "out"); + symlinkSync(bundle, path.join(fx.paths.exportDir, "out")); + const bundleBefore = readdirSync(bundle).sort(); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: path.join(fx.paths.exportDir, "out") }), { + kind: "deploy-site", + target: "anilyzer", + to: "local", + force: true, + }), + 1, + /export\/out (holds|is inside) /, + ); + assert.deepEqual(readdirSync(bundle).sort(), bundleBefore); + // …and so is any directory inside export/, the builds dir or the corpus. + for (const [inner, what] of [ + [path.join(fx.paths.exportDir, "site-out"), "export/"], + [path.join(fx.paths.exportBuildsDir, "site"), "the builds directory"], + [path.join(fx.paths.transcriptsDir, "site"), "the corpus"], + ]) { + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: inner }), { kind: "deploy-site", target: "anilyzer", to: "local", force: true }), + 1, + new RegExp(`is inside ${what.replace("/", "\\/")}`), + ); + assert.equal(existsSync(inner), false, `${inner} was made`); + } } finally { fx.cleanup(); } @@ -556,7 +589,7 @@ test("the homepage's local deploy copies homepage/out into ARCHILYZER_HOMEPAGE_O // page (its source step refused) is never shipped. await refused( runDeployStage(ctx(fx, { ARCHILYZER_HOMEPAGE_OUT: dest }), req), - 1, + 3, /homepage\/out has no \/source page/, ); // A `--no-source` build: the page's empty state and nothing else. @@ -616,7 +649,7 @@ test("the hub: its project, its bundle, and every tombstone probed plain and bus // The homepage's project is never the hub's. writeJson(fx.paths.homepageConfigFile, { cloudflareProject: "archilyzer" }); - await refused(runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", force: true }), 1, /homepage's/); + await refused(runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", force: true }), 3, /homepage's/); await refused( runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", to: "local" }), 2, diff --git a/common/publish/deployStage.ts b/common/publish/deployStage.ts @@ -1,52 +1,54 @@ // THE DEPLOY STAGE (release 18): one body for `publish-deploy-site`, -// `publish-deploy-hub` and `publish-deploy-homepage`, whichever runner built -// the bundle. +// `publish-deploy-hub` and `publish-deploy-homepage` (stageBodies.ts runs it), +// whichever runner built the bundle. // // It ships `<exportBuildsDir>/<target>/out` (the homepage: `homepage/out`), // the bundle a build stage wrote and stamped `built.json`, and records what it -// did in `deployed.json` beside it. In order — and NOTHING is written to -// `deployed.json` unless every step before the record succeeded: +// did in `deployed.json` beside it (publish/stamps.ts, S1's shapes and +// writers). The stage's `needs()` (stages.ts needsDeploy) is asked BEFORE this +// body and answers most preconditions first — no build, a run's `builtAfter` +// (it alone: it knows a no-op build's `checkedAt`), a private site, no Pages +// project, the production branch, a bundle that is not the target's, freshness. +// This body asks them again as the last word before wrangler, with the same +// exit codes. In order — and NOTHING is written to `deployed.json` unless every +// step before the record succeeded: // -// 1. the request: a preview branch name Cloudflare keeps verbatim; a local -// deploy has no branch; the hub has no local target -// 2. the target's own refusals, before anything else is read: a private site -// (siteDeployProblem), a missing Pages project, the hub's project -// (hubProjectProblem) -// 3. the build: `built.json` must exist (exit 3, "no build of X in <dir>"), be -// newer than `builtAfter` when the run started a build (exit 3), and — -// unless forced — not be the one this slot already shipped (a no-op) -// 4. PRODUCTION ships only a build of `main`: a `built.branch` that is set -// and is not `main` is refused (a preview is fine) -// 5. today's bundle guards over the bundle itself: builtBundleProblem (the -// site's own, by site.json AND corpus.json — the stricter twin of +// 1. the request (exit 2): a preview branch name Cloudflare keeps verbatim; +// a local deploy has no branch; the hub has no local target; the hub's +// and the homepage's targets are fixed +// 2. the target's own refusals (exit 3): a private site (siteDeployProblem), a +// missing Pages project, the hub's project (hubProjectProblem) +// 3. the build: `built.json` must exist (exit 3, "no build of X in <dir>"), +// and — unless forced — not be the one this slot already shipped (a no-op) +// 4. PRODUCTION ships only a build of `main` (exit 3): a build from another +// branch, or with no branch recorded, is refused (a preview is fine) +// 5. the bundle guards over the bundle itself (exit 3): builtBundleProblem +// (the site's own, by site.json AND corpus.json — the stricter twin of // builtSiteProblem, naming the directory), builtAudienceProblem, // builtScopeProblem; the hub's builtHubProblem; the homepage's // builtHomepageProblem + publishedSourceProblem // 6. `--to local`: the bundle is copied into ARCHILYZER_SITE_OUT (the // directory the compose `site` service serves; the homepage's is -// ARCHILYZER_HOMEPAGE_OUT, what the `homepage` service serves) and the -// stage records `local` — no credential, no R2, no wrangler, no live check -// 7. the credential preflight: no CLOUDFLARE_API_TOKEN and no wrangler OAuth -// login on disk → refused before wrangler +// ARCHILYZER_HOMEPAGE_OUT) after localDestProblem, and the stage records +// `local` — no credential, no R2, no wrangler, no live check +// 7. the credential preflight (exit 1): no CLOUDFLARE_API_TOKEN and no +// wrangler OAuth login on disk → refused before wrangler // 8. a site's oversize archives to R2, from `<id>/.r2-staging` // 9. the pinned wrangler (wranglerBin), `--branch main` or `--branch <b>`; // Cloudflare refusing the credential reads as CLOUDFLARE_AUTH_REFUSED // 10. the live check (liveCheck.ts): a WARNING, never a failure -// 11. the `deployed.json` record (atomic: temp file + rename) +// 11. the `deployed.json` record (recordDeploy: temp file + rename) // // A refusal or a failure THROWS a DeployStageError carrying the exit code the -// stage contract names (1 refused/failed, 2 a request the stage cannot run — a -// bad branch name, a kind and target that do not match, local with a preview — -// 3 precondition not met, 130 cancelled); its message is the sentence the log -// already ends on, word for word. -// -// The stamp shapes are release 18's model (plans/release-18.md, "Model"). S1's -// publish/stamps.ts owns them once it lands — the fields here are the same. +// stage contract names (1 refused/failed, 2 usage, 3 precondition not met — +// the codes needs() gives the same refusals — 130 cancelled); its message is +// the sentence the log already ends on, word for word, and stageRun.ts does +// not print it again. import os from "node:os"; import path from "node:path"; import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { cp, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { runChildIntoLog } from "../jobs/runChild"; import { builtAudienceProblem, @@ -202,32 +204,57 @@ function readdirSyncDirs(dir: string): string[] { .sort(); } +// A path with its symlinks resolved: realpath of its nearest existing +// ancestor, the rest appended (the destination may not exist yet). +async function resolvedPath(p: string): Promise<string> { + let head = path.resolve(p); + const tail: string[] = []; + for (;;) { + try { + return path.join(await realpath(head), ...tail.reverse()); + } catch { + const parent = path.dirname(head); + if (parent === head) return path.resolve(p); + tail.push(path.basename(head)); + head = parent; + } + } +} + // Why `dest` may not be emptied and filled with `outDir`, or null. The local // copy EMPTIES its destination, and the stage runs on hosts as well as in the // container, so a mis-set ARCHILYZER_SITE_OUT (a home dir, the repo, a data -// volume) must not be wiped: the destination may not be, or contain, the -// checkout, the corpus, the builds or the bundle, and a non-empty destination -// must look like a bundle this copy made (an `index.html` at its top — the +// volume, `export/out` — a link to the last bundle) must not be wiped. With +// every symlink resolved on both sides, the destination may not CONTAIN the +// checkout, the corpus, the builds, export/ or the bundle, nor lie INSIDE the +// corpus, the builds, export/ or the bundle; and a non-empty destination must +// look like a bundle this copy made (an `index.html` at its top — the // container's placeholder page has one too). export async function localDestProblem( dest: string, outDir: string, paths: Pick<Paths, "monorepoRoot" | "transcriptsDir" | "exportBuildsDir" | "exportDir">, ): Promise<string | null> { - const d = path.resolve(dest); + const d = await resolvedPath(dest); const inside = (parent: string, child: string) => { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); }; - for (const [what, dir] of [ - ["the checkout", paths.monorepoRoot], - ["the corpus", paths.transcriptsDir], - ["the builds directory", paths.exportBuildsDir], - ["export/", paths.exportDir], - ["the bundle", outDir], - ] as const) { - if (inside(d, path.resolve(dir)) || inside(path.resolve(outDir), d)) { - return `${d} holds ${what} (or is inside the bundle) — a local deploy empties its destination; set it to the directory the local server serves.`; + const protectedRoots: [string, string, boolean][] = [ + // [what, dir, may the destination lie inside it?] + ["the checkout", paths.monorepoRoot, true], + ["the corpus", paths.transcriptsDir, false], + ["the builds directory", paths.exportBuildsDir, false], + ["export/", paths.exportDir, false], + ["the bundle", outDir, false], + ]; + for (const [what, dir, insideOk] of protectedRoots) { + const root = await resolvedPath(dir); + if (inside(d, root)) { + return `${dest} holds ${what} (${root}) — a local deploy empties its destination; set it to the directory the local server serves.`; + } + if (!insideOk && inside(root, d)) { + return `${dest} is inside ${what} (${d}) — a local deploy empties its destination; set it to the directory the local server serves.`; } } let entries: string[]; @@ -309,14 +336,14 @@ export async function runDeployStage( if (req.kind === "deploy-site") { site = getSite(target, paths); const privateProblem = siteDeployProblem(site); - if (privateProblem) refuse(`${privateProblem}.`); + if (privateProblem) refuse(`${privateProblem}.`, 3); project = site.cloudflareProject?.trim() ?? ""; - if (!project && !toLocal) refuse(`Site "${target}" has no Cloudflare Pages project configured.`); + if (!project && !toLocal) refuse(`Site "${target}" has no Cloudflare Pages project configured.`, 3); publicUrl = site.siteUrl?.trim() || undefined; } else if (req.kind === "deploy-hub") { const hub = getHomepageConfig(paths); const problem = hubProjectProblem(hub.cloudflareProject); - if (problem) refuse(problem); + if (problem) refuse(problem, 3); project = hub.cloudflareProject!.trim(); publicUrl = hub.siteUrl; } else { @@ -356,6 +383,7 @@ export async function runDeployStage( ? `the build of ${target} has no branch recorded (a detached HEAD, or an image built without ARCHILYZER_BRANCH)` : `the build of ${target} was made from branch "${b.branch}", not ${PRODUCTION_BRANCH}`) + `: production ships only a build of ${PRODUCTION_BRANCH}. Build it from ${PRODUCTION_BRANCH}, or deploy this one as a preview.`, + 3, ); } @@ -364,15 +392,15 @@ export async function runDeployStage( const problem = builtBundleProblem(outDir, target) ?? builtAudienceProblem(outDir) ?? builtScopeProblem(site!, outDir); if (problem) { - refuse(`${problem}. Nothing was sent to Cloudflare Pages; build ${target} again, then deploy.`); + refuse(`${problem}. Nothing was sent to Cloudflare Pages; build ${target} again, then deploy.`, 3); } } else if (req.kind === "deploy-hub") { const problem = builtHubProblem(outDir); - if (problem) refuse(`${problem.replace(/^export\/out/, outDir)}.`); + if (problem) refuse(`${problem.replace(/^export\/out/, outDir)}.`, 3); } else { const problem = builtHomepageProblem(outDir) ?? (await (await import("./source")).publishedSourceProblem(paths, outDir)); - if (problem) refuse(problem); + if (problem) refuse(problem, 3); } const builtAt = b.builtAt; diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts @@ -173,7 +173,11 @@ test("a site the index has not seen is blocked; a fresh site's build is a no-op; const local = await stage("deploy-site", "jer", { to: "local" }); assert.equal(local.code, 0, local.message ?? ""); assert.equal(local.outcome?.status, "ran"); - assert.ok(existsSync(path.join(siteOut, "index.html"))); + // The copy happened: the bundle's own files arrived (the destination had + // neither), its index.html replaced the seeded one, and the stale file went. + assert.ok(existsSync(path.join(siteOut, "corpus.json"))); + assert.ok(existsSync(path.join(siteOut, "site.json"))); + assert.notEqual(readFileSync(path.join(siteOut, "index.html"), "utf8"), "old"); assert.ok(!existsSync(path.join(siteOut, "stale.html"))); const rec = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "jer"), "local"); assert.equal(rec?.builtStampId, "b-jer");