commit 3499cbd7ba8e03020d04f4f05a3377f8d8c69a85
parent 38fc30bfe46955394023d204ffa337a278a34150
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 10:55:26 -0400
Merge r18/deploy-hardening (slice S2, cleanup: a symlink-safe local-deploy destination guard, exit 3 for every precondition, malformed-token Cloudflare codes in the auth classifier)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 160 insertions(+), 65 deletions(-)
diff --git a/common/lib/pagesDeploy.test.ts b/common/lib/pagesDeploy.test.ts
@@ -136,6 +136,10 @@ test("wranglerAuthFailureIn: Cloudflare's refusals and wrangler's missing-login
"✘ [ERROR] A request to the Cloudflare API (/accounts/x/pages/projects/y) failed. Authentication error [code: 10000]",
"Invalid access token [code: 9109]",
"Unable to authenticate request [code: 10001]",
+ // A malformed token (CLOUDFLARE_API_TOKEN=bogus, the container smoke):
+ " Invalid request headers [code: 6003]",
+ " Invalid format for Authorization header [code: 6111]",
+ "✘ [ERROR] A request to the Cloudflare API (/accounts/x/pages/projects/y) failed. Invalid request headers [code: 6003]",
"In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work.",
"You are not authenticated. Please run `wrangler login`.",
"Failed to refresh OAuth token",
@@ -146,6 +150,8 @@ test("wranglerAuthFailureIn: Cloudflare's refusals and wrangler's missing-login
"✨ Success! Uploaded 12 files (40 already uploaded)",
"Project not found. The specified project name does not match any of your existing projects. [code: 8000007]",
"Take a peek over at https://abc123.anilyzer.pages.dev",
+ "Invalid request headers",
+ "[code: 6003]",
"",
]) {
assert.equal(wranglerAuthFailureIn(line), false, line);
diff --git a/common/lib/pagesDeploy.ts b/common/lib/pagesDeploy.ts
@@ -123,12 +123,18 @@ export const CLOUDFLARE_NO_CREDENTIALS =
// What wrangler 4 prints when Cloudflare rejects, or it cannot find, a
// credential: the API's own error codes (10000 "Authentication error", 9109
-// "Invalid access token", 10001 "Unable to authenticate request") and wrangler's
-// own sentences for a missing login in a non-interactive run.
+// "Invalid access token" — a well-formed token that is wrong — 10001 "Unable to
+// authenticate request", 6003 "Invalid request headers" and 6111 "Invalid format
+// for Authorization header" — a malformed one) and wrangler's own sentences for
+// a missing login in a non-interactive run.
const AUTH_FAILURE_RES: readonly RegExp[] = [
/Authentication error \[code: 10000\]/,
/Invalid access token \[code: 9109\]/,
/Unable to authenticate request \[code: 10001\]/,
+ // A malformed token (not a token's shape at all): Cloudflare rejects the
+ // header before it reads the credential (seen with CLOUDFLARE_API_TOKEN=bogus).
+ /Invalid request headers \[code: 6003\]/,
+ /Invalid format for Authorization header \[code: 6111\]/,
/necessary to set a CLOUDFLARE_API_TOKEN environment variable/,
/You are not authenticated\. Please run `wrangler login`/,
/Failed to refresh (?:the )?OAuth token/i,
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");
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,8 +2,7 @@
## [Unreleased]
- **Deploys are pinned and checked live.** wrangler is an exact dependency of the workspace (4.147.0), so a deploy runs the version installed with the code instead of whatever `pnpm dlx` fetched that day, and every deploy names its branch: production is `--branch main`, never taken from the checkout it ran in (where a "production" deploy from a feature branch used to land as a preview). The publish stages' deploy (release 18) refuses before wrangler runs when there is no Cloudflare credential at all — "set CLOUDFLARE_API_TOKEN in .env" — and says "REFUSED by Cloudflare — the API token was not accepted" when Cloudflare rejects one; it refuses a production deploy of a build made from a branch other than `main`. After each deploy it reads `corpus.json` at the site's address twice, as a visitor would and cache-busted, and records the verdict: ok, stale-edge (the deployment is right, Cloudflare's edge still serves an older copy), mismatch, or unreachable. A verdict short of ok is a warning in the log; the deploy itself succeeded. What each target last shipped, where, and how it read is kept in `deployed.json` beside its build.
-- **The hub builds again.** Since 2026-10-05 every hub build was refused as "still carries a site's data (reports, m)": the export app's own report and moment pages are part of every build, the hub's included. A hub build is now refused only for report data a site's build wrote — a report index, a report's page, citations, exports or history, a moment — and still for any other site data.
-- **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel a public site carries, with an empty posts manifest; a channel only private sites carry is never named on the hub. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back.
+- **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel a public site carries, with an empty posts manifest; a channel only on a private site, or on no site, is never named on the hub. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back.
- **Publishing is stages, from the command line: `archilyzer publish`.** `publish index` updates the index — the LMDB index, the stats datasets and the chart templates, in one child process with an 8 GB heap — and writes an index stamp (`export/.export-index/stamp.json`) naming, for each site, a signature of everything that site's build reads. `publish build <id|all>` builds a site from that index (no data phase of its own) into its own bundle, `export/.export-builds/<id>/out`, and stamps it (`built.json`); a site whose bundle already matches the index is a no-op unless `--force`. `publish deploy <id|all> [--preview <branch>] [--to local]` ships that bundle — to Cloudflare Pages, or with `--to local` into the directory the docker `site` service serves — and records the deploy (`deployed.json`); deploying the same build again is a no-op unless `--force`. `all` passes over private sites and, to Pages, sites with no Pages project; any other site it cannot deploy is a failure, said after the rest are tried. `publish hub [--deploy]` and `publish homepage [--deploy]` do the same for the hub (`_hub/out`) and the homepage. A stage whose input is not there says so and exits 3: "update the index first", "no build of jeralyzer — archilyzer publish build jeralyzer". Production refuses a bundle built on a branch other than `main`, or with no branch recorded (a detached checkout; an image sets `ARCHILYZER_BRANCH`) — a preview of it is fine. Exit codes: 0 done or nothing to do, 1 failed, 2 usage, 3 precondition not met, 130 cancelled.
- **One publish at a time on a machine.** Every stage takes `export/.export-builds/.publish.lock`; a second one — an `archilyzer publish` beside the editor, say — waits for it, saying once whom it waits for, and Ctrl-C ends the wait. A lock left by a process that is gone is taken over. A cancelled stage takes the whole process tree it started with it (`next build`'s workers, wrangler, docker).
- **`export/out` is now a link to the bundle built last.** Each site, and the hub, keeps its own bundle, so building one site no longer replaces another's; `export/out` points at whichever was built most recently, so `serve out` and anything else that read it keeps working.
diff --git a/plans/release-18.md b/plans/release-18.md
@@ -395,8 +395,10 @@ export 6910), one Opus implementer, beside S1 (stage core) and S5's image half.
from `dockerSiteStagingDir` (`<id>/.r2-staging`), a preview logging `PREVIEW_SHARES_ARCHIVES_NOTICE`; the pinned
wrangler with the classifier on its stream; the live check; and LAST the record, written atomically (temp +
rename) into `deployed.json` (`production` / `local` / `previews[branch]`, other slots kept). A refusal or failure
- throws `DeployStageError` (`exitCode` 1 refused/failed, 2 a request it cannot run — a bad branch name, local with a
- preview, the hub locally, a kind and target that do not match — 3 precondition not met, 130 cancelled) whose message
+ throws `DeployStageError` (`exitCode` 1 failed or refused at the credential/destination step, 2 a request it cannot
+ run — a bad branch name, local with a preview, the hub locally, a kind and target that do not match — 3 precondition
+ not met: no build, a private site, no Pages project, a production build not of main, a bundle guard — the codes
+ `needs()` gives the same refusals; 130 cancelled) whose message
is the line the log already ends on, word for word, and `deployed.json` is untouched. `deploy-hub` and
`deploy-homepage` take only `_hub` / `_homepage`, and a site deploy refuses either. Wrangler's own lines reach the log
through the same `log()`, each ending in a newline. A local destination that holds the checkout, the corpus, the
@@ -656,13 +658,30 @@ written; `stage deploy-site smoke --preview smoke` 0 through the stage row (fake
is the unit tests'; the same again: no-op (fresh); `E2E_FAKE_WRANGLER_AUTH_FAIL=1`: exit 1, `[deploy] REFUSED by
Cloudflare — the API token was not accepted` once, then `[stage] deploy-site smoke: FAILED (exit 1)`, no record;
`stage deploy-hub _hub --preview smoke` 0 (`previews.smoke` in `_hub/deployed.json`); `stage deploy-homepage smoke`
-refused by S1's argv parser ("the target is _homepage", exit 1); `publish deploy smoke --to local` 0 (195 files into
+refused by S1's argv parser ("the target is _homepage", exit 2 — the step's 1 was `pnpm exec`'s); `publish deploy smoke --to local` 0 (195 files into
the scratch `siteout`, `local` recorded with `liveCheck: null`).
| Run | At | Specs | Result |
|---|---|---|---|
| 5 (editor) | `f1541070` | the five + `build`'s neighbours: `deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope`, `cut-release` | not run: the export webServer timed out (120 s) — the smoke, run while this one waited 16 min in the queue, had composed the hub into this worktree's `export/public` (no `summaries/`). `export/public` restored (`git clean -X` + the fixture links), then run 6 |
| 6 (editor) | `f1541070` | the same six | **35 passed**, 0 failed, 2.3 min (no queue wait) |
+
+**The hub fix is a record, not a changelog line:** the regression (main `5c09cd7b`, 2026-10-05) is in no release, so
+no user ever saw it.
+
+#### Round-2 review cleanups (review SHIP, `$T/s2-review-2.md`)
+
+| # | Fix | Commit |
+|---|---|---|
+| L1 | `localDestProblem` resolves symlinks (realpath of the destination's nearest existing ancestor, and of every protected root) and refuses a destination INSIDE the builds dir, `export/` (so `export/out`, the link to the last bundle) or the corpus, as well as one containing them or the checkout; tests: `ARCHILYZER_SITE_OUT=<export>/out` refused with the bundle untouched, and a directory inside each of the three refused and not made | `c5f9100f` |
+| L2 | The deploy body's exit codes agree with `needs()`: a private site, no Pages project (site or hub), a production build not of main, and the bundle guards (incl. the homepage's source gate) exit 3, not 1 | `c5f9100f` |
+| L3 | `deployStage.ts`'s header: S1 has landed; `builtAfter` is `needs()`'s; the exit codes per step | `c5f9100f` |
+| L4 | `stageRun.test.ts` proves the local copy by files the destination did not have (`corpus.json`, `site.json`) and the replaced `index.html` | `c5f9100f` |
+| L7 | The smoke row: the mismatched homepage deploy exits 2; the changelog's tombstone bullet says "a channel only on a private site, or on no site"; the "hub builds again" changelog bullet removed (the statement above stays) | `c5f9100f` |
+| S5 smoke | A malformed token (`CLOUDFLARE_API_TOKEN=bogus`) gets `Invalid request headers [code: 6003]` / `Invalid format for Authorization header [code: 6111]` from Cloudflare; both now read as `[deploy] REFUSED by Cloudflare — the API token was not accepted` (9109, a well-formed wrong token, already did); a test line each | `51ef7fd1` |
+
+Gates after the cleanups (at `51ef7fd1`, after merging `r18/integration` `f9f7cfbf`, S5 complete, clean): tsc clean;
+**common 3,282/3,282**; **editor unit 142/142**.
### Slice S1, as shipped — the stage contract, the stamps, the lock, per-target bundles and the CLI (2026-10-06)
Branch `r18/stage-core` off `ce66f2d3` (the plan commit on `r18/integration`), worktree `~/Projects/r18-stage-core`