commit a899a505ed1839480c53d8d04c28648fdccded20
parent b7f718a268278aa1a525cc5dbe136201e4cee501
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 12:18:43 -0400
publish: the deploy stage's request checks are resolveDeployRequest (asked before a job exists by the surfaces); a stage ends "Done"
Steps 1–3 of runDeployStage (the request, the target's own refusals, a build
to ship) move unchanged into an exported resolver that returns the sentence
and its exit code; the stage refuses with it as before. The editor's publish
actions and the ops route ask the same function before enqueueing, so
"no build of X in <dir> — archilyzer publish build X" is one sentence in both
places. The success line reads "Done" / "Done (no-op)": a console's reader
waits for it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 63 insertions(+), 24 deletions(-)
diff --git a/common/publish/deployStage.ts b/common/publish/deployStage.ts
@@ -90,6 +90,7 @@ import {
HUB_TARGET,
deployRecordFor,
readBuiltStamp,
+ type BuiltStamp,
readDeployedFile,
recordDeploy,
targetDir,
@@ -292,40 +293,56 @@ async function copyToLocal(outDir: string, dest: string): Promise<number> {
* slot already holds this build); THROWS a DeployStageError on every refusal
* and failure, leaving `deployed.json` untouched.
*/
-export async function runDeployStage(
- ctx: DeployStageContext,
- req: DeployStageRequest,
-): Promise<DeployStageOutcome> {
- const { paths, signal } = ctx;
- const env = ctx.env ?? process.env;
- const now = ctx.now ?? (() => new Date());
- const log = (line: string) => ctx.onLog(line.endsWith("\n") ? line : `${line}\n`);
- const refuse = (why: string, exitCode: 1 | 2 | 3 = 1): never => {
- const line = /^\[deploy\] REFUSED/.test(why) ? why : `[deploy] REFUSED — ${why}`;
- log(line);
- throw new DeployStageError(line, exitCode);
- };
+/** What a deploy request resolves to, once its own refusals are answered. */
+export type ResolvedDeploy = {
+ target: string;
+ toLocal: boolean;
+ branch: string | undefined;
+ recordKind: DeployRecord["kind"];
+ site: Site | null;
+ project: string;
+ publicUrl: string | undefined;
+ cwd: string;
+ stampDir: string;
+ outDir: string;
+ built: BuiltStamp;
+};
+/** The sentence a deploy request is refused with, and its exit code. */
+export type DeployRequestProblem = { problem: string; exitCode: 2 | 3 };
+
+/**
+ * Steps 1–3 of the deploy stage (the request, the target's own refusals, a
+ * build to ship), answered without touching anything. The stage body asks it
+ * first; the editor's actions and the ops route ask it BEFORE a job exists, so
+ * a refusal is the same sentence, word for word, either way. A site id that is
+ * not one throws (getSite), as the stage always has.
+ */
+export async function resolveDeployRequest(
+ paths: Paths,
+ req: DeployStageRequest,
+): Promise<ResolvedDeploy | DeployRequestProblem> {
// --- 1. the request (a refusal here is a usage error, exit 2) ---
const target = req.target.trim();
+ const fail = (problem: string, exitCode: 2 | 3): DeployRequestProblem => ({ problem, exitCode });
// The hub's and the homepage's targets are fixed, and a site's is never one
// of them: a mismatch would read and WRITE another target's stamps (a
// homepage deploy recorded in a site's production slot).
const fixed = req.kind === "deploy-hub" ? HUB_TARGET : req.kind === "deploy-homepage" ? HOMEPAGE_TARGET : null;
if (fixed !== null && target !== fixed) {
- refuse(`${req.kind} deploys "${fixed}", not "${target}".`, 2);
+ return fail(`${req.kind} deploys "${fixed}", not "${target}".`, 2);
}
if (fixed === null && (target === HUB_TARGET || target === HOMEPAGE_TARGET)) {
- refuse(`"${target}" is not a site — deploy it with ${target === HUB_TARGET ? "deploy-hub" : "deploy-homepage"}.`, 2);
+ return fail(`"${target}" is not a site — deploy it with ${target === HUB_TARGET ? "deploy-hub" : "deploy-homepage"}.`, 2);
}
const toLocal = req.to === "local";
if (req.preview !== undefined) {
const problem = previewBranchProblem(req.preview);
- if (problem) refuse(problem, 2);
+ if (problem) return fail(problem, 2);
}
const branch = req.preview?.trim() || undefined;
- if (toLocal && branch) refuse("a local deploy has no preview branch — deploy locally or as a preview, not both.", 2);
- if (toLocal && req.kind === "deploy-hub") refuse("the hub has no local target — deploy it to Cloudflare Pages.", 2);
+ if (toLocal && branch) return fail("a local deploy has no preview branch — deploy locally or as a preview, not both.", 2);
+ if (toLocal && req.kind === "deploy-hub") return fail("the hub has no local target — deploy it to Cloudflare Pages.", 2);
const recordKind: DeployRecord["kind"] = toLocal ? "local" : branch ? "preview" : "production";
// --- 2. the target's own refusals ---
@@ -336,14 +353,14 @@ export async function runDeployStage(
if (req.kind === "deploy-site") {
site = getSite(target, paths);
const privateProblem = siteDeployProblem(site);
- if (privateProblem) refuse(`${privateProblem}.`, 3);
+ if (privateProblem) return fail(`${privateProblem}.`, 3);
project = site.cloudflareProject?.trim() ?? "";
- if (!project && !toLocal) refuse(`Site "${target}" has no Cloudflare Pages project configured.`, 3);
+ if (!project && !toLocal) return fail(`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, 3);
+ if (problem) return fail(problem, 3);
project = hub.cloudflareProject!.trim();
publicUrl = hub.siteUrl;
} else {
@@ -358,12 +375,32 @@ export async function runDeployStage(
const built = await readBuiltStamp(paths, target);
const cliTarget = req.kind === "deploy-hub" ? "hub" : req.kind === "deploy-homepage" ? "homepage" : target;
if (!built) {
- refuse(
+ return fail(
`no build of ${target} in ${stampDir} — archilyzer publish ${req.kind === "deploy-site" ? `build ${cliTarget}` : cliTarget}`,
3,
);
}
- const b = built!;
+ return { target, toLocal, branch, recordKind, site, project, publicUrl, cwd, stampDir, outDir, built };
+}
+
+export async function runDeployStage(
+ ctx: DeployStageContext,
+ req: DeployStageRequest,
+): Promise<DeployStageOutcome> {
+ const { paths, signal } = ctx;
+ const env = ctx.env ?? process.env;
+ const now = ctx.now ?? (() => new Date());
+ const log = (line: string) => ctx.onLog(line.endsWith("\n") ? line : `${line}\n`);
+ const refuse = (why: string, exitCode: 1 | 2 | 3 = 1): never => {
+ const line = /^\[deploy\] REFUSED/.test(why) ? why : `[deploy] REFUSED — ${why}`;
+ log(line);
+ throw new DeployStageError(line, exitCode);
+ };
+
+ const resolved = await resolveDeployRequest(paths, req);
+ if ("problem" in resolved) refuse(resolved.problem, resolved.exitCode);
+ const { target, toLocal, branch, recordKind, site, project, publicUrl, cwd, outDir } = resolved as ResolvedDeploy;
+ const b = (resolved as ResolvedDeploy).built;
// (A run's `builtAfter` is the stage's needs() — stages.ts needsDeploy — asked
// before this body runs: it knows a no-op build's `checkedAt`.)
const slot = deployRecordFor(await readDeployedFile(paths, target), recordKind, branch);
diff --git a/common/publish/stageRun.ts b/common/publish/stageRun.ts
@@ -110,7 +110,9 @@ export async function runStage(
try {
const outcome = await STAGES[req.kind].run({ paths, onLog, signal }, req);
if (signal.aborted) throw new StageCancelled();
- onLog(`[stage] ${name}: ${outcome.status === "noop" ? "no-op" : "done"} — ${outcome.summary}\n`);
+ // "Done" is what a console's reader waits for (the e2e buildIndex helper
+ // reads "Build index output" for it, as it did before the stages).
+ onLog(`[stage] ${name}: ${outcome.status === "noop" ? "Done (no-op)" : "Done"} — ${outcome.summary}\n`);
return { code: STAGE_EXIT.ok, outcome, message: null };
} catch (err) {
if (err instanceof StageCancelled || signal.aborted) {