commit 52419c40f4355a7346226648ade0417a4c662255
parent bd5081a5a0396b60da4e2c3d69d7462cfd38e829
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 13:28:57 -0400
Merge r18/surfaces (slice S4: the /sites Publish panel, /operations/publish, POST|GET /api/ops/publish and the eight aliases, pnpm ops publish, the site form's publish policy, the run-wide Cancel; the index judged by its settings signature)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
83 files changed, 3645 insertions(+), 2842 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -73,6 +73,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not
| `ARCHIVE_CHANNEL_CONCURRENCY` | `4` | How many channels' archive zips `build archives` builds at once. | common/bin/build-archives.ts |
| `HOST` | every interface | The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine. | export/scripts/serve-out.mjs |
| `MAX_ARCHIVE_BYTES` | the Cloudflare-safe cap | The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins. | common/bin/compose-site.ts |
+| `EXPORT_NEXT_BIN` | unset: `pnpm exec next build` in export/ | The `next` a site's or the hub's build runs as `<bin> build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out. | common/publish/build.ts (nextBuildStep) |
| `WRANGLER_BIN` | `common/node_modules/.bin/wrangler` (the pinned devDependency) | The wrangler a deploy spawns. The editor's e2e suite points it at its fake. | common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts |
| `CHOUGH_BIN` | `chough` on PATH | The chough transcription engine, when a worker names no binary. | common/lib/transcriptionApps.ts |
| `CHOUGH_MODEL` | chough's own | Passed to chough from a worker's model field; chough auto-downloads one when unset. | chough (set by common/lib/transcriptionApps.ts) |
diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts
@@ -439,9 +439,15 @@ test("the publish rows and their flags (status and now are S3's)", () => {
assert.deepEqual(row("publish", "build", "jer")?.flags, { runner: "string", force: "boolean", "skip-archives": "boolean" });
assert.equal(row("publish", "build", "all")?.maxPositionals, 1);
assert.deepEqual(row("publish", "deploy", "all")?.flags, { preview: "string", to: "string", force: "boolean" });
- assert.deepEqual(row("publish", "hub")?.flags, { deploy: "boolean", preview: "string", force: "boolean" });
+ assert.deepEqual(row("publish", "hub")?.flags, {
+ deploy: "boolean",
+ "deploy-only": "boolean",
+ preview: "string",
+ force: "boolean",
+ });
assert.deepEqual(row("publish", "homepage")?.flags, {
deploy: "boolean",
+ "deploy-only": "boolean",
preview: "string",
to: "string",
force: "boolean",
@@ -464,6 +470,8 @@ test("build site, build all and deploy site are printed aliases of the publish r
assert.equal(ALIASES.buildSite("jer", true), "archilyzer publish build jer --force", "--nodata skips the index");
assert.equal(ALIASES.buildAll, "archilyzer publish index && archilyzer publish build all --runner auto");
assert.equal(ALIASES.deploySite("jer"), "archilyzer publish deploy jer");
+ assert.equal(ALIASES.deployHub(), "archilyzer publish hub --deploy-only");
+ assert.equal(ALIASES.deployHomepage("r18"), "archilyzer publish homepage --deploy-only --preview r18");
assert.equal(ALIASES.deploySite("jer", "r18"), "archilyzer publish deploy jer --preview r18");
const row = (...p: string[]) => resolveCommand(COMMANDS, p)!.command;
// Same flags as ever: a script that called them still parses.
@@ -475,6 +483,15 @@ test("build site, build all and deploy site are printed aliases of the publish r
}
});
+test("publish hub|homepage refuse --deploy with --deploy-only (usage, nothing run)", async () => {
+ const { publishHub, publishHomepage } = await import("./publish");
+ const lines: string[] = [];
+ const out = { log: (l: string) => lines.push(l), error: (l: string) => lines.push(l) };
+ assert.equal(await publishHub({ deploy: true, deployOnly: true }, out), 2);
+ assert.equal(await publishHomepage({ deploy: true, deployOnly: true }, out), 2);
+ assert.match(lines.join("\n"), /--deploy-only ships the bundle as built — one of them/);
+});
+
test("publish hub / homepage refuse a deploy's flag without --deploy (usage, nothing run)", async () => {
const { publishHub, publishHomepage } = await import("./publish");
const errors: string[] = [];
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -128,11 +128,12 @@ export const COMMANDS: Command[] = [
{
path: ["publish", "hub"],
usage:
- "[--deploy] [--preview <branch>] [--force] build the hub into its bundle <exportBuildsDir>/_hub/out, then (--deploy) ship it",
- flags: { deploy: "boolean", preview: "string", force: "boolean" },
+ "[--deploy | --deploy-only] [--preview <branch>] [--force] build the hub into its bundle <exportBuildsDir>/_hub/out, then (--deploy) ship it; --deploy-only ships the bundle as built",
+ flags: { deploy: "boolean", "deploy-only": "boolean", preview: "string", force: "boolean" },
run: async ({ flags }) =>
(await import("./publish")).publishHub({
deploy: flags.deploy === true,
+ deployOnly: flags["deploy-only"] === true,
preview: typeof flags.preview === "string" ? flags.preview : undefined,
force: flags.force === true,
}),
@@ -140,8 +141,8 @@ export const COMMANDS: Command[] = [
{
path: ["publish", "homepage"],
usage:
- "[--deploy] [--preview <branch>] [--to local] [--force] build homepage/out (source mirror included), then (--deploy) ship it",
- flags: { deploy: "boolean", preview: "string", to: "string", force: "boolean" },
+ "[--deploy | --deploy-only] [--preview <branch>] [--to local] [--force] build homepage/out (source mirror included), then (--deploy) ship it; --deploy-only ships it as built",
+ flags: { deploy: "boolean", "deploy-only": "boolean", preview: "string", to: "string", force: "boolean" },
run: async ({ flags }) => {
const to = flags.to;
if (to !== undefined && to !== "pages" && to !== "local") {
@@ -150,6 +151,7 @@ export const COMMANDS: Command[] = [
}
return (await import("./publish")).publishHomepage({
deploy: flags.deploy === true,
+ deployOnly: flags["deploy-only"] === true,
preview: typeof flags.preview === "string" ? flags.preview : undefined,
to: to as "pages" | "local" | undefined,
force: flags.force === true,
@@ -219,7 +221,11 @@ export const COMMANDS: Command[] = [
},
{
path: ["build", "hub"],
- usage: "compose:hub + INSTANCE_MODE=hub next build into export/out",
+ // Kept RAW (export's `build:hub`, which e2e:2origin runs — an alias of
+ // `publish index` would index whatever corpus is visible). Unstamped:
+ // `deploy hub` ships the bundle `publish hub` builds, never this.
+ usage:
+ "compose:hub + INSTANCE_MODE=hub next build into export/out — a raw build, unstamped; to deploy the hub build it with `publish hub`",
run: async () => {
const { buildHub } = await import("../publish/build");
const code = await buildHub({ signal: interrupted() });
@@ -364,32 +370,22 @@ export const COMMANDS: Command[] = [
{
path: ["deploy", "hub"],
usage:
- "[--preview <branch>] ship the hub built in export/out to homepage.json's Pages project",
+ "[--preview <branch>] alias: publish hub --deploy-only [--preview <branch>] — ship the hub's bundle to homepage.json's Pages project",
flags: { preview: "string" },
- run: async ({ flags }) => {
- const { deployHub } = await import("../publish/build");
- return refusalsExit(() =>
- deployHub({
- signal: interrupted(),
- previewBranch: typeof flags.preview === "string" ? flags.preview : undefined,
- }),
- );
- },
+ run: async ({ flags }) =>
+ (await import("./publish")).deployHubAlias({
+ preview: typeof flags.preview === "string" ? flags.preview : undefined,
+ }),
},
{
path: ["deploy", "homepage"],
usage:
- "[--preview <branch>] ship homepage/out to the Pages project archilyzer (production branch main)",
+ "[--preview <branch>] alias: publish homepage --deploy-only [--preview <branch>] — ship homepage/out to the Pages project archilyzer",
flags: { preview: "string" },
- run: async ({ flags }) => {
- const { deployHomepage } = await import("../publish/build");
- return refusalsExit(() =>
- deployHomepage({
- signal: interrupted(),
- previewBranch: typeof flags.preview === "string" ? flags.preview : undefined,
- }),
- );
- },
+ run: async ({ flags }) =>
+ (await import("./publish")).deployHomepageAlias({
+ preview: typeof flags.preview === "string" ? flags.preview : undefined,
+ }),
},
{
path: ["run"],
@@ -614,16 +610,4 @@ function interrupted(): AbortSignal {
return ac.signal;
}
-// An entry point that THROWS its refusals and failures (the deploys): the
-// sentence goes to stderr and the exit code is 1.
-async function refusalsExit(fn: () => Promise<void>): Promise<number> {
- try {
- await fn();
- return 0;
- } catch (err) {
- console.error((err as Error).message);
- return 1;
- }
-}
-
runIfEntryPoint(import.meta.url, () => runCli(COMMANDS, process.argv.slice(2)));
diff --git a/common/bin/publish.ts b/common/bin/publish.ts
@@ -195,23 +195,53 @@ export async function publishDeploy(a: DeployArgs, out: Out = console): Promise<
}
export async function publishHub(
- a: { deploy?: boolean; preview?: string; force?: boolean; runId?: string; paths?: Paths; signal?: AbortSignal },
+ a: {
+ deploy?: boolean;
+ // Ship the hub's bundle as it is: no build (`deploy hub`, the old row).
+ deployOnly?: boolean;
+ preview?: string;
+ force?: boolean;
+ runId?: string;
+ paths?: Paths;
+ signal?: AbortSignal;
+ },
out: Out = console,
): Promise<number> {
- if (a.preview && !a.deploy) {
+ if (a.deploy && a.deployOnly) {
+ out.error("publish hub: --deploy builds then deploys, --deploy-only ships the bundle as built — one of them");
+ return STAGE_EXIT.usage;
+ }
+ const deploy = a.deploy || a.deployOnly;
+ if (a.preview && !deploy) {
out.error("publish hub: --preview is a deploy — add --deploy");
return STAGE_EXIT.usage;
}
const signal = a.signal ?? interrupted();
const runId = a.runId ?? cliRunId();
- const built = await run({ kind: "build-hub", target: "_hub", runId, ...(a.force ? { force: true } : {}) }, signal, a.paths);
- if (built !== 0 || !a.deploy) return built;
- return run({ kind: "deploy-hub", target: "_hub", runId, ...(a.preview ? { preview: a.preview } : {}) }, signal, a.paths);
+ if (!a.deployOnly) {
+ const built = await run({ kind: "build-hub", target: "_hub", runId, ...(a.force ? { force: true } : {}) }, signal, a.paths);
+ if (built !== 0 || !deploy) return built;
+ }
+ return run(
+ {
+ kind: "deploy-hub",
+ target: "_hub",
+ runId,
+ ...(a.preview ? { preview: a.preview } : {}),
+ // --deploy-only --force re-ships the bundle as built; with a build in the
+ // run, --force was the build's.
+ ...(a.deployOnly && a.force ? { force: true } : {}),
+ },
+ signal,
+ a.paths,
+ );
}
export async function publishHomepage(
a: {
deploy?: boolean;
+ // Ship homepage/out as it is: no build (`deploy homepage`, the old row).
+ deployOnly?: boolean;
preview?: string;
to?: "pages" | "local";
force?: boolean;
@@ -221,18 +251,25 @@ export async function publishHomepage(
},
out: Out = console,
): Promise<number> {
- if ((a.preview || a.to) && !a.deploy) {
+ if (a.deploy && a.deployOnly) {
+ out.error("publish homepage: --deploy builds then deploys, --deploy-only ships it as built — one of them");
+ return STAGE_EXIT.usage;
+ }
+ const deploy = a.deploy || a.deployOnly;
+ if ((a.preview || a.to) && !deploy) {
out.error("publish homepage: --preview and --to are a deploy's — add --deploy");
return STAGE_EXIT.usage;
}
const signal = a.signal ?? interrupted();
const runId = a.runId ?? cliRunId();
- const built = await run(
- { kind: "build-homepage", target: "_homepage", runId, ...(a.force ? { force: true } : {}) },
- signal,
- a.paths,
- );
- if (built !== 0 || !a.deploy) return built;
+ if (!a.deployOnly) {
+ const built = await run(
+ { kind: "build-homepage", target: "_homepage", runId, ...(a.force ? { force: true } : {}) },
+ signal,
+ a.paths,
+ );
+ if (built !== 0 || !deploy) return built;
+ }
return run(
{
kind: "deploy-homepage",
@@ -240,6 +277,7 @@ export async function publishHomepage(
runId,
...(a.preview ? { preview: a.preview } : {}),
...(a.to ? { to: a.to } : {}),
+ ...(a.deployOnly && a.force ? { force: true } : {}),
},
signal,
a.paths,
@@ -254,6 +292,9 @@ export const ALIASES = {
buildAll: "archilyzer publish index && archilyzer publish build all --runner auto",
deploySite: (id: string, preview?: string) =>
`archilyzer publish deploy ${id}${preview ? ` --preview ${preview}` : ""}`,
+ deployHub: (preview?: string) => `archilyzer publish hub --deploy-only${preview ? ` --preview ${preview}` : ""}`,
+ deployHomepage: (preview?: string) =>
+ `archilyzer publish homepage --deploy-only${preview ? ` --preview ${preview}` : ""}`,
} as const;
/** `build site <id> [--nodata]` = `publish index` (skipped by --nodata) + `publish build <id> --force`. */
@@ -297,3 +338,15 @@ export async function deploySiteAlias(
out.log(`[alias] deploy site is now: ${ALIASES.deploySite(a.siteId, a.preview)}`);
return publishDeploy({ target: a.siteId, preview: a.preview }, out);
}
+
+/** `deploy hub` = `publish hub --deploy-only` (the hub's bundle, as built). */
+export async function deployHubAlias(a: { preview?: string }, out: Out = console): Promise<number> {
+ out.log(`[alias] deploy hub is now: ${ALIASES.deployHub(a.preview)}`);
+ return publishHub({ deployOnly: true, preview: a.preview }, out);
+}
+
+/** `deploy homepage` = `publish homepage --deploy-only` (homepage/out, as built). */
+export async function deployHomepageAlias(a: { preview?: string }, out: Out = console): Promise<number> {
+ out.log(`[alias] deploy homepage is now: ${ALIASES.deployHomepage(a.preview)}`);
+ return publishHomepage({ deployOnly: true, preview: a.preview }, out);
+}
diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts
@@ -624,7 +624,9 @@ const JOB_KINDS: Record<string, JobKindMeta> = {
// THE HUB'S AND THE HOMEPAGE'S BUILD AND DEPLOY (release 13 slice W1). They
// ran from /sites — the hub since release 7, the homepage since release 11 —
// with no entry here, so /jobs showed their raw machine kinds. The labels
- // are the lanes' own titles on /sites (HubBuildButtons, HomepageBuildButtons).
+ // were the lanes' own titles on /sites; since release 18 those buttons are
+ // the Publish panel's and run `publish-*` stages, and these kinds are only
+ // read back from the history.
//
// Queue: BUILD_QUEUE for a build, DEPLOY_QUEUE for a deploy or a
// build-and-deploy (the actions' `queueKey`), hence "custom". Neither
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -228,9 +228,8 @@ test("builtHubProblem: the shell's reports/ and m/ pages pass; report data, mome
});
// The homepage package builds into its own homepage/out, which nothing else
-// writes, so "built" is one question: is there an index.html? The deploy-only
-// action refuses before any job on exactly the file deployHomepage checks
-// inside it.
+// writes, so "built" is one question: is there an index.html? The homepage's
+// deploy stage (deployStage.ts) refuses on exactly that file.
test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => {
const built = tempOut();
const empty = tempOut();
@@ -317,9 +316,8 @@ test("builtBundleProblem refuses a bundle missing either identity file, naming t
}
});
-// deploySite and the editor's deploy action answer with builtSiteProblem before
-// any job; the deploy itself refuses with builtBundleProblem just before
-// wrangler. The two must never disagree about a bundle: a legitimately built
+// builtSiteProblem is the looser question; the deploy stage refuses with
+// builtBundleProblem just before wrangler. The two must never disagree about a bundle: a legitimately built
// site refused only at the last step, or a bad one let through to it.
test("builtSiteProblem refuses exactly what builtBundleProblem refuses", () => {
const cases: { name: string; site?: unknown; corpus?: unknown }[] = [
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -58,7 +58,7 @@ export function builtSiteIdIn(outDir: string): string | null {
* larger surprise than a refusal naming the fix.
*
* It refuses exactly what builtBundleProblem refuses — the check the deploy
- * itself makes before wrangler (publish/build.ts, runDeployIntoLog) — in the
+ * itself makes before wrangler (publish/deployStage.ts, runDeployStage) — in the
* operator's words, so an action's fast answer and the deploy's last word
* never disagree about a bundle.
*/
@@ -116,9 +116,9 @@ export function builtBundleProblem(outDir: string, siteId: string): string | nul
* reading copy: it may carry what the public may not (X posts while
* `social.x.visibility` is "private"), so no deploy path ships it. Every
* deploy path asks this BEFORE ANY UPLOAD — the R2 archive push included — in
- * the place it asks builtBundleProblem: runDeployIntoLog, the container deploy
- * phase, deploySite, and the editor's Build & deploy, Deploy and Build &
- * deploy all. A build without a deploy is untouched.
+ * the place it asks builtBundleProblem: the deploy stage (publish/deployStage.ts)
+ * and its request checks, which every deploy surface goes through. A build
+ * without a deploy is untouched.
*/
export function siteDeployProblem(site: {
siteId: string;
@@ -174,8 +174,8 @@ export function deployAudienceProblem(
// may hold only what the list below names. Anything else (a stale summaries
// tree, a transcripts shard, an archive, a service worker) refuses the build
// and every deploy of it: builtSiteProblem and builtBundleProblem ask it, so
-// runDeployIntoLog, the container deploy phase, deploySite, the editor's
-// deploy actions and docker/build-site.sh all refuse what it refuses, and
+// the deploy stage (publish/deployStage.ts), its request checks and
+// docker/build-site.sh all refuse what it refuses, and
// `archilyzer build site` fails on it (publish/build.ts runBuildPhase).
//
// The bundle names its own scope — corpus.json's `site.scope: "cited"`, which
@@ -412,9 +412,10 @@ function corpusSiteIdIn(outDir: string): string | null {
* Why `outDir` may not be deployed as the HUB, as one sentence — or null when
* it holds a hub build.
*
- * The hub is the export app built with INSTANCE_MODE=hub into the SAME
- * export/out a site build uses, so the two overwrite each other. A hub build
- * names itself by what it carries and what it does not: compose-hub writes
+ * The hub is the export app built with INSTANCE_MODE=hub into export/out, the
+ * directory a site's build uses too (release 18 moves each build into its own
+ * bundle afterwards, `_hub/out`), so the two are told apart by content. A hub
+ * build names itself by what it carries and what it does not: compose-hub writes
* `hub-sites.json` and the hub build removes `site.json` first, while a site's
* compose removes `hub-sites.json` and writes `site.json`. A site.json here is
* therefore a site's bundle, whatever else is beside it.
@@ -576,7 +577,7 @@ export function hubReportDataIn(outDir: string, limit = 5): string[] {
*
* Unlike export/out nothing else ever builds into homepage/out, so the only
* question is whether a build is there at all. `index.html` is the file
- * deployHomepage checks inside its job, so the refusal an operator gets before
+ * the deploy stage checks inside its job, so the refusal an operator gets before
* the job and the one the job would give agree on what "built" means.
*/
export function builtHomepageProblem(outDir: string): string | null {
diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts
@@ -109,6 +109,7 @@ const DECLARED: EnvVarDecl[] = [
{ name: "ARCHIVE_CHANNEL_CONCURRENCY", audience: "runtime", default: "`4`", readBy: "common/bin/build-archives.ts", doc: "How many channels' archive zips `build archives` builds at once." },
{ name: "HOST", audience: "runtime", default: "every interface", readBy: "export/scripts/serve-out.mjs", doc: "The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine." },
{ name: "MAX_ARCHIVE_BYTES", audience: "runtime", default: "the Cloudflare-safe cap", readBy: "common/bin/compose-site.ts", doc: "The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins." },
+ { name: "EXPORT_NEXT_BIN", audience: "runtime", default: "unset: `pnpm exec next build` in export/", readBy: "common/publish/build.ts (nextBuildStep)", doc: "The `next` a site's or the hub's build runs as `<bin> build` in export/, in place of `pnpm exec next build`. The editor's e2e suite points it at its fake, which copies the composed public dir to export/out." },
{ name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." },
{ name: "CHOUGH_BIN", audience: "runtime", default: "`chough` on PATH", readBy: "common/lib/transcriptionApps.ts", doc: "The chough transcription engine, when a worker names no binary." },
{ name: "CHOUGH_MODEL", audience: "runtime", default: "chough's own", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's model field; chough auto-downloads one when unset." },
diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts
@@ -31,7 +31,7 @@ export type HomepageConfig = {
// Absolute public URL of the deployed hub, e.g. "https://archilyzer-hub.pages.dev".
siteUrl?: string;
// Cloudflare Pages project the hub deploys to (e.g. "archilyzer-hub"; never
- // "archilyzer", the homepage's — deployHub refuses it).
+ // "archilyzer", the homepage's — the hub's deploy refuses it, hubProjectProblem).
cloudflareProject?: string;
// The transcript modal's per-video export controls (Download menu, Copy MD;
// the yt-dlp clip command shows either way) on the hub's Browse and Ask pages — the same switch
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -459,7 +459,7 @@ export const DIGEST_SETTINGS_FIELD_DOCS: FieldDocs<DigestSettings> = {
};
// Build all / Build & deploy all (editor buildAction.ts, `archilyzer build all`)
-// fan out in containers (publish/build.ts, runDockerBuildAllPhase) whenever
+// fan out in containers (publish/stageBodies.ts, buildAllDocker) whenever
// `docker version` answers, and build serially on the host otherwise. There is
// no mode switch: the `mode` key ("basic" | "docker") was a label nothing read,
// and it was dropped on 2026-09-28 (release 11, follow-up O6c). A settings.json
diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts
@@ -5,30 +5,22 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
- readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
-import { runChildIntoLog } from "../jobs/runChild";
import type { Paths } from "../lib/paths";
-import type { Site } from "../lib/site";
import {
HOMEPAGE_PAGES_PROJECT,
buildHomepage,
buildHubSteps,
buildSiteSteps,
hubProjectProblem,
- homepageDeployArgs,
homepageOutDir,
- deployHomepage,
- deploySite,
dockerSiteOutDir,
dockerSiteStagingDir,
resolveOutDir,
- runDeployIntoLog,
- runDockerDeployAllPhase,
} from "./build";
// Run with:
@@ -124,6 +116,20 @@ test("buildSiteSteps: allowMissingMedia lets compose through a report citation w
);
});
+test("EXPORT_NEXT_BIN replaces `pnpm exec next build` in a site's and the hub's build (the e2e fake)", () => {
+ const p = { exportDir: "/x/export", transcriptsDir: "/x/t", exportPublicDir: "/x/t/.p" } as Paths;
+ const site = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } });
+ assert.deepEqual(site.map((s) => [s.command, ...s.args]), [
+ ["pnpm", "run", "compose:site"],
+ ["/bin/fake-next", "build"],
+ ]);
+ const hub = buildHubSteps({ paths: p, baseEnv: { EXPORT_NEXT_BIN: "/bin/fake-next" } });
+ assert.deepEqual(hub[1].command, "/bin/fake-next");
+ assert.equal(hub[1].env?.INSTANCE_MODE, "hub");
+ const plain = buildSiteSteps({ siteId: "jer", paths: p, skipData: true, baseEnv: {} });
+ assert.deepEqual(plain[1].args, ["exec", "next", "build"]);
+});
+
test("buildHubSteps: compose:hub, then next build with INSTANCE_MODE=hub, in export/", () => {
const steps = buildHubSteps({ paths, baseEnv: { PATH: "/bin" } });
const env = {
@@ -157,15 +163,8 @@ test("hubProjectProblem refuses a missing project and the homepage's", () => {
assert.equal(hubProjectProblem("archilyzer-hub"), null);
});
-test("homepageDeployArgs: production is branch main; a preview is its own branch and never main", () => {
- const base = ["pages", "deploy", "/repo/homepage/out", "--project-name", "archilyzer"];
- assert.deepEqual(homepageDeployArgs("/repo/homepage/out"), [...base, "--branch", "main"]);
- assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " "), [...base, "--branch", "main"]);
- assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " r8-home "), [...base, "--branch", "r8-home"]);
-});
-
-// The editor's homepage actions judge "is there a build?" on this path before
-// any job, so it must be the directory deployHomepage ships.
+// The build-homepage stage writes and the deploy stage ships this path, and
+// both judge "is there a build?" on it.
test("homepageOutDir is homepage/out of the checkout", () => {
assert.equal(homepageOutDir({ monorepoRoot: "/repo" } as Paths), "/repo/homepage/out");
});
@@ -245,289 +244,3 @@ test("buildHomepage: the source step sits between compose and next build; a refu
rmSync(root, { recursive: true, force: true });
}
});
-
-// M1: a deploy-only ships homepage/out as the last build left it. Its source
-// ships only with a record that it was audited under today's rules (the
-// positive case is source.test.ts' round trip, over publishedSourceProblem).
-test("deployHomepage refuses an out/ whose source has no record of the rules it was audited under", async () => {
- const root = mkdtempSync(path.join(os.tmpdir(), "deploy-homepage-"));
- try {
- const out = path.join(root, "homepage", "out");
- mkdirSync(path.join(out, "source"), { recursive: true });
- writeFileSync(path.join(out, "index.html"), "<p>home</p>");
- writeFileSync(path.join(out, "source", "index.html"), "<p>the /source page</p>");
- writeFileSync(
- path.join(out, "source", "manifest.json"),
- JSON.stringify({
- version: 1, generatedAt: "2026-09-28T12:00:00.000Z", branch: "main", sourceCommit: "1".repeat(40),
- mirrorHead: "2".repeat(40), subject: "s", files: 1, bytes: 1, mirror: { files: 1, bytes: 1, packs: 1 },
- tree: { files: 1, dirs: 1, bytes: 1 }, tarball: { href: "/downloads/archilyzer-source.tar.gz", bytes: 1, sha256: "3".repeat(64) },
- audit: { objects: 1, commits: 1, gitleaks: "clean" }, tools: {},
- }),
- );
- const p = { monorepoRoot: root } as Paths;
- await assert.rejects(
- deployHomepage({ paths: p, previewBranch: "r12-source" }),
- /homepage\/out's source has no record of the rules it was audited under — run `archilyzer build homepage`/,
- );
- // A half-removed source (no manifest) refuses too…
- rmSync(path.join(out, "source", "manifest.json"));
- mkdirSync(path.join(out, "source", "archilyzer.git"));
- await assert.rejects(deployHomepage({ paths: p }), /without a valid manifest — run `archilyzer build homepage`/);
- // …and so does a build whose source step refused (buildHomepage took
- // out/source away, the page with it).
- rmSync(path.join(out, "source"), { recursive: true });
- await assert.rejects(deployHomepage({ paths: p }), /has no \/source page \(its source step refused/);
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("deployHomepage refuses a bad preview branch before it looks for a build", async () => {
- const noBuild = { monorepoRoot: "/nonexistent-repo" } as Paths;
- await assert.rejects(
- deployHomepage({ paths: noBuild, previewBranch: "main" }),
- /"main" is the production branch/,
- );
- await assert.rejects(
- deployHomepage({ paths: noBuild, previewBranch: "Not_Valid" }),
- /not a valid preview branch name/,
- );
- await assert.rejects(
- deployHomepage({ paths: noBuild, previewBranch: "r8-home" }),
- /homepage\/out holds no build/,
- );
-});
-
-// Phase C ships each per-site out/ a container wrote. It must be THAT site's
-// bundle: a container once published the public/ baked into its image, so an
-// out/ could carry another site's data. The check comes first. These sites have
-// NO Cloudflare project, so were it ever removed they would be "skipped", and
-// this test could never reach a real upload or deploy.
-test("runDockerDeployAllPhase refuses a per-site out/ that is not the site's own bundle", async () => {
- const root = mkdtempSync(path.join(os.tmpdir(), "deploy-all-"));
- try {
- const outFor = (id: string) => path.join(root, id, "out");
- mkdirSync(outFor("anilyzer"), { recursive: true });
- writeFileSync(path.join(outFor("anilyzer"), "site.json"), JSON.stringify({ siteId: "jeralyzer" }));
- writeFileSync(path.join(outFor("anilyzer"), "corpus.json"), JSON.stringify({ site: { id: "jeralyzer" } }));
- mkdirSync(outFor("bonnellyzer"), { recursive: true }); // built, but no bundle in it
- const log: string[] = [];
- const sites = [
- { siteId: "anilyzer" },
- { siteId: "bonnellyzer" },
- ] as Site[];
- const outcomes = await runDockerDeployAllPhase(
- (l) => log.push(l),
- new AbortController().signal,
- sites,
- new Set(["anilyzer", "bonnellyzer"]),
- { ...paths, exportBuildsDir: root } as Paths,
- outFor,
- );
- assert.deepEqual(outcomes.map((o) => [o.siteId, o.status]), [["anilyzer", "failed"], ["bonnellyzer", "failed"]]);
- assert.match(outcomes[0].reason!, /holds a build of "jeralyzer", not "anilyzer"/);
- assert.match(outcomes[1].reason!, /has no site\.json naming a site/);
- assert.ok(log.some((l) => l.startsWith("[anilyzer] deploy REFUSED — ")), log.join("\n"));
- assert.ok(!log.some((l) => l.startsWith("=== Deploy")), "nothing reached the deploy");
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-function writeBundle(dir: string, siteId: string | null, corpusId: string | null): void {
- mkdirSync(dir, { recursive: true });
- if (siteId !== null) writeFileSync(path.join(dir, "site.json"), JSON.stringify({ siteId }));
- if (corpusId !== null) writeFileSync(path.join(dir, "corpus.json"), JSON.stringify({ site: { id: corpusId } }));
- writeFileSync(path.join(dir, "index.html"), "<!doctype html>");
-}
-
-// runDeployIntoLog is the one door every SITE deploy goes through — the
-// container Phase C, deploySite, the Publish tab's Build & deploy, the host
-// Build & deploy all — and the build queue can rewrite export/out between a
-// site's build and its deploy. So it refuses a bundle that is not the site's
-// own right before wrangler. PATH here holds only a fake `pnpm` that records
-// its argv: with the check or without it, no run of this test can reach a real
-// wrangler, and the fake is proved to answer before anything is deployed.
-test("runDeployIntoLog refuses a bundle that is not the site's own before wrangler, and ships the site's own", async () => {
- const root = mkdtempSync(path.join(os.tmpdir(), "deploy-guard-"));
- const bin = path.join(root, "bin");
- const argvFile = path.join(root, "pnpm-argv");
- mkdirSync(bin);
- writeFileSync(
- path.join(bin, "pnpm"),
- `#!/bin/sh\nprintf '%s\\n' "$@" >> '${argvFile}'\necho "Take a peek over at https://abc123.w3c-never-real.pages.dev"\n`,
- );
- chmodSync(path.join(bin, "pnpm"), 0o755);
- const savedPath = process.env.PATH;
- process.env.PATH = bin;
- // The deploy spawns wranglerBin(paths): WRANGLER_BIN, here the same fake.
- const savedWrangler = process.env.WRANGLER_BIN;
- process.env.WRANGLER_BIN = path.join(bin, "pnpm");
- const signal = new AbortController().signal;
- const site = { siteId: "anilyzer", cloudflareProject: "w3c-never-real" } as Site;
- const testPaths = { ...paths, exportDir: root } as Paths;
- try {
- // The fake answers the same spawn the deploy makes, or nothing below runs.
- await runChildIntoLog(() => {}, signal, { command: "pnpm", args: ["--fake?"], cwd: root, env: { ...process.env } });
- assert.equal(readFileSync(argvFile, "utf8"), "--fake?\n");
- rmSync(argvFile);
-
- const refusals: [string, string | null, string | null, RegExp][] = [
- ["another site's bundle", "jeralyzer", "jeralyzer", /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)/],
- ["the hub's bundle (no site.json)", null, null, /has no site\.json naming a site/],
- ["a torn bundle", "anilyzer", "jeralyzer", /describes "jeralyzer", not "anilyzer" \(corpus\.json\)/],
- ["a bundle with no corpus.json", "anilyzer", null, /has no corpus\.json naming a site/],
- ];
- for (const [name, siteId, corpusId, why] of refusals) {
- const out = path.join(root, name.replace(/\W+/g, "-"), "out");
- writeBundle(out, siteId, corpusId);
- const log: string[] = [];
- const code = await runDeployIntoLog((l) => log.push(l), signal, site, out, testPaths);
- assert.equal(code, 1, name);
- assert.equal(log.length, 1, `${name}: ${log.join("")}`);
- assert.match(log[0], /^\[deploy\] REFUSED — /, name);
- assert.match(log[0], why, name);
- assert.match(log[0], /Nothing was sent to Cloudflare Pages; build anilyzer again, then deploy\.\n$/, name);
- assert.equal(existsSync(argvFile), false, `${name}: pnpm was spawned`);
- }
-
- // A legitimately built site is never refused: the same argv as ever.
- const good = path.join(root, "good", "out");
- writeBundle(good, "anilyzer", "anilyzer");
- const log: string[] = [];
- assert.equal(await runDeployIntoLog((l) => log.push(l), signal, site, good, testPaths), 0, log.join("\n"));
- assert.equal(
- readFileSync(argvFile, "utf8"),
- ["pages", "deploy", good, "--project-name", "w3c-never-real", "--branch", "main", ""].join("\n"),
- );
- assert.ok(log.includes("[deployed] https://abc123.w3c-never-real.pages.dev\n"), log.join("\n"));
- } finally {
- process.env.PATH = savedPath;
- if (savedWrangler === undefined) delete process.env.WRANGLER_BIN;
- else process.env.WRANGLER_BIN = savedWrangler;
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-// A PRIVATE site (site.json `audience: "private"`, release 17 slice XP) is never
-// deployed, and neither is a bundle built private (its corpus.json says so):
-// refused at the same door as the wrong-site bundle, before wrangler, in words
-// naming the audience. The fake `pnpm` is the test above's.
-function writePrivateBundle(dir: string, siteId: string): void {
- writeBundle(dir, siteId, siteId);
- writeFileSync(
- path.join(dir, "corpus.json"),
- JSON.stringify({ site: { id: siteId, audience: "private" } }),
- );
-}
-
-test("runDeployIntoLog refuses a private site and a private build before wrangler", async () => {
- const root = mkdtempSync(path.join(os.tmpdir(), "deploy-private-"));
- const bin = path.join(root, "bin");
- const argvFile = path.join(root, "pnpm-argv");
- mkdirSync(bin);
- writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh\nprintf '%s\\n' "$@" >> '${argvFile}'\n`);
- chmodSync(path.join(bin, "pnpm"), 0o755);
- const savedPath = process.env.PATH;
- process.env.PATH = bin;
- // The deploy spawns wranglerBin(paths): WRANGLER_BIN, here the same fake.
- const savedWrangler = process.env.WRANGLER_BIN;
- process.env.WRANGLER_BIN = path.join(bin, "pnpm");
- const signal = new AbortController().signal;
- const testPaths = { ...paths, exportDir: root } as Paths;
- try {
- await runChildIntoLog(() => {}, signal, { command: "pnpm", args: ["--fake?"], cwd: root, env: { ...process.env } });
- assert.equal(readFileSync(argvFile, "utf8"), "--fake?\n");
- rmSync(argvFile);
-
- // The site is private: its own, well-formed bundle is still refused.
- const own = path.join(root, "own", "out");
- writeBundle(own, "mine", "mine");
- const priv = { siteId: "mine", cloudflareProject: "w3c-never-real", audience: "private" } as Site;
- let log: string[] = [];
- assert.equal(await runDeployIntoLog((l) => log.push(l), signal, priv, own, testPaths), 1);
- assert.equal(log.length, 1, log.join(""));
- assert.match(
- log[0],
- /^\[deploy\] REFUSED — Site "mine" is private \(audience: private\): it is built for reading on this machine and is never deployed\./,
- );
- assert.match(log[0], /Nothing was sent to Cloudflare Pages\.\n$/);
-
- // The site is public now, but the bundle was built private.
- const built = path.join(root, "built", "out");
- writePrivateBundle(built, "mine");
- log = [];
- const pub = { siteId: "mine", cloudflareProject: "w3c-never-real" } as Site;
- assert.equal(await runDeployIntoLog((l) => log.push(l), signal, pub, built, testPaths), 1);
- assert.match(log[0], /holds a private build of "mine" \(its corpus\.json says "audience": "private"\)/);
- assert.equal(existsSync(argvFile), false, "pnpm was spawned");
- } finally {
- process.env.PATH = savedPath;
- if (savedWrangler === undefined) delete process.env.WRANGLER_BIN;
- else process.env.WRANGLER_BIN = savedWrangler;
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("runDockerDeployAllPhase skips a private site before the upload, in the audience's words", async () => {
- const root = mkdtempSync(path.join(os.tmpdir(), "deploy-all-private-"));
- try {
- const outFor = (id: string) => path.join(root, id, "out");
- writeBundle(outFor("mine"), "mine", "mine");
- writePrivateBundle(outFor("built"), "built");
- const log: string[] = [];
- // A Cloudflare project on each: without the audience check the run would
- // reach the upload, which the log would show.
- const sites = [
- { siteId: "mine", audience: "private", cloudflareProject: "w3c-never-real" },
- { siteId: "built", cloudflareProject: "w3c-never-real" },
- ] as Site[];
- const outcomes = await runDockerDeployAllPhase(
- (l) => log.push(l),
- new AbortController().signal,
- sites,
- new Set(["mine", "built"]),
- { ...paths, exportBuildsDir: root } as Paths,
- outFor,
- );
- assert.deepEqual(outcomes.map((o) => [o.siteId, o.status]), [["mine", "skipped"], ["built", "skipped"]]);
- assert.match(outcomes[0].reason!, /^Site "mine" is private \(audience: private\)/);
- assert.match(outcomes[1].reason!, /private build of "built"/);
- assert.ok(log.some((l) => l.startsWith("[mine] deploy skipped — Site \"mine\" is private")), log.join("\n"));
- assert.ok(!log.some((l) => l.startsWith("=== Deploy")), "nothing reached the deploy");
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
-
-test("deploySite (archilyzer deploy site) refuses a private site before anything, and a private build before the upload", async () => {
- const root = mkdtempSync(path.join(os.tmpdir(), "deploy-site-private-"));
- try {
- const sitesDir = path.join(root, "sites");
- const site = (id: string, extra: Record<string, unknown> = {}) => {
- mkdirSync(path.join(sitesDir, id), { recursive: true });
- writeFileSync(
- path.join(sitesDir, id, "site.json"),
- JSON.stringify({ siteId: id, cloudflareProject: "w3c-never-real", ...extra }),
- );
- };
- site("mine", { audience: "private" });
- site("other");
- const testPaths = { ...paths, exportDir: root, sitesDir } as Paths;
- const log: string[] = [];
- await assert.rejects(
- deploySite("mine", { paths: testPaths, onLog: (l) => log.push(l) }),
- /^Error: Site "mine" is private \(audience: private\): it is built for reading on this machine and is never deployed\. Build it without deploying, or set its audience to public on its Settings tab\.$/,
- );
- // Public, but export/out holds a private build of it.
- writePrivateBundle(path.join(root, "out"), "other");
- await assert.rejects(
- deploySite("other", { paths: testPaths, onLog: (l) => log.push(l) }),
- /private build of "other".*Build other again, then deploy\.$/,
- );
- assert.deepEqual(log, [], "nothing was logged: no upload, no deploy");
- } finally {
- rmSync(root, { recursive: true, force: true });
- }
-});
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -1,8 +1,7 @@
-// The publish layer's build/deploy primitives: one site's host build, the
-// docker per-site fan-out, the R2 archive upload and the Pages deploy. Moved
-// here from the editor (`editor/app/sites/lib/buildDeployCore.ts`) in one-core
-// Phase 4 slice 1, unchanged; the editor's build/deploy server actions
-// (`editor/app/sites/lib/{buildAction,deployAction}.ts`) call them as jobs.
+// The publish layer's build primitives: one site's host build, the docker
+// per-site container, the R2 archive upload, the hub and homepage builds and the
+// per-target bundles. The publish stages (stageBodies.ts, deployStage.ts) are
+// their callers; the deploy itself is the deploy stage's (deployStage.ts).
//
// These take an `onLog` callback and an AbortSignal, so they CANNOT live in a
// "use server" module (every export there becomes a server action, which
@@ -15,31 +14,20 @@ import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { runChildIntoLog } from "../jobs/runChild";
import {
- builtAudienceProblem,
builtBundleProblem,
builtHubProblem,
builtScopeProblem,
- builtSiteProblem,
citedBuildProblem,
- deployAudienceProblem,
- siteDeployProblem,
} from "../lib/builtExport";
-import { getHomepageConfig } from "../lib/homepage";
-import {
- deploymentUrlIn,
- pagesDeployArgs,
- previewAliasUrl,
- previewBranchProblem,
- wranglerBin,
-} from "../lib/pagesDeploy";
import { getPaths, type Paths } from "../lib/paths";
import { getSettings } from "../lib/settings";
-import { getSite, listSites, type Site } from "../lib/site";
+import { getSite, type Site } from "../lib/site";
import { builtStampPath } from "./stamps";
-// Where the basic (host) build writes the static bundle to deploy: the fixed
-// export/out, composed one site at a time. The docker fan-out writes per-site
-// out/ dirs instead — see dockerSiteOutDir.
+// Where `next build` writes a site's or the hub's static export: the fixed
+// export/out, one build at a time (the publish lock). The build stage then
+// moves it into the target's bundle (`bundleDir`) and makes export/out a link
+// to it; the docker runner writes per-site out/ dirs instead — dockerSiteOutDir.
export function resolveOutDir(_siteId: string, paths: Paths): string {
return path.join(paths.exportDir, "out");
}
@@ -112,10 +100,23 @@ export function buildSiteSteps(opts: {
return [
...(opts.skipData ? [] : [step(["run", "build:data"])]),
step(["run", "compose:site"]),
- step(["exec", "next", "build"]),
+ nextBuildStep(paths, env),
];
}
+/**
+ * The export app's `next build`: `pnpm exec next build` in export/, or
+ * `<EXPORT_NEXT_BIN> build` when that is set — the editor's e2e suite points
+ * it at a fake that copies the composed public dir to out/ (a real build there
+ * would rebuild the app beside its running dev server).
+ */
+export function nextBuildStep(paths: Paths, env: NodeJS.ProcessEnv): BuildStep {
+ const bin = env.EXPORT_NEXT_BIN?.trim();
+ return bin
+ ? { command: bin, args: ["build"], cwd: paths.exportDir, env }
+ : { command: "pnpm", args: ["exec", "next", "build"], cwd: paths.exportDir, env };
+}
+
// Run a list of child steps in order, streaming into `onLog`, stopping at the
// first non-zero exit (or a cancel). Returns the exit code.
async function runSteps(
@@ -133,10 +134,8 @@ async function runSteps(
// Run the basic (host) build phase for one site, streaming into `onLog`,
// returning the exit code: buildSiteSteps, in export/ (serialized upstream on
-// the build queue, since the export/ tree is shared). This is the single-site
-// build for basic mode, and the fallback the all-sites docker action drops to
-// when no container engine is available. The parallel per-site container path
-// lives in runDockerBuildAllPhase.
+// the build queue, since the export/ tree is shared). buildSiteBundle runs it,
+// for the local runner and inside the docker per-site container alike.
export async function runBuildPhase(
onLog: (line: string) => void,
signal: AbortSignal,
@@ -238,8 +237,8 @@ export async function runArchiveUploadIntoLog(
site: Site,
paths: Paths,
// Where the oversize archives were staged. Defaults to the basic host location;
- // the docker deploy phase passes dockerSiteStagingDir since its container wrote
- // .r2-staging under exportBuildsDir/<siteId> instead.
+ // the deploy stage passes dockerSiteStagingDir, where every runner's bundle
+ // keeps them (stageSiteArchives).
stagingDirOverride?: string,
): Promise<number> {
const bucket = getSettings().archiveStorage?.bucket?.trim();
@@ -340,108 +339,14 @@ export async function runArchiveUploadIntoLog(
}
}
-// Deploy a previously-built static bundle (`outDir`) to the site's Cloudflare
-// Pages project, streaming into `onLog`, returning the exit code. Runs on the
-// host with the host's Cloudflare credentials (process.env) — deploy never runs
-// inside a container, so container wrangler auth is never needed.
-//
-// `opts.previewBranch` makes it a PREVIEW deploy: Cloudflare treats a deploy to
-// any branch but the project's production branch as a preview, reachable at the
-// branch alias. Omitting it deploys production, `--branch main` (release 18:
-// the branch is named, never inferred from the checkout, where a "production"
-// deploy from a non-main checkout used to become a preview silently). The
-// binary is the pinned wrangler (wranglerBin), not `pnpm dlx`.
-//
-// Either way, the URL wrangler prints ("Take a peek over at …") earns one
-// terminal line of its own, because the streamed log scrolls and an operator
-// who looked away has nowhere else to find it. A preview also gets the stable
-// branch alias, which is knowable without reading the log at all.
-export async function runDeployIntoLog(
- onLog: (line: string) => void,
- signal: AbortSignal,
- site: Site,
- outDir: string,
- paths: Paths,
- opts?: { previewBranch?: string },
-): Promise<number> {
- // The last word before wrangler: the bundle must be this site's own
- // (site.json AND corpus.json name it — builtBundleProblem). Every SITE deploy
- // comes through here — the container Phase C, deploySite, the Publish tab's
- // Build & deploy and the host Build & deploy all — and the deploy queue runs
- // beside the build queue, so between a site's build and this line another
- // job (a build of another site, the hub) can rewrite export/out. Nothing runs
- // between this check and the spawn. The hub and the homepage deploy through
- // runPagesDeployIntoLog and never come here.
- //
- // A PRIVATE site, or a bundle built private, is refused first: it is never
- // deployed, whatever the bundle's identity (deployAudienceProblem).
- const audienceProblem = deployAudienceProblem(site, outDir);
- if (audienceProblem) {
- onLog(
- `[deploy] REFUSED — ${audienceProblem}. Nothing was sent to Cloudflare Pages.\n`,
- );
- return 1;
- }
- const bundleProblem = builtBundleProblem(outDir, site.siteId);
- if (bundleProblem) {
- onLog(
- `[deploy] REFUSED — ${bundleProblem}. Nothing was sent to Cloudflare Pages; ` +
- `build ${site.siteId} again, then deploy.\n`,
- );
- return 1;
- }
- const project = site.cloudflareProject as string;
- const previewBranch = opts?.previewBranch?.trim() || undefined;
-
- // Spot the deployment URL as it streams past rather than re-reading the
- // finished log file: the log is the operator's too, and buffering it a second
- // time to grep it would double a big deploy's memory for one line of output.
- let deploymentUrl: string | null = null;
- const watch = (line: string) => {
- if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, project);
- onLog(line);
- };
-
- const code = await runChildIntoLog(watch, signal, {
- command: wranglerBin(paths),
- args: pagesDeployArgs({ outDir, project, previewBranch }),
- cwd: paths.exportDir,
- env: {
- ...process.env,
- NODE_ENV: "production",
- TRANSCRIPTS_DIR: paths.transcriptsDir,
- EXPORT_PUBLIC_DIR: paths.exportPublicDir,
- SITE_ID: site.siteId,
- },
- });
-
- // Only on success. A URL scraped out of a failed run points at nothing — or
- // worse, at the deployment that is still live.
- if (code === 0) {
- if (previewBranch) {
- const alias = previewAliasUrl(project, previewBranch);
- onLog(
- `[preview] ${alias}` +
- (deploymentUrl ? ` (this deployment: ${deploymentUrl})` : "") +
- "\n",
- );
- } else if (deploymentUrl) {
- onLog(`[deployed] ${deploymentUrl}\n`);
- }
- }
- return code;
-}
-
// ---------------------------------------------------------------------------
-// Docker export pipeline (Build all, whenever a container engine answers)
-//
-// Three ordered phases (see PUBLISH.md, "Building every site in containers"):
-// A) HOST, serial: build:data (shared LMDB + .export-index) then build:archives
-// (warm the shared archive cache). One writer of the shared state.
-// B) CONTAINERS, parallel (cap maxParallelBuilds): each site's compose + next
-// build in its own container, read-only over the shared caches, writing only
-// its per-site out/ under exportBuildsDir/<siteId>.
-// C) HOST, serial: deploy each built site (handled by runDockerDeployAllPhase).
+// Docker export pipeline: the parts of the build-all stage's docker runner
+// (stageBodies.ts, buildAllDocker — see PUBLISH.md, "Building every site in
+// containers"): the host's build:archives warm (runHostScript), the image
+// (ensureBuildImage), then each site's compose + next build in its own
+// container (runDockerBuildOne), up to maxParallelBuilds at once
+// (runWithConcurrency), read-only over the shared caches and writing only its
+// per-site out/ under exportBuildsDir/<siteId>.
// ---------------------------------------------------------------------------
// The container engine binary. Defaults to `docker`; podman is a CLI drop-in
@@ -461,13 +366,6 @@ export function buildImageArgs(pipeline: {
return ["build", "-f", pipeline.dockerfile, "-t", pipeline.dockerImage, "."];
}
-export type SiteBuildOutcome = { siteId: string; code: number };
-export type SiteDeployOutcome = {
- siteId: string;
- status: "deployed" | "skipped" | "failed";
- reason?: string;
-};
-
// Cheap probe: is the container engine installed and its daemon reachable? Used
// to fall back to serial host builds when docker isn't available.
export async function dockerAvailable(signal: AbortSignal): Promise<boolean> {
@@ -479,7 +377,7 @@ export async function dockerAvailable(signal: AbortSignal): Promise<boolean> {
return code === 0;
}
-// Run a host-side export pnpm script (build:data / build:archives) for Phase A.
+// Run a host-side export pnpm script (build:data / build:archives) before the fan-out.
// These are pool-wide: no SITE_ID, and no EXPORT_PUBLIC_DIR override so the
// shared index/staging land at their canonical export/.export-index location
// (exactly what the fan-out containers mount read-only).
@@ -504,7 +402,7 @@ export async function runHostScript(
}
// Build (or reuse cached layers of) the per-site build image. Runs before every
-// Phase B, so a fan-out never meets an image older than the checkout: a code
+// fan-out, so a fan-out never meets an image older than the checkout: a code
// change re-runs only `COPY . .` onward, but a Dockerfile or lockfile change
// re-installs every dependency first, inside this job. `archilyzer doctor`
// warns ahead of that when the image is absent or older than the Dockerfile.
@@ -588,139 +486,6 @@ export async function runDockerBuildOne(
});
}
-// Phases A + B. Returns each site's build exit code (0 = ok). Throws only on an
-// infrastructure failure (data phase / archive warm / image build) that aborts
-// the whole run before any site could build; per-site build failures are
-// returned, not thrown, so one bad site never blocks the rest.
-export async function runDockerBuildAllPhase(
- onLog: (line: string) => void,
- signal: AbortSignal,
- sites: Site[],
- paths: Paths,
- opts?: { skipArchives?: boolean },
-): Promise<SiteBuildOutcome[]> {
- const { maxParallelBuilds } = getSettings().buildPipeline;
-
- // --- Phase A: shared data + archive cache (host, serial) ---
- onLog("=== Phase A: shared data + archive cache (host) ===");
- const dataCode = await runHostScript(onLog, signal, paths, "build:data");
- if (signal.aborted) return [];
- if (dataCode !== 0) throw new Error(`Data phase failed (exit ${dataCode}).`);
- if (!opts?.skipArchives) {
- const archCode = await runHostScript(onLog, signal, paths, "build:archives");
- if (signal.aborted) return [];
- if (archCode !== 0) throw new Error(`Archive cache warm failed (exit ${archCode}).`);
- }
-
- const imgCode = await ensureBuildImage(onLog, signal, paths);
- if (signal.aborted) return [];
- if (imgCode !== 0) throw new Error(`Docker image build failed (exit ${imgCode}).`);
-
- // --- Phase B: per-site fan-out (containers, parallel) ---
- onLog(
- `=== Phase B: building ${sites.length} site(s), up to ${maxParallelBuilds} in parallel ===`,
- );
- return runWithConcurrency(sites, maxParallelBuilds, async (site) => {
- if (signal.aborted) return { siteId: site.siteId, code: 1 };
- const code = await runDockerBuildOne(onLog, signal, site.siteId, paths, opts);
- onLog(`[${site.siteId}] build ${code === 0 ? "ok" : `FAILED (exit ${code})`}`);
- return { siteId: site.siteId, code };
- });
-}
-
-// Phase C: deploy each built site SERIALLY on the host, after the build barrier.
-// Partial-failure tolerant — a site that fails to upload/deploy is recorded and
-// the loop continues. Sites that failed to build, or have no Cloudflare project,
-// are skipped; a bundle that is not the site's own is refused (builtBundleProblem).
-// `outDirFor` resolves each site's built bundle (docker: per-site;
-// basic fallback: export/out).
-export async function runDockerDeployAllPhase(
- onLog: (line: string) => void,
- signal: AbortSignal,
- sites: Site[],
- builtOk: Set<string>,
- paths: Paths,
- outDirFor: (siteId: string) => string,
-): Promise<SiteDeployOutcome[]> {
- const outcomes: SiteDeployOutcome[] = [];
- for (const site of sites) {
- if (signal.aborted) break;
- if (!builtOk.has(site.siteId)) {
- onLog(`[${site.siteId}] deploy skipped — build failed`);
- outcomes.push({ siteId: site.siteId, status: "skipped", reason: "build failed" });
- continue;
- }
- // A private site (or a private build) is not deployed at all: skipped, in
- // its own words, so a family with one private site does not fail every run.
- // Asked first; a per-site dir holding another site's bundle built private
- // therefore reads "skipped" rather than "REFUSED" — never shipped either way.
- //
- // Then the bundle must be this site's own — the check build-site.sh makes
- // before it hands the bundle back, made again over whatever the per-site dir
- // holds now — before anything past it (the R2 upload, the Pages deploy) is
- // reached with another site's data.
- const audienceProblem = deployAudienceProblem(site, outDirFor(site.siteId));
- if (audienceProblem) {
- onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`);
- outcomes.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem });
- continue;
- }
- const bundleProblem = builtBundleProblem(outDirFor(site.siteId), site.siteId);
- if (bundleProblem) {
- onLog(`[${site.siteId}] deploy REFUSED — ${bundleProblem}`);
- outcomes.push({ siteId: site.siteId, status: "failed", reason: bundleProblem });
- continue;
- }
- if (!site.cloudflareProject) {
- onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`);
- outcomes.push({
- siteId: site.siteId,
- status: "skipped",
- reason: "no cloudflareProject",
- });
- continue;
- }
- onLog(`=== Deploy ${site.siteId} ===`);
- const uploadCode = await runArchiveUploadIntoLog(
- onLog,
- signal,
- site,
- paths,
- dockerSiteStagingDir(paths, site.siteId),
- );
- if (signal.aborted) break;
- if (uploadCode !== 0) {
- onLog(`[${site.siteId}] deploy FAILED — R2 upload exit ${uploadCode}`);
- outcomes.push({
- siteId: site.siteId,
- status: "failed",
- reason: `R2 upload exit ${uploadCode}`,
- });
- continue;
- }
- const deployCode = await runDeployIntoLog(
- onLog,
- signal,
- site,
- outDirFor(site.siteId),
- paths,
- );
- if (signal.aborted) break;
- if (deployCode !== 0) {
- onLog(`[${site.siteId}] deploy FAILED — exit ${deployCode}`);
- outcomes.push({
- siteId: site.siteId,
- status: "failed",
- reason: `deploy exit ${deployCode}`,
- });
- continue;
- }
- onLog(`[${site.siteId}] deployed.`);
- outcomes.push({ siteId: site.siteId, status: "deployed" });
- }
- return outcomes;
-}
-
// Bounded-concurrency map over a fixed work set, preserving input order in the
// results. No external dep; a fresh worker pulls the next index until exhausted.
export async function runWithConcurrency<T, R>(
@@ -743,11 +508,8 @@ export async function runWithConcurrency<T, R>(
}
// ---------------------------------------------------------------------------
-// Named entry points (one-core Phase 4 slice 2): one call per thing an operator
-// publishes, over the run* phases above. The editor's actions wrap these in
-// runManagedFunction (a job, a queue, a log); the archilyzer CLI calls them
-// straight, logging to the terminal. Every run* export stays — they are still
-// the parts, and build.test.ts pins some of them.
+// Named entry points: one call per thing the stages build, over the parts
+// above. Their options default to the terminal and a never-aborted signal.
// ---------------------------------------------------------------------------
export type PublishOpts = {
@@ -776,120 +538,6 @@ function resolved(opts: PublishOpts): {
};
}
-/** Build one site into export/out (basic/host mode). Returns the exit code. */
-export async function buildSite(
- siteId: string,
- opts: PublishOpts & { skipData?: boolean; skipArchives?: boolean; allowMissingMedia?: boolean } = {},
-): Promise<number> {
- const { paths, onLog, signal } = resolved(opts);
- return runBuildPhase(onLog, signal, siteId.trim(), paths, {
- skipData: opts.skipData,
- skipArchives: opts.skipArchives,
- allowMissingMedia: opts.allowMissingMedia,
- });
-}
-
-/**
- * Deploy the site already built in export/out: its oversize archives to R2,
- * then the bundle to its Pages project (a PREVIEW with `previewBranch`).
- * THROWS on every refusal and failure, with the sentences the editor's deploy
- * job has always ended on; returns quietly on a cancel.
- *
- * The refusals are checked here even though the editor action checks them
- * before it starts the job, because a queued deploy can start after another
- * site's build has replaced export/out — the action's check is the fast answer,
- * this one is the last word.
- */
-export async function deploySite(
- siteId: string,
- opts: PublishOpts & {
- previewBranch?: string;
- // The bundle to ship and where its oversize archives were staged. Default:
- // export/out and the host staging dir (the editor's deploy action); the
- // deploy-site stage passes the site's own bundle under exportBuildsDir.
- outDir?: string;
- stagingDir?: string;
- } = {},
-): Promise<void> {
- const { paths, onLog, signal } = resolved(opts);
- if (opts.previewBranch !== undefined) {
- const problem = previewBranchProblem(opts.previewBranch);
- if (problem) throw new Error(problem);
- }
- const branch = opts.previewBranch?.trim() || undefined;
- const site = getSite(siteId.trim(), paths);
- // Before anything else is asked of a private site: it is never deployed.
- const privateProblem = siteDeployProblem(site);
- if (privateProblem) throw new Error(`${privateProblem}.`);
- if (!site.cloudflareProject) {
- throw new Error(
- `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
- );
- }
- const outDir = opts.outDir ?? resolveOutDir(site.siteId, paths);
- const builtProblem = builtSiteProblem(outDir, site.siteId);
- if (builtProblem) throw new Error(builtProblem);
- // Before the R2 upload below: a bundle built private is never deployed.
- const builtPrivate = builtAudienceProblem(outDir);
- if (builtPrivate) throw new Error(`${builtPrivate}. Build ${site.siteId} again, then deploy.`);
- // …nor a full build of a site that now publishes only its reports.
- const builtScope = builtScopeProblem(site, outDir);
- if (builtScope) throw new Error(`${builtScope}.`);
- // The production path logs no banner and gains none here: its log has
- // always opened on wrangler's own first line.
- if (branch) {
- onLog(`=== Deploy (preview "${branch}") ===\n`);
- onLog(PREVIEW_SHARES_ARCHIVES_NOTICE);
- }
- // Push oversize archives to R2 first, so the manifest URLs the Pages deploy
- // publishes resolve immediately. No-op when R2 isn't configured.
- const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths, opts.stagingDir);
- if (signal.aborted) return;
- if (uploadCode !== 0) {
- throw new Error(`Archive R2 upload failed (exit ${uploadCode}).`);
- }
- const code = await runDeployIntoLog(onLog, signal, site, outDir, paths, {
- previewBranch: branch,
- });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Deploy failed (exit ${code}).`);
-}
-
-/**
- * Build every configured site (or `sites`), without deploying. "docker" is the
- * parallel per-site container fan-out (runDockerBuildAllPhase, which throws on
- * an infrastructure failure); "basic" is a serial host loop whose shared
- * export/out is overwritten per site, so only the last survives. The pool-wide
- * data phase runs once either way. Per-site failures are returned, not thrown.
- */
-export async function buildAll(
- opts: PublishOpts & {
- mode: "docker" | "basic";
- skipArchives?: boolean;
- sites?: Site[];
- },
-): Promise<SiteBuildOutcome[]> {
- const { paths, onLog, signal } = resolved(opts);
- const sites = opts.sites ?? listSites(paths);
- if (opts.mode === "docker") {
- return runDockerBuildAllPhase(onLog, signal, sites, paths, {
- skipArchives: opts.skipArchives,
- });
- }
- const outcomes: SiteBuildOutcome[] = [];
- for (let i = 0; i < sites.length; i++) {
- if (signal.aborted) break;
- const site = sites[i];
- onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`);
- const code = await runBuildPhase(onLog, signal, site.siteId, paths, {
- skipData: i > 0,
- skipArchives: opts.skipArchives,
- });
- outcomes.push({ siteId: site.siteId, code });
- }
- return outcomes;
-}
-
// ---------------------------------------------------------------------------
// The hub and the homepage — two apps, two Pages projects (decision
// 2026-09-25).
@@ -948,12 +596,7 @@ export function buildHubSteps(opts: {
};
return [
{ command: "pnpm", args: ["run", "compose:hub"], cwd: paths.exportDir, env },
- {
- command: "pnpm",
- args: ["exec", "next", "build"],
- cwd: paths.exportDir,
- env: { ...env, INSTANCE_MODE: "hub" },
- },
+ nextBuildStep(paths, { ...env, INSTANCE_MODE: "hub" }),
];
}
@@ -976,92 +619,14 @@ export async function buildHub(opts: PublishOpts = {}): Promise<number> {
return runSteps(onLog, signal, buildHubSteps({ paths }));
}
-// One Pages deploy of `outDir` to `project`, streaming into onLog, with the
-// deployment URL (or the preview alias) repeated as the last line on success
-// — runDeployIntoLog's shape, for a bundle that is not a Site.
-async function runPagesDeployIntoLog(
- onLog: (line: string) => void,
- signal: AbortSignal,
- opts: {
- outDir: string;
- project: string;
- cwd: string;
- // The binary (wranglerBin): the pinned devDependency, or WRANGLER_BIN.
- wrangler: string;
- previewBranch?: string;
- },
-): Promise<number> {
- let deploymentUrl: string | null = null;
- const watch = (line: string) => {
- if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, opts.project);
- onLog(line);
- };
- const code = await runChildIntoLog(watch, signal, {
- command: opts.wrangler,
- args: pagesDeployArgs({
- outDir: opts.outDir,
- project: opts.project,
- previewBranch: opts.previewBranch,
- }),
- cwd: opts.cwd,
- env: { ...process.env, NODE_ENV: "production" },
- });
- if (code === 0) {
- if (opts.previewBranch) {
- const alias = previewAliasUrl(opts.project, opts.previewBranch);
- onLog(
- `[preview] ${alias}` +
- (deploymentUrl ? ` (this deployment: ${deploymentUrl})` : "") +
- "\n",
- );
- } else if (deploymentUrl) {
- onLog(`[deployed] ${deploymentUrl}\n`);
- }
- }
- return code;
-}
-
-/**
- * Deploy the hub built in export/out to homepage.json's Pages project (a
- * PREVIEW with `previewBranch`). THROWS every refusal and failure; returns
- * quietly on a cancel. No R2 step: the hub holds no archives.
- */
-export async function deployHub(
- opts: PublishOpts & { previewBranch?: string; outDir?: string } = {},
-): Promise<void> {
- const { paths, onLog, signal } = resolved(opts);
- if (opts.previewBranch !== undefined) {
- const problem = previewBranchProblem(opts.previewBranch);
- if (problem) throw new Error(problem);
- }
- const branch = opts.previewBranch?.trim() || undefined;
- const project = getHomepageConfig(paths).cloudflareProject;
- const projectProblem = hubProjectProblem(project);
- if (projectProblem) throw new Error(projectProblem);
- const outDir = opts.outDir ?? resolveOutDir("", paths);
- const builtProblem = builtHubProblem(outDir);
- if (builtProblem) throw new Error(builtProblem);
- if (branch) onLog(`=== Deploy hub (preview "${branch}") ===\n`);
- const code = await runPagesDeployIntoLog(onLog, signal, {
- outDir,
- project: project!.trim(),
- cwd: paths.exportDir,
- wrangler: wranglerBin(paths),
- previewBranch: branch,
- });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Hub deploy failed (exit ${code}).`);
-}
-
function homepageDir(paths: Paths): string {
return path.join(paths.monorepoRoot, "homepage");
}
/**
- * Where buildHomepage writes and deployHomepage ships: `homepage/out` of this
- * checkout. Exported for the editor's homepage actions, which refuse a
- * deploy-only before any job when nothing is built there
- * (builtHomepageProblem) and show when it was built.
+ * Where buildHomepage writes and the deploy-homepage stage ships: `homepage/out` of this
+ * checkout. Exported for the build-homepage and deploy stages, which judge
+ * whether anything is built there (builtHomepageProblem).
*/
export function homepageOutDir(paths: Paths): string {
return path.join(homepageDir(paths), "out");
@@ -1096,7 +661,7 @@ export async function composeHomepage(opts: PublishOpts = {}): Promise<number> {
* source twice over: the step itself removes the last publish from
* homepage/public, and this removes the last BUILD's copy from homepage/out
* (`out/source`, the tarball, `snapshot.json`), so a deploy-only cannot ship
- * a source today's rules were never applied to (deployHomepage checks too).
+ * a source today's rules were never applied to (the deploy stage checks too).
* The audit report is in the log.
*
* `skipSource` (the CLI's `--no-source`) REMOVES the published source instead:
@@ -1155,51 +720,6 @@ async function withdrawBuiltSource(paths: Paths, onLog: (line: string) => void):
}
}
-/**
- * The wrangler argv (after the binary, wranglerBin) for a homepage deploy of
- * `outDir`: the production branch `main`, or, with `previewBranch`, that
- * preview branch and no `main` — pagesDeployArgs, which names the branch for
- * every project now. Pure, so the test pins exactly what deployHomepage runs.
- */
-export function homepageDeployArgs(outDir: string, previewBranch?: string): string[] {
- return pagesDeployArgs({ outDir, project: HOMEPAGE_PAGES_PROJECT, previewBranch });
-}
-
-/**
- * Deploy homepage/out to the homepage's constant project: production (branch
- * `main`), or a PREVIEW with `previewBranch` — the same refusals and alias line
- * as deployHub. THROWS on failure or when there is no build.
- */
-export async function deployHomepage(
- opts: PublishOpts & { previewBranch?: string } = {},
-): Promise<void> {
- const { paths, onLog, signal } = resolved(opts);
- if (opts.previewBranch !== undefined) {
- const problem = previewBranchProblem(opts.previewBranch);
- if (problem) throw new Error(problem);
- }
- const branch = opts.previewBranch?.trim() || undefined;
- // The directory the editor's actions judged "built" (builtHomepageProblem).
- const outDir = homepageOutDir(paths);
- if (!existsSync(path.join(outDir, "index.html"))) {
- throw new Error("homepage/out holds no build — run archilyzer build homepage first");
- }
- // The source in out/ ships only if it was audited under TODAY's rules, of
- // today's main (source.ts). An out/ with no source deploys as before.
- const sourceProblem = await (await import("./source")).publishedSourceProblem(paths, outDir);
- if (sourceProblem) throw new Error(sourceProblem);
- if (branch) onLog(`=== Deploy homepage (preview "${branch}") ===\n`);
- const code = await runPagesDeployIntoLog(onLog, signal, {
- outDir,
- project: HOMEPAGE_PAGES_PROJECT,
- cwd: homepageDir(paths),
- wrangler: wranglerBin(paths),
- previewBranch: branch,
- });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Homepage deploy failed (exit ${code}).`);
-}
-
// ---------------------------------------------------------------------------
// Per-target bundles (release 18). `<exportBuildsDir>/<target>/out` is THE
// bundle every deploy of `target` ships, whichever runner built it: a site's
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/inputSig.ts b/common/publish/inputSig.ts
@@ -145,6 +145,44 @@ export async function siteInputSig(i: SiteSigInputs): Promise<string> {
return sha1(lines.join("\n"));
}
+/** The settings the index stage reads (see indexSettingsSig). */
+export type IndexSettingsInputs = Pick<
+ SiteSettings,
+ | "socialLinks"
+ | "homepageUrl"
+ | "buildArchives"
+ | "archiveStorage"
+ | "social"
+ | "maxTranscriptPageBytes"
+ | "storage"
+>;
+
+/**
+ * sha1 over the settings keys the index stage and the builds it signs read —
+ * NOT the settings file's mtime, which every pause click, priority change and
+ * drive auto-pause moves (release 18 S4 step 1). The keys: the social links and
+ * the homepage url (the builds render them), `buildArchives`, `archiveStorage`,
+ * `social.x.visibility` (which members a site publishes), the transcript page
+ * size (the index shards by it) and the storage locations' roots (which
+ * channels the index can reach). A superset is conservative: a short-circuited
+ * index update, never a missed one. The charts config is its own file, judged
+ * by its mtime.
+ */
+export function indexSettingsSig(settings: Partial<IndexSettingsInputs>): string {
+ return sha1(
+ JSON.stringify({
+ v: 1,
+ socialLinks: settings.socialLinks ?? null,
+ homepageUrl: settings.homepageUrl ?? null,
+ buildArchives: settings.buildArchives ?? null,
+ archiveStorage: settings.archiveStorage ?? null,
+ xVisibility: settings.social?.x?.visibility ?? null,
+ maxTranscriptPageBytes: settings.maxTranscriptPageBytes ?? null,
+ locations: (settings.storage?.locations ?? []).map((l) => [l.id, l.root]),
+ }),
+ );
+}
+
/** Every member's channel config, read once (unreadable = null). */
export async function readMemberConfigs(
paths: Paths,
diff --git a/common/publish/publishPlan.ts b/common/publish/publishPlan.ts
@@ -129,6 +129,8 @@ export type PublishInputs = {
lastIngestDoneAt: number | null;
// The newest mtime of an index input config file, or null.
configChangedAt: number | null;
+ // indexSettingsSig of the settings now (stages.ts NeedsInput).
+ settingsSig?: string;
};
// Per channel: when its newest ingest ended (a job meta, or its report's
// regeneration — see publishState.ts).
diff --git a/common/publish/publishStages.ts b/common/publish/publishStages.ts
@@ -15,7 +15,13 @@
// ORDER IS ON DISK, NOT IN MEMORY: a run's builds carry `indexAfter` and its
// deploys `builtAfter` (the plan sets them), and a child whose precondition is
// not met when it starts exits 3 — the job ends `failed` with the sentence in
-// its log. That is intended: nothing here chains one job to another.
+// its log. That is intended: nothing here chains one job to another. One
+// window it leaves: a click's run (foreground) that finds the lane's index
+// update already QUEUED (background — behind a job on another queue's slot,
+// say) reuses it as `existing`, and its own build, foreground, can then start
+// first and end "waiting for the index update this run started". The lane
+// yields while any publish stage is queued, so the window is narrow; Retry
+// re-enqueues the build.
//
// The spec IS the request: `{kind: "publish-<stage>", slug: target, params:
// {runId, …req}}` (`parseJobSpec` requires `slug`; there is no `channelSlug`),
diff --git a/common/publish/publishState.test.ts b/common/publish/publishState.test.ts
@@ -28,6 +28,10 @@ process.env.CURATED_TAGS_FILE = path.join(ROOT, "transcripts", "tags.json");
const { getPaths } = await import("../lib/paths");
const { readPublishInputs, readPublishStatus, resetPublishStateCache } = await import("./publishState");
const { stageSpec } = await import("./publishStages");
+const { indexSettingsSig } = await import("./inputSig");
+const { writeIndexStamp } = await import("./stamps");
+const { indexStamp } = await import("./__fixtures__/stamps");
+const { getSettings } = await import("../lib/settings");
type JobRecord = import("../jobs/registry").JobRecord;
after(() => rm(ROOT, { recursive: true, force: true }));
@@ -163,3 +167,30 @@ test("readPublishStatus: the chips a fresh scratch corpus shows", async () => {
assert.equal(s.lane.enabled, true);
assert.equal(s.lane.due, true, "no stamp: a pass is due");
});
+
+test("the index is stale on the settings it reads, not on a settings-file write", async () => {
+ setup();
+ resetPublishStateCache();
+ // A stamp newer than every input, signed over the settings as they are.
+ await writeIndexStamp(paths, indexStamp({ scannedAt: T + 1, builtAt: T + 1, settingsSig: indexSettingsSig(getSettings()) }));
+ const fresh = async () => (await readPublishStatus(paths, { now: T + 2, live: [], laneKnown: false })).index;
+ assert.equal((await fresh()).fresh, true, "signed over these settings");
+
+ // A priority change (a pause click, a drive auto-pause) writes the file NOW.
+ writeJson(paths.settingsFile, {
+ publish: { enabled: true, hub: "build", previewBranch: "smoke" },
+ channelPriority: { channels: { "a-one": { tier: "high" } } },
+ });
+ resetPublishStateCache();
+ assert.equal((await fresh()).fresh, true, "the file is newer than the stamp; what the index reads is not");
+
+ // A social link is rendered by every build: the index is stale.
+ writeJson(paths.settingsFile, {
+ publish: { enabled: true, hub: "build", previewBranch: "smoke" },
+ socialLinks: [{ label: "Site", url: "https://example.com", svg: '<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>' }],
+ });
+ resetPublishStateCache();
+ const after = await fresh();
+ assert.equal(after.fresh, false);
+ assert.deepEqual(after.freshness, { state: "stale", reason: "the settings the index reads changed" });
+});
diff --git a/common/publish/publishState.ts b/common/publish/publishState.ts
@@ -7,7 +7,9 @@
// hub's and the homepage's (settings.publish);
// - the input files' mtimes the plan lists: for the index tags.json,
// search-aliases.json, duplicates*.json, every sites/*/site.json,
-// homepage.json, the settings file and the charts config; for a site its
+// homepage.json and the charts config — the SETTINGS by the keys the index
+// reads (inputSig.ts indexSettingsSig), never the file's mtime, which
+// every pause click moves; for a site its
// site.json, its tags and aliases and the corpus-wide three; for the hub
// homepage.json and every site.json; for the homepage homepage.json;
// - the ingest signal, per channel: every job meta of an ingest kind
@@ -37,6 +39,7 @@ import { getHomepageConfig } from "../lib/homepage";
import { getPaths, type Paths } from "../lib/paths";
import { PROJECT_URL } from "../lib/project";
import { getSettings, normalizeHomepageUrl } from "../lib/settings";
+import { indexSettingsSig } from "./inputSig";
import {
isListedSite,
isPrivateSite,
@@ -324,9 +327,11 @@ export async function readPublishInputs(
corpusWide,
...siteJsonAt.values(),
homepageJsonAt,
- await mtimeOf(paths.settingsFile),
await mtimeOf(paths.chartsConfigFile),
);
+ // The settings are judged by the keys the index reads, not the file's mtime
+ // (every pause click writes the file).
+ const settingsSig = indexSettingsSig(settings);
const siteInputs: PublishSiteInput[] = [];
for (const s of sites) {
@@ -357,7 +362,7 @@ export async function readPublishInputs(
const mainHead = await memoized("mainHead", now, () => mainHeadOf(paths));
const commit = await memoized("commit", now, async () => (await checkoutInfo(paths)).commit);
return {
- index: { stamp: needs.index.stamp, lastIngestDoneAt, configChangedAt: indexConfigAt },
+ index: { stamp: needs.index.stamp, lastIngestDoneAt, configChangedAt: indexConfigAt, settingsSig },
ingestByChannel: byChannel,
commit,
sites: siteInputs,
diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts
@@ -547,7 +547,7 @@ test("round trip: --check writes nothing; publish; a dumb clone of the mirror is
writeFileSync(path.join(repo, "later.txt"), "x\n");
gitIn(repo, "add", "-A");
gitIn(repo, "commit", "-q", "-m", "later");
- assert.match((await publishedSourceProblem(checkPaths, out, check))!, /main is now [0-9a-f]{12} — run `archilyzer build homepage`/);
+ assert.match((await publishedSourceProblem(checkPaths, out, check))!, /main is now [0-9a-f]{12} — run `archilyzer publish homepage`/);
// A REFUSAL WITHDRAWS THE PUBLISH (M1): deny a literal the mirror holds.
// The deploy check refuses first (other rules), then the publish refuses
@@ -727,7 +727,7 @@ test("history: published with the source — the allowlist at /source/git/, the
const outLog = path.join(out, "source", "git", "log.html");
const was = readFileSync(outLog);
writeFileSync(outLog, Buffer.concat([was, Buffer.from(" ")]));
- assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited — run `archilyzer build homepage`/);
+ assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited — run `archilyzer publish homepage`/);
rmSync(path.join(out, "source", "git"), { recursive: true });
assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited/);
cpSync(path.join(pub, "source", "git"), path.join(out, "source", "git"), { recursive: true });
@@ -976,3 +976,18 @@ test("history: a refusal with a render cache it cannot remove still exits 1 and
chmodSync(cacheDir, 0o755);
}
});
+
+test("a homepage/out holding a source publish with no manifest is refused (the deploy check; its old home was build.test.ts deployHomepage)", async () => {
+ const root = mkdtempSync(path.join(os.tmpdir(), "source-no-manifest-"));
+ try {
+ const out = path.join(root, "homepage", "out");
+ mkdirSync(path.join(out, "source", MIRROR_DIR), { recursive: true });
+ writeFileSync(path.join(out, "source", "index.html"), "<p>the /source page</p>");
+ assert.match(
+ (await publishedSourceProblem({ monorepoRoot: root } as Paths, out))!,
+ /homepage\/out holds a source publish without a valid manifest/,
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/publish/source.ts b/common/publish/source.ts
@@ -453,7 +453,7 @@ export function sourcePublicDir(paths: Paths, override?: string): string {
}
// The skip key, kept BESIDE the public dir, never in it: it holds the rules
-// hash, and public/ is deployed. It is also what `deployHomepage` checks
+// hash, and public/ is deployed. It is also what the homepage's deploy stage checks
// homepage/out's source against (publishedSourceProblem).
function statePath(publicDir: string): string {
return path.join(path.dirname(publicDir), ".source-publish.json");
@@ -1276,7 +1276,7 @@ export async function clearPublishedSource(
/**
* Why homepage/out's source may not be deployed, as one sentence — or null.
- * `deployHomepage` asks before every deploy, production and preview alike: a
+ * The homepage's deploy stage asks before every deploy, production and preview alike: a
* deploy-only ships `out/` as the last build left it, and that build's source
* was audited under the rules of its day. It deploys only when the last
* publish (the skip key beside public/) was made under TODAY's rules and step
@@ -1301,7 +1301,9 @@ export async function publishedSourceProblem(
): Promise<string | null> {
const outSource = path.join(outDir, "source");
const outTarball = path.join(outDir, "downloads", TARBALL_NAME);
- const rebuild = "run `archilyzer build homepage` (it re-audits), then deploy";
+ // The stage, not the bare `build homepage`: only a stage stamps the build
+ // (`_homepage/built.json`), and a deploy ships what is stamped.
+ const rebuild = "run `archilyzer publish homepage` (it re-audits), then deploy";
// The /source/ PAGE is always in a finished build (it renders its empty
// state without a publish). Without it, the build's source step refused —
// it took out/source away — or the build predates the page.
diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts
@@ -229,7 +229,7 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> {
const { buildIndex } = await import("../controller/buildIndex");
const { buildStats } = await import("../controller/buildStats");
const { syncTemplatesToExport } = await import("../lib/chartsStore");
- const { hubInputSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig");
+ const { hubInputSig, indexSettingsSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig");
// A CLI process has no health pass: the drive-health timings the builds'
// watchdog runs on are applied here, once (bin/build-index.ts does the same).
@@ -311,6 +311,7 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> {
},
sites: siteEntries,
hubSig: await hubInputSig(paths, stampId, sites),
+ settingsSig: indexSettingsSig(settings),
};
const { writeIndexStamp } = await import("./stamps");
await writeIndexStamp(paths, stamp);
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) {
diff --git a/common/publish/stages.test.ts b/common/publish/stages.test.ts
@@ -100,6 +100,29 @@ test("update-index: fresh when a stamp exists and nothing is newer than its scan
assert.equal(reason(needs(input(), req("update-index", "_index", { force: true }))), "forced");
});
+test("update-index: the settings it reads, by signature — not judged when the caller read none", () => {
+ const signed = (stampSig: string | undefined, nowSig: string | undefined) =>
+ input({
+ index: {
+ stamp: indexStamp(stampSig === undefined ? {} : { settingsSig: stampSig }),
+ lastIngestDoneAt: null,
+ configChangedAt: null,
+ ...(nowSig === undefined ? {} : { settingsSig: nowSig }),
+ },
+ });
+ assert.equal(state(needs(signed("a", "a"), req("update-index", "_index"))), "fresh");
+ assert.equal(
+ reason(needs(signed("a", "b"), req("update-index", "_index"))),
+ "the settings the index reads changed",
+ );
+ assert.equal(
+ reason(needs(signed(undefined, "b"), req("update-index", "_index"))),
+ "the settings the index reads changed",
+ "a stamp from before the signature reads as changed",
+ );
+ assert.equal(state(needs(signed("a", undefined), req("update-index", "_index"))), "fresh");
+});
+
// --- build-site <id> ----------------------------------------------------------
test("build-site: blocked 'update the index first' with no stamp — even forced", () => {
diff --git a/common/publish/stages.ts b/common/publish/stages.ts
@@ -9,7 +9,8 @@
// run(ctx, req) the body, in that child or in the CLI's own process
//
// `needs()` reads a `NeedsInput` — the minimal PublishStatus-shaped input
-// defined here. S3's status view (common/views/publishStatus.ts) satisfies it
+// defined here. S3's status view (publish/publishPlan.ts, re-exported by
+// views/publishStatus.ts) satisfies it
// from the stamps plus the job metas (`changedChannels`) and config mtimes; the
// stage child builds one from disk alone (`readNeedsInput`, stageBodies.ts),
// because ordering is enforced ON DISK: a stage whose precondition is not met
@@ -125,8 +126,11 @@ export type NeedsInput = {
lastIngestDoneAt: number | null;
// The newest mtime (ms) of an index input config file (tags.json,
// search-aliases.json, duplicates*.json, sites/*/site.json,
- // homepage.json, the settings file, the charts config), or null.
+ // homepage.json, the charts config), or null.
configChangedAt: number | null;
+ // indexSettingsSig over the settings as they are now; compared with the
+ // stamp's. Absent: not judged (a caller that did not read settings).
+ settingsSig?: string;
};
sites: Record<string, TargetState>;
hub: TargetState;
@@ -167,6 +171,9 @@ function needsIndex(s: NeedsInput, r: StageRequest): Freshness {
if (s.index.configChangedAt !== null && s.index.configChangedAt > stamp.scannedAt) {
return stale("a config file changed since the last index");
}
+ if (s.index.settingsSig !== undefined && s.index.settingsSig !== stamp.settingsSig) {
+ return stale("the settings the index reads changed");
+ }
return FRESH;
}
diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts
@@ -48,6 +48,9 @@ export type IndexStamp = {
// (null when absent), and the inputSig compose's skip rule is computed from.
sites: Record<string, { siteFp: string | null; statsFp: string | null; inputSig: string }>;
hubSig: string;
+ // inputSig.ts indexSettingsSig over the settings the index read (absent on
+ // a stamp written before release 18's surfaces: it reads as changed).
+ settingsSig?: string;
};
export type BuiltKind = "site" | "hub" | "homepage";
@@ -169,6 +172,7 @@ export function asIndexStamp(v: unknown): IndexStamp | null {
return null;
}
if (!isObj(v.sites) || !isStr(v.hubSig)) return null;
+ if (v.settingsSig !== undefined && !isStr(v.settingsSig)) return null;
for (const s of Object.values(v.sites)) {
if (!isObj(s) || !isStr(s.inputSig) || !isStrOrNull(s.siteFp) || !isStrOrNull(s.statsFp)) return null;
}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,14 +1,14 @@
# Changelog
## [Unreleased]
-- **The publish lane.** Publishing can run itself: turn on `publish.enabled` in settings and the lane checks every `checkEveryMinutes` (10) whether the index is stale; when it is — and its last update is at least `refreshEveryMinutes` (360) old — it updates it, then builds every site whose channels changed or whose data the new index moved, one stage at a time on the `publish` queue. What it may do with a site is the site's own: `site.json` `publish.auto` is `off` (the default: left alone), `build`, `preview` (built and deployed to the preview branch `publish.previewBranch`) or `production`; the hub and the homepage have `publish.hub` and `publish.homepage`. A private site is only ever built, and a site needs its Cloudflare Pages project before it may deploy. Hold the lane and the stage running finishes and no next one starts; quiet hours (`publish.quietHours`) do the same; Drain finishes the stage and ends the runner. The lane never forces a stage: a stage that finds its target current does nothing. On /jobs every stage of one run reads `run <id> · <target>`, and a stage still queued when the editor restarts is cancelled, never re-queued — the lane works out again what is stale from what is on disk. `archilyzer publish now` runs the same plan from the command line, one stage after another in its own process.
+- **The publish lane.** Publishing can run itself: turn it on at **/operations/publish** (the runner's Start, Drain and Stop, the hold, and the lane's settings; or `publish.enabled` in settings) and the lane checks every `checkEveryMinutes` (10) whether the index is stale; when it is — and its last update is at least `refreshEveryMinutes` (360) old — it updates it, then builds every site whose channels changed or whose data the new index moved, one stage at a time on the `publish` queue. What it may do with a site is the site's own — the **Publish policy** on the site's settings form, `site.json` `publish.auto` —: `off` (the default: left alone), `build`, `preview` (built and deployed to the preview branch `publish.previewBranch`) or `production`; the hub and the homepage have `publish.hub` and `publish.homepage`. A private site is only ever built, and a site needs its Cloudflare Pages project before it may deploy. Hold the lane and the stage running finishes and no next one starts; quiet hours (`publish.quietHours`) do the same; Drain finishes the stage and ends the runner. The lane never forces a stage: a stage that finds its target current does nothing. On /jobs every stage of one run reads `run <id> · <target>`, and a stage still queued when the editor restarts is cancelled, never re-queued — the lane works out again what is stale from what is on disk. `archilyzer publish now` runs the same plan from the command line, one stage after another in its own process.
- **One index for every site.** The index is updated once and every site, the hub and the homepage are built from it; `archilyzer publish status` says, per site, whether its build is current — "stale: 3 channels changed (a, b, c)" as soon as a download, transcription or digest on one of its channels finishes, before any index runs; "stale: data changed" once the index has run and the site's data moved; "stale: config changed" after its site.json, tags or aliases changed — and whether what is deployed is that build, with a build made by older code marked "code newer" but not stale.
- **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.
- **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.
+- **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. In the editor, **/sites has a Publish panel** in place of "Build all sites" and the hub's and the homepage's build sections: a row per site, the hub and the homepage, each with four chips — index, built, deployed, live — and Build, Deploy preview, Deploy production (and Deploy local where the container serves one); **Publish now** runs what the lane would, **Build all stale** builds every stale site whatever its policy, and the plan Publish now would run is listed above the rows. Every button is stages on the `publish` queue, followed in one log, and a manual Build or Deploy always runs (the index is updated first when it is stale). The Pool's **Build index** is the index stage, and **Build stats dataset is gone**: the stats are part of it. A site's Publish tab is stages too, and its "Last deployed" is what that site last shipped and how its live check read. Over HTTP, `pnpm ops publish` takes `{"verb": "index" | "build" | "deploy" | "hub" | "homepage" | "now" | "stale"}` and `pnpm ops get publish` is the status; `build-index`, `build-site`, `build-deploy`, `deploy-site`, `build-hub`, `deploy-hub`, `build-homepage` and `deploy-homepage` still answer as before, as stages — except that `build-site` and `build-deploy` with `"all": true` answer a job per site (their `jobId` is the run's last), and a deploy-only of a build already deployed there is a no-op. A console's **Cancel** cancels its whole run.
- **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.
-- **`build site`, `build all` and `deploy site` are aliases of the publish commands** and print what they run: `build site <id>` is `publish index` (skipped with `--nodata`) then `publish build <id> --force`; `build all` is `publish index` then `publish build all --runner auto` (containers when an engine answers, else one site at a time on the host); `deploy site <id>` is `publish deploy <id>`, which now ships the site's own bundle and refuses a site never built that way. `publish build all --runner docker` builds every stale site in containers on a Linux host and refuses with "the docker runner needs an engine on this host" where there is none.
+- **`build site`, `build all` and `deploy site` are aliases of the publish commands** and print what they run: `build site <id>` is `publish index` (skipped with `--nodata`) then `publish build <id> --force`; `build all` is `publish index` then `publish build all --runner auto` (containers when an engine answers, else one site at a time on the host); `deploy site <id>` is `publish deploy <id>`, which now ships the site's own bundle and refuses a site never built that way; `deploy hub` and `deploy homepage` are `publish hub --deploy-only` and `publish homepage --deploy-only` (a no-op when that build is already deployed there, unless `--force`; a refusal exits 3). `publish build all --runner docker` builds every stale site in containers on a Linux host and refuses with "the docker runner needs an engine on this host" where there is none.
- **Substitute your own yt-dlp in Docker.** Point `YTDLP_BIN` at a zipapp you built, or set `YTDLP_SOURCE_HOST_DIR` to a yt-dlp checkout and start with `docker-compose.ytdlp.yml`: the image runs it with its own python, and nothing is rebuilt. Every editor boot logs `yt-dlp: <path> <version> (image|override)` (`MISSING` when it does not run; the editor still starts), and `YTDLP_AUTO_UPDATE` updates the image's yt-dlp only, warning instead of touching yours.
- **The Docker image can publish.** It carries python, `pipx` and a pinned `git-filter-repo`, so the homepage's `/source` mirror builds in the container; `docker-compose.source.yml` mounts your repository read-only for it, and the scrub rules and denylist live in the config volume (`/data/config/archilyzer`). Cloudflare and R2 credentials come from `.env`. Run publish commands with `docker compose exec editor pnpm archilyzer …`, not `run --rm`. The `homepage` service serves a local deploy from the builds volume once there is one. RUNNING_IN_DOCKER.md has a Windows checklist.
- **`archilyzer doctor` checks what a publish needs.** Which yt-dlp runs (the image's, the host's or an override, and whether it runs), whether the Cloudflare token and the R2 keys are set (never their values; R2 only when a bucket is configured) — judged exactly as a deploy judges them —, the wrangler a deploy runs (the pinned one or your `WRANGLER_BIN`, and that it starts and is the expected major), free space for the site bundles, the publish lock (free, held by a running stage, or left by one that is gone — with the command to clear it; never cleared for you), the index stamp's age and which sites were built from an older one, the repository the source mirror reads, the private config dir, and whether this Node is new enough for the pinned wrangler (deploys need 22).
diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts
@@ -293,66 +293,6 @@ export function optPreviewBranch(body: OpsBody): string | undefined {
return (raw as string).trim();
}
-// The site fan-out the deploy routes run: start one managed job per site,
-// keeping every id, and refuse with a 400 when NONE started.
-//
-// SHARED BECAUSE THE CAREFUL PART IS EASY TO GET WRONG TWICE. One site's throw
-// cannot cost the others their job ids — an exception out of the loop becomes a
-// 500 carrying no `jobs` at all, while the jobs already queued run on with
-// nobody holding their ids. And asking for deploys and getting NONE is a 400
-// carrying the reason, not a cheerful `{ ok: true, jobs: [] }`: `--wait` would
-// exit 0 on that and report success about a deploy that never started.
-//
-// `decorate` adds per-site keys to a job entry (the preview alias); they are
-// repeated at the top level in the single-job case, exactly as `jobId` is, so a
-// one-site caller never has to index into `jobs`.
-export async function fanOutSiteJobs(
- siteIds: string[],
- start: (siteId: string) => Promise<StreamActionResult>,
- decorate?: (siteId: string) => Record<string, unknown>,
-): Promise<NextResponse> {
- const jobs: Record<string, unknown>[] = [];
- const extras: Record<string, unknown>[] = [];
- const skipped: { siteId: string; reason: string }[] = [];
- let info = false;
- for (const siteId of siteIds) {
- let result: StreamActionResult;
- try {
- result = await start(siteId);
- } catch (e) {
- skipped.push({ siteId, reason: (e as Error).message });
- continue;
- }
- if (!result.ok) {
- skipped.push({ siteId, reason: result.error });
- info = info || result.info === true;
- continue;
- }
- // The stream is cancelled, never returned — see this file's header.
- void result.stream.cancel();
- const extra = decorate?.(siteId) ?? {};
- jobs.push({ siteId, jobId: result.jobId, ...extra });
- extras.push(extra);
- }
- if (jobs.length === 0) {
- // One site asked for, one reason: the bare sentence the action gave,
- // exactly as jobResponse has always returned it.
- return opsFail(
- skipped.length === 1
- ? skipped[0].reason
- : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "),
- 400,
- info ? { info: true } : undefined,
- );
- }
- return NextResponse.json({
- ok: true,
- jobs,
- skipped,
- ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...extras[0] } : {}),
- });
-}
-
export function oneOf<T extends string>(
body: OpsBody,
key: string,
diff --git a/editor/app/api/ops/_publish.ts b/editor/app/api/ops/_publish.ts
@@ -0,0 +1,95 @@
+// The ops API's way into the publish stages (release 18 S4): `POST
+// /api/ops/publish` and the eight routes it replaced, which stay as aliases
+// with their old bodies and answers (build-index, build-site, build-deploy,
+// deploy-site, build-hub, deploy-hub, build-homepage, deploy-homepage).
+//
+// Adapters only (this directory's rule, _lib.ts): every refusal is
+// publishCore's — the deploy stage's own sentence, asked before a job exists —
+// and every job is `enqueuePublishRun`'s, under one run id.
+
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import type { PublishPlan } from "yt-dlp-transcript-common/publish/publishPlan";
+import type { PublishRunResult } from "yt-dlp-transcript-common/publish/publishStages";
+import {
+ enqueueRun,
+ publishStatus,
+ refusal,
+ stageKindsOf,
+ wantedPlan,
+ type TargetAsk,
+} from "../../sites/lib/publishCore";
+import { opsFail } from "./_lib";
+
+export type OpsRefusal = { target: string; kind?: string; error: string };
+
+export type OpsRun = PublishRunResult & { preRefused: OpsRefusal[] };
+
+/** Enqueue a plan as it stands (Publish now, Build all stale, the index). */
+export async function enqueuePlan(plan: PublishPlan): Promise<OpsRun> {
+ const run = await enqueueRun(getPaths(), plan);
+ return { ...run, preRefused: [] };
+}
+
+/**
+ * Enqueue what the asks name: each is refused before any job exists when the
+ * deploy stage would refuse it (the others still run); the index update goes
+ * first when a build is asked for and the index is not fresh.
+ */
+export async function enqueueAsks(asks: TargetAsk[]): Promise<OpsRun> {
+ const paths = getPaths();
+ const ok: TargetAsk[] = [];
+ const preRefused: OpsRefusal[] = [];
+ for (const ask of asks) {
+ // One target's throw (an unreadable site.json) is that target's refusal,
+ // never the whole request's 500: the others still run.
+ let why: string | null;
+ try {
+ why = await refusal(paths, ask);
+ } catch (e) {
+ why = (e as Error).message;
+ }
+ if (why) {
+ const kinds = stageKindsOf(ask.target);
+ preRefused.push({ target: ask.target, kind: ask.deploy ? kinds.deploy : kinds.build, error: why });
+ } else ok.push(ask);
+ }
+ if (ok.length === 0) return { runId: "", jobs: [], skipped: [], refused: [], preRefused };
+ const status = await publishStatus(paths);
+ const run = await enqueueRun(paths, wantedPlan(status, ok, { withIndex: true, paths }));
+ return { ...run, preRefused };
+}
+
+/** Every refusal of a run, before a job or by the queue. */
+export function refusalsOf(run: OpsRun): OpsRefusal[] {
+ return [...run.preRefused, ...run.refused];
+}
+
+/**
+ * The publish route's answer: `{ok, runId, jobs, skipped, refused}` and a
+ * top-level `jobId` when exactly one job (so `--wait` follows it). Asking for
+ * jobs and getting none because every one was refused is a 400 naming the
+ * reasons; a plan with nothing to run is an `ok` with no jobs.
+ */
+export function publishResponse(run: OpsRun): NextResponse {
+ const refused = refusalsOf(run);
+ if (run.jobs.length === 0 && refused.length > 0) {
+ return opsFail(
+ refused.length === 1 ? refused[0].error : refused.map((r) => `${r.target}: ${r.error}`).join("; "),
+ );
+ }
+ return NextResponse.json({
+ ok: true,
+ runId: run.runId,
+ jobs: run.jobs,
+ skipped: run.skipped,
+ refused,
+ ...(run.jobs.length === 1 ? { jobId: run.jobs[0].jobId } : {}),
+ ...(run.jobs.length === 1 && run.jobs[0].previewUrl ? { previewUrl: run.jobs[0].previewUrl } : {}),
+ });
+}
+
+/** The last job of a run for `target` (its deploy when it has one), or undefined. */
+export function lastJobOf(run: OpsRun, target: string) {
+ return [...run.jobs].reverse().find((j) => j.target === target);
+}
diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts
@@ -1,48 +1,22 @@
import { NextResponse } from "next/server";
-import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSite } from "yt-dlp-transcript-common/lib/site";
-import {
- buildAndDeployAction,
- buildAndDeployAllSitesAction,
-} from "../../../sites/lib/buildAction";
-import {
- fanOutSiteJobs,
- jobResponse,
- OpsInputError,
- ops,
- optBool,
- optPreviewBranch,
- reqSiteIds,
-} from "../_lib";
+import { getSite, listSiteIds } from "yt-dlp-transcript-common/lib/site";
+import { siteDeployProblem } from "yt-dlp-transcript-common/lib/builtExport";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { OpsInputError, ops, optBool, optPreviewBranch, opsFail, reqSiteIds } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { siteId: string | siteIds: string[], skipArchives?, preview? }
-// | { all: true, skipArchives? }
-// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }],
-// skipped: [{ siteId, reason }], jobId?, previewUrl? }
+// AN ALIAS (release 18): each site's build, then its deploy (the deploy waits on disk for that build). The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "build"} runs them.
//
-// Build THEN deploy: one managed job per site (one log, one Cancel each), so
-// the caller polls /api/jobs/<jobId>/log exactly as it does for build-site.
-//
-// `siteId` and `siteIds` are the same key (reqSiteIds), and the response is
-// build-site's: this route took one id and that one a list, so the two halves
-// of the same sentence in a runbook needed different JSON. `jobId` is still
-// there when exactly one job started, so every existing single-site caller —
-// and jobResponse's own shape — is unchanged; `jobs`/`skipped` are additive,
-// and `pnpm ops --wait` already reads `jobs[]`.
-//
-// Asking for builds and getting NONE is still a 400 carrying the reason, not a
-// cheerful `{ ok: true, jobs: [] }`: --wait would exit 0 on it and report
-// success about a deploy that never started. That, and the per-site error
-// handling, now live in fanOutSiteJobs, shared with deploy-site.
-//
-// `preview` names a branch and makes the DEPLOY half a Cloudflare Pages preview
-// (see deploy-site). It is refused alongside `all`: the all-sites path deploys
-// every configured site through the docker phase runner, which has no per-site
-// branch to thread one through, and silently building every site and shipping
-// them to production would be the worst possible reading of the request.
+// `all` = every DEPLOYABLE site to production (an explicit request: the
+// sites' policies are not asked); a private or project-less site is skipped
+// with its reason. → { ok, jobs: [{siteId, jobId, previewUrl?}], skipped }
+// with each site's DEPLOY job (the last of its run), as before. `all` used to
+// answer ONE job (build-deploy-all); it answers the per-site list now, and its
+// top-level `jobId` is the run's last job (`--wait` follows `jobs` anyway).
export async function POST(request: Request) {
return ops(
request,
@@ -50,37 +24,56 @@ export async function POST(request: Request) {
async (body) => {
const skipArchives = optBool(body, "skipArchives");
const preview = optPreviewBranch(body);
- if (optBool(body, "all")) {
+ const paths = getPaths();
+ let ids: string[];
+ const skippedAll: { siteId: string; reason: string }[] = [];
+ const all = optBool(body, "all") === true;
+ if (all) {
if (body.siteId !== undefined || body.siteIds !== undefined) {
- throw new OpsInputError(
- 'send either "siteId"/"siteIds" or "all", not both',
- );
+ throw new OpsInputError('send either "siteId"/"siteIds" or "all", not both');
}
if (preview !== undefined) {
throw new OpsInputError(
'"preview" is not supported with "all" — name the sites to preview with "siteIds"',
);
}
- return jobResponse(await buildAndDeployAllSitesAction(skipArchives));
- }
- const siteIds = reqSiteIds(body);
- return fanOutSiteJobs(
- siteIds,
- (siteId) =>
- buildAndDeployAction(
- siteId,
- skipArchives,
- preview ? { previewBranch: preview } : undefined,
- ),
- preview
- ? (siteId) => {
- // Only reached for a site whose job STARTED, which the action
- // does only once it has a cloudflareProject.
- const project = getSite(siteId, getPaths()).cloudflareProject;
- return project ? { previewUrl: previewAliasUrl(project, preview) } : {};
- }
- : undefined,
+ ids = [];
+ for (const id of listSiteIds(paths)) {
+ const site = getSite(id, paths);
+ const why = siteDeployProblem(site) ?? (site.cloudflareProject ? null : "no cloudflareProject");
+ if (why) skippedAll.push({ siteId: id, reason: why });
+ else ids.push(id);
+ }
+ if (ids.length === 0) return opsFail("No deployable sites configured.");
+ } else ids = reqSiteIds(body);
+ const where = preview ? ("preview" as const) : ("production" as const);
+ const run = await enqueueAsks(
+ ids.map((target) => ({
+ target,
+ build: { force: true, skipArchives },
+ deploy: { where, ...(preview ? { preview } : {}), force: true },
+ })),
);
+ const jobs = ids.flatMap((siteId) => {
+ const j = lastJobOf(run, siteId);
+ return j ? [{ siteId, jobId: j.jobId, ...(j.previewUrl ? { previewUrl: j.previewUrl } : {}) }] : [];
+ });
+ const skipped = [...skippedAll, ...refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error }))];
+ if (jobs.length === 0) {
+ return opsFail(skipped.length === 1 ? skipped[0].reason : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "));
+ }
+ const lastOfRun = all ? run.jobs.at(-1)?.jobId : undefined;
+ return NextResponse.json({
+ ok: true,
+ runId: run.runId,
+ jobs,
+ skipped,
+ ...(jobs.length === 1
+ ? { jobId: jobs[0].jobId, ...(jobs[0].previewUrl ? { previewUrl: jobs[0].previewUrl } : {}) }
+ : lastOfRun
+ ? { jobId: lastOfRun }
+ : {}),
+ });
},
);
}
diff --git a/editor/app/api/ops/build-homepage/route.ts b/editor/app/api/ops/build-homepage/route.ts
@@ -1,18 +1,16 @@
import { NextResponse } from "next/server";
-import {
- buildAndDeployHomepageAction,
- buildHomepageAction,
-} from "../../../sites/lib/homepageDeployActions";
-import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib";
+import { HOMEPAGE_TARGET } from "yt-dlp-transcript-common/publish/stamps";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { ops, optBool, optPreviewBranch, opsFail, OpsInputError } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { deploy?: boolean, preview? } -> { ok: true, jobId }
+// AN ALIAS (release 18): the homepage's build (forced), then with `deploy` its deploy. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "homepage"} runs them.
//
-// Builds the HOMEPAGE (the `homepage` package, Archilyzer's own site) into
-// homepage/out. `deploy: true` makes it the one-job build-then-deploy (`/sites`
-// → Homepage, "Deploy after build") to the Pages project `archilyzer`, which
-// deploys only when the build exits 0; `preview` needs `deploy`.
+// → { ok, jobId } — the run's last job (the deploy when there is one), as
+// before; `previewUrl` with a preview.
export async function POST(request: Request) {
return ops(request, ["deploy", "preview"], async (body) => {
const deploy = optBool(body, "deploy") === true;
@@ -20,11 +18,23 @@ export async function POST(request: Request) {
if (preview !== undefined && !deploy) {
throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing');
}
- return jobResponse(
- deploy
- ? await buildAndDeployHomepageAction(preview ? { previewBranch: preview } : undefined)
- : await buildHomepageAction(),
- );
+ const run = await enqueueAsks([
+ {
+ target: HOMEPAGE_TARGET,
+ build: { force: true },
+ ...(deploy
+ ? { deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true } }
+ : {}),
+ },
+ ]);
+ const job = lastJobOf(run, HOMEPAGE_TARGET);
+ if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued");
+ return NextResponse.json({
+ ok: true,
+ jobId: job.jobId,
+ runId: run.runId,
+ ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}),
+ });
});
}
diff --git a/editor/app/api/ops/build-hub/route.ts b/editor/app/api/ops/build-hub/route.ts
@@ -1,15 +1,16 @@
import { NextResponse } from "next/server";
-import { buildAndDeployHubAction, buildHubAction } from "../../../sites/lib/hubActions";
-import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib";
+import { HUB_TARGET } from "yt-dlp-transcript-common/publish/stamps";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { ops, optBool, optPreviewBranch, opsFail, OpsInputError } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { deploy?: boolean, preview? } -> { ok: true, jobId }
+// AN ALIAS (release 18): the hub's build (forced), then with `deploy` its deploy. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "hub"} runs them.
//
-// Builds the HUB (the export app under INSTANCE_MODE=hub) into export/out — the
-// same directory a site build uses. `deploy: true` makes it the one-job
-// build-then-deploy (`/sites` → Hub, "Deploy after build"), which refuses a
-// missing or wrong Pages project BEFORE building; `preview` needs `deploy`.
+// → { ok, jobId } — the run's last job (the deploy when there is one), as
+// before; `previewUrl` with a preview.
export async function POST(request: Request) {
return ops(request, ["deploy", "preview"], async (body) => {
const deploy = optBool(body, "deploy") === true;
@@ -17,11 +18,23 @@ export async function POST(request: Request) {
if (preview !== undefined && !deploy) {
throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing');
}
- return jobResponse(
- deploy
- ? await buildAndDeployHubAction(preview ? { previewBranch: preview } : undefined)
- : await buildHubAction(),
- );
+ const run = await enqueueAsks([
+ {
+ target: HUB_TARGET,
+ build: { force: true },
+ ...(deploy
+ ? { deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true } }
+ : {}),
+ },
+ ]);
+ const job = lastJobOf(run, HUB_TARGET);
+ if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued");
+ return NextResponse.json({
+ ok: true,
+ jobId: job.jobId,
+ runId: run.runId,
+ ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}),
+ });
});
}
diff --git a/editor/app/api/ops/build-index/route.ts b/editor/app/api/ops/build-index/route.ts
@@ -1,11 +1,23 @@
-import { buildIndexAction } from "../../../sites/lib/buildAction";
-import { jobResponse, ops, optString } from "../_lib";
+import { NextResponse } from "next/server";
+import { indexPlan } from "../../../sites/lib/publishCore";
+import { enqueuePlan, publishResponse } from "../_publish";
+import { ops, optString } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { queueKey? } -> { ok: true, jobId }. Rebuilds the LMDB corpus index.
+// AN ALIAS (release 18): the index update. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "index"} runs them.
+//
+// `queueKey` is accepted and ignored: the index update is a publish stage, and
+// the publish queue runs its stages one at a time.
export async function POST(request: Request) {
- return ops(request, ["queueKey"], async (body) =>
- jobResponse(await buildIndexAction(optString(body, "queueKey"))),
- );
+ return ops(request, ["queueKey"], async (body) => {
+ optString(body, "queueKey");
+ return publishResponse(await enqueuePlan(indexPlan()));
+ });
+}
+
+export function GET() {
+ return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 });
}
diff --git a/editor/app/api/ops/build-site/route.ts b/editor/app/api/ops/build-site/route.ts
@@ -1,69 +1,50 @@
import { NextResponse } from "next/server";
-import {
- buildAllSitesAction,
- buildExportAction,
-} from "../../../sites/lib/buildAction";
-import {
- jobResponse,
- OpsInputError,
- ops,
- optBool,
- reqSiteIds,
-} from "../_lib";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listSiteIds } from "yt-dlp-transcript-common/lib/site";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { OpsInputError, ops, optBool, reqSiteIds } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { siteIds: string[] | siteId: string, skipData?, skipArchives? }
-// | { all: true, skipArchives? }
+// AN ALIAS (release 18): each site's build, forced, the index first when it is stale. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "build"} runs them.
//
-// BUILD WITHOUT DEPLOYING. One build-export job per site on the shared build
-// queue (they run one at a time, as they do from /sites), returning
-// `{ jobs: [{ siteId, jobId }] }` — a list, because there is a job per site and
-// a caller waiting on them needs all the ids. `{ all: true }` is the single
-// build-all job instead, which is the docker fan-out.
-//
-// `siteId` and `siteIds` both work, via reqSiteIds: this route and build-deploy
-// used to disagree about the spelling, which made the pair unguessable.
+// `skipData` is accepted and ignored (the data is the index stage, shared by
+// every site); `all` builds every site. → { ok, jobs: [{siteId, jobId}],
+// skipped: [{siteId, reason}] } (+ `jobId` when one site), as before. `all`
+// used to answer ONE job (build-all); it answers the per-site list now, and
+// its top-level `jobId` is the run's LAST job (`--wait` follows `jobs` anyway).
export async function POST(request: Request) {
return ops(
request,
["siteId", "siteIds", "all", "skipData", "skipArchives"],
async (body) => {
const skipArchives = optBool(body, "skipArchives");
+ optBool(body, "skipData");
+ let ids: string[];
if (optBool(body, "all")) {
if (body.siteIds !== undefined || body.siteId !== undefined) {
- throw new OpsInputError(
- 'send either "siteId"/"siteIds" or "all", not both',
- );
- }
- return jobResponse(await buildAllSitesAction(skipArchives));
- }
- const skipData = optBool(body, "skipData");
- const jobs: { siteId: string; jobId: string }[] = [];
- const skipped: { siteId: string; reason: string }[] = [];
- for (const siteId of reqSiteIds(body)) {
- // One site's throw cannot cost the others their job ids — see the same
- // guard in build-deploy.
- let result: Awaited<ReturnType<typeof buildExportAction>>;
- try {
- result = await buildExportAction(
- siteId,
- undefined,
- skipData,
- skipArchives,
- );
- } catch (e) {
- skipped.push({ siteId, reason: (e as Error).message });
- continue;
- }
- if (!result.ok) {
- skipped.push({ siteId, reason: result.error });
- continue;
+ throw new OpsInputError('send either "siteId"/"siteIds" or "all", not both');
}
- void result.stream.cancel();
- jobs.push({ siteId, jobId: result.jobId });
- }
- return NextResponse.json({ ok: true, jobs, skipped });
+ ids = listSiteIds(getPaths());
+ if (ids.length === 0) throw new OpsInputError("No sites configured.");
+ } else ids = reqSiteIds(body);
+ const run = await enqueueAsks(ids.map((target) => ({ target, build: { force: true, skipArchives } })));
+ const jobs = ids.flatMap((siteId) => {
+ const j = lastJobOf(run, siteId);
+ return j ? [{ siteId, jobId: j.jobId }] : [];
+ });
+ const skipped = refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error }));
+ const all = optBool(body, "all") === true;
+ const top = jobs.length === 1 ? jobs[0].jobId : all ? run.jobs.at(-1)?.jobId : undefined;
+ return NextResponse.json({
+ ok: true,
+ runId: run.runId,
+ jobs,
+ skipped,
+ ...(top ? { jobId: top } : {}),
+ });
},
);
}
diff --git a/editor/app/api/ops/deploy-homepage/route.ts b/editor/app/api/ops/deploy-homepage/route.ts
@@ -1,28 +1,32 @@
import { NextResponse } from "next/server";
-import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { HOMEPAGE_PAGES_PROJECT } from "yt-dlp-transcript-common/publish/build";
-import { deployHomepageAction } from "../../../sites/lib/homepageDeployActions";
-import { jobResponse, ops, optPreviewBranch } from "../_lib";
+import { HOMEPAGE_TARGET } from "yt-dlp-transcript-common/publish/stamps";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { ops, optPreviewBranch, opsFail } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { preview? } -> { ok: true, jobId, previewUrl? }
+// AN ALIAS (release 18): the homepage's BUILT bundle, deployed — never a build. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "homepage"} runs them.
//
-// DEPLOY ONLY — the homepage already built in homepage/out, to its constant
-// Pages project `archilyzer`: production (branch `main`), or with `preview` a
-// branch alias, knowable before the job runs. Refused before any job when the
-// preview name is bad or homepage/out holds no build.
+// → { ok, jobId, previewUrl? }, as before; a homepage never built is refused
+// before any job ("no build of _homepage in <dir> — archilyzer publish homepage").
export async function POST(request: Request) {
return ops(request, ["preview"], async (body) => {
const preview = optPreviewBranch(body);
- const res = jobResponse(
- await deployHomepageAction(preview ? { previewBranch: preview } : undefined),
- );
- if (!preview || res.status !== 200) return res;
- const payload = (await res.json()) as Record<string, unknown>;
+ const run = await enqueueAsks([
+ {
+ target: HOMEPAGE_TARGET,
+ deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true },
+ },
+ ]);
+ const job = lastJobOf(run, HOMEPAGE_TARGET);
+ if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued");
return NextResponse.json({
- ...payload,
- previewUrl: previewAliasUrl(HOMEPAGE_PAGES_PROJECT, preview),
+ ok: true,
+ jobId: job.jobId,
+ runId: run.runId,
+ ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}),
});
});
}
diff --git a/editor/app/api/ops/deploy-hub/route.ts b/editor/app/api/ops/deploy-hub/route.ts
@@ -1,33 +1,33 @@
import { NextResponse } from "next/server";
-import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
-import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { deployHubAction } from "../../../sites/lib/hubActions";
-import { jobResponse, ops, optPreviewBranch } from "../_lib";
+import { HUB_TARGET } from "yt-dlp-transcript-common/publish/stamps";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { ops, optPreviewBranch, opsFail } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { preview? } -> { ok: true, jobId, previewUrl? }
+// AN ALIAS (release 18): the hub's BUILT bundle, deployed — never a build. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "hub"} runs them.
//
-// DEPLOY ONLY — the hub already built in export/out, to homepage.json's
-// Cloudflare Pages project. Refused before any job when there is no project,
-// when the project is the homepage's own ("archilyzer"), or when export/out
-// holds a site rather than the hub. `preview` is a branch name, as on
-// deploy-site; the alias is knowable before the job runs.
+// → { ok, jobId, previewUrl? }, as before; a hub never built is refused
+// before any job ("no build of _hub in <dir> — archilyzer publish hub").
export async function POST(request: Request) {
return ops(request, ["preview"], async (body) => {
const preview = optPreviewBranch(body);
- const res = jobResponse(
- await deployHubAction(preview ? { previewBranch: preview } : undefined),
- );
- if (!preview || res.status !== 200) return res;
- // Only reached once the job started, which the action does only with a
- // usable project — so this read cannot be what fails.
- const project = getHomepageConfig(getPaths()).cloudflareProject;
- const payload = (await res.json()) as Record<string, unknown>;
- return NextResponse.json(
- project ? { ...payload, previewUrl: previewAliasUrl(project, preview) } : payload,
- );
+ const run = await enqueueAsks([
+ {
+ target: HUB_TARGET,
+ deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true },
+ },
+ ]);
+ const job = lastJobOf(run, HUB_TARGET);
+ if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued");
+ return NextResponse.json({
+ ok: true,
+ jobId: job.jobId,
+ runId: run.runId,
+ ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}),
+ });
});
}
diff --git a/editor/app/api/ops/deploy-site/route.ts b/editor/app/api/ops/deploy-site/route.ts
@@ -1,45 +1,39 @@
import { NextResponse } from "next/server";
-import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSite } from "yt-dlp-transcript-common/lib/site";
-import { deployExportAction } from "../../../sites/lib/deployAction";
-import { fanOutSiteJobs, ops, optPreviewBranch, reqSiteIds } from "../_lib";
+import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish";
+import { ops, optPreviewBranch, opsFail, reqSiteIds } from "../_lib";
export const dynamic = "force-dynamic";
-// POST { siteId: string | siteIds: string[], preview? }
-// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }],
-// skipped: [{ siteId, reason }], jobId?, previewUrl? }
+// AN ALIAS (release 18): each site's BUILT bundle, deployed — never a build. The body and the answer are the
+// route's old ones; the work is publish stages on the publish queue
+// (../_publish.ts), as `POST /api/ops/publish` {"verb": "deploy"} runs them.
//
-// DEPLOY ONLY — the already-built export/out, no build. build-deploy's other
-// half, and the half a preview is actually for: build once, look at the preview,
-// then roll the SAME bundle to production without rebuilding it.
-//
-// `preview` is a branch name. Cloudflare Pages treats a deploy to any branch but
-// the project's production branch as a preview, reachable at the branch alias —
-// which is why `previewUrl` can be in the response at all, before the job has
-// done anything: the alias is a function of the project and the branch, not of
-// the deployment. The immutable per-deployment URL only exists afterwards and is
-// in the job's log.
-//
-// Response shape is build-deploy's, so a runbook can swap one for the other.
+// A site never built is refused before any job: "no build of <id> in <dir> —
+// archilyzer publish build <id>". → { ok, jobs: [{siteId, jobId,
+// previewUrl?}], skipped } (+ `jobId` when one site); none started → 400.
export async function POST(request: Request) {
return ops(request, ["siteId", "siteIds", "preview"], async (body) => {
const preview = optPreviewBranch(body);
- const siteIds = reqSiteIds(body);
- return fanOutSiteJobs(
- siteIds,
- (siteId) => deployExportAction(siteId, preview ? { previewBranch: preview } : undefined),
- preview
- ? (siteId) => {
- // Only reached for a site whose job STARTED, which the action does
- // only once it has a cloudflareProject — so this read cannot be the
- // thing that fails.
- const project = getSite(siteId, getPaths()).cloudflareProject;
- return project ? { previewUrl: previewAliasUrl(project, preview) } : {};
- }
- : undefined,
+ const ids = reqSiteIds(body);
+ const where = preview ? ("preview" as const) : ("production" as const);
+ const run = await enqueueAsks(
+ ids.map((target) => ({ target, deploy: { where, ...(preview ? { preview } : {}), force: true } })),
);
+ const jobs = ids.flatMap((siteId) => {
+ const j = lastJobOf(run, siteId);
+ return j ? [{ siteId, jobId: j.jobId, ...(j.previewUrl ? { previewUrl: j.previewUrl } : {}) }] : [];
+ });
+ const skipped = refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error }));
+ if (jobs.length === 0) {
+ return opsFail(skipped.length === 1 ? skipped[0].reason : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "));
+ }
+ return NextResponse.json({
+ ok: true,
+ runId: run.runId,
+ jobs,
+ skipped,
+ ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...(jobs[0].previewUrl ? { previewUrl: jobs[0].previewUrl } : {}) } : {}),
+ });
});
}
diff --git a/editor/app/api/ops/publish/route.ts b/editor/app/api/ops/publish/route.ts
@@ -0,0 +1,164 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { planPublishRun } from "yt-dlp-transcript-common/publish/publishPlan";
+import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState";
+import { ALL_TARGET, HOMEPAGE_TARGET, HUB_TARGET, INDEX_TARGET } from "yt-dlp-transcript-common/publish/stamps";
+import { indexPlan } from "../../../sites/lib/publishCore";
+import { enqueueAsks, enqueuePlan, publishResponse } from "../_publish";
+import {
+ OpsInputError,
+ oneOf,
+ ops,
+ opsAuth,
+ optBool,
+ optPreviewBranch,
+ reqSiteIds,
+ type OpsBody,
+} from "../_lib";
+
+export const dynamic = "force-dynamic";
+
+const VERBS = ["index", "build", "deploy", "hub", "homepage", "now", "stale"] as const;
+type Verb = (typeof VERBS)[number];
+
+// POST { verb: index | build | deploy | hub | homepage | now | stale,
+// siteId | siteIds?, preview?, to?: "pages" | "local", force?,
+// runner?: "local" | "docker", deploy?, skipArchives? }
+//
+// index the index update (a no-op when nothing changed)
+// build each named site's build, forced — the index first when stale;
+// `runner: "docker"` builds every site in containers (host only)
+// deploy each named site's built bundle: production, `preview`, or
+// `to: "local"` (ARCHILYZER_SITE_OUT); `force` redeploys a
+// bundle already shipped there
+// hub the hub's build; `deploy: true` then deploys it (production, or
+// `preview`)
+// homepage the same for the homepage (`to: "local"` deploys locally)
+// now Publish now: what the lane would run — the stale index, then
+// each policy target (site.json publish.auto, settings.publish)
+// stale Build all stale: every stale site, whatever its policy
+//
+// → { ok, runId, jobs: [{ target, kind, jobId, previewUrl?, existing? }],
+// skipped: [{ kind, target, reason }], refused: [{ target, kind?, error }] }
+// plus `jobId` when exactly one job. Every request refused → 400.
+//
+// GET → the publish status (`readPublishStatus`): the index, the lane, a row
+// per target with its chips, and the plan Publish now would run.
+const KEYS = ["verb", "siteId", "siteIds", "preview", "to", "force", "runner", "deploy", "skipArchives"];
+
+function only(body: OpsBody, verb: Verb, allowed: string[]): void {
+ const extra = Object.keys(body).filter((k) => k !== "verb" && !allowed.includes(k));
+ if (extra.length) {
+ throw new OpsInputError(
+ `verb "${verb}" takes ${allowed.length ? allowed.join(", ") : "no other keys"} — not ${extra.join(", ")}`,
+ );
+ }
+}
+
+export async function POST(request: Request) {
+ return ops(request, KEYS, async (body) => {
+ const verb = oneOf(body, "verb", VERBS);
+ const force = optBool(body, "force");
+ const skipArchives = optBool(body, "skipArchives");
+ switch (verb) {
+ case "index":
+ only(body, verb, []);
+ return publishResponse(await enqueuePlan(indexPlan()));
+ case "now":
+ case "stale": {
+ only(body, verb, []);
+ const status = await readPublishStatus(getPaths());
+ const plan =
+ verb === "now"
+ ? planPublishRun(status, { deploys: "policy" })
+ : planPublishRun(status, { builds: "stale", deploys: "none" });
+ return publishResponse(await enqueuePlan(plan));
+ }
+ case "build": {
+ only(body, verb, ["siteId", "siteIds", "force", "runner", "skipArchives"]);
+ const runner = body.runner === undefined ? "local" : oneOf(body, "runner", ["local", "docker"] as const);
+ if (runner === "docker") {
+ if (body.siteId !== undefined || body.siteIds !== undefined) {
+ throw new OpsInputError('"runner": "docker" builds every site — send no "siteId"/"siteIds"');
+ }
+ // As every build: the index update first when it is not fresh, and
+ // the containers wait on disk for it (indexAfter).
+ const runStart = Date.now();
+ const status = await readPublishStatus(getPaths());
+ const withIndex = !status.index.fresh;
+ return publishResponse(
+ await enqueuePlan({
+ runStart,
+ steps: [
+ ...(withIndex
+ ? [{ kind: "update-index" as const, target: INDEX_TARGET, reason: "the index is not fresh" }]
+ : []),
+ {
+ kind: "build-site",
+ target: ALL_TARGET,
+ runner: "docker",
+ reason: "asked",
+ ...(force !== false ? { force: true } : {}),
+ ...(skipArchives ? { skipArchives: true } : {}),
+ ...(withIndex ? { indexAfter: runStart } : {}),
+ },
+ ],
+ skipped: [],
+ }),
+ );
+ }
+ const ids = reqSiteIds(body);
+ return publishResponse(
+ await enqueueAsks(ids.map((target) => ({ target, build: { force: force !== false, skipArchives } }))),
+ );
+ }
+ case "deploy": {
+ only(body, verb, ["siteId", "siteIds", "preview", "to", "force"]);
+ const preview = optPreviewBranch(body);
+ const to = body.to === undefined ? "pages" : oneOf(body, "to", ["pages", "local"] as const);
+ if (to === "local" && preview !== undefined) {
+ throw new OpsInputError("a local deploy has no preview branch — deploy locally or as a preview, not both.");
+ }
+ const where = to === "local" ? "local" : preview ? "preview" : "production";
+ return publishResponse(
+ await enqueueAsks(
+ reqSiteIds(body).map((target) => ({
+ target,
+ deploy: { where, ...(preview ? { preview } : {}), force: force === true },
+ })),
+ ),
+ );
+ }
+ case "hub":
+ case "homepage": {
+ only(body, verb, ["deploy", "preview", "to", "force", "skipArchives"]);
+ const target = verb === "hub" ? HUB_TARGET : HOMEPAGE_TARGET;
+ const deploy = optBool(body, "deploy") === true;
+ const preview = optPreviewBranch(body);
+ const to = body.to === undefined ? "pages" : oneOf(body, "to", ["pages", "local"] as const);
+ if (!deploy && (preview !== undefined || body.to !== undefined)) {
+ throw new OpsInputError('"preview" and "to" need "deploy": true — a build alone deploys nothing');
+ }
+ if (to === "local" && preview !== undefined) {
+ throw new OpsInputError("a local deploy has no preview branch — deploy locally or as a preview, not both.");
+ }
+ const where = to === "local" ? "local" : preview ? "preview" : "production";
+ return publishResponse(
+ await enqueueAsks([
+ {
+ target,
+ build: { force: force !== false, skipArchives },
+ ...(deploy ? { deploy: { where, ...(preview ? { preview } : {}), force: true } } : {}),
+ },
+ ]),
+ );
+ }
+ }
+ });
+}
+
+export async function GET(request: Request) {
+ const denied = opsAuth(request);
+ if (denied) return denied;
+ return NextResponse.json({ ok: true, ...(await readPublishStatus(getPaths())) });
+}
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -215,6 +215,10 @@ async function setLaneHeld(
revalidatePath("/jobs");
revalidatePath("/workers");
revalidatePath("/operations/[id]", "page");
+ if (lane === "publish") {
+ revalidatePath("/operations/publish");
+ revalidatePath("/sites");
+ }
return { ok: true };
}
diff --git a/editor/app/operations/components/OperationsBoard.tsx b/editor/app/operations/components/OperationsBoard.tsx
@@ -1,6 +1,7 @@
"use client";
import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/autoQueueStatus";
+import Link from "next/link";
import type { SyncRowView } from "../syncRow";
import { OperationRail } from "./OperationRail";
import { railStates } from "./railStates";
@@ -52,6 +53,17 @@ export function OperationsBoard({
selectedId={null}
sync={sync}
/>
+ {/* THE PUBLISH LANE (release 18) is a pipeline lane, not an operation of
+ the catalog: a plain row below the rail, to its own page. */}
+ <Link
+ href="/operations/publish"
+ className="flex items-baseline justify-between gap-3 rounded-md border border-border px-3 py-2 text-sm hover:bg-muted"
+ >
+ <span className="font-medium">Publishing</span>
+ <span className="text-muted-foreground">
+ the publish lane — index, builds, deploys
+ </span>
+ </Link>
</div>
);
}
diff --git a/editor/app/operations/components/PublishLaneView.tsx b/editor/app/operations/components/PublishLaneView.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "yt-dlp-transcript-common/components/ui/button";
+import type { PublishLaneStatus, PublishPlan } from "yt-dlp-transcript-common/views/publishStatus";
+import { PauseLaneButton } from "../../components/lanes/pauseControl";
+import {
+ drainPublishLaneAction,
+ startPublishLaneAction,
+ stopPublishLaneAction,
+ type LaneControlResult,
+} from "../../sites/lib/publishActions";
+import { Chip } from "../../sites/components/PublishPanel";
+import { useHydrated } from "./useOperationsStatus";
+
+// The publish lane's console (release 18): the runner and its three controls
+// (Start / Drain / Stop — the runner, as on every lane), the hold (the LANE's
+// gate: a hold stops dispatching between stages and never kills one), what the
+// runner last did and when it looks next, and the plan a pass would run now.
+//
+// The page is server-rendered from readPublishStatus; this refreshes it every
+// few seconds while it is open, and after every control.
+
+const REFRESH_MS = 5_000;
+
+function at(ms: number | null): string {
+ return ms === null ? "—" : new Date(ms).toLocaleString();
+}
+
+export function PublishLaneView({
+ lane,
+ plan,
+}: {
+ lane: PublishLaneStatus;
+ plan: Pick<PublishPlan, "steps" | "skipped">;
+}) {
+ const router = useRouter();
+ const hydrated = useHydrated();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const running = lane.live.running;
+
+ useEffect(() => {
+ const t = setInterval(() => router.refresh(), REFRESH_MS);
+ return () => clearInterval(t);
+ }, [router]);
+
+ async function control(action: () => Promise<LaneControlResult>) {
+ setBusy(true);
+ setError(null);
+ try {
+ const r = await action();
+ if (!r.ok) setError(r.error ?? "The control failed.");
+ } finally {
+ setBusy(false);
+ router.refresh();
+ }
+ }
+
+ return (
+ <div className="flex flex-col gap-4" data-hydrated={hydrated ? "true" : undefined}>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
+ <h2 className="font-display text-lg font-semibold tracking-tight">Publish lane</h2>
+ <span
+ className={`rounded-full border px-3 py-1 text-sm ${
+ running ? "border-success/30 bg-success-soft text-success" : "border-border bg-card text-muted-foreground"
+ }`}
+ >
+ {running ? "Runner running" : "Runner stopped"}
+ </span>
+ <Chip name="lane" chip={lane.chip} />
+ <span className="ml-auto flex flex-wrap gap-2">
+ <Button
+ type="button"
+ size="sm"
+ aria-label="Start publish lane"
+ onClick={() => control(startPublishLaneAction)}
+ disabled={busy || running}
+ >
+ Start
+ </Button>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ aria-label="Drain publish lane"
+ title="Finish the stage in flight, start no next one, then stop"
+ onClick={() => control(drainPublishLaneAction)}
+ disabled={busy || !running}
+ className="border-warning/30 text-warning hover:bg-warning-soft hover:text-warning"
+ >
+ Drain
+ </Button>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ aria-label="Stop publish lane"
+ title="Stop the runner now; a stage in flight finishes as its own job (Cancel it on /jobs)"
+ onClick={() => control(stopPublishLaneAction)}
+ disabled={busy || !running}
+ >
+ Stop
+ </Button>
+ <PauseLaneButton lane="publish" held={lane.held} onChange={() => router.refresh()} />
+ </span>
+ </div>
+ {error && (
+ <p role="alert" className="text-sm text-destructive">
+ {error}
+ </p>
+ )}
+
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm" data-testid="publish-lane-facts">
+ <dt className="text-muted-foreground">Lane</dt>
+ <dd>
+ {lane.enabled ? "on" : "off"}
+ {lane.held ? " · held" : ""}
+ {lane.quietNow ? " · quiet hours" : ""}
+ {lane.blockedReason ? ` — ${lane.blockedReason}` : ""}
+ </dd>
+ <dt className="text-muted-foreground">A pass now</dt>
+ <dd>
+ {lane.due ? "due" : "not due"} — {lane.reason}
+ </dd>
+ <dt className="text-muted-foreground">Last check</dt>
+ <dd>{at(lane.live.lastCheckAt)}</dd>
+ <dt className="text-muted-foreground">Next check</dt>
+ <dd>{at(lane.live.nextCheckAt)}</dd>
+ <dt className="text-muted-foreground">Last pass</dt>
+ <dd>
+ {at(lane.live.lastPassAt)}
+ {lane.live.lastPassSummary ? ` — ${lane.live.lastPassSummary}` : ""}
+ </dd>
+ <dt className="text-muted-foreground">Last decision</dt>
+ <dd>{lane.live.lastDecision ?? "—"}</dd>
+ {lane.live.jobId && (
+ <>
+ <dt className="text-muted-foreground">Runner</dt>
+ <dd>
+ <Link href={`/jobs/${lane.live.jobId}`} className="underline underline-offset-2">
+ job log
+ </Link>
+ </dd>
+ </>
+ )}
+ </dl>
+
+ <div className="text-sm" data-testid="publish-lane-plan">
+ <h3 className="font-semibold">What a pass would run now</h3>
+ {plan.steps.length === 0 ? (
+ <p className="text-muted-foreground">Nothing: every target its policies cover is current.</p>
+ ) : (
+ <ol className="ml-5 list-decimal">
+ {plan.steps.map((s) => (
+ <li key={`${s.kind}:${s.target}:${s.preview ?? ""}`}>
+ <code>
+ {s.kind} {s.target}
+ {s.preview ? ` --preview ${s.preview}` : ""}
+ </code>{" "}
+ <span className="text-muted-foreground">— {s.reason}</span>
+ </li>
+ ))}
+ </ol>
+ )}
+ {plan.skipped.length > 0 && (
+ <ul className="mt-1 ml-5 list-disc text-xs text-muted-foreground">
+ {plan.skipped.map((s) => (
+ <li key={`${s.kind}:${s.target}`}>
+ <code>
+ {s.kind} {s.target}
+ </code>
+ : {s.reason}
+ </li>
+ ))}
+ </ul>
+ )}
+ <p className="mt-2 text-xs text-muted-foreground">
+ The same plan is <strong>Publish now</strong> on{" "}
+ <Link href="/sites" className="underline">
+ /sites
+ </Link>
+ ; a site’s policy is set on its own page.
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/operations/components/settings/PublishSettingsForm.tsx b/editor/app/operations/components/settings/PublishSettingsForm.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useActionState } from "react";
+import type { PublishSettings } from "yt-dlp-transcript-common/lib/settings";
+import { Field } from "../../../components/forms/Field";
+import { seedChecked, seedValue } from "../../../lib/formState";
+import { savePublishSettingsAction } from "../../settingsActions";
+import type { SaveResult } from "../../../settings/actions";
+
+// `settings.publish` (release 18), every field but `held` — the hold button
+// beside the runner writes that one. The policies of the hub and the homepage
+// live here; a SITE's policy is its own (site.json `publish.auto`, set on the
+// site's page).
+
+const POLICIES = [
+ ["off", "Off"],
+ ["build", "Build"],
+ ["preview", "Preview"],
+ ["production", "Production"],
+] as const;
+
+function PolicySelect({
+ label,
+ name,
+ initial,
+ state,
+}: {
+ label: string;
+ name: string;
+ initial: string;
+ state: SaveResult | undefined;
+}) {
+ return (
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">{label}</span>
+ <select
+ name={name}
+ defaultValue={seedValue(state, name, initial)}
+ className="w-48 rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ {POLICIES.map(([v, l]) => (
+ <option key={v} value={v}>
+ {l}
+ </option>
+ ))}
+ </select>
+ </label>
+ );
+}
+
+export function PublishSettingsForm({ initial }: { initial: PublishSettings }) {
+ const [state, formAction, pending] = useActionState<SaveResult | undefined, FormData>(
+ savePublishSettingsAction,
+ undefined,
+ );
+
+ return (
+ <form action={formAction} data-settings-block="publish" className="flex flex-col gap-3">
+ <fieldset className="flex flex-col gap-3 rounded border border-border p-3">
+ <legend className="px-1 text-sm font-medium">Publish lane settings</legend>
+ <label className="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ name="publishEnabled"
+ defaultChecked={seedChecked(state, "publishEnabled", initial.enabled)}
+ className="mt-1"
+ />
+ <span className="flex flex-col gap-1">
+ <span className="font-medium">Run the publish lane</span>
+ <span className="text-xs text-muted-foreground">
+ The runner wakes, and when the index is stale (or a policy target is left stale) it updates
+ the index, then builds and deploys what each policy says — one stage at a time on the publish
+ queue. Off, the buttons on /sites still run.
+ </span>
+ </span>
+ </label>
+ <Field
+ label="Check every (minutes)"
+ name="publishCheckEveryMinutes"
+ type="number"
+ min="1"
+ state={state}
+ defaultValue={String(initial.checkEveryMinutes)}
+ hint="How often the runner asks whether a pass is due. 1–1440."
+ />
+ <Field
+ label="Refresh every (minutes)"
+ name="publishRefreshEveryMinutes"
+ type="number"
+ min="0"
+ state={state}
+ defaultValue={String(initial.refreshEveryMinutes)}
+ hint="The least time between two index updates the lane starts; 0 = whenever the index is stale."
+ />
+ <div className="flex flex-wrap gap-3">
+ <Field
+ label="Quiet hours: start (0–23)"
+ name="publishQuietStart"
+ type="number"
+ min="0"
+ state={state}
+ defaultValue={initial.quietHours ? String(initial.quietHours.start) : ""}
+ placeholder="none"
+ />
+ <Field
+ label="Quiet hours: end (0–23)"
+ name="publishQuietEnd"
+ type="number"
+ min="0"
+ state={state}
+ defaultValue={initial.quietHours ? String(initial.quietHours.end) : ""}
+ placeholder="none"
+ hint="No pass starts in [start, end) on this machine's clock (may wrap midnight). Both empty: none."
+ />
+ </div>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Build runner</span>
+ <select
+ name="publishRunner"
+ defaultValue={seedValue(state, "publishRunner", initial.runner)}
+ className="w-48 rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="local">Local (one site at a time)</option>
+ <option value="docker">Docker (host only)</option>
+ </select>
+ <span className="text-xs text-muted-foreground">
+ Docker builds every stale site in containers — a host with a container engine only; refused in
+ a container.
+ </span>
+ </label>
+ <Field
+ label="Preview branch"
+ name="publishPreviewBranch"
+ state={state}
+ defaultValue={initial.previewBranch}
+ hint="Where a “preview” policy deploys (wrangler --branch). Lowercase letters, digits and dashes; never main."
+ />
+ <div className="flex flex-wrap gap-4">
+ <PolicySelect label="Hub policy" name="publishHub" initial={initial.hub} state={state} />
+ <PolicySelect label="Homepage policy" name="publishHomepage" initial={initial.homepage} state={state} />
+ </div>
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save publish settings"}
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-sm text-success">
+ Saved.
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-sm text-destructive">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </fieldset>
+ </form>
+ );
+}
diff --git a/editor/app/operations/publish/page.tsx b/editor/app/operations/publish/page.tsx
@@ -0,0 +1,44 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState";
+import { PublishLaneView } from "../components/PublishLaneView";
+import { PublishSettingsForm } from "../components/settings/PublishSettingsForm";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Publish lane" };
+
+// THE PUBLISH LANE (release 18) — a static route beside `operations/[id]`
+// (Next prefers it): the publish lane is not an operation of the catalog
+// (lib/operations.ts), whose settings switch and lane keys are the ingest
+// lanes', so it has its own page rather than a catalog entry.
+//
+// Everything on it is read through readPublishStatus — the same status the
+// /sites Publish panel, `archilyzer publish status` and `GET /api/ops/publish`
+// read.
+export default async function PublishLanePage() {
+ const status = await readPublishStatus(getPaths());
+ const settings = getSettings().publish;
+ return (
+ <div className="flex max-w-4xl flex-col gap-6">
+ <div>
+ <h1 className="text-2xl font-semibold">Publishing</h1>
+ <p className="text-sm text-muted-foreground">
+ The lane keeps the published sites current: when the index is stale it updates it, then builds
+ what changed and deploys where each target’s policy says. Every stage runs on the{" "}
+ <code>publish</code> queue, one at a time. The rows and their buttons are on{" "}
+ <Link href="/sites" className="underline">
+ /sites
+ </Link>
+ .
+ </p>
+ </div>
+ <PublishLaneView
+ lane={status.lane}
+ plan={{ steps: status.plan.steps, skipped: status.plan.skipped }}
+ />
+ <PublishSettingsForm initial={settings} />
+ </div>
+ );
+}
diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts
@@ -24,6 +24,8 @@ import {
isDigestTimestampMode,
} from "yt-dlp-transcript-common/lib/digest";
import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates";
+import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
+import { sanitizePublish } from "yt-dlp-transcript-common/lib/settingsSchema";
import type { SaveResult } from "../settings/actions";
import { formValues } from "../lib/formState";
@@ -280,3 +282,54 @@ export async function saveAttributionSettingsAction(
revalidateOperations();
return { ok: true };
}
+
+// The publish lane (release 18): `settings.publish`, saved by
+// PublishSettingsForm on /operations/publish. `held` is not on the form — the
+// hold button writes it (operations/actions.ts setLaneHeld) — so it is carried
+// from the stored block. A preview branch name the sanitizer would silently
+// replace by the default is refused instead, with the sentence the deploy
+// buttons use.
+export async function savePublishSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const values = formValues(formData);
+ const current = getSettings().publish;
+ const text = (key: string) => String(formData.get(key) ?? "").trim();
+ const previewBranch = text("publishPreviewBranch") || current.previewBranch;
+ const branchProblem = previewBranchProblem(previewBranch);
+ if (branchProblem) return { ok: false, error: branchProblem, values };
+ const quietStart = text("publishQuietStart");
+ const quietEnd = text("publishQuietEnd");
+ if ((quietStart === "") !== (quietEnd === "")) {
+ return { ok: false, error: "Quiet hours need both a start and an end hour (or neither).", values };
+ }
+ const next = sanitizePublish({
+ ...current,
+ enabled: formData.get("publishEnabled") === "on",
+ checkEveryMinutes: num(formData, "publishCheckEveryMinutes", current.checkEveryMinutes),
+ refreshEveryMinutes: num(formData, "publishRefreshEveryMinutes", current.refreshEveryMinutes),
+ quietHours: quietStart === "" ? null : { start: Number(quietStart), end: Number(quietEnd) },
+ runner: text("publishRunner") || current.runner,
+ previewBranch,
+ hub: text("publishHub") || current.hub,
+ homepage: text("publishHomepage") || current.homepage,
+ });
+ if (quietStart !== "" && next.quietHours === null) {
+ return { ok: false, error: "Quiet hours are two different hours from 0 to 23.", values };
+ }
+ try {
+ await saveSettings({ publish: next });
+ } catch (e) {
+ return { ok: false, error: (e as Error).message, values };
+ }
+ // Switched on: the runner starts (idempotent — one already up is kept).
+ // Switched off: a running runner notices at its next tick and ends.
+ if (next.enabled && !current.enabled) {
+ const { startPublishRunner } = await import("yt-dlp-transcript-common/publish/publishRunner");
+ await startPublishRunner();
+ }
+ revalidatePath("/operations/publish");
+ revalidatePath("/sites");
+ return { ok: true };
+}
diff --git a/editor/app/sites/[siteId]/publish/page.tsx b/editor/app/sites/[siteId]/publish/page.tsx
@@ -1,9 +1,12 @@
-import { statSync } from "node:fs";
-import path from "node:path";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ readBuiltStamp,
+ readDeployedFile,
+ type DeployRecord,
+} from "yt-dlp-transcript-common/publish/stamps";
import { BuildDeployButton } from "../../components/BuildDeployButton";
import { BuildExportButton } from "../../components/BuildExportButton";
import { DeployButton } from "../../components/DeployButton";
@@ -13,16 +16,21 @@ export const dynamic = "force-dynamic";
// → "Publish — <id> — Sites" through the layout's title template.
export const metadata: Metadata = { title: "Publish" };
-// The mtime of the last built export. resolveOutDir ignores the site id, so
-// this is the family's most recent build, not this site's — carried as-is from
-// the retired /deploy page.
-function getLastDeployedAt(): string | null {
- try {
- const indexPath = path.join(getPaths().exportDir, "out", "index.html");
- return statSync(indexPath).mtime.toISOString();
- } catch {
- return null;
- }
+const VERDICT: Record<string, string> = {
+ ok: "live check ok",
+ "stale-edge": "live check: the edge still serves an older build",
+ mismatch: "live check: a different build answers",
+ unreachable: "live check: unreachable",
+ skipped: "live check skipped",
+};
+
+function when(at: number): string {
+ return new Date(at).toLocaleString();
+}
+
+function recordLine(r: DeployRecord): string {
+ const verdict = r.liveCheck ? ` · ${VERDICT[r.liveCheck.verdict] ?? r.liveCheck.verdict}` : "";
+ return `${when(r.at)}${r.url ? ` · ${r.url}` : ""}${verdict}`;
}
export default async function SitePublishPage({
@@ -34,8 +42,17 @@ export default async function SitePublishPage({
const site = getSiteCached(siteId);
if (!site) notFound();
const siteTitle = site.siteTitle || siteId;
- const lastDeployedAt = getLastDeployedAt();
- const existingQueues = getRegistry().activeQueueNames();
+ const paths = getPaths();
+ // This site's own records (release 18): its bundle's built.json and what
+ // was shipped from it, deployed.json — no longer export/out's mtime, which
+ // was the family's last build, whichever site that was.
+ const [built, deployed] = await Promise.all([
+ readBuiltStamp(paths, siteId),
+ readDeployedFile(paths, siteId),
+ ]);
+ const previews = Object.entries(deployed?.previews ?? {}).sort((a, b) => b[1].at - a[1].at);
+ const lastPreview = previews[0];
+ const previewBranch = getSettings().publish.previewBranch;
return (
<div className="flex flex-col gap-8 max-w-3xl">
@@ -44,15 +61,22 @@ export default async function SitePublishPage({
<div>
<h2 className="text-lg font-semibold">Build & deploy</h2>
<p className="text-sm text-muted-foreground">
- Builds the static export, then deploys it to Cloudflare Pages in one
- step. Cancelling before the build finishes skips the deploy.
- </p>
- <p className="text-sm text-muted-foreground mt-1">
- Last deployed:{" "}
- {lastDeployedAt
- ? new Date(lastDeployedAt).toLocaleString()
- : "never"}
+ Updates the index when it is stale, builds this site into its own
+ bundle, then deploys that bundle to Cloudflare Pages production —
+ stages on the publish queue, one at a time. Cancelling the build
+ leaves the deploy nothing new to ship: it ends refused.
</p>
+ <dl
+ className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 text-sm text-muted-foreground"
+ data-testid="last-deployed"
+ >
+ <dt>Built:</dt>
+ <dd>{built ? `${when(built.builtAt)}${built.branch ? ` from ${built.branch}` : ""}` : "never"}</dd>
+ <dt>Last deployed:</dt>
+ <dd>{deployed?.production ? recordLine(deployed.production) : "never"}</dd>
+ <dt>Last preview:</dt>
+ <dd>{lastPreview ? `${lastPreview[0]} · ${recordLine(lastPreview[1])}` : "never"}</dd>
+ </dl>
</div>
<BuildDeployButton
siteId={siteId}
@@ -72,30 +96,29 @@ export default async function SitePublishPage({
<div>
<h3 className="font-semibold">Build static export</h3>
<p className="text-sm text-muted-foreground">
- Spawns <code>pnpm run build</code> in <code>export/</code> —
- index build, then <code>next build</code> to{" "}
- <code>export/out/</code>. No deploy.
+ The site’s build stage: compose, then{" "}
+ <code>next build</code> into{" "}
+ <code>export/.export-builds/{siteId}/out</code> (and{" "}
+ <code>export/out</code> links to it). The index is updated
+ first when it is stale. No deploy.
</p>
</div>
- <BuildExportButton
- existingQueues={existingQueues}
- siteId={siteId}
- siteTitle={siteTitle}
- />
+ <BuildExportButton siteId={siteId} siteTitle={siteTitle} />
</div>
<div className="flex flex-col gap-3">
<div>
<h3 className="font-semibold">Deploy static export</h3>
<p className="text-sm text-muted-foreground">
- Publishes the most recently built site at{" "}
- <code>export/out/</code> to Cloudflare Pages — to production,
- or to a preview branch you can look at first. Build first.
+ Publishes this site’s built bundle to Cloudflare Pages
+ — to production, or to a preview branch you can look at
+ first — and checks the live site. Build first.
</p>
</div>
<DeployButton
siteId={siteId}
siteTitle={siteTitle}
cloudflareProject={site.cloudflareProject ?? null}
+ previewBranch={previewBranch}
/>
</div>
</div>
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { PUBLISH_POLICIES, type PublishPolicy } from "yt-dlp-transcript-common/lib/settingsSchema";
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import {
@@ -118,6 +119,14 @@ export async function saveSiteAction(
return { ok: false, error: `Unknown audience "${audienceRaw}".`, values };
}
const isPrivate = audienceRaw === "private";
+ // The publish lane's policy for this site (release 18). writeSite refuses a
+ // deploying policy with no Pages project (sitePublishProblem) and stores a
+ // private site's as "build".
+ const publishAutoRaw = String(formData.get("publishAuto") ?? "off");
+ if (!(PUBLISH_POLICIES as readonly string[]).includes(publishAutoRaw)) {
+ return { ok: false, error: `Unknown publish policy "${publishAutoRaw}".`, values };
+ }
+ const publishAuto = publishAutoRaw as PublishPolicy;
// Search (opt-out; off = report-only) and the report list, which is not on
// this form: the stored one is carried over.
let storedReports: Pick<Site, "reports"> | undefined;
@@ -265,6 +274,7 @@ export async function saveSiteAction(
// The Site is rebuilt from the form: a key missing here is dropped on save.
...(listed ? {} : { listed: false }),
...(isPrivate ? { audience: "private" as const } : {}),
+ ...(publishAuto !== "off" ? { publish: { auto: publishAuto } } : {}),
...reportKeys,
...(hubUrl ? { hubUrl } : {}),
...(pwa ? { pwa: true } : {}),
diff --git a/editor/app/sites/components/BuildAllSitesButton.tsx b/editor/app/sites/components/BuildAllSitesButton.tsx
@@ -1,78 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import {
- buildAllSitesAction,
- buildAndDeployAllSitesAction,
-} from "../lib/buildAction";
-import { JobLane } from "./JobLane";
-
-type Lane = { deploy: boolean; skipArchives: boolean; key: number };
-
-// One-click orchestrated pipeline over EVERY site as a SINGLE managed job (one
-// log, one Cancel): whenever a container engine answers it runs the shared
-// data phase + archive warm once, fans the per-site builds out in parallel (capped by
-// maxParallelBuilds), then deploys the built sites serially. Without one it falls
-// back to a serial host build+deploy. Distinct from the per-site panel below,
-// which launches one separate job per selected site.
-export function BuildAllSitesButton() {
- const [deploy, setDeploy] = useState(true);
- const [skipArchives, setSkipArchives] = useState(false);
- const [lane, setLane] = useState<Lane | null>(null);
- const [run, setRun] = useState(0);
-
- function launch() {
- const next = run + 1;
- setRun(next);
- setLane({ deploy, skipArchives, key: next });
- }
-
- return (
- <div className="flex flex-col gap-3">
- <div className="flex flex-wrap items-center gap-3">
- <button
- type="button"
- onClick={launch}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
- >
- {deploy ? "Build & deploy all sites" : "Build all sites"}
- </button>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={deploy}
- onChange={(e) => setDeploy(e.target.checked)}
- />
- Deploy after build
- </label>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={skipArchives}
- onChange={(e) => setSkipArchives(e.target.checked)}
- />
- Skip archive zips
- </label>
- </div>
- <p className="text-xs text-muted-foreground">
- When a container engine answers: the shared data phase runs once, per-site builds run in parallel in containers, then
- deploys run serially. With none, a serial host build and deploy, one site
- at a time.
- </p>
- {lane && (
- <JobLane
- key={lane.key}
- title={lane.deploy ? "Build & deploy all sites" : "Build all sites"}
- subtitle={
- "Containers when an engine answers, else serial on the host"
- }
- trigger={() =>
- lane.deploy
- ? buildAndDeployAllSitesAction(lane.skipArchives)
- : buildAllSitesAction(lane.skipArchives)
- }
- />
- )}
- </div>
- );
-}
diff --git a/editor/app/sites/components/BuildButtons.tsx b/editor/app/sites/components/BuildButtons.tsx
@@ -13,11 +13,10 @@ import {
archiveCombinedTranscriptsAction,
archiveLiveChatAction,
archiveTranscriptsAction,
- buildIndexAction,
- buildStatsAction,
normalizeLiveChatAction,
normalizeTranscriptsAction,
} from "../lib/buildAction";
+import { cancelPublishRunAction, updateIndexAction } from "../lib/publishActions";
import { ArchiveOptionsControl } from "./ArchiveOptionsControl";
type Props = {
@@ -27,8 +26,6 @@ type Props = {
const DEFAULT_BUILD_QUEUE = "build";
export function BuildButtons({ existingQueues }: Props) {
- const [indexQueue, setIndexQueue] = useState(DEFAULT_BUILD_QUEUE);
- const [statsQueue, setStatsQueue] = useState(DEFAULT_BUILD_QUEUE);
const [normalizeQueue, setNormalizeQueue] = useState(DEFAULT_BUILD_QUEUE);
const [archiveQueue, setArchiveQueue] = useState(DEFAULT_BUILD_QUEUE);
const [combinedQueue, setCombinedQueue] = useState(DEFAULT_BUILD_QUEUE);
@@ -58,54 +55,20 @@ export function BuildButtons({ existingQueues }: Props) {
<div>
<h2 className="text-lg font-semibold">Build index</h2>
<p className="text-sm text-muted-foreground">
- Re-scans <code>transcripts/channels/</code> and rewrites paginated
- JSON in <code>export/public/</code>. Cheap when nothing changed
- (mtime short-circuit).
+ The index update every site, the hub and the homepage are built
+ from: the search index over <code>transcripts/channels/</code>, the
+ stats datasets and the chart templates, in one child process on
+ the <code>publish</code> queue. Cheap when nothing changed (each
+ part short-circuits).
</p>
</div>
+ {/* No queue control: the index update is a publish stage, and the
+ publish queue runs its stages one at a time. */}
<StreamActionLog
- trigger={() => buildIndexAction(indexQueue)}
- cancelAction={cancelJobAction}
+ trigger={() => updateIndexAction()}
+ cancelAction={cancelPublishRunAction}
buttonLabel="Build index"
runningLabel="Building index…"
- extraControls={
- <QueueControl
- value={indexQueue}
- onChange={setIndexQueue}
- defaultQueueKey={DEFAULT_BUILD_QUEUE}
- existingQueues={existingQueues}
- actionLabel="Build index"
- />
- }
- />
- </section>
-
- <section className="flex flex-col gap-3 border-t border-border pt-6">
- <div>
- <h2 className="text-lg font-semibold">Build stats dataset</h2>
- <p className="text-sm text-muted-foreground">
- Extracts per-video stats (views, likes, comments, duration,
- categories, …) into <code>export/public/stats/</code> for the viewer
- charts. Reads engagement metrics from each{" "}
- <code>metadata.info.json</code>; cue counts come from the search
- index, so run <strong>Build index</strong> first. Incremental
- (mtime short-circuit).
- </p>
- </div>
- <StreamActionLog
- trigger={() => buildStatsAction(statsQueue)}
- cancelAction={cancelJobAction}
- buttonLabel="Build stats dataset"
- runningLabel="Building stats dataset…"
- extraControls={
- <QueueControl
- value={statsQueue}
- onChange={setStatsQueue}
- defaultQueueKey={DEFAULT_BUILD_QUEUE}
- existingQueues={existingQueues}
- actionLabel="Build stats dataset"
- />
- }
/>
</section>
diff --git a/editor/app/sites/components/BuildDeployButton.tsx b/editor/app/sites/components/BuildDeployButton.tsx
@@ -2,8 +2,7 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { buildAndDeployAction } from "../lib/buildAction";
-import { cancelJobAction } from "../../jobs/actions";
+import { cancelPublishRunAction, buildAndDeployTargetAction } from "../lib/publishActions";
type Props = {
// The site whose Publish tab this is. Always a real site now — the control
@@ -14,8 +13,10 @@ type Props = {
cloudflareProject: string | null;
};
-// One-click build-then-deploy for this site: builds, and on success deploys —
-// one combined streamed log, one Cancel (see buildAndDeployAction).
+// One-click build-then-deploy for this site: the index update when it is
+// stale, the build (forced), then its production deploy — publish stages on
+// the publish queue, followed in one log (publishActions.ts). The deploy waits
+// ON DISK for this run's build: a failed or cancelled build ends it refused.
export function BuildDeployButton({
siteId,
siteTitle,
@@ -43,8 +44,8 @@ export function BuildDeployButton({
return (
<StreamActionLog
- trigger={() => buildAndDeployAction(siteId, skipArchives)}
- cancelAction={cancelJobAction}
+ trigger={() => buildAndDeployTargetAction(siteId, { where: "production", skipArchives })}
+ cancelAction={cancelPublishRunAction}
buttonLabel="Build & deploy"
runningLabel="Building & deploying…"
label="Build and deploy"
diff --git a/editor/app/sites/components/BuildExportButton.tsx b/editor/app/sites/components/BuildExportButton.tsx
@@ -2,30 +2,25 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { buildExportAction } from "../lib/buildAction";
-import { QueueControl } from "../../components/QueueControl";
-import { cancelJobAction } from "../../jobs/actions";
+import { cancelPublishRunAction, buildTargetAction } from "../lib/publishActions";
type Props = {
- existingQueues: string[];
// The site whose Publish tab this is — always a real site.
siteId: string;
siteTitle: string;
};
-const DEFAULT_BUILD_QUEUE = "build";
-
-export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props) {
- const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
- const [skipData, setSkipData] = useState(false);
+// The site's build stage, forced (a click always builds): the index update
+// first when it is stale, then compose + next build into the site's own
+// bundle, `export/.export-builds/<id>/out` (release 18). There is no "skip
+// data" any more: the data is the index stage, shared by every site.
+export function BuildExportButton({ siteId, siteTitle }: Props) {
const [skipArchives, setSkipArchives] = useState(false);
return (
<StreamActionLog
- trigger={() =>
- buildExportAction(siteId, exportQueue, skipData, skipArchives)
- }
- cancelAction={cancelJobAction}
+ trigger={() => buildTargetAction(siteId, { skipArchives })}
+ cancelAction={cancelPublishRunAction}
buttonLabel="Build static export"
runningLabel="Building static export…"
extraControls={
@@ -36,18 +31,6 @@ export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props)
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
- checked={skipData}
- onChange={(e) => setSkipData(e.target.checked)}
- />
- Skip data rebuild (index, stats, charts)
- </label>
- <p className="text-xs text-muted-foreground">
- Reuses the existing index/stats — only composes the site and runs
- next build. Do a full build first if data is stale.
- </p>
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
checked={skipArchives}
onChange={(e) => setSkipArchives(e.target.checked)}
/>
@@ -57,13 +40,6 @@ export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props)
Skips generating the downloadable archive zips for this build only.
Leave off to keep the site's Downloads page current.
</p>
- <QueueControl
- value={exportQueue}
- onChange={setExportQueue}
- defaultQueueKey={DEFAULT_BUILD_QUEUE}
- existingQueues={existingQueues}
- actionLabel="Build static export"
- />
</div>
}
/>
diff --git a/editor/app/sites/components/BuildSitesPanel.tsx b/editor/app/sites/components/BuildSitesPanel.tsx
@@ -1,146 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { buildExportAction, buildAndDeployAction } from "../lib/buildAction";
-import { JobLane } from "./JobLane";
-
-export type SiteOption = {
- siteId: string;
- siteTitle: string;
- cloudflareProject: string | null;
-};
-
-type Lane = { siteId: string; title: string; deploy: boolean; key: string };
-
-// Batch build (and optionally deploy) several sites at once. Each selected site
-// launches its own managed job, rendered as its own live JobLane. The jobs share
-// the build/deploy queue and run one at a time (the export/ tree is shared);
-// "Build all sites" is the parallel path.
-export function BuildSitesPanel({ sites }: { sites: SiteOption[] }) {
- const [selected, setSelected] = useState<Set<string>>(new Set());
- const [deployAfter, setDeployAfter] = useState(false);
- const [lanes, setLanes] = useState<Lane[] | null>(null);
- // Bumps so re-running the same selection remounts fresh lanes.
- const [run, setRun] = useState(0);
-
- function toggle(siteId: string) {
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(siteId)) next.delete(siteId);
- else next.add(siteId);
- return next;
- });
- }
-
- function launch() {
- const chosen = sites.filter((s) => selected.has(s.siteId));
- if (chosen.length === 0) return;
- const nextRun = run + 1;
- setRun(nextRun);
- setLanes(
- chosen.map((s) => ({
- siteId: s.siteId,
- title: s.siteTitle,
- deploy: deployAfter && Boolean(s.cloudflareProject),
- key: `${nextRun}:${s.siteId}`,
- })),
- );
- }
-
- const missingProject =
- deployAfter &&
- sites.some((s) => selected.has(s.siteId) && !s.cloudflareProject);
-
- return (
- // Named group: this panel's "Deploy after build" checkbox is a twin of the
- // one in BuildAllSitesButton, and both sit in the same <section> on /sites.
- // Without a name to scope by, getByLabel("Deploy after build") resolves to
- // two elements and every assertion on it dies with a strict-mode violation.
- <div
- role="group"
- aria-label="Build specific sites"
- className="flex flex-col gap-4"
- >
- <div className="flex flex-col gap-2 rounded-md border border-border p-3">
- <div className="flex flex-wrap gap-x-6 gap-y-2">
- {sites.map((s) => (
- <label
- key={s.siteId}
- className="flex items-center gap-2 text-sm"
- >
- <input
- type="checkbox"
- checked={selected.has(s.siteId)}
- onChange={() => toggle(s.siteId)}
- aria-label={`Select ${s.siteTitle}`}
- />
- <span className="font-medium">{s.siteTitle}</span>
- <code className="text-xs text-muted-foreground">{s.siteId}</code>
- {!s.cloudflareProject && (
- <span className="text-xs text-warning">
- no deploy target
- </span>
- )}
- </label>
- ))}
- {sites.length === 0 && (
- <span className="text-sm italic text-muted-foreground">
- No sites configured.
- </span>
- )}
- </div>
- </div>
-
- <div className="flex flex-wrap items-center gap-3">
- <button
- type="button"
- onClick={launch}
- disabled={selected.size === 0}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- {deployAfter ? "Build & deploy selected" : "Build selected"}
- </button>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={deployAfter}
- onChange={(e) => setDeployAfter(e.target.checked)}
- />
- Deploy after build
- </label>
- <span className="text-xs text-muted-foreground">
- {selected.size} selected
- </span>
- </div>
-
- {missingProject && (
- <p className="text-xs text-warning">
- Some selected sites have no Cloudflare Pages project — those will build
- only (deploy is skipped for them).
- </p>
- )}
- <p className="text-xs text-muted-foreground">
- These run one at a time (the build output tree is shared). “Build all
- sites” above builds every site in parallel, in containers, whenever a
- container engine answers.
- </p>
-
- {lanes && lanes.length > 0 && (
- <div className="flex flex-col gap-2">
- {lanes.map((lane) => (
- <JobLane
- key={lane.key}
- title={lane.title}
- subtitle={`${lane.siteId}${lane.deploy ? " · build + deploy" : " · build"}`}
- trigger={() =>
- lane.deploy
- ? buildAndDeployAction(lane.siteId)
- : buildExportAction(lane.siteId)
- }
- />
- ))}
- </div>
- )}
- </div>
- );
-}
diff --git a/editor/app/sites/components/DeployButton.tsx b/editor/app/sites/components/DeployButton.tsx
@@ -7,8 +7,7 @@ import {
previewAliasUrl,
previewBranchProblem,
} from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { cancelJobAction } from "../../jobs/actions";
-import { deployExportAction } from "../lib/deployAction";
+import { cancelPublishRunAction, deployTargetAction } from "../lib/publishActions";
type Props = {
// The site whose Publish tab this is — always a real site.
@@ -19,23 +18,26 @@ type Props = {
// there is no preview to offer, and the control says that instead of linking
// a hostname that cannot exist.
cloudflareProject?: string | null;
+ // settings.publish.previewBranch: the preview box starts on it.
+ previewBranch?: string;
};
const DEFAULT_PREVIEW_BRANCH = "preview";
-// Two deploys of the same already-built bundle: to production, or to a
-// Cloudflare Pages preview branch.
+// Two deploys of the site's built bundle (`export/.export-builds/<id>/out`):
+// to production, or to a Cloudflare Pages preview branch — each a deploy
+// stage on the publish queue, forced (a click always deploys).
//
// THE PRODUCTION BUTTON SAYS "PRODUCTION" NOW. With a preview control beside
// it, a button labelled only "Deploy" is the one an operator clicks by reflex
// when they meant the safe one; the extra word is the whole cost of not doing
// that.
-export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) {
+export function DeployButton({ siteId, siteTitle, cloudflareProject, previewBranch }: Props) {
return (
<div className="flex flex-col gap-6">
<StreamActionLog
- trigger={() => deployExportAction(siteId)}
- cancelAction={cancelJobAction}
+ trigger={() => deployTargetAction(siteId, { where: "production" })}
+ cancelAction={cancelPublishRunAction}
buttonLabel="Deploy to production"
runningLabel="Deploying…"
label="Deploy export"
@@ -48,6 +50,7 @@ export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) {
<PreviewDeploy
siteId={siteId}
cloudflareProject={cloudflareProject ?? null}
+ initialBranch={previewBranch || DEFAULT_PREVIEW_BRANCH}
/>
</div>
);
@@ -56,11 +59,13 @@ export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) {
function PreviewDeploy({
siteId,
cloudflareProject,
+ initialBranch,
}: {
siteId: string;
cloudflareProject: string | null;
+ initialBranch: string;
}) {
- const [branch, setBranch] = useState(DEFAULT_PREVIEW_BRANCH);
+ const [branch, setBranch] = useState(initialBranch);
const [deployed, setDeployed] = useState<string | null>(null);
// The SAME function the action and the ops route refuse with, so the button
// greys out on exactly the names the server would have rejected and the
@@ -88,8 +93,8 @@ function PreviewDeploy({
</p>
</div>
<StreamActionLog
- trigger={() => deployExportAction(siteId, { previewBranch: branch })}
- cancelAction={cancelJobAction}
+ trigger={() => deployTargetAction(siteId, { where: "preview", preview: branch })}
+ cancelAction={cancelPublishRunAction}
buttonLabel="Deploy preview"
runningLabel="Deploying preview…"
label="Deploy preview"
diff --git a/editor/app/sites/components/EditorChartsClient.tsx b/editor/app/sites/components/EditorChartsClient.tsx
@@ -33,8 +33,8 @@ function Inner({
return (
<p className="text-sm text-destructive">
Failed to load the stats dataset ({error.message}). Run{" "}
- <strong>Build index</strong> then <strong>Build stats dataset</strong>{" "}
- under Pool on the Sites page first.
+ <strong>Build index</strong> under Pool on the Sites page first — it
+ builds the stats datasets too.
</p>
);
}
diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx
@@ -1,216 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import {
- MAX_PREVIEW_BRANCH,
- previewAliasUrl,
- previewBranchProblem,
-} from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { PROJECT_URL } from "yt-dlp-transcript-common/lib/project";
-import {
- buildAndDeployHomepageAction,
- buildHomepageAction,
- deployHomepageAction,
-} from "../lib/homepageDeployActions";
-import { useHydrated } from "../../operations/components/useOperationsStatus";
-import { JobLane, type LaneOutcome } from "./JobLane";
-
-type Lane = {
- kind: "build" | "build-deploy" | "deploy";
- key: number;
- // The preview branch AS LAUNCHED. Editing the field afterwards must not
- // change what a running lane says it is deploying to.
- preview: string | undefined;
-};
-
-const TITLE: Record<Lane["kind"], string> = {
- build: "Build homepage",
- "build-deploy": "Build & deploy homepage",
- deploy: "Deploy homepage",
-};
-
-type Props = {
- // The homepage's Pages project — the constant HOMEPAGE_PAGES_PROJECT, passed
- // down because it lives beside the deploy code (server-only), not copied.
- project: string;
- // When homepage/out/index.html was built, formatted by the server; null
- // when there is no build there.
- builtAt: string | null;
-};
-
-// The homepage's build and deploy — the `homepage` package, built into
-// homepage/out and deployed to its own Pages project. HubBuildButtons' shape
-// exactly: one lane at a time (each launch replaces the previous lane; its job
-// keeps running and stays on /jobs), and "Deploy after build" starts
-// unchecked, because a homepage deploy replaces the software's public site.
-//
-// Plus a preview branch, which the hub's buttons do not have: empty is
-// production, a name is a Cloudflare Pages preview of the same project. The
-// name is judged by previewBranchProblem, the function the action and the ops
-// route refuse with, so the buttons grey out on exactly the names the server
-// would reject.
-export function HomepageBuildButtons({ project, builtAt }: Props) {
- const [deploy, setDeploy] = useState(false);
- const [preview, setPreview] = useState("");
- const [lane, setLane] = useState<Lane | null>(null);
- const [run, setRun] = useState(0);
- const hydrated = useHydrated();
- const router = useRouter();
-
- const branch = preview.trim();
- const problem = branch ? previewBranchProblem(branch) : null;
- // Knowable before any deploy: project + branch and nothing else.
- const alias = branch && problem === null ? previewAliasUrl(project, branch) : null;
-
- function launch(kind: Lane["kind"]) {
- const next = run + 1;
- setRun(next);
- setLane({ kind, key: next, preview: branch || undefined });
- }
-
- // "built <when>" is read from homepage/out when /sites renders, so a lane
- // that BUILT has to re-render the page for the line to move (release 13
- // slice W1). Every outcome of a job, not only "done": a failed or cancelled
- // build may already have rewritten or emptied homepage/out, and the line —
- // which is what Deploy homepage would ship — must say so. "error" started no
- // job, and a deploy writes nothing the page reads. StreamActionLog refreshes
- // after any run that started, for the same reason.
- function settled(l: Lane, outcome: LaneOutcome) {
- if (l.kind !== "deploy" && outcome !== "error") router.refresh();
- }
-
- function trigger(l: Lane) {
- const opts = l.preview ? { previewBranch: l.preview } : undefined;
- return l.kind === "build"
- ? buildHomepageAction()
- : l.kind === "build-deploy"
- ? buildAndDeployHomepageAction(opts)
- : deployHomepageAction(opts);
- }
-
- return (
- // data-hydrated: a TESTING AFFORDANCE, as on the operation lanes. A click
- // that lands before React hydrates fires nothing at all, so a spec waits
- // for this before it clicks Build homepage.
- <div
- role="group"
- aria-label="Homepage build"
- className="flex flex-col gap-3"
- data-hydrated={hydrated ? "true" : undefined}
- >
- <div className="flex flex-wrap items-center gap-3">
- <button
- type="button"
- onClick={() => launch(deploy ? "build-deploy" : "build")}
- // A build alone deploys nothing, so only a build that will deploy
- // is held back by a bad preview name.
- disabled={deploy && problem !== null}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- Build homepage
- </button>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={deploy}
- onChange={(e) => setDeploy(e.target.checked)}
- />
- Deploy after build
- </label>
- <button
- type="button"
- onClick={() => launch("deploy")}
- disabled={problem !== null}
- className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50"
- >
- Deploy homepage
- </button>
- <label className="flex items-center gap-2 text-sm">
- <span className="text-muted-foreground">Preview branch</span>
- <input
- type="text"
- aria-label="preview branch"
- placeholder="none: production"
- value={preview}
- // Twice the limit, so an over-long name is refused with the
- // sentence that says WHY rather than silently truncated.
- maxLength={MAX_PREVIEW_BRANCH * 2}
- onChange={(e) => setPreview(e.target.value)}
- className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm"
- />
- </label>
- </div>
- {problem !== null && (
- <p
- role="status"
- // Not "preview branch problem": a name that CONTAINS the input's
- // would make every by-label lookup for the input ambiguous.
- aria-label="preview problem"
- className="text-sm text-destructive"
- >
- {problem}
- </p>
- )}
- <p className="text-xs text-muted-foreground" data-testid="homepage-ships">
- {builtAt ? (
- <>
- Deploy homepage ships <code>homepage/out</code>, built {builtAt},
- </>
- ) : (
- <>
- <code>homepage/out</code> holds no build yet, so Deploy homepage has
- nothing to ship. A deploy goes
- </>
- )}{" "}
- to <code>{project}</code>{" "}
- {!branch ? (
- <>
- (production): <UrlLink url={PROJECT_URL} />.
- </>
- ) : alias ? (
- <>
- (preview <code>{branch}</code>): <UrlLink url={alias} />; the live
- site is left alone.
- </>
- ) : (
- <>
- (preview <code>{branch}</code>).
- </>
- )}
- </p>
- <p className="text-xs text-muted-foreground">
- The homepage reads the search index as it stands: run Build index
- (under Pool jobs, below) first when its numbers should move.
- </p>
- <p className="text-xs text-muted-foreground">
- Build homepage also publishes the source mirror (<code>archilyzer
- source publish</code>) and refuses if a denied string survives the
- scrub; a refusal removes the last published source, from
- homepage/out too. Deploy homepage refuses a build whose source was
- not audited under today’s rules.
- </p>
- {lane && (
- <JobLane
- key={lane.key}
- title={TITLE[lane.kind]}
- subtitle={
- lane.kind === "build"
- ? "homepage/out"
- : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}`
- }
- trigger={() => trigger(lane)}
- onSettled={(outcome) => settled(lane, outcome)}
- />
- )}
- </div>
- );
-}
-
-function UrlLink({ url }: { url: string }) {
- return (
- <a className="font-mono underline" href={url} target="_blank" rel="noreferrer">
- {url}
- </a>
- );
-}
diff --git a/editor/app/sites/components/HubBuildButtons.tsx b/editor/app/sites/components/HubBuildButtons.tsx
@@ -1,84 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import {
- buildAndDeployHubAction,
- buildHubAction,
- deployHubAction,
-} from "../lib/hubActions";
-import { JobLane } from "./JobLane";
-
-type Lane = { kind: "build" | "build-deploy" | "deploy"; key: number };
-
-const TITLE: Record<Lane["kind"], string> = {
- build: "Build hub",
- "build-deploy": "Build & deploy hub",
- deploy: "Deploy hub",
-};
-
-// The hub's build and deploy — the export app built with INSTANCE_MODE=hub into
-// export/out, then deployed to the Cloudflare Pages project set in the form
-// above. One lane at a time, like BuildAllSitesButton: each launch replaces the
-// previous lane (its job keeps running and stays on /jobs).
-//
-// "Deploy after build" starts unchecked here, unlike the all-sites batch: a
-// hub deploy replaces a public site, and the first one should be a decision.
-export function HubBuildButtons({ project }: { project: string | null }) {
- const [deploy, setDeploy] = useState(false);
- const [lane, setLane] = useState<Lane | null>(null);
- const [run, setRun] = useState(0);
-
- function launch(kind: Lane["kind"]) {
- const next = run + 1;
- setRun(next);
- setLane({ kind, key: next });
- }
-
- return (
- <div role="group" aria-label="Hub build" className="flex flex-col gap-3">
- <div className="flex flex-wrap items-center gap-3">
- <button
- type="button"
- onClick={() => launch(deploy ? "build-deploy" : "build")}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
- >
- Build hub
- </button>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={deploy}
- onChange={(e) => setDeploy(e.target.checked)}
- />
- Deploy after build
- </label>
- <button
- type="button"
- onClick={() => launch("deploy")}
- className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted"
- >
- Deploy hub
- </button>
- </div>
- <p className="text-xs text-muted-foreground">
- {project
- ? `Builds into export/out (shared with site builds) and deploys to the Pages project "${project}". Deploy hub ships what is already built.`
- : "Builds into export/out (shared with site builds). Set a Cloudflare Pages project above to deploy."}
- </p>
- {lane && (
- <JobLane
- key={lane.key}
- title={TITLE[lane.kind]}
- subtitle="INSTANCE_MODE=hub · export/out"
- trigger={() =>
- lane.kind === "build"
- ? buildHubAction()
- : lane.kind === "build-deploy"
- ? buildAndDeployHubAction()
- : deployHubAction()
- }
- />
- )}
- </div>
- );
-}
diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx
@@ -26,6 +26,9 @@ type Props = {
// e.g. to refresh a server-rendered "built <when>" line after a build. Not
// called when the lane is unmounted first (another launch replaced it).
onSettled?: (outcome: LaneOutcome) => void;
+ // What Cancel calls with the lane's job id (default: that one job). A
+ // publish run's lanes pass cancelPublishRunAction: the run's every stage.
+ cancelAction?: (jobId: string) => Promise<{ ok: boolean }>;
};
const CHIP: Record<LaneStatus, { label: string; cls: string }> = {
@@ -43,7 +46,7 @@ const CHIP: Record<LaneStatus, { label: string; cls: string }> = {
// the terminal status. A light poll surfaces queued/running + queue position
// before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in
// miniature so several lanes can run at once on the batch panel.
-export function JobLane({ title, subtitle, trigger, onSettled }: Props) {
+export function JobLane({ title, subtitle, trigger, onSettled, cancelAction }: Props) {
const [status, setStatus] = useState<LaneStatus>("starting");
const [log, setLog] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -182,7 +185,7 @@ export function JobLane({ title, subtitle, trigger, onSettled }: Props) {
if (!jobId) return;
setCancelling(true);
try {
- await cancelJobAction(jobId);
+ await (cancelAction ?? cancelJobAction)(jobId);
} finally {
setCancelling(false);
}
diff --git a/editor/app/sites/components/PublishPanel.tsx b/editor/app/sites/components/PublishPanel.tsx
@@ -0,0 +1,512 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ MAX_PREVIEW_BRANCH,
+ previewAliasUrl,
+ previewBranchProblem,
+} from "yt-dlp-transcript-common/lib/pagesDeploy";
+import { useHydrated } from "../../operations/components/useOperationsStatus";
+import {
+ buildAllStaleAction,
+ buildAndDeployTargetAction,
+ buildTargetAction,
+ cancelPublishRunAction,
+ deployTargetAction,
+ publishNowAction,
+} from "../lib/publishActions";
+import { JobLane } from "./JobLane";
+
+// THE PUBLISH PANEL (release 18): one row per site, then the hub, then the
+// homepage — each with the four chips the publish status computes (index |
+// built | deployed | live, publish/publishPlan.ts) and its buttons, each
+// button one run of publish stages on the `publish` queue
+// (sites/lib/publishActions.ts). Above the rows: Publish now (what the lane
+// would run — the stale index, then each policy target, never forced), Build
+// all stale (every stale site, whatever its policy, no deploys), the lane's
+// chip, and the plan Publish now would enqueue, so the button is predictable.
+//
+// The chips are the server's: a lane that settles refreshes the page.
+
+export type PanelChip = { tone: "ok" | "stale" | "blocked" | "busy" | "warn" | "off"; text: string };
+
+export type PanelRow = {
+ // A site id, "_hub" or "_homepage".
+ target: string;
+ kind: "site" | "hub" | "homepage";
+ title: string;
+ chips: { index: PanelChip; built: PanelChip; deployed: PanelChip; live: PanelChip };
+ policy: string;
+ next: string;
+ cloudflareProject: string | null;
+ // Why it never deploys (a private site), or null.
+ deployProblem: string | null;
+ // The preview alias of the policy's branch, when known.
+ previewUrl: string | null;
+ // When its bundle was built (ms), or null.
+ builtAt: number | null;
+ // The public URL a production deploy answers on, or null.
+ url: string | null;
+};
+
+export type PanelPlan = {
+ steps: { kind: string; target: string; reason: string; preview?: string }[];
+ skipped: { kind: string; target: string; reason: string }[];
+};
+
+type Props = {
+ rows: PanelRow[];
+ index: PanelChip;
+ lane: PanelChip;
+ plan: PanelPlan;
+ // settings.publish.previewBranch: a site row's preview textbox starts on it.
+ previewBranch: string;
+ // ARCHILYZER_SITE_OUT / ARCHILYZER_HOMEPAGE_OUT are set: "Deploy local".
+ siteOut: boolean;
+ homepageOut: boolean;
+};
+
+const TONE: Record<PanelChip["tone"], string> = {
+ ok: "border-success/30 bg-success-soft text-success",
+ stale: "border-warning/30 bg-warning-soft text-warning",
+ blocked: "border-destructive/30 bg-destructive-soft text-destructive",
+ busy: "border-info/30 bg-info-soft text-info",
+ warn: "border-warning/30 bg-warning-soft text-warning",
+ off: "border-border bg-muted text-muted-foreground",
+};
+
+export function Chip({ name, chip }: { name: string; chip: PanelChip }) {
+ return (
+ <span
+ data-chip={name}
+ data-tone={chip.tone}
+ className={`inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${TONE[chip.tone]}`}
+ >
+ <span className="font-mono uppercase tracking-wide opacity-70">{name}</span>
+ <span className="truncate">{chip.text}</span>
+ </span>
+ );
+}
+
+const PRIMARY =
+ "px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50";
+const SECONDARY =
+ "px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50";
+
+type Lane = { key: number; title: string; subtitle: string; run: () => ReturnType<typeof publishNowAction> };
+
+function useLane() {
+ const [lane, setLane] = useState<Lane | null>(null);
+ const [n, setN] = useState(0);
+ const launch = (l: Omit<Lane, "key">) => {
+ const key = n + 1;
+ setN(key);
+ setLane({ ...l, key });
+ };
+ return { lane, launch };
+}
+
+export function PublishPanel({ rows, index, lane, plan, previewBranch, siteOut, homepageOut }: Props) {
+ const router = useRouter();
+ const hydrated = useHydrated();
+ const { lane: head, launch } = useLane();
+
+ return (
+ <div className="flex flex-col gap-4" data-hydrated={hydrated ? "true" : undefined}>
+ <div role="group" aria-label="Publish all" className="flex flex-col gap-3">
+ <div className="flex flex-wrap items-center gap-3">
+ <button
+ type="button"
+ className={PRIMARY}
+ onClick={() =>
+ launch({ title: "Publish now", subtitle: "the plan below, in order", run: () => publishNowAction() })
+ }
+ >
+ Publish now
+ </button>
+ <button
+ type="button"
+ className={SECONDARY}
+ onClick={() =>
+ launch({
+ title: "Build all stale",
+ subtitle: "every stale site, whatever its policy; nothing deployed",
+ run: () => buildAllStaleAction(),
+ })
+ }
+ >
+ Build all stale
+ </button>
+ <Chip name="index" chip={index} />
+ <Link href="/operations/publish" aria-label="publish lane" className="no-underline">
+ <Chip name="lane" chip={lane} />
+ </Link>
+ </div>
+ <div className="text-sm" data-testid="publish-plan">
+ {plan.steps.length === 0 ? (
+ <p className="text-muted-foreground">Publish now has nothing to run: everything its policies cover is current.</p>
+ ) : (
+ <>
+ <p className="text-muted-foreground">Publish now would run, in order:</p>
+ <ol className="ml-5 list-decimal">
+ {plan.steps.map((s) => (
+ <li key={`${s.kind}:${s.target}:${s.preview ?? ""}`}>
+ <code>
+ {s.kind} {s.target}
+ </code>{" "}
+ <span className="text-muted-foreground">— {s.reason}</span>
+ </li>
+ ))}
+ </ol>
+ </>
+ )}
+ {plan.skipped.length > 0 && (
+ <details className="mt-1">
+ <summary className="cursor-pointer text-xs text-muted-foreground select-none">
+ {plan.skipped.length} left out
+ </summary>
+ <ul className="ml-5 list-disc text-xs text-muted-foreground">
+ {plan.skipped.map((s) => (
+ <li key={`${s.kind}:${s.target}`}>
+ <code>
+ {s.kind} {s.target}
+ </code>
+ : {s.reason}
+ </li>
+ ))}
+ </ul>
+ </details>
+ )}
+ </div>
+ {head && (
+ <JobLane
+ key={head.key}
+ title={head.title}
+ subtitle={head.subtitle}
+ trigger={head.run}
+ onSettled={() => router.refresh()}
+ cancelAction={cancelPublishRunAction}
+ />
+ )}
+ </div>
+
+ <ul className="flex flex-col divide-y divide-border rounded-md border border-border">
+ {rows.map((row) => (
+ <li key={row.target} className="p-3">
+ {row.kind === "site" ? (
+ <SiteRow row={row} previewBranch={previewBranch} siteOut={siteOut} hydrated={hydrated} />
+ ) : (
+ <FamilyRow row={row} localOut={row.kind === "homepage" && homepageOut} hydrated={hydrated} />
+ )}
+ </li>
+ ))}
+ </ul>
+ </div>
+ );
+}
+
+function RowHead({ row }: { row: PanelRow }) {
+ return (
+ <div className="flex flex-col gap-1">
+ <div className="flex flex-wrap items-baseline gap-x-2">
+ <h3 className="font-semibold">{row.title}</h3>
+ {row.kind === "site" && (
+ <Link href={`/sites/${row.target}/publish`} className="text-xs text-muted-foreground underline">
+ <code>{row.target}</code>
+ </Link>
+ )}
+ <span className="text-xs text-muted-foreground">policy: {row.policy}</span>
+ </div>
+ <div className="flex flex-wrap gap-1.5">
+ <Chip name="index" chip={row.chips.index} />
+ <Chip name="built" chip={row.chips.built} />
+ <Chip name="deployed" chip={row.chips.deployed} />
+ <Chip name="live" chip={row.chips.live} />
+ </div>
+ <p className="text-xs text-muted-foreground">next: {row.next}</p>
+ </div>
+ );
+}
+
+function PreviewInput({
+ value,
+ onChange,
+ placeholder,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder: string;
+}) {
+ return (
+ <label className="flex items-center gap-2 text-sm">
+ <span className="text-muted-foreground">Preview branch</span>
+ <input
+ type="text"
+ aria-label="preview branch"
+ placeholder={placeholder}
+ value={value}
+ // Twice the limit, so an over-long name is refused with the sentence
+ // that says WHY rather than silently truncated.
+ maxLength={MAX_PREVIEW_BRANCH * 2}
+ onChange={(e) => onChange(e.target.value)}
+ className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm"
+ />
+ </label>
+ );
+}
+
+function PreviewProblem({ problem }: { problem: string | null }) {
+ if (problem === null) return null;
+ return (
+ // Not "preview branch problem": a name that CONTAINS the input's would
+ // make every by-label lookup for the input ambiguous.
+ <p role="status" aria-label="preview problem" className="text-sm text-destructive">
+ {problem}
+ </p>
+ );
+}
+
+function SiteRow({
+ row,
+ previewBranch,
+ siteOut,
+ hydrated,
+}: {
+ row: PanelRow;
+ previewBranch: string;
+ siteOut: boolean;
+ hydrated: boolean;
+}) {
+ const router = useRouter();
+ const { lane, launch } = useLane();
+ const [preview, setPreview] = useState(previewBranch);
+ const branch = preview.trim();
+ const problem = previewBranchProblem(branch);
+ const project = row.cloudflareProject;
+ const pages = row.deployProblem === null && project !== null;
+ const id = row.target;
+
+ return (
+ <div
+ role="group"
+ aria-label={`Publish ${id}`}
+ className="flex flex-col gap-2"
+ data-hydrated={hydrated ? "true" : undefined}
+ >
+ <RowHead row={row} />
+ <div className="flex flex-wrap items-center gap-2">
+ <button
+ type="button"
+ className={PRIMARY}
+ onClick={() =>
+ launch({ title: `Build ${id}`, subtitle: `.export-builds/${id}/out`, run: () => buildTargetAction(id) })
+ }
+ >
+ Build
+ </button>
+ {pages && (
+ <>
+ <button
+ type="button"
+ className={SECONDARY}
+ disabled={problem !== null}
+ onClick={() =>
+ launch({
+ title: `Deploy preview ${id}`,
+ subtitle: `${project} (preview ${branch})`,
+ run: () => deployTargetAction(id, { where: "preview", preview: branch }),
+ })
+ }
+ >
+ Deploy preview
+ </button>
+ <PreviewInput value={preview} onChange={setPreview} placeholder={previewBranch} />
+ <button
+ type="button"
+ className={SECONDARY}
+ onClick={() =>
+ launch({
+ title: `Deploy production ${id}`,
+ subtitle: `${project} (production)`,
+ run: () => deployTargetAction(id, { where: "production" }),
+ })
+ }
+ >
+ Deploy production
+ </button>
+ </>
+ )}
+ {siteOut && row.deployProblem === null && (
+ <button
+ type="button"
+ className={SECONDARY}
+ onClick={() =>
+ launch({
+ title: `Deploy local ${id}`,
+ subtitle: "ARCHILYZER_SITE_OUT",
+ run: () => deployTargetAction(id, { where: "local" }),
+ })
+ }
+ >
+ Deploy local
+ </button>
+ )}
+ </div>
+ {pages && <PreviewProblem problem={problem} />}
+ {pages && problem === null && branch && (
+ <p className="text-xs text-muted-foreground">
+ A preview goes to <UrlLink url={previewAliasUrl(project!, branch)} />; production to{" "}
+ {row.url ? <UrlLink url={row.url} /> : <code>{project}</code>}.
+ </p>
+ )}
+ {row.deployProblem !== null && <p className="text-xs text-muted-foreground">{row.deployProblem}.</p>}
+ {row.deployProblem === null && project === null && (
+ <p className="text-xs text-warning">
+ No Cloudflare Pages project — set one on the site's page to deploy.
+ </p>
+ )}
+ {lane && (
+ <JobLane
+ key={lane.key}
+ title={lane.title}
+ subtitle={lane.subtitle}
+ trigger={lane.run}
+ onSettled={() => router.refresh()}
+ cancelAction={cancelPublishRunAction}
+ />
+ )}
+ </div>
+ );
+}
+
+// The hub's and the homepage's row: the names they have always had ("Build
+// hub", "Deploy homepage", "Deploy after build", a preview box that starts
+// empty = production), so the family's two sites read as they did.
+function FamilyRow({ row, localOut, hydrated }: { row: PanelRow; localOut: boolean; hydrated: boolean }) {
+ const router = useRouter();
+ const { lane, launch } = useLane();
+ const [deploy, setDeploy] = useState(false);
+ const [preview, setPreview] = useState("");
+ const noun = row.kind === "hub" ? "hub" : "homepage";
+ const target = row.target;
+ const branch = preview.trim();
+ const problem = branch ? previewBranchProblem(branch) : null;
+ const project = row.cloudflareProject;
+ const alias = branch && problem === null && project ? previewAliasUrl(project, branch) : null;
+ const where = branch ? ({ where: "preview", preview: branch } as const) : ({ where: "production" } as const);
+ const dest = `${project ?? "no project"}${branch ? ` (preview ${branch})` : " (production)"}`;
+ const out = row.kind === "hub" ? ".export-builds/_hub/out" : "homepage/out";
+ const built = row.builtAt === null ? null : new Date(row.builtAt).toLocaleString();
+
+ return (
+ <div
+ role="group"
+ aria-label={row.kind === "hub" ? "Hub build" : "Homepage build"}
+ className="flex flex-col gap-2"
+ data-hydrated={hydrated ? "true" : undefined}
+ >
+ <RowHead row={row} />
+ <div className="flex flex-wrap items-center gap-3">
+ <button
+ type="button"
+ className={PRIMARY}
+ // A build alone deploys nothing, so only a build that will deploy is
+ // held back by a bad preview name.
+ disabled={deploy && problem !== null}
+ onClick={() =>
+ deploy
+ ? launch({
+ title: `Build & deploy ${noun}`,
+ subtitle: `${out} · ${dest}`,
+ run: () => buildAndDeployTargetAction(target, where),
+ })
+ : launch({ title: `Build ${noun}`, subtitle: out, run: () => buildTargetAction(target) })
+ }
+ >
+ Build {noun}
+ </button>
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
+ <input type="checkbox" checked={deploy} onChange={(e) => setDeploy(e.target.checked)} />
+ Deploy after build
+ </label>
+ <button
+ type="button"
+ className={SECONDARY}
+ disabled={problem !== null}
+ onClick={() =>
+ launch({ title: `Deploy ${noun}`, subtitle: `${out} · ${dest}`, run: () => deployTargetAction(target, where) })
+ }
+ >
+ Deploy {noun}
+ </button>
+ <PreviewInput value={preview} onChange={setPreview} placeholder="none: production" />
+ {localOut && (
+ <button
+ type="button"
+ className={SECONDARY}
+ onClick={() =>
+ launch({
+ title: `Deploy local ${noun}`,
+ subtitle: "ARCHILYZER_HOMEPAGE_OUT",
+ run: () => deployTargetAction(target, { where: "local" }),
+ })
+ }
+ >
+ Deploy local
+ </button>
+ )}
+ </div>
+ <PreviewProblem problem={problem} />
+ <p className="text-xs text-muted-foreground" data-testid={`${noun}-ships`}>
+ {built ? (
+ <>
+ Deploy {noun} ships <code>{out}</code>, built {built},
+ </>
+ ) : (
+ <>
+ <code>{out}</code> holds no build yet, so Deploy {noun} has nothing to ship. A deploy goes
+ </>
+ )}{" "}
+ to <code>{project ?? "no Pages project"}</code>{" "}
+ {!branch ? (
+ <>(production){row.url ? <>: <UrlLink url={row.url} />.</> : "."}</>
+ ) : alias ? (
+ <>
+ (preview <code>{branch}</code>): <UrlLink url={alias} />; the live site is left alone.
+ </>
+ ) : (
+ <>
+ (preview <code>{branch}</code>).
+ </>
+ )}
+ </p>
+ {row.kind === "homepage" && (
+ <p className="text-xs text-muted-foreground">
+ Build homepage also publishes the source mirror (<code>archilyzer source publish</code>) and refuses if
+ a denied string survives the scrub; a refusal removes the last published source, from homepage/out too.
+ Deploy homepage refuses a build whose source was not audited under today’s rules.
+ </p>
+ )}
+ {lane && (
+ <JobLane
+ key={lane.key}
+ title={lane.title}
+ subtitle={lane.subtitle}
+ trigger={lane.run}
+ onSettled={() => router.refresh()}
+ cancelAction={cancelPublishRunAction}
+ />
+ )}
+ </div>
+ );
+}
+
+function UrlLink({ url }: { url: string }) {
+ return (
+ <a className="font-mono underline" href={url} target="_blank" rel="noreferrer">
+ {url}
+ </a>
+ );
+}
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -361,6 +361,26 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
into while Settings keeps X posts private.
</span>
</label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Publish policy</span>
+ <SeededSelect
+ state={state}
+ name="publishAuto"
+ aria-label="Publish policy"
+ initial={initial.publish?.auto ?? "off"}
+ className="w-fit rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="off">Off — built and deployed only when you press a button</option>
+ <option value="build">Build — Publish now and the publish lane rebuild it when it is stale</option>
+ <option value="preview">Preview — and deploy it to the preview branch</option>
+ <option value="production">Production — and deploy it to production</option>
+ </SeededSelect>
+ <span className="text-xs text-muted-foreground">
+ What Publish now and the publish lane do for this site (site.json{" "}
+ <code>publish.auto</code>). Preview and Production need a Cloudflare
+ Pages project; a private site is built, never deployed.
+ </span>
+ </label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts
@@ -1,8 +1,10 @@
"use server";
-import { safeRevalidate } from "../../lib/safeRevalidate";
-import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex";
-import { buildStats } from "yt-dlp-transcript-common/controller/buildStats";
+// The Pool's corpus-wide jobs that are not publish stages: normalize and the
+// downloadable archives. Build index, the stats dataset, a site's build and
+// deploy, "Build all sites" and the hub's and the homepage's buttons are
+// publish stages now (release 18): sites/lib/publishActions.ts.
+
import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll";
import { normalizeAllLiveChat } from "yt-dlp-transcript-common/controller/normalizeAllLiveChat";
import {
@@ -15,186 +17,12 @@ import {
archiveCombinedLiveChat,
} from "yt-dlp-transcript-common/controller/archiveLiveChat";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import {
- deployAudienceProblem,
- siteDeployProblem,
-} from "yt-dlp-transcript-common/lib/builtExport";
-import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site";
import {
runManagedFunction,
type StreamActionResult,
} from "yt-dlp-transcript-common/jobs/streamCommand";
-import {
- PREVIEW_SHARES_ARCHIVES_NOTICE,
- buildAll,
- buildSite,
- dockerAvailable,
- dockerSiteOutDir,
- resolveOutDir,
- runArchiveUploadIntoLog,
- runBuildPhase,
- runDeployIntoLog,
- runDockerBuildAllPhase,
- runDockerDeployAllPhase,
- type SiteBuildOutcome,
- type SiteDeployOutcome,
-} from "yt-dlp-transcript-common/publish/build";
const DEFAULT_BUILD_QUEUE = "build";
-const DEPLOY_QUEUE = "deploy";
-
-export async function buildIndexAction(
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const result = await runManagedFunction({
- kind: "build-index",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog) => {
- await buildIndex({ paths, onLog });
- },
- });
- return result;
-}
-
-export async function buildStatsAction(
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "build-stats",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await buildStats({ paths, onLog, signal });
- // every site's Charts tab previews the dataset this just rebuilt.
- safeRevalidate([["/sites/[siteId]/charts", "page"]]);
- },
- });
-}
-
-export async function buildExportAction(
- siteId: string,
- queueKey?: string,
- skipData?: boolean,
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const id = siteId.trim();
- if (!id) {
- return { ok: false, error: "Select a site to build" };
- }
- // Run as a managed function (not a raw command) so the build goes through
- // runBuildPhase (a single site always builds in export/), and so the same
- // child-streaming path is shared with build-then-deploy. Cancel still works:
- // the abort signal is forwarded to the child by runChildIntoLog.
- // `skipData` composes from the existing .export-index staging without
- // regenerating the pool-wide index/stats/charts.
- return runManagedFunction({
- kind: "build-export",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- const code = await buildSite(id, {
- paths,
- onLog,
- signal,
- skipData,
- skipArchives,
- });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Build failed (exit ${code})`);
- },
- });
-}
-
-// One-click build-then-deploy: build the active site and, only if the build
-// succeeds (and wasn't cancelled), deploy it — all in ONE managed job so the UI
-// shows a single combined streamed log with a single Cancel. Serialized on the
-// deploy queue so it never overlaps a standalone deploy or another build-deploy.
-//
-// `opts.previewBranch` makes the deploy half a Cloudflare Pages PREVIEW (branch
-// alias; production untouched) — same job kind, same queue, same single log.
-export async function buildAndDeployAction(
- siteId: string,
- skipArchives?: boolean,
- opts?: { previewBranch?: string },
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const id = siteId.trim();
- if (!id) {
- return { ok: false, error: "Select a site to build and deploy" };
- }
- // Refuse an unusable preview name BEFORE the build starts. Discovering a typo
- // at the deploy step would cost a whole build to learn it.
- const previewBranch = opts?.previewBranch;
- if (previewBranch !== undefined) {
- const problem = previewBranchProblem(previewBranch);
- if (problem) return { ok: false, error: problem };
- }
- const branch = previewBranch?.trim();
- const site = getSite(id, paths);
- // A private site is never deployed (site.json `audience`), so Build & deploy
- // refuses before the build; its Build button still builds it.
- const privateProblem = siteDeployProblem(site);
- if (privateProblem) return { ok: false, error: `${privateProblem}.` };
- if (!site.cloudflareProject) {
- return {
- ok: false,
- error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
- };
- }
- return runManagedFunction({
- kind: "build-deploy",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- onLog("=== Build ===\n");
- const buildCode = await buildSite(id, {
- paths,
- onLog,
- signal,
- skipArchives,
- });
- // A cancel mid-build must NOT proceed to deploy.
- if (signal.aborted) return;
- if (buildCode !== 0) {
- throw new Error(`Build failed (exit ${buildCode}) — not deploying.`);
- }
- // Asked again before the upload, over the site as it is now and the
- // bundle just built: an audience switched to private while this job
- // waited on the queue is not deployed.
- const audienceProblem = deployAudienceProblem(
- getSite(id, paths),
- resolveOutDir(id, paths),
- );
- if (audienceProblem) throw new Error(`${audienceProblem} — not deploying.`);
- onLog(branch ? `\n=== Deploy (preview "${branch}") ===\n` : "\n=== Deploy ===\n");
- if (branch) onLog(PREVIEW_SHARES_ARCHIVES_NOTICE);
- // Push oversize archives to R2 before the Pages deploy (no-op when R2
- // isn't configured), so the published manifest URLs resolve.
- const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths);
- if (signal.aborted) return;
- if (uploadCode !== 0) {
- throw new Error(`Archive R2 upload failed (exit ${uploadCode}).`);
- }
- const deployCode = await runDeployIntoLog(
- onLog,
- signal,
- site,
- resolveOutDir(id, paths),
- paths,
- { previewBranch: branch },
- );
- if (signal.aborted) return;
- if (deployCode !== 0) {
- throw new Error(`Deploy failed (exit ${deployCode}).`);
- }
- },
- });
-}
export async function normalizeTranscriptsAction(
queueKey?: string,
@@ -283,206 +111,3 @@ export async function archiveCombinedLiveChatAction(
},
});
}
-
-// Build EVERY configured site, then deploy each — the docker-pipeline entry point.
-// With a container engine available: run the shared data phase + archive warm
-// once, fan the per-site builds out in parallel (capped by maxParallelBuilds),
-// then deploy the built sites SERIALLY on the host. Without one: fall back to a
-// serial host build+deploy per site. One managed job (single streamed log +
-// Cancel), serialized on the deploy queue so it never overlaps another deploy.
-export async function buildAndDeployAllSitesAction(
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const sites = listSites(paths);
- if (sites.length === 0) return { ok: false, error: "No sites configured." };
- return runManagedFunction({
- kind: "build-deploy-all",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- const useDocker = await dockerAvailable(signal);
- if (signal.aborted) return;
-
- if (!useDocker) {
- onLog(
- "[notice] No container engine available (start the daemon, or set " +
- "DOCKER_BIN) — falling back to serial host build+deploy.\n",
- );
- const deploys = await basicBuildAndDeployAll(
- onLog,
- signal,
- sites,
- paths,
- skipArchives,
- );
- if (signal.aborted) return;
- summarize(onLog, deploys);
- return;
- }
-
- // Docker: build all (Phase A + B), barrier, then deploy all serially (C).
- const outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, {
- skipArchives,
- });
- if (signal.aborted) return;
- const builtOk = new Set(
- outcomes.filter((o) => o.code === 0).map((o) => o.siteId),
- );
- onLog(`\n=== Phase C: deploy (${builtOk.size}/${sites.length} built) ===`);
- const deploys = await runDockerDeployAllPhase(
- onLog,
- signal,
- sites,
- builtOk,
- paths,
- (id) => dockerSiteOutDir(paths, id),
- );
- if (signal.aborted) return;
- summarize(onLog, deploys, outcomes);
- },
- });
-}
-
-// Build EVERY configured site WITHOUT deploying — e.g. to warm caches or validate
-// that each site composes and next-builds. Docker mode fans the builds out in
-// parallel; otherwise it's a serial host loop (whose shared export/out is
-// overwritten per site, so only the last survives — build-all is chiefly a docker
-// feature). Queued on the build queue.
-export async function buildAllSitesAction(
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const sites = listSites(paths);
- if (sites.length === 0) return { ok: false, error: "No sites configured." };
- return runManagedFunction({
- kind: "build-all",
- queueKey: DEFAULT_BUILD_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- const useDocker = await dockerAvailable(signal);
- if (signal.aborted) return;
- if (!useDocker) {
- onLog(
- "[notice] No container engine available — building sites serially on the host.\n",
- );
- }
- const outcomes = await buildAll({
- paths,
- onLog,
- signal,
- mode: useDocker ? "docker" : "basic",
- skipArchives,
- sites,
- });
- if (signal.aborted) return;
- const ok = outcomes.filter((o) => o.code === 0).length;
- const failed = outcomes
- .filter((o) => o.code !== 0)
- .map((o) => `${o.siteId} (exit ${o.code})`);
- onLog(`\n=== Summary: ${ok}/${sites.length} built ===`);
- if (failed.length) throw new Error(`Build failures: ${failed.join(", ")}`);
- },
- });
-}
-
-// Serial host build+deploy fallback for buildAndDeployAllSitesAction when no
-// container engine is available. Interleaved (build A → deploy A → build B …)
-// because the basic build shares export/out — building all first would leave only
-// the last site's bundle. Rebuilds the pool-wide data once (first site).
-async function basicBuildAndDeployAll(
- onLog: (line: string) => void,
- signal: AbortSignal,
- sites: Site[],
- paths: ReturnType<typeof getPaths>,
- skipArchives?: boolean,
-): Promise<SiteDeployOutcome[]> {
- const basicOut = resolveOutDir("", paths);
- const deploys: SiteDeployOutcome[] = [];
- for (let i = 0; i < sites.length; i++) {
- if (signal.aborted) break;
- const site = sites[i];
- onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`);
- const code = await runBuildPhase(onLog, signal, site.siteId, paths, {
- skipData: i > 0,
- skipArchives,
- });
- if (signal.aborted) break;
- if (code !== 0) {
- onLog(`[${site.siteId}] build FAILED (exit ${code}) — skipping deploy`);
- deploys.push({
- siteId: site.siteId,
- status: "skipped",
- reason: `build exit ${code}`,
- });
- continue;
- }
- // A private site is built and never deployed — skipped before the upload.
- const audienceProblem = deployAudienceProblem(site, basicOut);
- if (audienceProblem) {
- onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`);
- deploys.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem });
- continue;
- }
- if (!site.cloudflareProject) {
- onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`);
- deploys.push({
- siteId: site.siteId,
- status: "skipped",
- reason: "no cloudflareProject",
- });
- continue;
- }
- onLog(`=== Deploy ${site.siteId} ===`);
- const up = await runArchiveUploadIntoLog(onLog, signal, site, paths);
- if (signal.aborted) break;
- if (up !== 0) {
- onLog(`[${site.siteId}] deploy FAILED — R2 upload exit ${up}`);
- deploys.push({
- siteId: site.siteId,
- status: "failed",
- reason: `R2 upload exit ${up}`,
- });
- continue;
- }
- const dep = await runDeployIntoLog(onLog, signal, site, basicOut, paths);
- if (signal.aborted) break;
- if (dep !== 0) {
- onLog(`[${site.siteId}] deploy FAILED — exit ${dep}`);
- deploys.push({
- siteId: site.siteId,
- status: "failed",
- reason: `deploy exit ${dep}`,
- });
- continue;
- }
- onLog(`[${site.siteId}] deployed.`);
- deploys.push({ siteId: site.siteId, status: "deployed" });
- }
- return deploys;
-}
-
-// Log the final tally and THROW if anything failed, so the managed job ends in a
-// failed state the UI surfaces. Skips are reported but not failures.
-function summarize(
- onLog: (line: string) => void,
- deploys: SiteDeployOutcome[],
- builds?: SiteBuildOutcome[],
-): void {
- const deployed = deploys
- .filter((d) => d.status === "deployed")
- .map((d) => d.siteId);
- const skipped = deploys.filter((d) => d.status === "skipped");
- const failed = [
- ...(builds ?? [])
- .filter((b) => b.code !== 0)
- .map((b) => `${b.siteId} (build exit ${b.code})`),
- ...deploys
- .filter((d) => d.status === "failed")
- .map((d) => `${d.siteId} (deploy: ${d.reason})`),
- ];
- onLog(`\n=== Summary: ${deployed.length} deployed ===`);
- if (deployed.length) onLog(` deployed: ${deployed.join(", ")}`);
- for (const s of skipped) onLog(` skipped ${s.siteId}: ${s.reason}`);
- if (failed.length) throw new Error(`Failures: ${failed.join(", ")}`);
-}
diff --git a/editor/app/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts
@@ -1,81 +0,0 @@
-"use server";
-
-import {
- builtAudienceProblem,
- builtSiteProblem,
- siteDeployProblem,
-} from "yt-dlp-transcript-common/lib/builtExport";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { getSite } from "yt-dlp-transcript-common/lib/site";
-import {
- runManagedFunction,
- type StreamActionResult,
-} from "yt-dlp-transcript-common/jobs/streamCommand";
-import {
- deploySite,
- resolveOutDir,
-} from "yt-dlp-transcript-common/publish/build";
-
-const DEPLOY_QUEUE = "deploy";
-
-// Deploy the already-built export/out. With `opts.previewBranch` it goes to a
-// Cloudflare Pages PREVIEW instead of production, reachable at the branch alias
-// — same job kind, same queue, same log, so nothing downstream has to learn a
-// new word for it.
-export async function deployExportAction(
- siteId: string,
- opts?: { previewBranch?: string },
-): Promise<StreamActionResult> {
- const paths = getPaths();
- if (!siteId.trim()) {
- return { ok: false, error: "Select a site to deploy" };
- }
- // NAMING A BRANCH IS THE REQUEST, so an unusable name is a refusal and never
- // a quiet fall-through to a production deploy — the one mistake this feature
- // must not make. Checked before the job starts, with the same sentence the
- // browser control and the ops route give.
- const previewBranch = opts?.previewBranch;
- if (previewBranch !== undefined) {
- const problem = previewBranchProblem(previewBranch);
- if (problem) return { ok: false, error: problem };
- }
- const branch = previewBranch?.trim();
- const site = getSite(siteId.trim(), paths);
- // A private site is never deployed (site.json `audience`): the first answer.
- const privateProblem = siteDeployProblem(site);
- if (privateProblem) return { ok: false, error: `${privateProblem}.` };
- if (!site.cloudflareProject) {
- return {
- ok: false,
- error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
- };
- }
- // DEPLOY-ONLY SHIPS WHAT IS IN export/out, AND export/out IS SHARED.
- // resolveOutDir ignores the site id — the basic build composes every site into
- // the same directory — so without this, building jeralyzer and then deploying
- // anilyzer put jeralyzer's bundle on anilyzer's Pages project, in production,
- // with a green log. The bundle says who it is, in its own site.json.
- //
- // Refused rather than rebuilt: "deploy the export" is the operator saying
- // ship what is there, and quietly building something else would be a far
- // bigger surprise than a sentence naming the fix. Checked before the job, so
- // the refusal is the action's answer rather than a failed job to go and read
- // — and so a preview cannot waste a deploy learning it either.
- const outDir = resolveOutDir(site.siteId, paths);
- const builtProblem = builtSiteProblem(outDir, site.siteId);
- if (builtProblem) return { ok: false, error: builtProblem };
- const builtPrivate = builtAudienceProblem(outDir);
- if (builtPrivate) {
- return { ok: false, error: `${builtPrivate}. Build ${site.siteId} again, then deploy.` };
- }
- return runManagedFunction({
- kind: "deploy-export",
- queueKey: DEPLOY_QUEUE,
- paths,
- // The refusals above answer before a job exists; deploySite checks them
- // again when the job actually starts, and does the upload + deploy.
- fn: (onLog, signal) =>
- deploySite(site.siteId, { paths, onLog, signal, previewBranch: branch }),
- });
-}
diff --git a/editor/app/sites/lib/homepageDeployActions.ts b/editor/app/sites/lib/homepageDeployActions.ts
@@ -1,104 +0,0 @@
-"use server";
-
-import { builtHomepageProblem } from "yt-dlp-transcript-common/lib/builtExport";
-import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
-import {
- runManagedFunction,
- type StreamActionResult,
-} from "yt-dlp-transcript-common/jobs/streamCommand";
-import {
- buildHomepage,
- deployHomepage,
- homepageOutDir,
-} from "yt-dlp-transcript-common/publish/build";
-
-// The homepage's build and deploy, as jobs — the `homepage` package, Archilyzer's
-// own site, deployed to the constant Pages project `archilyzer`
-// (HOMEPAGE_PAGES_PROJECT). The same three shapes as the hub's (hubActions.ts)
-// and the same bodies `archilyzer build homepage` / `deploy homepage` run.
-//
-// The build writes homepage/out, not export/out, so it cannot race a site
-// build for export/ — but `next build` is heavy, and the build queue is what
-// serialises heavy builds. The deploys take the deploy queue.
-const BUILD_QUEUE = "build";
-const DEPLOY_QUEUE = "deploy";
-
-// Refusals a deploy can give BEFORE any job starts: a bad preview name and —
-// for a deploy-only — no build in homepage/out. No project check: the project
-// is a constant. deployHomepage checks both again when its job runs, because a
-// queued deploy can start after the build it was going to ship is gone.
-function deployRefusal(
- paths: Paths,
- previewBranch: string | undefined,
- checkBuilt: boolean,
-): string | null {
- if (previewBranch !== undefined) {
- const problem = previewBranchProblem(previewBranch);
- if (problem) return problem;
- }
- if (checkBuilt) return builtHomepageProblem(homepageOutDir(paths));
- return null;
-}
-
-export async function buildHomepageAction(): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "build-homepage",
- queueKey: BUILD_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- const code = await buildHomepage({ paths, onLog, signal });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Homepage build failed (exit ${code})`);
- },
- });
-}
-
-// Deploy the homepage already built in homepage/out: production (branch
-// `main`), or with `opts.previewBranch` a Cloudflare Pages PREVIEW of the
-// `archilyzer` project.
-export async function deployHomepageAction(opts?: {
- previewBranch?: string;
-}): Promise<StreamActionResult> {
- const paths = getPaths();
- const refusal = deployRefusal(paths, opts?.previewBranch, true);
- if (refusal) return { ok: false, error: refusal };
- const branch = opts?.previewBranch?.trim();
- return runManagedFunction({
- kind: "deploy-homepage",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: (onLog, signal) =>
- deployHomepage({ paths, onLog, signal, previewBranch: branch }),
- });
-}
-
-// Build the homepage and, only if that succeeds, deploy it — one job, one log,
-// one Cancel, on the deploy queue (as build-deploy-hub is for the hub). A failed
-// build never reaches the deploy: homepage/out would still hold the PREVIOUS
-// build, and deploying that after a failure would ship old news as new.
-export async function buildAndDeployHomepageAction(opts?: {
- previewBranch?: string;
-}): Promise<StreamActionResult> {
- const paths = getPaths();
- // Before the build: learning the preview name is wrong after a build wastes it.
- const refusal = deployRefusal(paths, opts?.previewBranch, false);
- if (refusal) return { ok: false, error: refusal };
- const branch = opts?.previewBranch?.trim();
- return runManagedFunction({
- kind: "build-deploy-homepage",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- onLog("=== Build homepage ===\n");
- const code = await buildHomepage({ paths, onLog, signal });
- if (signal.aborted) return;
- if (code !== 0) {
- throw new Error(`Homepage build failed (exit ${code}) — not deploying.`);
- }
- onLog("\n=== Deploy homepage ===\n");
- await deployHomepage({ paths, onLog, signal, previewBranch: branch });
- },
- });
-}
diff --git a/editor/app/sites/lib/hubActions.ts b/editor/app/sites/lib/hubActions.ts
@@ -1,101 +0,0 @@
-"use server";
-
-import { builtHubProblem } from "yt-dlp-transcript-common/lib/builtExport";
-import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
-import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
-import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
-import {
- runManagedFunction,
- type StreamActionResult,
-} from "yt-dlp-transcript-common/jobs/streamCommand";
-import {
- buildHub,
- deployHub,
- hubProjectProblem,
- resolveOutDir,
-} from "yt-dlp-transcript-common/publish/build";
-
-// The hub's build and deploy, as jobs. The hub is the export app built with
-// INSTANCE_MODE=hub into the SAME export/out a site build uses, so its build
-// takes the build queue (it would race a site build for export/) and its
-// deploys take the deploy queue — the queues build-export and deploy-export
-// use (buildAction.ts / deployAction.ts).
-const BUILD_QUEUE = "build";
-const DEPLOY_QUEUE = "deploy";
-
-// Refusals a deploy can give BEFORE any job starts: a bad preview name, no
-// project (or the homepage's), and — for a deploy-only — no hub in export/out.
-// deployHub checks them again when its job runs, because a queued deploy can
-// start after a site build has replaced export/out.
-function deployRefusal(
- paths: Paths,
- previewBranch: string | undefined,
- checkBuilt: boolean,
-): string | null {
- if (previewBranch !== undefined) {
- const problem = previewBranchProblem(previewBranch);
- if (problem) return problem;
- }
- const projectProblem = hubProjectProblem(getHomepageConfig(paths).cloudflareProject);
- if (projectProblem) return projectProblem;
- if (checkBuilt) return builtHubProblem(resolveOutDir("", paths));
- return null;
-}
-
-export async function buildHubAction(): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "build-hub",
- queueKey: BUILD_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- const code = await buildHub({ paths, onLog, signal });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Hub build failed (exit ${code})`);
- },
- });
-}
-
-// Deploy the hub already built in export/out. With `opts.previewBranch` it is
-// a Cloudflare Pages PREVIEW of the hub's project.
-export async function deployHubAction(opts?: {
- previewBranch?: string;
-}): Promise<StreamActionResult> {
- const paths = getPaths();
- const refusal = deployRefusal(paths, opts?.previewBranch, true);
- if (refusal) return { ok: false, error: refusal };
- const branch = opts?.previewBranch?.trim();
- return runManagedFunction({
- kind: "deploy-hub",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: (onLog, signal) => deployHub({ paths, onLog, signal, previewBranch: branch }),
- });
-}
-
-// Build the hub and, only if that succeeds, deploy it — one job, one log, one
-// Cancel, on the deploy queue (as build-deploy is for a site).
-export async function buildAndDeployHubAction(opts?: {
- previewBranch?: string;
-}): Promise<StreamActionResult> {
- const paths = getPaths();
- // Before the build: learning the project is wrong after a build wastes it.
- const refusal = deployRefusal(paths, opts?.previewBranch, false);
- if (refusal) return { ok: false, error: refusal };
- const branch = opts?.previewBranch?.trim();
- return runManagedFunction({
- kind: "build-deploy-hub",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- onLog("=== Build hub ===\n");
- const code = await buildHub({ paths, onLog, signal });
- if (signal.aborted) return;
- if (code !== 0) {
- throw new Error(`Hub build failed (exit ${code}) — not deploying.`);
- }
- onLog("\n=== Deploy hub ===\n");
- await deployHub({ paths, onLog, signal, previewBranch: branch });
- },
- });
-}
diff --git a/editor/app/sites/lib/publishActions.ts b/editor/app/sites/lib/publishActions.ts
@@ -0,0 +1,186 @@
+"use server";
+
+// The publish surfaces' server actions (release 18 S4): every button on the
+// /sites Publish panel, a site's Publish tab and the Pool's Build index, and
+// the publish lane's runner controls on /operations/publish.
+//
+// Each returns the shape JobLane and StreamActionLog already take — `{ok,
+// jobId, stream, done}` or `{ok: false, error, info?}` — plus `runId` and,
+// for a preview, `previewUrl`. A run of several stages is followed in ONE
+// console (publishRunStream.ts): the index update when a build needs it, the
+// build, its deploy. The jobs are the `publish` queue's (publish/
+// publishStages.ts) and run one at a time; their order is on disk.
+//
+// What these do NOT do: build or deploy in the editor's process. Every stage
+// is a child (`archilyzer stage …`), so a heavy build cannot starve the editor.
+
+import { revalidatePath } from "next/cache";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { planPublishRun, type PublishPlan } from "yt-dlp-transcript-common/publish/publishPlan";
+import {
+ drainPublishRunner,
+ startPublishRunner,
+ startPublishRunnerBlockedReason,
+ stopPublishRunner,
+} from "yt-dlp-transcript-common/publish/publishRunner";
+import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState";
+import { PUBLISH_QUEUE } from "yt-dlp-transcript-common/publish/publishStages";
+import {
+ enqueueRun,
+ indexPlan,
+ refusal,
+ skippedLines,
+ wantedPlan,
+ type DeployWhere,
+ type TargetAsk,
+} from "./publishCore";
+import { followRun } from "./publishRunStream";
+
+export type PublishActionResult =
+ | (Extract<StreamActionResult, { ok: true }> & { runId: string; previewUrl?: string })
+ | { ok: false; error: string; info?: boolean };
+
+function revalidatePublish(): void {
+ revalidatePath("/sites");
+ revalidatePath("/sites/[siteId]/publish", "page");
+ revalidatePath("/operations/publish");
+}
+
+// Enqueue `plan` and hand back one console over it.
+async function runPlan(plan: PublishPlan, empty: string): Promise<PublishActionResult> {
+ const paths = getPaths();
+ if (plan.steps.length === 0) {
+ const why = skippedLines(plan.skipped);
+ return { ok: false, info: true, error: why.length ? `${empty} ${why.join("; ")}` : empty };
+ }
+ const run = await enqueueRun(paths, plan, { keepStreams: true });
+ revalidatePublish();
+ if (run.parts.length === 0) {
+ return {
+ ok: false,
+ error: run.refused.map((r) => r.error).join("; ") || "Nothing was enqueued.",
+ };
+ }
+ const preface = [
+ `[publish] run ${run.runId}: ${run.jobs.length} stage${run.jobs.length === 1 ? "" : "s"}`,
+ ...skippedLines(run.skipped),
+ ...run.refused.map((r) => `[publish] refused ${r.kind} ${r.target}: ${r.error}`),
+ ];
+ const previewUrl = run.jobs.find((j) => j.previewUrl)?.previewUrl;
+ return { ...followRun(run.parts, preface), runId: run.runId, ...(previewUrl ? { previewUrl } : {}) };
+}
+
+async function runAsks(asks: TargetAsk[]): Promise<PublishActionResult> {
+ const paths = getPaths();
+ for (const ask of asks) {
+ const why = await refusal(paths, ask);
+ if (why) return { ok: false, error: why };
+ }
+ const status = await readPublishStatus(paths);
+ return runPlan(wantedPlan(status, asks, { withIndex: true, paths }), "Nothing to run.");
+}
+
+/** Publish now: what the lane would run — the stale index, then each policy target. */
+export async function publishNowAction(): Promise<PublishActionResult> {
+ const status = await readPublishStatus(getPaths());
+ return runPlan(planPublishRun(status, { deploys: "policy" }), "Nothing to publish: everything is current.");
+}
+
+/** Build all stale: every stale site whatever its policy, no deploys. */
+export async function buildAllStaleAction(): Promise<PublishActionResult> {
+ const status = await readPublishStatus(getPaths());
+ return runPlan(planPublishRun(status, { builds: "stale", deploys: "none" }), "Nothing to build: every site is current.");
+}
+
+/** The Pool's Build index: the index update (a no-op when nothing changed). */
+export async function updateIndexAction(): Promise<PublishActionResult> {
+ return runPlan(indexPlan(), "Nothing to run.");
+}
+
+/** Build one target (a site id, "_hub", "_homepage") — forced; the index first when stale. */
+export async function buildTargetAction(
+ target: string,
+ opts: { skipArchives?: boolean } = {},
+): Promise<PublishActionResult> {
+ return runAsks([{ target, build: { force: true, skipArchives: opts.skipArchives } }]);
+}
+
+/** Deploy what is built: production, a preview branch, or the local service. */
+export async function deployTargetAction(
+ target: string,
+ opts: { where: DeployWhere; preview?: string },
+): Promise<PublishActionResult> {
+ return runAsks([{ target, deploy: { where: opts.where, preview: opts.preview, force: true } }]);
+}
+
+/** Build, then deploy that build (the deploy waits on disk for it). */
+export async function buildAndDeployTargetAction(
+ target: string,
+ opts: { where: DeployWhere; preview?: string; skipArchives?: boolean },
+): Promise<PublishActionResult> {
+ return runAsks([
+ {
+ target,
+ build: { force: true, skipArchives: opts.skipArchives },
+ deploy: { where: opts.where, preview: opts.preview, force: true },
+ },
+ ]);
+}
+
+/**
+ * A publish console's Cancel: the RUN, not the one job the console holds. Every
+ * queued or running stage carrying that job's run id is cancelled, newest
+ * first (so a queued stage is gone before the slot ahead of it frees and
+ * nothing is promoted). A job that is not a publish stage, or carries no run
+ * id, is cancelled alone. Another run's jobs — another tab's, the lane's — carry
+ * another id and are never touched.
+ */
+export async function cancelPublishRunAction(jobId: string): Promise<{ ok: boolean }> {
+ const registry = getRegistry();
+ const record = registry.get(jobId);
+ const runId = record?.spec?.params?.runId;
+ let ok = false;
+ if (typeof runId === "string" && record?.queueKey === PUBLISH_QUEUE) {
+ const live = registry
+ .list()
+ .filter(
+ (r) =>
+ r.queueKey === PUBLISH_QUEUE &&
+ (r.status === "queued" || r.status === "running") &&
+ r.spec?.params?.runId === runId,
+ )
+ .sort((a, b) => b.queuedAt - a.queuedAt);
+ for (const r of live) ok = registry.cancel(r.id) || ok;
+ } else {
+ ok = registry.cancel(jobId);
+ }
+ revalidatePath("/jobs");
+ revalidatePublish();
+ return { ok };
+}
+
+// --- the publish lane's runner (/operations/publish) -------------------------
+
+export type LaneControlResult = { ok: boolean; error?: string };
+
+export async function startPublishLaneAction(): Promise<LaneControlResult> {
+ const blocked = startPublishRunnerBlockedReason();
+ if (blocked) return { ok: false, error: blocked };
+ const id = await startPublishRunner(getPaths());
+ revalidatePublish();
+ return id ? { ok: true } : { ok: false, error: "The publish lane's runner did not start." };
+}
+
+export async function stopPublishLaneAction(): Promise<LaneControlResult> {
+ stopPublishRunner();
+ revalidatePublish();
+ return { ok: true };
+}
+
+export async function drainPublishLaneAction(): Promise<LaneControlResult> {
+ drainPublishRunner();
+ revalidatePublish();
+ return { ok: true };
+}
diff --git a/editor/app/sites/lib/publishCore.ts b/editor/app/sites/lib/publishCore.ts
@@ -0,0 +1,216 @@
+// THE SURFACES' ONE WAY INTO THE PUBLISH STAGES (release 18 S4).
+//
+// The /sites Publish panel, a site's Publish tab, the Pool's Build index and
+// the ops route (`POST /api/ops/publish` and its old aliases) all ask for
+// stages here, as a plan (publish/publishPlan.ts's shape), and all enqueue it
+// through `enqueuePublishRun` (publish/publishStages.ts) under one run id —
+// so a click and an HTTP caller get the same jobs and the same refusals.
+//
+// wantedPlan(...) what a button asks for → a plan: the index update first
+// when the index is not fresh (a build of stale data ships
+// old data — the data phase every build used to run), a
+// build `--force` (a manual build always builds), a
+// deploy carrying `builtAfter` when its build is in the run
+// refusal(...) the sentence a request is refused with BEFORE any job
+// exists: the deploy stage's own (resolveDeployRequest),
+// word for word, and a preview name's
+// enqueueRun(...) the plan as jobs; with `keepStreams` the jobs' streams
+// are kept for one console (publishRunStream.ts)
+//
+// Not a "use server" module (it exports types and sync helpers).
+
+import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
+import { previewAliasUrl, previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
+import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
+import { getSite, isValidSiteId, listSiteIds } from "yt-dlp-transcript-common/lib/site";
+import type { JobDoneResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { HOMEPAGE_PAGES_PROJECT } from "yt-dlp-transcript-common/publish/build";
+import { resolveDeployRequest } from "yt-dlp-transcript-common/publish/deployStage";
+import type { PlanSkip, PlanStep, PublishPlan, PublishStatus } from "yt-dlp-transcript-common/publish/publishPlan";
+import {
+ enqueuePublishRun,
+ enqueueStage,
+ newPublishRunId,
+ type PublishRunResult,
+} from "yt-dlp-transcript-common/publish/publishStages";
+import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState";
+import { STAGES, type StageKind } from "yt-dlp-transcript-common/publish/stages";
+import { HOMEPAGE_TARGET, HUB_TARGET, INDEX_TARGET } from "yt-dlp-transcript-common/publish/stamps";
+import type { RunPart } from "./publishRunStream";
+
+export type DeployWhere = "production" | "preview" | "local";
+
+export type TargetAsk = {
+ // A site id, "_hub" or "_homepage".
+ target: string;
+ build?: { force?: boolean; skipArchives?: boolean };
+ deploy?: { where: DeployWhere; preview?: string; force?: boolean };
+};
+
+/** The build and deploy stage kinds of a target. */
+export function stageKindsOf(target: string): { build: StageKind; deploy: StageKind } {
+ if (target === HUB_TARGET) return { build: "build-hub", deploy: "deploy-hub" };
+ if (target === HOMEPAGE_TARGET) return { build: "build-homepage", deploy: "deploy-homepage" };
+ return { build: "build-site", deploy: "deploy-site" };
+}
+
+/** "Build site jeralyzer", "Deploy hub", "Update the index". */
+export function stepLabel(s: { kind: StageKind; target: string }): string {
+ const label = STAGES[s.kind].label;
+ return s.target.startsWith("_") ? label : `${label} ${s.target}`;
+}
+
+/** The Pages project a target deploys to, or null (none configured). */
+export function projectOf(paths: Paths, target: string): string | null {
+ if (target === HUB_TARGET) return getHomepageConfig(paths).cloudflareProject?.trim() || null;
+ if (target === HOMEPAGE_TARGET) return HOMEPAGE_PAGES_PROJECT;
+ try {
+ return getSite(target, paths).cloudflareProject?.trim() || null;
+ } catch {
+ return null;
+ }
+}
+
+/** A target that is not one: an unknown or invalid site id. */
+export function targetProblem(paths: Paths, target: string): string | null {
+ if (target === HUB_TARGET || target === HOMEPAGE_TARGET) return null;
+ if (!isValidSiteId(target)) return `"${target}" is not a valid site id`;
+ // A missing site.json reads as every default (getSite never throws past the
+ // id): an unknown id is refused here, in the stages' words.
+ return listSiteIds(paths).includes(target) ? null : `no site "${target}"`;
+}
+
+/**
+ * Why `ask` is refused before any job exists, or null. A deploy is asked
+ * through the deploy stage's own resolver (the same sentences the stage would
+ * end on); when the run builds first, "no build of X" is not a refusal.
+ */
+export async function refusal(paths: Paths, ask: TargetAsk): Promise<string | null> {
+ const bad = targetProblem(paths, ask.target);
+ if (bad) return bad;
+ if (!ask.deploy) return null;
+ // A preview is asked BY NAME: a preview with none is refused, never let fall
+ // through to production (a plan step with no `preview` is production).
+ if (ask.deploy.where === "preview" || ask.deploy.preview !== undefined) {
+ const p = previewBranchProblem(ask.deploy.preview ?? "");
+ if (p) return p;
+ }
+ const r = await resolveDeployRequest(paths, {
+ kind: stageKindsOf(ask.target).deploy as "deploy-site" | "deploy-hub" | "deploy-homepage",
+ target: ask.target,
+ preview: ask.deploy.where === "preview" ? ask.deploy.preview : undefined,
+ to: ask.deploy.where === "local" ? "local" : "pages",
+ });
+ if (!("problem" in r)) return null;
+ if (ask.build && r.problem.startsWith("no build of ")) return null;
+ return r.problem;
+}
+
+/**
+ * What the asks enqueue, in order: the index update when `withIndex` says so
+ * and the index is not fresh; then per ask its build (forced) and its deploy.
+ */
+export function wantedPlan(
+ status: Pick<PublishStatus, "index">,
+ asks: TargetAsk[],
+ opts: { withIndex: boolean; runStart?: number; paths?: Paths } = { withIndex: true },
+): PublishPlan {
+ const runStart = opts.runStart ?? Date.now();
+ const paths = opts.paths ?? getPaths();
+ const steps: PlanStep[] = [];
+ const indexStep = opts.withIndex && asks.some((a) => a.build) && !status.index.fresh;
+ if (indexStep) {
+ steps.push({
+ kind: "update-index",
+ target: INDEX_TARGET,
+ reason: status.index.freshness.state === "fresh" ? "the index is stale" : status.index.freshness.reason,
+ });
+ }
+ for (const ask of asks) {
+ const kinds = stageKindsOf(ask.target);
+ if (ask.build) {
+ steps.push({
+ kind: kinds.build,
+ target: ask.target,
+ reason: "asked",
+ ...(ask.build.force !== false ? { force: true } : {}),
+ ...(ask.build.skipArchives ? { skipArchives: true } : {}),
+ ...(indexStep ? { indexAfter: runStart } : {}),
+ });
+ }
+ if (ask.deploy) {
+ const preview = ask.deploy.where === "preview" ? ask.deploy.preview?.trim() : undefined;
+ if (ask.deploy.where === "preview" && !preview) {
+ throw new Error(`a preview deploy of ${ask.target} names no branch`);
+ }
+ const project = preview ? projectOf(paths, ask.target) : null;
+ steps.push({
+ kind: kinds.deploy,
+ target: ask.target,
+ reason: "asked",
+ ...(preview ? { preview } : {}),
+ ...(ask.deploy.where === "local" ? { to: "local" as const } : {}),
+ ...(ask.deploy.force ? { force: true } : {}),
+ ...(ask.build ? { builtAfter: runStart } : {}),
+ ...(preview && project ? { previewUrl: previewAliasUrl(project, preview) } : {}),
+ });
+ }
+ }
+ return { runStart, steps, skipped: [] };
+}
+
+/** The plan for the Pool's Build index: the index update, always (a no-op when fresh). */
+export function indexPlan(runStart = Date.now()): PublishPlan {
+ return { runStart, steps: [{ kind: "update-index", target: INDEX_TARGET, reason: "asked" }], skipped: [] };
+}
+
+export type EnqueuedRun = PublishRunResult & {
+ // With `keepStreams`: each enqueued job's stream, in plan order.
+ parts: RunPart[];
+};
+
+/**
+ * Enqueue a plan under one run id. With `keepStreams` every new job's stream
+ * is teed before enqueuePublishRun releases it, so one console can follow the
+ * run (publishRunStream.ts followRun).
+ */
+export async function enqueueRun(
+ paths: Paths,
+ plan: PublishPlan,
+ opts: { keepStreams?: boolean; runId?: string } = {},
+): Promise<EnqueuedRun> {
+ const kept = new Map<string, { stream: ReadableStream<string>; done: Promise<JobDoneResult> }>();
+ const enqueue: typeof enqueueStage = async (p, req, o) => {
+ const res = await enqueueStage(p, req, o);
+ if (!res.ok || !opts.keepStreams) return res;
+ const [released, keep] = res.stream.tee();
+ kept.set(res.jobId, { stream: keep, done: res.done });
+ return { ...res, stream: released };
+ };
+ let run: PublishRunResult;
+ try {
+ run = await enqueuePublishRun(paths, plan, { runId: opts.runId ?? newPublishRunId(), enqueue });
+ } catch (e) {
+ // The jobs already enqueued run on (their logs are on disk); a kept branch
+ // nobody will read would buffer its job's whole log in memory.
+ for (const k of kept.values()) void k.stream.cancel().catch(() => {});
+ throw e;
+ }
+ const parts: RunPart[] = run.jobs.map((j) => ({
+ label: stepLabel(j),
+ jobId: j.jobId,
+ ...(j.existing ? { existing: true } : {}),
+ ...(kept.get(j.jobId) ?? {}),
+ }));
+ return { ...run, parts };
+}
+
+/** One line per skipped target of a plan ("skipped hub: its policy is off"). */
+export function skippedLines(skipped: PlanSkip[]): string[] {
+ return skipped.map((s) => `[publish] skipped ${stepLabel(s)}: ${s.reason}`);
+}
+
+/** The status every surface reads (re-exported for the server pages). */
+export async function publishStatus(paths: Paths = getPaths()): Promise<PublishStatus> {
+ return readPublishStatus(paths);
+}
diff --git a/editor/app/sites/lib/publishRunStream.ts b/editor/app/sites/lib/publishRunStream.ts
@@ -0,0 +1,101 @@
+// ONE CONSOLE FOR A PUBLISH RUN (release 18 S4). A run is several jobs on the
+// `publish` queue — the index update, a build, its deploy — each with its own
+// log; the /sites consoles (JobLane, StreamActionLog) follow ONE stream. This
+// joins the run's streams in order, each under a `=== <stage> <target> ===`
+// line, and settles when the last one does: the run's verdict is its first
+// job that did not end `done`, else `done`.
+//
+// A CANCEL IS THE RUN'S. The console's Cancel is `cancelPublishRunAction`
+// (publishActions.ts), which cancels every live job carrying the run's id; and
+// here, a job of the run that ends `cancelled` cancels the run's jobs after it,
+// queued or running — else the console would wait on a build still queued
+// behind something else, for a run its operator called off. A part that was
+// ALREADY QUEUED by someone else (another tab's run, the lane's pass) is never
+// cancelled from here, never the console's job id, and not waited on: it is
+// another run's.
+//
+// Nothing here chains one job to another — the order is on disk (a build
+// carries `indexAfter`, a deploy `builtAfter`; publishStages.ts). A deploy
+// whose build failed ends `failed` itself, with the sentence in its log.
+//
+// Not a "use server" module: a server action file may only export async
+// functions, and these are plain helpers the actions call.
+
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import type { JobDoneResult, StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+
+export type RunPart = {
+ // "Build site jeralyzer" — the stage label and its target.
+ label: string;
+ jobId: string;
+ // Absent for a stage that was already queued (its own console has it).
+ stream?: ReadableStream<string>;
+ done?: Promise<JobDoneResult>;
+ // Another run's job this run found queued (enqueuePublishRun `existing`).
+ existing?: boolean;
+};
+
+/** The parts as one stream + one verdict (the first job's id is the run's). */
+export function followRun(
+ parts: RunPart[],
+ preface: string[] = [],
+): Extract<StreamActionResult, { ok: true }> {
+ if (parts.length === 0) throw new Error("followRun: a run with no jobs");
+ // The console's job: the first this run enqueued (its Cancel cancels the
+ // run by that job's run id), else — every part another run's — the first.
+ const first = parts.find((p) => !p.existing) ?? parts[0];
+ let resolveDone!: (r: JobDoneResult) => void;
+ const done = new Promise<JobDoneResult>((r) => {
+ resolveDone = r;
+ });
+ const stream = new ReadableStream<string>({
+ async start(c) {
+ let closed = false;
+ const push = (text: string) => {
+ if (closed) return;
+ try {
+ c.enqueue(text);
+ } catch {
+ closed = true;
+ }
+ };
+ for (const line of preface) push(line.endsWith("\n") ? line : `${line}\n`);
+ let verdict: JobDoneResult | null = null;
+ for (const [i, part] of parts.entries()) {
+ if (parts.length > 1) push(`${i === 0 ? "" : "\n"}=== ${part.label} (job ${part.jobId}) ===\n`);
+ if (!part.stream) {
+ push(`[publish] ${part.label} was already queued — its log is on /jobs/${part.jobId}\n`);
+ continue;
+ }
+ const reader = part.stream.getReader();
+ try {
+ while (true) {
+ const { value, done: end } = await reader.read();
+ if (end) break;
+ if (value) push(value);
+ }
+ } catch {
+ /* the job's stream tore down; its log is on disk */
+ }
+ const term = part.done ? await part.done : null;
+ if (term && term.status !== "done" && verdict === null) verdict = term;
+ if (term?.status === "cancelled") {
+ for (const later of parts.slice(i + 1)) if (!later.existing) getRegistry().cancel(later.jobId);
+ }
+ }
+ resolveDone(verdict ?? { status: "done", jobId: first.jobId });
+ if (!closed) {
+ try {
+ c.close();
+ } catch {
+ /* already closed */
+ }
+ }
+ },
+ cancel() {
+ // The consumer left (navigation): the jobs run on, their logs on disk.
+ for (const p of parts) void p.stream?.cancel().catch(() => {});
+ },
+ });
+ return { ok: true, jobId: first.jobId, stream, done };
+}
diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx
@@ -8,38 +8,28 @@ import {
suggestNextVersion,
} from "yt-dlp-transcript-common/lib/changelog";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { builtHomepageAt } from "yt-dlp-transcript-common/lib/builtExport";
import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage";
import { listSites } from "yt-dlp-transcript-common/lib/site";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
-import {
- HOMEPAGE_PAGES_PROJECT,
- homepageOutDir,
-} from "yt-dlp-transcript-common/publish/build";
+import type { PublishTargetStatus } from "yt-dlp-transcript-common/views/publishStatus";
+import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState";
import { liveJobRows } from "../jobs/active/buildActiveJobs";
import { RunningJobsList } from "../jobs/components/RunningJobsList";
-import { BuildAllSitesButton } from "./components/BuildAllSitesButton";
import { BuildButtons } from "./components/BuildButtons";
-import { BuildSitesPanel } from "./components/BuildSitesPanel";
import { CutReleaseForm } from "./components/CutReleaseForm";
-import { HomepageBuildButtons } from "./components/HomepageBuildButtons";
import { HomepageConfigForm } from "./components/HomepageConfigForm";
-import { HubBuildButtons } from "./components/HubBuildButtons";
+import { PublishPanel, type PanelRow } from "./components/PublishPanel";
import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Sites" };
-// Which live jobs the Pool section lists: the eight kinds the Pool's own
-// buttons enqueue (BuildButtons.tsx → sites/lib/buildAction.ts), and only
-// those. build-export and build-deploy are a site's Publish tab's, build-all
-// and build-deploy-all are the batch panel's above, build-hub / deploy-hub /
-// build-deploy-hub the Hub section's, build-homepage / deploy-homepage /
-// build-deploy-homepage the Homepage section's — each has its own console and
-// is not repeated here.
+// Which live jobs the Pool section lists: the kinds the Pool's own buttons
+// enqueue (BuildButtons.tsx), and only those — the index update included
+// (Build index is a `publish-update-index` stage now, release 18). Every other
+// publish stage has its console on its row of the Publish panel.
const BUILD_KINDS = new Set([
- "build-index",
- "build-stats",
+ "publish-update-index",
"normalize-transcripts",
"archive-transcripts",
"archive-combined-transcripts",
@@ -80,7 +70,7 @@ export default async function SitesPage() {
// (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds).
const activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind));
const hubConfig = getHomepageConfig(paths);
- const homepageBuiltAt = builtHomepageAt(homepageOutDir(paths));
+ const status = await readPublishStatus(paths);
return (
<div className="flex flex-col gap-8">
@@ -157,77 +147,60 @@ export default async function SitesPage() {
/>
</section>
- {/* Batch: build (and optionally deploy) several sites at once. Build all
- fans out in containers whenever an engine answers; there is no mode
- switch (dropped 2026-09-28, O6c). */}
+ {/* Publish: every site, the hub and the homepage as stages (release
+ 18) — the index update shared by all of them, one build and one
+ deploy at a time on the publish queue. Replaces "Build all sites"
+ and the hub's and the homepage's own build sections. */}
<section className="flex flex-col gap-3 border-t border-border pt-6">
<div>
- <h2 className="text-lg font-semibold">Build all sites</h2>
- <p className="text-sm text-muted-foreground">
- One job over every site: the shared data phase runs once, per-site
- builds run in parallel in containers (when a container engine
- answers), then deploys run serially.
- </p>
- </div>
- <BuildAllSitesButton />
-
- <div className="mt-4">
- <h3 className="font-semibold">Or pick specific sites</h3>
+ <h2 className="text-lg font-semibold">Publish</h2>
<p className="text-sm text-muted-foreground">
- Build (and optionally deploy) selected sites, each in its own live log
- lane.
+ The index is updated once and every site, the hub and the homepage
+ are built from it, each into its own bundle; deploys ship that
+ bundle and check the live site. A site’s policy (its
+ settings: off, build, preview, production) is what Publish now and
+ the{" "}
+ <Link href="/operations/publish" className="underline">
+ publish lane
+ </Link>{" "}
+ do for it; the buttons on a row always run.
</p>
</div>
- <BuildSitesPanel
- sites={sites.map((s) => ({
- siteId: s.siteId,
- siteTitle: s.siteTitle,
- cloudflareProject: s.cloudflareProject ?? null,
- }))}
+ <PublishPanel
+ rows={[...status.sites, status.hub, status.homepage].map(panelRow)}
+ index={status.index.chip}
+ lane={status.lane.chip}
+ plan={{
+ steps: status.plan.steps.map((x) => ({
+ kind: x.kind,
+ target: x.target,
+ reason: x.reason,
+ ...(x.preview ? { preview: x.preview } : {}),
+ })),
+ skipped: status.plan.skipped,
+ }}
+ previewBranch={status.settings.previewBranch}
+ siteOut={Boolean(process.env.ARCHILYZER_SITE_OUT?.trim())}
+ homepageOut={Boolean(process.env.ARCHILYZER_HOMEPAGE_OUT?.trim())}
/>
</section>
{/* The family's own hub — a site of sorts, but never a [siteId]: its
- config lives in sites/_homepage, which isValidSiteId rejects. */}
+ config lives in sites/_homepage, which isValidSiteId rejects. Its
+ build and deploy are its row of the Publish panel above. */}
<section className="flex flex-col gap-3 border-t border-border pt-6">
<div>
- <h2 className="text-lg font-semibold">Hub</h2>
+ <h2 className="text-lg font-semibold">Hub config</h2>
<p className="mt-1 text-sm text-muted-foreground">
The hub is the export app built in hub mode: one search over every
site that has a public URL, reading each archive where it is
published. This config names and brands it, and its Public URL and
Cloudflare Pages project are the hub’s own (for example{" "}
- <code>archilyzer-hub</code>).
+ <code>archilyzer-hub</code>). The homepage (Archilyzer’s own
+ site) shares its social links.
</p>
</div>
<HomepageConfigForm config={hubConfig} />
- <HubBuildButtons project={hubConfig.cloudflareProject ?? null} />
- </section>
-
- {/* The software's own site: the `homepage` package, never the hub. It
- has no config of its own here — it shares the hub form's social
- links — so the section is only its build and deploy. */}
- <section className="flex flex-col gap-3 border-t border-border pt-6">
- <div>
- <h2 className="text-lg font-semibold">Homepage</h2>
- <p className="mt-1 text-sm text-muted-foreground">
- The <code>homepage</code> package is Archilyzer’s own site,
- with the docs and the source download. It shares the Hub
- form’s social links and links to the hub, but it builds into{" "}
- <code>homepage/out</code> and deploys to its own Pages project,{" "}
- <code>{HOMEPAGE_PAGES_PROJECT}</code>. The buttons run the same code
- as <code>archilyzer build homepage</code> and{" "}
- <code>archilyzer deploy homepage</code>, as jobs.
- </p>
- </div>
- <HomepageBuildButtons
- project={HOMEPAGE_PAGES_PROJECT}
- builtAt={
- homepageBuiltAt === null
- ? null
- : new Date(homepageBuiltAt).toLocaleString()
- }
- />
</section>
{/* The shared pool: corpus-wide, no site involved. */}
@@ -256,3 +229,20 @@ export default async function SitesPage() {
</div>
);
}
+
+// The panel's row: what the client needs of a target's status, and no more.
+function panelRow(t: PublishTargetStatus): PanelRow {
+ return {
+ target: t.target,
+ kind: t.kind,
+ title: t.title,
+ chips: t.chips,
+ policy: t.policy,
+ next: t.next,
+ cloudflareProject: t.cloudflareProject,
+ deployProblem: t.deployProblem ?? null,
+ previewUrl: t.previewUrl ?? null,
+ builtAt: t.built?.builtAt ?? null,
+ url: t.url,
+ };
+}
diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts
@@ -90,9 +90,11 @@ test("build static export lives on the site's Publish tab, not the family page",
});
await expect(exportButton).toBeEnabled();
- // The build-only control offers a "skip data rebuild" option (compose from
- // existing staging without regenerating index/stats/charts). Default off.
- const skipData = page.getByLabel("Skip data rebuild (index, stats, charts)");
- await expect(skipData).toBeVisible();
- await expect(skipData).not.toBeChecked();
+ // No "skip data rebuild" any more (release 18): the data is the index
+ // stage, shared by every site, and a build updates it first when it is
+ // stale. The archive opt-out stays, default off.
+ await expect(page.getByLabel("Skip data rebuild (index, stats, charts)")).toHaveCount(0);
+ const skipArchives = page.getByLabel("Skip archive zips (transcript & live-chat downloads)");
+ await expect(skipArchives).toBeVisible();
+ await expect(skipArchives).not.toBeChecked();
});
diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts
@@ -2,30 +2,35 @@ import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { resetData, writeSite } from "./helpers";
-// The "pick specific sites" panel (BuildSitesPanel). /sites renders two
-// controls named "Deploy after build" — one here, one in BuildAllSitesButton —
-// so anything targeting this panel's copy must be scoped to it by name.
-const batchPanel = (page: Page) =>
- page.getByRole("group", { name: "Build specific sites" });
+// Where publishing lives (release 18): the family page has the Publish panel —
+// a row per site, the hub and the homepage, Publish now and Build all stale —
+// and a site's Publish tab has its own Build & deploy. The panel's stages run
+// in publish.spec; this pins the layout and the wiring decided before a job.
+
+const row = (page: Page, siteId: string) =>
+ page.getByRole("group", { name: `Publish ${siteId}` });
test.beforeEach(async () => {
await resetData("empty");
});
-test("publishing splits: the family page releases and batches, the site page builds & deploys", async ({
+test("publishing splits: the family page releases and publishes, the site page builds & deploys", async ({
page,
}) => {
await writeSite("testsite", { cloudflareProject: "proj" });
await page.goto("/sites");
const release = page.getByRole("heading", { name: "Release notes" });
- const batch = page.getByRole("heading", { name: "Build all sites" });
+ const publish = page.getByRole("heading", { name: "Publish", exact: true });
await expect(release).toBeVisible();
- await expect(batch).toBeVisible();
- // Order: the release sits above the batch panel.
+ await expect(publish).toBeVisible();
+ // Order: the release sits above the Publish panel.
expect((await release.boundingBox())!.y).toBeLessThan(
- (await batch.boundingBox())!.y,
+ (await publish.boundingBox())!.y,
);
+ // "Build all sites" and the batch panel are gone.
+ await expect(page.getByRole("heading", { name: "Build all sites" })).toHaveCount(0);
+ await expect(page.getByRole("group", { name: "Build specific sites" })).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Build & deploy", exact: true }),
).toHaveCount(0);
@@ -35,7 +40,7 @@ test("publishing splits: the family page releases and batches, the site page bui
page.getByRole("heading", { name: "Build & deploy", exact: true }),
).toBeVisible();
await expect(
- page.getByRole("heading", { name: "Build all sites" }),
+ page.getByRole("heading", { name: "Publish", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Release notes" }),
@@ -51,16 +56,12 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje
// With a Cloudflare project → enabled.
await page.goto("/sites/with-proj/publish");
await expect(
- // exact: true — kept from the /deploy days, where "Build & deploy all
- // sites" sat below; harmless here.
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeEnabled();
// Without one → disabled, with an explanatory notice.
await page.goto("/sites/no-proj/publish");
await expect(
- // exact: true — kept from the /deploy days, where "Build & deploy all
- // sites" sat below; harmless here.
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeDisabled();
await expect(
@@ -68,46 +69,50 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje
).toBeVisible();
});
-// The Basic | Docker toggle was a label nothing read, and it is gone (release
-// 11, follow-up O6c): Build all uses containers whenever an engine answers.
-test("/sites has no build-mode toggle, and Build all says when it uses containers", async ({
+test("/sites has no build-mode toggle: the Publish panel has Publish now and Build all stale", async ({
page,
}) => {
await writeSite("testsite", { cloudflareProject: "proj" });
await page.goto("/sites");
- await expect(page.getByRole("heading", { name: "Build all sites" })).toBeVisible();
await expect(page.getByRole("group", { name: "Build mode" })).toHaveCount(0);
- await expect(
- page.getByText(/When a container engine answers: the shared data phase runs once/i),
- ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Publish now", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Build all stale", exact: true })).toBeVisible();
+ // The plan Publish now would run is shown before it is pressed: with no
+ // index stamp yet, the index update comes first.
+ await expect(page.getByTestId("publish-plan")).toContainText("update-index _index");
});
-test("batch panel: selecting sites enables the launch button and reflects deploy intent", async ({
+test("a row per site: its chips, and the deploy buttons only where it can deploy", async ({
page,
}) => {
- // Deliberately does NOT click the launch button — that would spawn a real
- // `pnpm run build`, which the e2e suite never does (cf. build.spec.ts only
- // runs the lightweight Build index job). We verify the panel's UI wiring.
await writeSite("alpha", { siteTitle: "Alpha", cloudflareProject: "a" });
- await writeSite("beta", { siteTitle: "Beta", cloudflareProject: "b" });
+ await writeSite("beta", { siteTitle: "Beta" });
+ await writeSite("mine", { siteTitle: "Mine", audience: "private" });
await page.goto("/sites");
- const launch = page.getByRole("button", { name: "Build selected" });
- await expect(launch).toBeDisabled();
+ const alpha = row(page, "alpha");
+ await expect(alpha.getByRole("button", { name: "Build", exact: true })).toBeVisible();
+ await expect(alpha.getByRole("button", { name: "Deploy preview", exact: true })).toBeVisible();
+ await expect(alpha.getByRole("button", { name: "Deploy production", exact: true })).toBeVisible();
+ // No index yet: every row says so on its index chip, and its build waits for it.
+ await expect(alpha.locator('[data-chip="index"]')).toContainText("no index yet");
+ await expect(alpha.locator('[data-chip="built"]')).toContainText("update the index first");
+ await expect(alpha.locator('[data-chip="deployed"]')).toBeVisible();
+ await expect(alpha.locator('[data-chip="live"]')).toBeVisible();
- await page.getByLabel("Select Alpha").check();
- await page.getByLabel("Select Beta").check();
- await expect(launch).toBeEnabled();
- await expect(page.getByText("2 selected")).toBeVisible();
+ // No project: built, never deployed — and it says why.
+ const beta = row(page, "beta");
+ await expect(beta.getByRole("button", { name: "Build", exact: true })).toBeVisible();
+ await expect(beta.getByRole("button", { name: "Deploy production", exact: true })).toHaveCount(0);
+ await expect(beta.getByText(/No Cloudflare Pages project/)).toBeVisible();
- // Opting into deploy renames the launch button. Scoped to the batch panel:
- // BuildAllSitesButton has an identically-labelled checkbox in the same
- // section, so an unscoped getByLabel resolves to two elements.
- await batchPanel(page).getByLabel("Deploy after build").check();
- await expect(
- page.getByRole("button", { name: "Build & deploy selected" }),
- ).toBeVisible();
+ // Private: never deployed.
+ const mine = row(page, "mine");
+ await expect(mine.getByRole("button", { name: "Deploy production", exact: true })).toHaveCount(0);
+ await expect(mine.getByText(/is private \(audience: private\)/)).toBeVisible();
- // The panel notes that the batch runs serially, whatever the mode.
- await expect(page.getByText(/These run one at a time/i)).toBeVisible();
+ // A preview name is judged as you type, with the server's sentence.
+ await alpha.getByRole("textbox", { name: "preview branch", exact: true }).fill("main");
+ await expect(alpha.getByRole("status", { name: "preview problem" })).toContainText(/production branch/);
+ await expect(alpha.getByRole("button", { name: "Deploy preview", exact: true })).toBeDisabled();
});
diff --git a/editor/e2e/duplicate-shorts.spec.ts b/editor/e2e/duplicate-shorts.spec.ts
@@ -180,14 +180,10 @@ async function seed(seeds: Seed[] = SEEDS): Promise<void> {
}
}
+// The index AND the stats: one stage since release 18 ("Build stats dataset"
+// is gone — the index update builds the stats datasets too).
async function buildData(page: import("@playwright/test").Page): Promise<void> {
await buildIndex(page);
- // Same page, disclosure still open.
- await page.getByRole("button", { name: "Build stats dataset" }).click();
- await expect(page.getByLabel("Build stats dataset output")).toContainText(
- "Stats built",
- { timeout: 30_000 },
- );
}
function clusterWith(report: DuplicateReport, slug: string): DuplicateCluster | undefined {
diff --git a/editor/e2e/exportOutLink.ts b/editor/e2e/exportOutLink.ts
@@ -0,0 +1,25 @@
+// export/out AFTER A RUN (release 18 S4). A site's or the hub's build stage
+// points the checkout's `export/out` at the bundle it built — in this suite a
+// bundle under `editor/test-transcripts/.export-builds/<id>/out`, which the
+// next resetData deletes. A link left dangling there makes a bare `next build`
+// of the export app fail on `stat export/out` (ENOENT) — the export build gate,
+// e2e:2origin. So the suite removes the link it made, at teardown and again at
+// setup (a killed run never tears down). Only a link INTO test-transcripts is
+// touched: a real directory, or a link to a real bundle, is the checkout's.
+
+import { lstat, readlink, rm } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const editorDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const exportOut = path.resolve(editorDir, "..", "export", "out");
+const testTranscripts = path.join(editorDir, "test-transcripts");
+
+export async function dropTestExportOutLink(when: "setup" | "teardown"): Promise<void> {
+ const st = await lstat(exportOut).catch(() => null);
+ if (!st?.isSymbolicLink()) return;
+ const target = path.resolve(path.dirname(exportOut), await readlink(exportOut));
+ if (target !== testTranscripts && !target.startsWith(`${testTranscripts}${path.sep}`)) return;
+ await rm(exportOut, { force: true });
+ console.log(`[e2e ${when}] removed export/out → ${path.relative(editorDir, target)} (a test bundle)`);
+}
diff --git a/editor/e2e/fixtures/bin/fake-next.mjs b/editor/e2e/fixtures/bin/fake-next.mjs
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+// E2E fake `next` for the export app's build (release 18 S4). A site's and the
+// hub's build stage run compose for real, then `<EXPORT_NEXT_BIN> build` in
+// export/ in place of `pnpm exec next build` (common/publish/build.ts) — which
+// in the editor's e2e would rebuild the export app beside its running dev
+// server, for a minute, to prove nothing about publishing.
+//
+// What a real `next build` of a static export leaves that the publish stages
+// read is `export/out`: the composed `public/` copied in (site.json and
+// corpus.json name the site — builtBundleProblem; the hub's tombstones —
+// builtHubProblem) and the rendered pages. This writes exactly that: the
+// composed public dir (EXPORT_PUBLIC_DIR, which the build step sets) copied to
+// ./out, and an index.html. Nothing is rendered.
+import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { installFixtureWatchdog } from "./_watchdog.mjs";
+
+installFixtureWatchdog();
+
+const argv = process.argv.slice(2);
+if (argv[0] === "--version" || argv[0] === "-v") {
+ process.stdout.write("Next.js v16.0.0 (fake)\n");
+ process.exit(0);
+}
+if (argv[0] !== "build") {
+ process.stderr.write(`[fake-next] unsupported invocation: ${argv.join(" ")}\n`);
+ process.exit(2);
+}
+
+const publicDir = process.env.EXPORT_PUBLIC_DIR?.trim() || path.resolve("public");
+if (!existsSync(publicDir)) {
+ process.stderr.write(`[fake-next] no public dir at ${publicDir}\n`);
+ process.exit(1);
+}
+const out = path.resolve("out");
+rmSync(out, { recursive: true, force: true });
+mkdirSync(out, { recursive: true });
+cpSync(publicDir, out, { recursive: true, dereference: true });
+const mode = process.env.INSTANCE_MODE === "hub" ? "hub" : "site";
+writeFileSync(
+ path.join(out, "index.html"),
+ `<!doctype html><html><head><title>fake ${mode} build</title></head><body>${mode}</body></html>\n`,
+);
+process.stdout.write(" ▲ Next.js 16 (fake)\n");
+process.stdout.write(` ✓ Exported ${mode} from ${publicDir} to ${out}\n`);
diff --git a/editor/e2e/fixtures/bin/fake-wrangler.mjs b/editor/e2e/fixtures/bin/fake-wrangler.mjs
@@ -13,11 +13,15 @@
// what was "deployed", from where, to which branch;
// - prints wrangler 4's success lines, ending on "Take a peek over at
// https://<branch>.<project>.pages.dev" — the line deploymentUrlIn reads;
-// - with E2E_FAKE_WRANGLER_AUTH_FAIL=1, prints wrangler's own refusal for a
-// token Cloudflare does not accept ("Authentication error [code: 10000]")
-// and exits 1, without writing the sidecar — the deploy stage must turn
-// that into "[deploy] REFUSED by Cloudflare — the API token was not
-// accepted" and leave deployed.json untouched;
+// - in AUTH-FAIL mode, prints wrangler's own refusal for a token Cloudflare
+// does not accept ("Authentication error [code: 10000]") and exits 1,
+// without writing the sidecar — the deploy stage must turn that into
+// "[deploy] REFUSED by Cloudflare — the API token was not accepted" and
+// leave deployed.json untouched. The mode is a MODE SIDECAR a spec writes
+// and removes, `<exportBuildsDir>/.fake-wrangler-mode.json` `{"authFail":
+// true}` (the editor's actions pass the stage no env of a spec's, so an
+// env var cannot reach one test; fake-ytdlp.mjs's sidecar pattern), or
+// E2E_FAKE_WRANGLER_AUTH_FAIL=1 as the fallback;
// - answers `--version` like the pinned binary.
//
// A missing <outDir> fails as wrangler does. Anything else is a usage error.
@@ -51,7 +55,27 @@ const branch = opt("--branch") ?? "main";
process.stdout.write(`\n ⛅️ wrangler ${VERSION} (fake)\n───────────────────\n`);
-if (process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") {
+// The mode sidecar: in EXPORT_BUILDS_DIR when set, else beside the editor's
+// export-public dir (paths.ts exportBuildsDir), else two levels above the
+// bundle (<builds>/<target>/out).
+function modeSidecar() {
+ const dirs = [
+ process.env.EXPORT_BUILDS_DIR,
+ process.env.EXPORT_PUBLIC_DIR && path.join(path.dirname(process.env.EXPORT_PUBLIC_DIR), ".export-builds"),
+ path.dirname(path.dirname(outDir)),
+ ].filter(Boolean);
+ for (const d of dirs) {
+ try {
+ return JSON.parse(readFileSync(path.join(d, ".fake-wrangler-mode.json"), "utf8"));
+ } catch {
+ // absent or unreadable: the next place
+ }
+ }
+ return {};
+}
+const mode = modeSidecar();
+
+if (mode.authFail === true || process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") {
process.stderr.write(
`\n✘ [ERROR] A request to the Cloudflare API (/accounts/0000/pages/projects/${project ?? "?"}) failed.\n\n` +
" Authentication error [code: 10000]\n\n" +
diff --git a/editor/e2e/globalSetup.ts b/editor/e2e/globalSetup.ts
@@ -10,8 +10,10 @@
// So this cannot clear a stale dev server ahead of the new one booting — it only
// reaps fixture children, which is what actually eats the CPU.
+import { dropTestExportOutLink } from "./exportOutLink";
import { sweepFixtureProcesses } from "./fixtureProcs";
export default async function globalSetup() {
await sweepFixtureProcesses("setup");
+ await dropTestExportOutLink("setup");
}
diff --git a/editor/e2e/globalTeardown.ts b/editor/e2e/globalTeardown.ts
@@ -10,8 +10,10 @@
//
// Anything reaped here is logged as a regression, not as routine cleanup.
+import { dropTestExportOutLink } from "./exportOutLink";
import { sweepFixtureProcesses } from "./fixtureProcs";
export default async function globalTeardown() {
await sweepFixtureProcesses("teardown");
+ await dropTestExportOutLink("teardown");
}
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -154,6 +154,7 @@ export async function writeSite(
...(site.siteUrl ? { siteUrl: site.siteUrl } : {}),
...(site.listed === false ? { listed: false } : {}),
...(site.search === false ? { search: false } : {}),
+ ...(site.audience !== undefined ? { audience: site.audience } : {}),
// The legacy report-only key, written as given (siteSchema migrates it).
...(site.publish !== undefined ? { publish: site.publish } : {}),
...(site.relatedSites ? { relatedSites: site.relatedSites } : {}),
@@ -451,17 +452,23 @@ export async function writeChannelConfig(
);
}
-// Run the corpus-wide Build index job from the family page, the way a user
-// does: open the Pool disclosure, press the button, wait for "Done". The
-// controls sit under <details> so /sites does not open on eight job consoles,
+// Run the corpus-wide Build index from the family page, the way a user does:
+// open the Pool disclosure, press the button, wait for the stage to end. The
+// controls sit under <details> so /sites does not open on six job consoles,
// and getByRole ignores what a closed disclosure hides — hence the click first.
-// Replaces the six-line copy three specs carried inline. goto() resets the
-// disclosure, so calling this twice in one test is fine.
-export async function buildIndex(page: Page, timeout = 30_000) {
+// goto() resets the disclosure, so calling this twice in one test is fine.
+//
+// Build index is the `update-index` publish stage since release 18: the LMDB
+// index, the stats datasets and the chart templates in one child process, so
+// the wait is for the STAGE's last line (`[stage] update-index _index: Done`),
+// not for the index build's own "Done in …" halfway through it — a spec reads
+// the stats right after.
+export async function buildIndex(page: Page, timeout = 60_000) {
await page.goto("/sites");
await page.getByText("Pool jobs", { exact: true }).click();
await page.getByRole("button", { name: "Build index" }).click();
- await expect(page.getByLabel("Build index output")).toContainText("Done", {
- timeout,
- });
+ await expect(page.getByLabel("Build index output")).toContainText(
+ /\[stage\] update-index _index: Done/,
+ { timeout },
+ );
}
diff --git a/editor/e2e/jobs.spec.ts b/editor/e2e/jobs.spec.ts
@@ -14,7 +14,9 @@ test("empty state when no jobs have run", async ({ page }) => {
await expect(page.getByText("No jobs have run yet.")).toBeVisible();
});
-test("kicks off a build-index job, lists it, and tails its log", async ({
+// Build index is the update-index publish stage (release 18): /jobs labels it
+// "Update the index" and reads it as one run, `run <id> · _index`.
+test("kicks off the index update, lists it, and tails its log", async ({
page,
}) => {
await resetData("one-youtube-channel-with-data");
@@ -23,14 +25,16 @@ test("kicks off a build-index job, lists it, and tails its log", async ({
await page.goto("/jobs");
const buildRow = page
.getByRole("row")
- .filter({ hasText: "build-index" })
+ .filter({ hasText: "Update the index" })
.first();
await expect(buildRow).toBeVisible();
// Click into the most recent job. The job-id link is the first link in the row.
await buildRow.getByRole("link").first().click();
+ // 30 s: under next dev the job page compiles on its first visit, and in a
+ // long run the index stage's child is still winding down beside it.
await expect(page.getByLabel("Job log")).toContainText("Done", {
- timeout: 10_000,
+ timeout: 30_000,
});
const status = await page.getByLabel("Job status").textContent();
expect(["done", "running"]).toContain((status ?? "").trim());
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -52,6 +52,7 @@ type OpsResponse = {
skipped?: { slug?: string; siteId?: string; reason: string }[];
jobs?: { siteId: string; jobId: string; previewUrl?: string }[];
previewUrl?: string;
+ runId?: string;
};
async function ops(
@@ -1130,22 +1131,16 @@ async function listJobIds(): Promise<string[]> {
.sort();
}
-// Hold the `build` AND `deploy` queues with fabricated running jobs
-// (/api/test/stuck-job; never released by the caller), so any build or deploy
-// job that a refusal under test failed to stop can only QUEUE, never run. BOTH
-// queues, because a regression can turn a refused call into a job on either
-// one: a dropped "preview needs deploy" guard makes a build, a dropped preview
-// check makes a deploy. The fabricated holders write no .meta.json, so they are
-// invisible to listJobIds; take `before` after this. The next resetData
-// cancels jobs newest first, so a queued job is removed before a holder's slot
-// is freed and nothing is ever promoted.
-async function holdBuildAndDeployQueues(request: APIRequestContext) {
- for (const queue of ["build", "deploy"]) {
- const hold = await request.get(
- `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`,
- );
- expect(hold.ok(), queue).toBe(true);
- }
+// Hold the `publish` queue with a fabricated running job (/api/test/stuck-job;
+// never released by the caller), so any build or deploy stage that a refusal
+// under test failed to stop can only QUEUE, never run — every build and deploy
+// is a publish stage on that one queue since release 18. The fabricated holder
+// writes no .meta.json, so it is invisible to listJobIds; take `before` after
+// this. The next resetData cancels jobs newest first, so a queued stage is
+// removed before the holder's slot is freed and nothing is ever promoted.
+async function holdPublishQueue(request: APIRequestContext) {
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
}
test("build-site and build-deploy each take siteId or siteIds, and refuse both or neither", async ({
@@ -1187,12 +1182,11 @@ test("build-site and build-deploy each take siteId or siteIds, and refuse both o
// --- preview deploys ----------------------------------------------------------
//
-// NOTHING HERE STARTS A DEPLOY JOB, and that is deliberate. Every external
-// binary this suite touches is a fake in e2e/fixtures/bin (yt-dlp, whisper,
-// ffmpeg, …); wrangler has no fake, because until previews existed no spec had
-// any reason to reach a deploy. So these assert the half that is decided
-// BEFORE a job exists — which is the whole of what the preview rules are — and
-// prove it by watching the job sidecars not appear.
+// NOTHING HERE STARTS A DEPLOY JOB. Wrangler has a fake since release 18
+// (fake-wrangler.mjs; publish.spec deploys through it), but the preview rules
+// are decided BEFORE a job exists — that is the whole of what they are — so
+// these assert that half, and prove it by watching the job sidecars not
+// appear.
test("deploy-site and build-deploy refuse a preview name that is not one, before any job", async ({
request,
}) => {
@@ -1264,41 +1258,34 @@ test("a valid preview is accepted and reaches the action, on both deploy routes"
}
});
-test("deploy-site refuses to ship a build of a DIFFERENT site", async ({
+test("deploy-site refuses a site that was never built, in the deploy stage's words, before any job", async ({
request,
}) => {
await resetData("title-filter-channel");
await settings();
await writeSite("previewsite", { cloudflareProject: "proj" });
- // export/out is ONE shared directory whichever site composed into it — the
- // basic build ignores the site id — so a deploy-only action used to ship
- // whatever was built last to whichever project was asked for. The bundle
- // names itself in its own site.json, and the action reads it before starting.
- //
- // Both refusals are asserted because both are correct depending on what is
- // on disk: a checkout that has never built has no export/out at all, and one
- // that has built holds some OTHER site (it is never "previewsite", which
- // exists only inside this fixture). Neither starts a job.
+ // A deploy ships the site's OWN bundle (export/.export-builds/<id>/out,
+ // stamped built.json) — never "whatever export/out holds", which is how a
+ // deploy-only used to ship one site's build to another's project. A site
+ // with no build is refused before any job, with the stage's sentence.
const before = await listJobIds();
const { status, body } = await ops(request, "deploy-site", {
siteId: "previewsite",
});
expect(status).toBe(400);
expect(body.error).toMatch(
- /export\/out holds (no built site|a build of ".*", not "previewsite")/,
+ /^no build of previewsite in .*\.export-builds\/previewsite — archilyzer publish build previewsite$/,
);
- expect(body.error).toContain("build previewsite first");
expect(await listJobIds()).toEqual(before);
- // A preview is refused for the same reason and just as early — it must not
- // cost a deploy to learn the bundle is somebody else's.
+ // A preview is refused for the same reason and just as early.
const preview = await ops(request, "deploy-site", {
siteId: "previewsite",
preview: "tags-exclude",
});
expect(preview.status).toBe(400);
- expect(preview.body.error).toContain("build previewsite first");
+ expect(preview.body.error).toContain("no build of previewsite");
expect(await listJobIds()).toEqual(before);
});
@@ -1345,7 +1332,7 @@ test("build-deploy refuses a preview alongside all rather than building everythi
expect(await listJobIds()).toEqual(before);
});
-test("build-site with a bare siteId starts one build-export job", async ({
+test("build-site with a bare siteId starts its build stage (the index first: there is none)", async ({
request,
}) => {
test.setTimeout(120_000);
@@ -1358,10 +1345,12 @@ test("build-site with a bare siteId starts one build-export job", async ({
});
expect(status).toBe(200);
expect(body.ok).toBe(true);
- // The response is the LIST shape whichever key was used — one job, named.
+ // The response is the LIST shape whichever key was used — one job per site,
+ // named: the site's build stage (the run's index update is not the site's).
expect(body.jobs?.length).toBe(1);
expect(body.jobs?.[0].siteId).toBe("buildsite");
expect(body.skipped).toEqual([]);
+ expect(typeof body.runId).toBe("string");
const jobId = body.jobs![0].jobId;
await expect
@@ -1371,7 +1360,7 @@ test("build-site with a bare siteId starts one build-export job", async ({
).catch(() => null);
return meta?.kind ?? null;
})
- .toBe("build-export");
+ .toBe("publish-build-site");
});
// The hub's deploy path (release 7). Every refusal here is answered BEFORE a
@@ -1384,7 +1373,7 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
}) => {
await resetData("title-filter-channel");
await settings();
- await holdBuildAndDeployQueues(request);
+ await holdPublishQueue(request);
const before = await listJobIds();
// The fixture has no homepage.json at all: no project.
@@ -1404,29 +1393,13 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
expect(homepages.status).toBe(400);
expect(homepages.body.error).toContain('"archilyzer", which is the Archilyzer homepage\'s');
- // A real project, but export/out holds no hub: never built here, or a
- // site's bundle (shared directory). `export/out` is the CHECKOUT's build dir
- // (resolveOutDir is not fixture-controlled), so in a checkout that holds a
- // hub build — e.g. after `e2e:2origin` built one — the refusal cannot fire,
- // and the call would start a REAL deploy of that hub. The sub-case is then
- // recorded as not exercisable here and never run.
+ // A real project, but no hub build: the hub's bundle is the FIXTURE's
+ // (test-transcripts/.export-builds/_hub, stamped built.json) since release
+ // 18, so this refusal fires in any checkout — export/out is not read.
await writeFile(hubFile, JSON.stringify({ cloudflareProject: "archilyzer-hub" }));
- const hubBuiltHere =
- (await pathExists("../export/out/hub-sites.json")) &&
- !(await pathExists("../export/out/site.json"));
- if (hubBuiltHere) {
- test.info().annotations.push({
- type: "subcase-not-exercisable",
- description:
- "export/out holds a hub build in this checkout, so the unbuilt refusal cannot fire (and the call would deploy it)",
- });
- } else {
- const unbuilt = await ops(request, "deploy-hub", { preview: "hub-check" });
- expect(unbuilt.status).toBe(400);
- expect(unbuilt.body.error).toMatch(
- /export\/out holds (no hub build|a build of ".*", not the hub) — build the hub first/,
- );
- }
+ const unbuilt = await ops(request, "deploy-hub", { preview: "hub-check" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toMatch(/^no build of _hub in .*\.export-builds\/_hub — archilyzer publish hub$/);
// build-hub: a preview without a deploy is a mistake, not a build.
const previewOnly = await ops(request, "build-hub", { preview: "hub-check" });
@@ -1439,25 +1412,24 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i
// The homepage's deploy path (release 11, slice O4). Every refusal is answered
// BEFORE a job exists, like deploy-hub's.
//
-// NOTHING HERE CAN REACH WRANGLER, and three separate things make sure of it:
-// 1. `homepage/out` is the CHECKOUT's (homepageOutDir = <repo>/homepage/out,
-// not fixture-controlled — the primary checkout holds the live homepage's
-// build), so the missing-build call is made only when this checkout has no
-// `homepage/out/index.html`, as deploy-hub's is (211d4666);
-// 2. that call asks for a PREVIEW, never production;
-// 3. the whole test runs with BOTH the `build` and the `deploy` queue held
-// by fabricated jobs (holdBuildAndDeployQueues). A build or deploy job
-// that a refusal failed to stop — `build-homepage {preview}` is a build
-// if the "preview needs deploy" guard regresses — would only QUEUE; the
-// listJobIds assertion then fails, and the next resetData cancels jobs
-// newest first, so the queued job is removed before a holder's slot is
-// freed.
+// NOTHING HERE CAN REACH WRANGLER (the fake's, even), and three things make
+// sure of it:
+// 1. "built" is the homepage's stamp, `_homepage/built.json` in the fixture's
+// .export-builds (release 18) — the checkout's homepage/out is not asked
+// until a stamp says it was built, and the fixture has none;
+// 2. the missing-build call asks for a PREVIEW, never production;
+// 3. the whole test runs with the `publish` queue held by a fabricated job
+// (holdPublishQueue). A stage that a refusal failed to stop —
+// `build-homepage {preview}` is a build if the "preview needs deploy"
+// guard regresses — would only QUEUE; the listJobIds assertion then fails,
+// and the next resetData cancels jobs newest first, so the queued stage is
+// removed before the holder's slot is freed.
test("deploy-homepage refuses a bad preview name and a missing build; build-homepage refuses a preview without a deploy", async ({
request,
}) => {
await resetData("empty");
await settings();
- await holdBuildAndDeployQueues(request);
+ await holdPublishQueue(request);
const before = await listJobIds();
// A bad preview name is judged before homepage/out is even looked at, so
@@ -1483,20 +1455,132 @@ test("deploy-homepage refuses a bad preview name and a missing build; build-home
expect(previewOnly.status).toBe(400);
expect(previewOnly.body.error).toContain('"preview" needs "deploy": true');
- // No build in homepage/out.
- if (await pathExists("../homepage/out/index.html")) {
- test.info().annotations.push({
- type: "subcase-not-exercisable",
- description:
- "homepage/out holds a build in this checkout, so the unbuilt refusal cannot fire (and the call would deploy it)",
- });
- } else {
- const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" });
- expect(unbuilt.status).toBe(400);
- expect(unbuilt.body.error).toBe(
- "homepage/out holds no build — build the homepage first",
- );
- }
+ // No build: the homepage's stamp is the fixture's.
+ const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toMatch(
+ /^no build of _homepage in .*\.export-builds\/_homepage — archilyzer publish homepage$/,
+ );
expect(await listJobIds()).toEqual(before);
});
+
+// --- publish (release 18) ---------------------------------------------------
+//
+// ONE route for the stages, a verb in the body, and its GET the status. The
+// publish queue is held throughout (holdPublishQueue): every stage the calls
+// enqueue only QUEUES, so what is asserted is the run each verb enqueues — its
+// kinds, its one run id, a duplicate answered with the existing job — and the
+// refusals before any job. publish.spec runs the stages.
+type PublishJobs = {
+ ok?: boolean;
+ error?: string;
+ runId?: string;
+ jobId?: string;
+ jobs?: { target: string; kind: string; jobId: string; existing?: boolean; previewUrl?: string }[];
+ skipped?: { kind: string; target: string; reason: string }[];
+ refused?: { target: string; error: string }[];
+};
+
+test("publish: each verb enqueues its run of stages on the publish queue; a duplicate stage is the existing job", async ({
+ request,
+}) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("pubsite", { cloudflareProject: "pubproj" });
+ await holdPublishQueue(request);
+
+ const index = (await ops(request, "publish", { verb: "index" })).body as PublishJobs;
+ expect(index.ok).toBe(true);
+ expect(index.jobs?.map((j) => [j.kind, j.target])).toEqual([["update-index", "_index"]]);
+ expect(index.jobId).toBe(index.jobs?.[0].jobId);
+ const meta = await readJson<{ kind: string; queueKey: string; status: string }>(
+ `test-transcripts/.jobs/${index.jobId}.meta.json`,
+ );
+ expect([meta.kind, meta.queueKey, meta.status]).toEqual(["publish-update-index", "publish", "queued"]);
+
+ // Publish now, every policy off: the index update alone — already queued,
+ // so it is the same job, not a second one.
+ const now = (await ops(request, "publish", { verb: "now" })).body as PublishJobs;
+ expect(now.ok).toBe(true);
+ expect(now.jobs?.map((j) => [j.kind, j.target, j.jobId, j.existing === true])).toEqual([
+ ["update-index", "_index", index.jobId, true],
+ ]);
+ expect(now.skipped?.some((x) => x.target === "pubsite")).toBe(true);
+
+ // Build: the site's build after the (queued) index update, one run.
+ const build = (await ops(request, "publish", { verb: "build", siteIds: ["pubsite"] })).body as PublishJobs;
+ expect(build.ok).toBe(true);
+ expect(build.jobs?.map((j) => [j.kind, j.target])).toEqual([
+ ["update-index", "_index"],
+ ["build-site", "pubsite"],
+ ]);
+ expect(build.jobs?.[0].existing).toBe(true);
+
+ // Build all stale: the same build, queued already.
+ const stale = (await ops(request, "publish", { verb: "stale" })).body as PublishJobs;
+ expect(stale.ok).toBe(true);
+ expect(stale.jobs?.find((j) => j.kind === "build-site")?.existing).toBe(true);
+});
+
+test("publish: refusals before any job — a never-built deploy, a preview that is not one, a key the verb does not take", async ({
+ request,
+}) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("pubsite", { cloudflareProject: "pubproj" });
+ await holdPublishQueue(request);
+ const before = await listJobIds();
+
+ const unbuilt = await ops(request, "publish", { verb: "deploy", siteId: "pubsite", preview: "r18" });
+ expect(unbuilt.status).toBe(400);
+ expect(unbuilt.body.error).toMatch(/^no build of pubsite in .* — archilyzer publish build pubsite$/);
+
+ const main = await ops(request, "publish", { verb: "deploy", siteId: "pubsite", preview: "main" });
+ expect(main.status).toBe(400);
+ expect(main.body.error).toContain("production branch");
+
+ const verb = await ops(request, "publish", { verb: "launch" });
+ expect(verb.status).toBe(400);
+ expect(verb.body.error).toContain('"verb" must be one of index, build, deploy, hub, homepage, now, stale');
+
+ const extra = await ops(request, "publish", { verb: "index", siteId: "pubsite" });
+ expect(extra.status).toBe(400);
+ expect(extra.body.error).toContain('verb "index" takes no other keys');
+
+ const hubPreview = await ops(request, "publish", { verb: "hub", preview: "r18" });
+ expect(hubPreview.status).toBe(400);
+ expect(hubPreview.body.error).toContain('need "deploy": true');
+
+ expect(await listJobIds()).toEqual(before);
+});
+
+test("GET publish is the publish status: the index, the lane, a row per target", async ({ request }) => {
+ await resetData("title-filter-channel");
+ await settings();
+ await writeSite("pubsite", { cloudflareProject: "pubproj" });
+
+ const res = await request.get(`${baseUrl}/api/ops/publish`, {
+ headers: { authorization: "Bearer test-worker-token" },
+ });
+ expect(res.status()).toBe(200);
+ const status = (await res.json()) as {
+ ok: boolean;
+ index: { chip: { text: string } };
+ lane: { held: boolean; enabled: boolean };
+ sites: { target: string; chips: { built: { text: string } } }[];
+ hub: { target: string };
+ homepage: { target: string };
+ plan: { steps: { kind: string }[] };
+ };
+ expect(status.ok).toBe(true);
+ expect(status.index.chip.text).toBe("no index yet");
+ expect(status.lane).toMatchObject({ held: false, enabled: false });
+ expect(status.sites.map((x) => x.target)).toEqual(["pubsite"]);
+ expect(status.sites[0].chips.built.text).toBe("update the index first");
+ expect([status.hub.target, status.homepage.target]).toEqual(["_hub", "_homepage"]);
+ expect(status.plan.steps.map((x) => x.kind)).toEqual(["update-index"]);
+
+ const anon = await request.get(`${baseUrl}/api/ops/publish`);
+ expect(anon.status()).toBe(401);
+});
diff --git a/editor/e2e/publish-lane.spec.ts b/editor/e2e/publish-lane.spec.ts
@@ -0,0 +1,134 @@
+// /operations/publish — the publish lane (release 18 S4): its settings form,
+// the runner's Start / Drain / Stop, and the hold, which stops the lane
+// DISPATCHING between stages and never kills the stage in flight.
+//
+// The lane's stages really run (publish.spec's header says how: a real index
+// update, compose + the fake `next`, the fake wrangler). Settings are written
+// through writeSettings (merged over the fixture, never wholesale — the disk
+// floor trap).
+
+import { readdir } from "node:fs/promises";
+import { test, expect, type Page } from "@playwright/test";
+import { baseUrl } from "./baseUrl";
+import { readJson, resetData, resolvePath, writeSettings, writeSite } from "./helpers";
+
+type Meta = { id: string; kind: string; status: string; spec?: { params?: { runId?: string } } };
+
+async function metasOf(kind: string): Promise<Meta[]> {
+ const files = (await readdir(resolvePath("test-transcripts/.jobs")).catch(() => [])).filter((f) =>
+ f.endsWith(".meta.json"),
+ );
+ const out: Meta[] = [];
+ for (const f of files) {
+ const m = await readJson<Meta>(`test-transcripts/.jobs/${f}`).catch(() => null);
+ if (m?.kind === kind) out.push(m);
+ }
+ return out;
+}
+
+async function openLane(page: Page) {
+ await page.goto("/operations/publish");
+ await expect(page.locator("[data-hydrated=true]").first()).toBeVisible({ timeout: 15_000 });
+}
+
+async function publishStatus(request: import("@playwright/test").APIRequestContext) {
+ const res = await request.get(`${baseUrl}/api/ops/publish`, {
+ headers: { authorization: "Bearer test-worker-token" },
+ });
+ expect(res.status()).toBe(200);
+ return (await res.json()) as { lane: { held: boolean; enabled: boolean; live: { running: boolean } } };
+}
+
+test.beforeEach(async () => {
+ await resetData("one-youtube-channel-with-data");
+ await writeSite("lanesite", {
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ publish: { auto: "build" },
+ });
+});
+
+test("the settings form saves settings.publish, and Start says why when the lane is off", async ({ page }) => {
+ await writeSettings({ minFreeDiskGB: 0, buildArchives: false });
+ await openLane(page);
+ await expect(page.getByRole("heading", { name: "Publish lane" })).toBeVisible();
+ await expect(page.getByText("Runner stopped")).toBeVisible();
+
+ // Off: Start is refused, in the runner's words.
+ await page.getByRole("button", { name: "Start publish lane" }).click();
+ // Filtered: Next's route announcer is an alert too.
+ await expect(page.getByRole("alert").filter({ hasText: "The publish lane is off" })).toBeVisible();
+
+ const form = page.locator('form[data-settings-block="publish"]');
+ await form.getByLabel("Check every (minutes)").fill("5");
+ await form.getByLabel("Preview branch").fill("lane-prev");
+ await form.locator('select[name="publishHub"]').selectOption("build");
+ await form.getByRole("button", { name: "Save publish settings" }).click();
+ await expect(form.getByRole("status")).toHaveText("Saved.");
+ await expect(async () => {
+ const s = await readJson<{ publish?: Record<string, unknown> }>("test-settings.json");
+ expect(s.publish).toMatchObject({ checkEveryMinutes: 5, previewBranch: "lane-prev", hub: "build", enabled: false });
+ }).toPass({ timeout: 10_000 });
+
+ // A preview name the deploy buttons refuse is refused here too.
+ await form.getByLabel("Preview branch").fill("main");
+ await form.getByRole("button", { name: "Save publish settings" }).click();
+ await expect(form.getByRole("alert")).toContainText("production branch");
+});
+
+test("enabled and started, the lane updates the index; a hold mid-stage stops the next dispatch, never the stage; Drain stops it", async ({
+ page,
+ request,
+}) => {
+ test.setTimeout(240_000);
+ await writeSettings({
+ minFreeDiskGB: 0,
+ buildArchives: false,
+ publish: { enabled: true, checkEveryMinutes: 1, refreshEveryMinutes: 0 },
+ });
+ await openLane(page);
+ // Hold the publish queue for a while, so the lane's first stage (the index
+ // update: there is no stamp) queues behind the holder and is IN FLIGHT from
+ // the lane's view while the hold is thrown. The holder lets go by itself —
+ // its clock starts here, after the page has compiled and hydrated, and runs
+ // long enough for the dispatch poll and the click below.
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish&releaseAfterMs=45000`);
+ expect(hold.ok()).toBe(true);
+ await page.getByRole("button", { name: "Start publish lane" }).click();
+ await expect(page.getByText("Runner running")).toBeVisible({ timeout: 15_000 });
+
+ // The pass dispatches the index update, which waits behind the holder.
+ await expect.poll(async () => (await metasOf("publish-update-index")).length, { timeout: 30_000 }).toBe(1);
+ const [index] = await metasOf("publish-update-index");
+ expect(index.spec?.params?.runId).toMatch(/^lane-/);
+
+ // Hold the lane while that stage is in flight.
+ await page.getByRole("button", { name: "pause publishing" }).click();
+ await expect(page.getByRole("button", { name: "resume publishing" })).toBeVisible();
+ expect((await publishStatus(request)).lane.held).toBe(true);
+ const settings = await readJson<{ publish?: { held?: boolean } }>("test-settings.json");
+ expect(settings.publish?.held).toBe(true);
+
+ // The stage is not killed: it runs once the holder lets go, and ends done.
+ await expect
+ .poll(async () => (await metasOf("publish-update-index"))[0]?.status, { timeout: 120_000 })
+ .toBe("done");
+ // …and nothing is dispatched after it: the site's policy is "build" and it
+ // was never built, but the lane is held.
+ await expect
+ .poll(async () => {
+ await page.reload();
+ return (await page.getByTestId("publish-lane-facts").textContent()) ?? "";
+ }, { timeout: 30_000 })
+ .toContain("no next stage dispatched");
+ expect(await metasOf("publish-build-site")).toEqual([]);
+
+ // Resume, then Drain: the runner finishes what it is doing and stops.
+ await page.getByRole("button", { name: "resume publishing" }).click();
+ await expect(page.getByRole("button", { name: "pause publishing" })).toBeVisible();
+ expect((await publishStatus(request)).lane.held).toBe(false);
+ await page.getByRole("button", { name: "Drain publish lane" }).click();
+ await expect(async () => {
+ await page.reload();
+ await expect(page.getByText("Runner stopped")).toBeVisible({ timeout: 2_000 });
+ }).toPass({ timeout: 120_000 });
+});
diff --git a/editor/e2e/publish.spec.ts b/editor/e2e/publish.spec.ts
@@ -0,0 +1,305 @@
+// Publishing as stages, end to end through the editor (release 18 S4): the
+// /sites Publish panel's rows, the Pool's Build index, a site's Publish tab,
+// and the site form's publish policy.
+//
+// EVERY STAGE HERE REALLY RUNS, as the child process the editor spawns
+// (`archilyzer stage …` on the publish queue), against the fixture corpus:
+// - the index update is the real one (LMDB index, stats, chart templates);
+// - a site's build runs compose for real, then EXPORT_NEXT_BIN — the fake
+// `next` (e2e/fixtures/bin/fake-next.mjs), which copies the composed public
+// dir to export/out — and installs the bundle under
+// test-transcripts/.export-builds/<id>/out;
+// - a deploy runs the fake wrangler (WRANGLER_BIN), which deploys nothing and
+// records its argv in `.fake-wrangler.json` beside the bundle; the live
+// check is skipped (E2E_LIVE_CHECK=skip). ARCHILYZER_BRANCH=main is what
+// lets production ship a build made in a worktree.
+// Nothing reaches Cloudflare, and nothing is built into the checkout but the
+// worktree's export/out — a link, re-pointed by every build.
+
+import { lstat, readdir, readlink, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { test, expect, type Page } from "@playwright/test";
+import { baseUrl } from "./baseUrl";
+import {
+ buildIndex,
+ pathExists,
+ readJson,
+ resetData,
+ resolvePath,
+ writeSettings,
+ writeSite,
+} from "./helpers";
+
+const BUILDS = "test-transcripts/.export-builds";
+const SITE = "pubsite";
+const PROJECT = "pubproj";
+
+const row = (page: Page, id = SITE) => page.getByRole("group", { name: `Publish ${id}` });
+const chip = (page: Page, name: string, id = SITE) => row(page, id).locator(`[data-chip="${name}"]`);
+
+type Meta = { id: string; kind: string; queueKey?: string; status: string; spec?: { params?: { runId?: string } } };
+
+async function publishMetas(): Promise<Meta[]> {
+ const files = (await readdir(resolvePath("test-transcripts/.jobs")).catch(() => [])).filter((f) =>
+ f.endsWith(".meta.json"),
+ );
+ const out: Meta[] = [];
+ for (const f of files) {
+ const m = await readJson<Meta>(`test-transcripts/.jobs/${f}`).catch(() => null);
+ if (m?.kind.startsWith("publish-")) out.push(m);
+ }
+ return out.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+async function logOf(kind: string): Promise<string> {
+ const metas = (await publishMetas()).filter((m) => m.kind === kind);
+ const last = metas[metas.length - 1];
+ if (!last) return "";
+ const { readFile } = await import("node:fs/promises");
+ return readFile(resolvePath(`test-transcripts/.jobs/${last.id}.log`), "utf8").catch(() => "");
+}
+
+type Invocation = { argv: string[]; project: string; branch: string; outDir: string; files: number };
+
+async function wranglerCalls(id = SITE): Promise<Invocation[]> {
+ return (
+ (await readJson<{ invocations: Invocation[] }>(`${BUILDS}/${id}/.fake-wrangler.json`).catch(() => null))
+ ?.invocations ?? []
+ );
+}
+
+async function openSites(page: Page) {
+ await page.goto("/sites");
+ await expect(row(page)).toHaveAttribute("data-hydrated", "true", { timeout: 15_000 });
+}
+
+// A lane on a row ends: its chip says so.
+async function laneEnds(page: Page, word: "Done" | "Failed", timeout = 90_000) {
+ await expect(row(page).getByText(word, { exact: true })).toBeVisible({ timeout });
+}
+
+async function setup() {
+ await resetData("one-youtube-channel-with-data");
+ // No archive zips: nothing here is about them, and they are the slow part of
+ // a compose.
+ await writeSettings({ minFreeDiskGB: 0, buildArchives: false });
+ await writeSite(SITE, {
+ siteTitle: "Pub Site",
+ channels: [{ slug: "test-youtube", groupId: "default" }],
+ cloudflareProject: PROJECT,
+ });
+}
+
+test("the index: no stamp, then the Pool's Build index writes it and every row's index chip turns fresh", async ({
+ page,
+}) => {
+ await setup();
+ await openSites(page);
+ await expect(chip(page, "index")).toContainText("no index yet");
+ await expect(chip(page, "built")).toContainText("update the index first");
+ expect(await pathExists("test-transcripts/.export-index/stamp.json")).toBe(false);
+
+ await buildIndex(page);
+ const stamp = await readJson<{ v: number; stampId: string; sites: Record<string, unknown>; settingsSig?: string }>(
+ "test-transcripts/.export-index/stamp.json",
+ );
+ expect(stamp.v).toBe(1);
+ expect(Object.keys(stamp.sites)).toEqual([SITE]);
+ expect(typeof stamp.settingsSig).toBe("string");
+
+ await openSites(page);
+ await expect(chip(page, "index")).toContainText("fresh");
+ await expect(chip(page, "built")).toContainText("never built");
+});
+
+test("a row's Build, Deploy preview and Deploy production: the site's own bundle, the fake wrangler's --branch, deployed.json", async ({
+ page,
+}) => {
+ test.setTimeout(300_000);
+ await setup();
+ await buildIndex(page);
+ await openSites(page);
+
+ await test.step("Build: the bundle, its stamp, and export/out a link to it", async () => {
+ await row(page).getByRole("button", { name: "Build", exact: true }).click();
+ await laneEnds(page, "Done");
+ const built = await readJson<{ target: string; kind: string; branch: string | null; files: number }>(
+ `${BUILDS}/${SITE}/built.json`,
+ );
+ expect([built.target, built.kind, built.branch]).toEqual([SITE, "site", "main"]);
+ expect(built.files).toBeGreaterThan(0);
+ expect(await pathExists(`${BUILDS}/${SITE}/out/index.html`)).toBe(true);
+ expect(await readJson<{ siteId: string }>(`${BUILDS}/${SITE}/out/site.json`)).toMatchObject({ siteId: SITE });
+ const exportOut = resolvePath("../export/out");
+ expect((await lstat(exportOut)).isSymbolicLink()).toBe(true);
+ expect(path.resolve(path.dirname(exportOut), await readlink(exportOut))).toBe(
+ resolvePath(`${BUILDS}/${SITE}/out`),
+ );
+ await openSites(page);
+ await expect(chip(page, "built")).toContainText("built just now");
+ });
+
+ await test.step("Deploy preview: --branch <b> to the site's project, recorded under previews[b]", async () => {
+ await row(page).getByRole("textbox", { name: "preview branch", exact: true }).fill("r18e2e");
+ await row(page).getByRole("button", { name: "Deploy preview", exact: true }).click();
+ await laneEnds(page, "Done");
+ const calls = await wranglerCalls();
+ expect(calls).toHaveLength(1);
+ expect(calls[0].argv).toEqual(expect.arrayContaining(["--branch", "r18e2e", "--project-name", PROJECT]));
+ expect(calls[0].outDir).toBe(resolvePath(`${BUILDS}/${SITE}/out`));
+ const deployed = await readJson<{ previews: Record<string, { kind: string; url: string | null }> }>(
+ `${BUILDS}/${SITE}/deployed.json`,
+ );
+ expect(deployed.previews.r18e2e.kind).toBe("preview");
+ expect(await logOf("publish-deploy-site")).toContain("https://r18e2e.pubproj.pages.dev");
+ await openSites(page);
+ await expect(chip(page, "deployed")).toContainText("preview r18e2e");
+ });
+
+ await test.step("Deploy production: --branch main, recorded as production", async () => {
+ await row(page).getByRole("button", { name: "Deploy production", exact: true }).click();
+ await laneEnds(page, "Done");
+ const calls = await wranglerCalls();
+ expect(calls).toHaveLength(2);
+ expect(calls[1].argv).toEqual(expect.arrayContaining(["--branch", "main", "--project-name", PROJECT]));
+ const deployed = await readJson<{ production?: { kind: string } }>(`${BUILDS}/${SITE}/deployed.json`);
+ expect(deployed.production?.kind).toBe("production");
+ await openSites(page);
+ await expect(chip(page, "deployed")).toContainText("production");
+ });
+
+ await test.step("Cloudflare refusing the token: the deploy fails with the sentence, deployed.json untouched", async () => {
+ const before = await readJson(`${BUILDS}/${SITE}/deployed.json`);
+ // The fake's mode sidecar: the editor's actions pass the stage no env of a
+ // spec's, so the mode is a file the fake reads.
+ await writeFile(resolvePath(`${BUILDS}/.fake-wrangler-mode.json`), JSON.stringify({ authFail: true }));
+ try {
+ await row(page).getByRole("button", { name: "Deploy production", exact: true }).click();
+ await laneEnds(page, "Failed");
+ expect(await logOf("publish-deploy-site")).toContain(
+ "[deploy] REFUSED by Cloudflare — the API token was not accepted",
+ );
+ expect(await readJson(`${BUILDS}/${SITE}/deployed.json`)).toEqual(before);
+ const last = (await publishMetas()).filter((m) => m.kind === "publish-deploy-site").pop();
+ expect(last?.status).toBe("failed");
+ } finally {
+ await rm(resolvePath(`${BUILDS}/.fake-wrangler-mode.json`), { force: true });
+ }
+ });
+});
+
+test("Publish now with every policy off is the index update alone, one run on /jobs", async ({ page }) => {
+ test.setTimeout(120_000);
+ await setup();
+ await openSites(page);
+ await expect(page.getByTestId("publish-plan")).toContainText("update-index _index");
+ await page.getByRole("button", { name: "Publish now", exact: true }).click();
+ await expect(
+ page.getByRole("group", { name: "Publish all" }).getByText("Done", { exact: true }),
+ ).toBeVisible({ timeout: 90_000 });
+
+ const metas = await publishMetas();
+ expect(metas.map((m) => m.kind)).toEqual(["publish-update-index"]);
+ expect(metas[0].queueKey).toBe("publish");
+ const runId = metas[0].spec?.params?.runId ?? "";
+ expect(runId).toMatch(/^run-/);
+
+ await page.goto("/jobs");
+ await expect(
+ page.getByRole("row").filter({ hasText: "Update the index" }).filter({ hasText: `run ${runId.slice(-6)}` }),
+ ).toBeVisible();
+
+ // With the index fresh and every policy off, Publish now has nothing left.
+ await openSites(page);
+ await expect(page.getByTestId("publish-plan")).toContainText("Publish now has nothing to run");
+});
+
+test("a site's Build & deploy is one run: its build and its production deploy, one run id; Last deployed updates", async ({
+ page,
+}) => {
+ test.setTimeout(240_000);
+ await setup();
+ await buildIndex(page);
+ await page.goto(`/sites/${SITE}/publish`);
+ const last = page.getByTestId("last-deployed");
+ await expect(last).toContainText("Last deployed:never");
+
+ await page.getByRole("button", { name: "Build & deploy", exact: true }).click();
+ await expect(page.getByLabel("Build and deploy output")).toContainText(
+ /\[stage\] deploy-site pubsite: Done/,
+ { timeout: 180_000 },
+ );
+
+ const metas = (await publishMetas()).filter((m) => m.kind !== "publish-update-index");
+ expect(metas.map((m) => m.kind)).toEqual(["publish-build-site", "publish-deploy-site"]);
+ const runs = new Set(metas.map((m) => m.spec?.params?.runId));
+ expect(runs.size).toBe(1);
+ expect((await wranglerCalls()).map((c) => c.branch)).toEqual(["main"]);
+
+ await page.reload();
+ await expect(last).not.toContainText("Last deployed:never");
+ await expect(last).toContainText("live check skipped");
+});
+
+test("a console's Cancel cancels its own run — every stage of it — and never another run's", async ({
+ page,
+ request,
+}) => {
+ await setup();
+ // Every stage below only queues: the publish queue is held, never released.
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
+ const status = async (kind: string) => (await publishMetas()).filter((m) => m.kind === kind).map((m) => m.status);
+
+ // Run 1: the Pool's Build index — the index update, queued.
+ await page.goto("/sites");
+ await page.getByText("Pool jobs", { exact: true }).click();
+ await page.getByRole("button", { name: "Build index" }).click();
+ await expect.poll(() => status("publish-update-index")).toEqual(["queued"]);
+
+ // Run 2: the row's Build — the index update is run 1's (already queued), the
+ // build is run 2's own. Its console's Cancel takes the build, not the index.
+ await openSites(page);
+ await row(page).getByRole("button", { name: "Build", exact: true }).click();
+ await expect.poll(() => status("publish-build-site")).toEqual(["queued"]);
+ await row(page).getByRole("button", { name: `Cancel Build ${SITE}` }).click();
+ await expect.poll(() => status("publish-build-site")).toEqual(["cancelled"]);
+ expect(await status("publish-update-index")).toEqual(["queued"]);
+
+ // Run 3: the site tab's Build & deploy — its build AND its deploy, both
+ // queued; one Cancel takes the run, whichever of its jobs the console holds.
+ await page.goto(`/sites/${SITE}/publish`);
+ await page.getByRole("button", { name: "Build & deploy", exact: true }).click();
+ await expect.poll(() => status("publish-deploy-site")).toEqual(["queued"]);
+ await page.getByRole("button", { name: "Cancel Build and deploy" }).click();
+ await expect.poll(() => status("publish-deploy-site")).toEqual(["cancelled"]);
+ expect(await status("publish-build-site")).toEqual(["cancelled", "cancelled"]);
+ expect(await status("publish-update-index")).toEqual(["queued"]);
+});
+
+test("the site form's publish policy: preview without a Pages project is refused, with the reason", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await writeSite("noproj", { siteTitle: "No Project" });
+ await page.goto("/sites/noproj");
+ const policy = page.getByLabel("Publish policy");
+ await expect(policy).toHaveValue("off");
+ await policy.selectOption("preview");
+ await page.getByRole("button", { name: /save site/i }).click();
+ // Filtered: Next's route announcer is an alert too.
+ await expect(
+ page.getByRole("alert").filter({ hasText: 'publish.auto "preview" deploys the site, and it has no cloudflareProject' }),
+ ).toBeVisible();
+ const site = await readJson<Record<string, unknown>>("test-transcripts/sites/noproj/site.json");
+ expect("publish" in site).toBe(false);
+
+ // Build is allowed without a project, and is written.
+ await policy.selectOption("build");
+ await page.getByRole("button", { name: /save site/i }).click();
+ await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
+ await expect(async () => {
+ const saved = await readJson<{ publish?: { auto: string } }>("test-transcripts/sites/noproj/site.json");
+ expect(saved.publish?.auto).toBe("build");
+ }).toPass({ timeout: 10_000 });
+});
diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts
@@ -1,22 +1,22 @@
-// /sites → Homepage: the `homepage` package's build and deploy (release 11,
-// slice O4), the twin of the Hub section's buttons.
+// /sites → Publish → the Homepage row: the `homepage` package's build and
+// deploy (release 11 slice O4; a row of the Publish panel since release 18),
+// the twin of the Hub row.
//
-// NOTHING HERE DEPLOYS, AND NOTHING HERE BUILDS. Wrangler has no fake in
-// e2e/fixtures/bin, and `homepage/out` is the CHECKOUT's own directory — in the
-// primary checkout it holds the live homepage's build — so a deploy clicked
-// from a spec would be a real one. So:
+// NOTHING HERE BUILDS THE HOMEPAGE, AND NOTHING HERE DEPLOYS IT. The homepage's
+// build publishes the source mirror (the operator's scrub and denylist files)
+// into the CHECKOUT's own homepage/out — in the primary checkout, the live
+// homepage's build. So:
// - no spec clicks Deploy homepage, and none ticks Deploy after build and
// then clicks Build homepage;
-// - the two specs that click Build homepage first hold BOTH the `build` and
-// the `deploy` queue with fabricated jobs (/api/test/stuck-job, never
-// released here), so the job each starts only ever QUEUES — the
-// build-homepage it expects on `build`, and equally a build-deploy-homepage
-// on `deploy` if a regression turned the click into a build-and-deploy —
-// and it is cancelled while still queued: from its own lane, or (the
-// re-render spec) by the harness reset. Its start function never runs: no
-// log file, no child, nothing written to homepage/public or homepage/out.
+// - the two specs that click Build homepage first hold the `publish` queue
+// with a fabricated job (/api/test/stuck-job, never released here), so the
+// stages the click enqueues — the index update (no index yet), then
+// build-homepage — only ever QUEUE, and are cancelled while still queued:
+// from the row's lane (a cancel of the run's first stage cancels the rest
+// of the run), or (the re-render spec) by the harness reset. No stage
+// starts: no log file, no child, nothing written to homepage/out.
// If a spec fails before its cancel, the next resetData cancels jobs newest
-// first, so the queued job is removed before a holder's slot is freed.
+// first, so the queued stages are removed before the holder's slot is freed.
import { readdir } from "node:fs/promises";
import { test, expect, type Page } from "@playwright/test";
@@ -39,8 +39,7 @@ const buildButton = (page: Page) =>
group(page).getByRole("button", { name: "Build homepage", exact: true });
const deployButton = (page: Page) =>
group(page).getByRole("button", { name: "Deploy homepage", exact: true });
-// Scoped: /sites has three other "Deploy after build" checkboxes (the batch,
-// the specific-sites panel, the hub).
+// Scoped: the Hub row has a "Deploy after build" checkbox too.
const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build");
const previewInput = (page: Page) =>
group(page).getByRole("textbox", { name: "preview branch", exact: true });
@@ -80,13 +79,16 @@ test.beforeEach(async () => {
await resetData("empty");
});
-test("the Homepage section follows Hub, with the build and deploy controls and what a deploy ships", async ({
+test("the Homepage row follows Hub's in the Publish panel, with the build and deploy controls and what a deploy ships", async ({
page,
}) => {
await page.goto("/sites");
- const hub = page.getByRole("heading", { name: "Hub", exact: true });
- const home = page.getByRole("heading", { name: "Homepage", exact: true });
+ const panel = page.locator("section").filter({
+ has: page.getByRole("heading", { name: "Publish", exact: true }),
+ });
+ const hub = panel.getByRole("heading", { name: "Hub", exact: true });
+ const home = panel.getByRole("heading", { name: "Homepage", exact: true });
await expect(home).toBeVisible();
expect((await hub.boundingBox())!.y).toBeLessThan((await home.boundingBox())!.y);
@@ -98,29 +100,22 @@ test("the Homepage section follows Hub, with the build and deploy controls and w
// Empty is production.
await expect(previewInput(page)).toHaveValue("");
- // One line: what homepage/out holds, the project, production, the live URL.
+ // One line: what the homepage's last build is, the project, production, the
+ // live URL. Its build is read from its stamp (_homepage/built.json), which a
+ // fresh test corpus has none of.
const ships = group(page).getByTestId("homepage-ships");
- if (await pathExists("../homepage/out/index.html")) {
- await expect(ships).toContainText(/Deploy homepage ships homepage\/out, built /);
- } else {
- await expect(ships).toContainText(
- "homepage/out holds no build yet, so Deploy homepage has nothing to ship.",
- );
- }
+ await expect(ships).toContainText(
+ "homepage/out holds no build yet, so Deploy homepage has nothing to ship.",
+ );
await expect(ships).toContainText("to archilyzer (production)");
await expect(
ships.getByRole("link", { name: "https://archilyzer.pages.dev", exact: true }),
).toHaveAttribute("href", "https://archilyzer.pages.dev");
- await expect(group(page)).toContainText(
- "The homepage reads the search index as it stands: run Build index",
- );
-
- // The homepage's prose moved out of the Hub section and into its own.
- const hubSection = page.locator("section").filter({ has: hub });
- const homeSection = page.locator("section").filter({ has: home });
- await expect(hubSection).not.toContainText("archilyzer deploy homepage");
- await expect(homeSection).toContainText("archilyzer deploy homepage");
- await expect(homeSection).toContainText("homepage/out");
+ // "Run Build index first" is the row's chips now: the index it would be
+ // built from, and its build waiting for it.
+ await expect(group(page).locator('[data-chip="index"]')).toContainText("no index yet");
+ await expect(group(page).locator('[data-chip="built"]')).toContainText("update the index first");
+ await expect(group(page)).toContainText("Build homepage also publishes the source mirror");
});
test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({
@@ -165,24 +160,20 @@ test("a bad preview name greys out every deploy and says why; a good one shows i
await previewInput(page).fill("");
await expect(ships).toContainText("to archilyzer (production)");
- // Nothing above was clicked: no homepage job of any kind exists.
- for (const kind of ["build-homepage", "deploy-homepage", "build-deploy-homepage"]) {
+ // Nothing above was clicked: no publish stage of any kind exists.
+ for (const kind of ["publish-update-index", "publish-build-homepage", "publish-deploy-homepage"]) {
expect(await metasOfKind(kind), kind).toEqual([]);
}
});
-test("Build homepage starts a build-homepage job on the build queue (held there, then cancelled from its lane)", async ({
+test("Build homepage enqueues the index update, then build-homepage, on the publish queue (held there, then cancelled from its lane)", async ({
page,
request,
}) => {
- // See the header: with both queues held, whatever the click starts can only
- // queue.
- for (const queue of ["build", "deploy"]) {
- const hold = await request.get(
- `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`,
- );
- expect(hold.ok(), queue).toBe(true);
- }
+ // See the header: with the publish queue held, whatever the click starts
+ // can only queue.
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
await openSites(page);
await expect(deployAfter(page)).not.toBeChecked();
@@ -192,13 +183,15 @@ test("Build homepage starts a build-homepage job on the build queue (held there,
await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 });
await expect(group(page).getByRole("alert")).toHaveCount(0);
- // One build-homepage job, on the build queue, queued — and nothing else.
- await expect.poll(async () => (await metasOfKind("build-homepage")).length).toBe(1);
- const [job] = await metasOfKind("build-homepage");
- expect(job.queueKey).toBe("build");
+ // One run: the index update (there is no index yet) and one build-homepage,
+ // both on the publish queue, queued — and no deploy.
+ await expect.poll(async () => (await metasOfKind("publish-build-homepage")).length).toBe(1);
+ const [job] = await metasOfKind("publish-build-homepage");
+ expect(job.queueKey).toBe("publish");
expect(job.status).toBe("queued");
- expect(await metasOfKind("build-deploy-homepage")).toEqual([]);
- expect(await metasOfKind("deploy-homepage")).toEqual([]);
+ const [index] = await metasOfKind("publish-update-index");
+ expect(index?.status).toBe("queued");
+ expect(await metasOfKind("publish-deploy-homepage")).toEqual([]);
await group(page).getByRole("button", { name: "Cancel Build homepage" }).click();
await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible();
@@ -217,7 +210,11 @@ test("Build homepage starts a build-homepage job on the build queue (held there,
// rewrote the meta, so a restart's boot pass could re-queue a job the
// operator had cancelled, and a cancelled clip fetch could read as queued.
await expect
- .poll(async () => (await metasOfKind("build-homepage"))[0]?.status)
+ .poll(async () => (await metasOfKind("publish-build-homepage"))[0]?.status)
+ .toBe("cancelled");
+ // The run's Cancel was its first stage's: the rest of the run went with it.
+ await expect
+ .poll(async () => (await metasOfKind("publish-update-index"))[0]?.status)
.toBe("cancelled");
});
@@ -225,7 +222,7 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is
page,
request,
}) => {
- // The line reads homepage/out when /sites renders, and nothing here may
+ // The line reads the homepage's stamp when /sites renders, and nothing here may
// build (see the header) — so the proof is that the page RE-RENDERS when the
// lane ends: a site written to disk after the page loaded appears without a
// reload. Passive refresh is off (it would re-render on the job's status
@@ -233,12 +230,8 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is
// the harness reset, because the lane's own Cancel is a server action that
// revalidates, and its response re-renders the page on its own.
await writeSettings({ autoRefreshIntervalSeconds: 0 });
- for (const queue of ["build", "deploy"]) {
- const hold = await request.get(
- `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`,
- );
- expect(hold.ok(), queue).toBe(true);
- }
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
+ expect(hold.ok()).toBe(true);
await openSites(page);
await buildButton(page).click();
@@ -248,7 +241,7 @@ test("a build lane that ends re-renders /sites, so the \"built <when>\" line is
const probe = page.locator('a[href="/sites/refresh-probe"]');
await expect(probe).toHaveCount(0);
- // Cancels every live job, newest first: the queued build before either
+ // Cancels every live job, newest first: the queued stages before the
// holder's slot is freed, so nothing is promoted and nothing builds.
const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`);
expect(reset.ok()).toBe(true);
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -28,7 +28,10 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// E2E_FAKE_WRANGLER_AUTH_FAIL=1 fake-wrangler.mjs fails as Cloudflare
// refusing the API token ("Authentication error
// [code: 10000]"), for the deploy stage's
-// "[deploy] REFUSED by Cloudflare" sentence
+// "[deploy] REFUSED by Cloudflare" sentence — the
+// fallback of its mode sidecar, which a spec
+// writes (`<exportBuildsDir>/.fake-wrangler-
+// mode.json` `{"authFail": true}`)
// E2E_LIVE_CHECK=skip the deploy stage's live check reads nothing and
// records "skipped" (common/publish/liveCheck.ts):
// the fake wrangler deploys nothing to read. Set
@@ -42,6 +45,21 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// (common/lib/pagesDeploy.ts wranglerBin), pointed below at
// e2e/fixtures/bin/fake-wrangler.mjs so no spec can reach Cloudflare — the fake
// writes its argv to `.fake-wrangler.json` beside the bundle it was handed.
+// Three more product variables the publish stages read are set for the test
+// server (release 18 S4):
+// ARCHILYZER_BRANCH=main the branch a build stamps (stamps.ts: the env
+// wins over git). A worktree's branch is never
+// main, and production refuses a build of any
+// other — without it every production deploy in
+// the suite would be refused
+// CLOUDFLARE_API_TOKEN a dummy: the deploy stage's credential
+// preflight asks for a token or a wrangler login
+// on disk; the fake wrangler never sends it, and
+// the suite must not depend on the host's login
+// EXPORT_NEXT_BIN e2e/fixtures/bin/fake-next.mjs: a site's and
+// the hub's build run compose for real, then this
+// in place of `next build` (it copies the composed
+// public dir to export/out)
//
// The ports are NOT prefixed: they are common/lib/ports.mjs's, injected per
// worktree by scripts/worktree.mjs and named in queue-lock's --ports list. Nor
@@ -56,6 +74,9 @@ const E2E_SERVER_ENV = {
E2E_AUDIO_CHECK_RECOVER_AFTER: "2",
E2E_LIVE_CHECK: "skip",
WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"),
+ ARCHILYZER_BRANCH: "main",
+ CLOUDFLARE_API_TOKEN: "e2e-fake-token-never-sent",
+ EXPORT_NEXT_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-next.mjs"),
};
const PORT = portFor("PORT");
diff --git a/plans/release-18.md b/plans/release-18.md
@@ -132,7 +132,11 @@ Probe = { status|null; generatedAt?; cfCacheStatus?; age?; cacheControl?; error?
(download/transcribe/digest/normalize/import/fetch-posts/tag kinds — the drainable kinds of `jobKinds.ts`) that ENDED
`done` after `stamp.scannedAt` — read through the registry's archive reader, no new writer anywhere; (2) a config file
newer than the stamp: `tags.json`, `search-aliases.json`, `duplicates*.json`, `sites/*/site.json`, `homepage.json`, the
- settings file, the charts config. Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT
+ charts config; (3) **the settings the index reads, by signature, not the settings file's mtime** (S4 step 1, after S3
+ found every pause click, priority change and drive auto-pause staling the index): `indexSettingsSig` (`inputSig.ts`)
+ over `socialLinks`, `homepageUrl`, `buildArchives`, `archiveStorage`, `social.x.visibility`,
+ `maxTranscriptPageBytes` and the storage locations' roots, recorded as `settingsSig` in the index stamp and compared
+ by `needs()` ("the settings the index reads changed"; a stamp without one reads as changed, once). Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT
changed (its fingerprints stat every sidecar and are the only correct detector; LMDB `generation` cannot see new files).
- **A site is marked stale by its channels, before any index runs** (operator, 2026-10-05): per site,
`changedChannels` = the member channels (by `site.json` membership, groups expanded) that have an ingest job meta
@@ -1279,6 +1283,158 @@ deploys: "none"}`); `enqueuePublishRun(paths, plan, {runId?})` and `enqueueStage
`resumeLaneAction("publish")` (`editor/app/operations/actions.ts`) and `isGateHeld(settings, "publish")`; the
policies `sitePublishPolicy(site)`, `sitePublishProblem(site)`, `settings.publish`.
+### Slice S4, as shipped — the publish surfaces: /sites Publish panel, /operations/publish, the ops API (2026-10-06)
+
+Branch `r18/surfaces` off `r18/integration` `85a38e92` (main `edadc712` merged in first, step 0, as `f2fd11a7`; then
+main's own red pin fixed, `85a38e92`), built in the integration worktree `~/Projects/r18-integration` (editor 7101,
+test 7111, export 7110) by the orchestrating Opus session — this session can run git only in its own worktree, so
+the prepared `r18-publish-surfaces` worktree was not used — with two Opus helpers (the build.ts deletion; the
+review). Scratch files `s4-*` in the job's `tmp`. The plan is "Surfaces", the step 1 pre-fix and S3's "Seams".
+
+**What it does.**
+- **The index is judged by the settings it reads** (step 1). `indexSettingsSig` (`publish/inputSig.ts`) signs
+ `socialLinks`, `homepageUrl`, `buildArchives`, `archiveStorage`, `social.x.visibility`,
+ `maxTranscriptPageBytes` and the storage locations' roots; update-index records it as `settingsSig` in the
+ stamp and `needs()` says "the settings the index reads changed" when it differs (an older stamp reads as
+ changed, once). The settings file's mtime is no longer an index input: a pause click, a priority change or a
+ drive auto-pause no longer stales the index. The charts config stays an mtime (its own file).
+- **/sites → Publish** (`sites/components/PublishPanel.tsx`) replaces "Build all sites", the batch panel and the
+ hub's and homepage's build sections: a row per site, then the hub, then the homepage, each with the status's
+ four chips (index | built | deployed | live), its policy and what is next. A site row: **Build**, **Deploy
+ preview** (box "preview branch", starts on `settings.publish.previewBranch`; status "preview problem"),
+ **Deploy production**, **Deploy local** (only with `ARCHILYZER_SITE_OUT`); a private site says it never
+ deploys, a site with no project says so. The hub and homepage rows keep their names ("Build hub", "Deploy
+ hub", "Build homepage", "Deploy homepage", "Deploy after build", an empty preview box = production, testid
+ `homepage-ships`, group "Homepage build" / "Hub build"). Above the rows: **Publish now**, **Build all stale**,
+ the index chip, the lane chip (a link to /operations/publish) and the plan Publish now would run. Each button
+ is one JobLane; the hub's config form stays below as "Hub config".
+- **The Pool**: **Build index** is the update-index stage (no queue control: it is a publish stage); **Build stats
+ dataset is removed** (the stats are part of the index update); normalize and the archives unchanged.
+- **A site's Publish tab**: Build & deploy (exact names kept) = the index update when stale, the build `--force`,
+ the production deploy with `builtAfter`; "Build static export" without "Skip data rebuild"; "Deploy to
+ production" / "Deploy preview"; "Last deployed" is this site's `deployed.json` — production, the last preview,
+ the live-check verdict — and "Built" its `built.json`.
+- **One console per run** (`sites/lib/publishRunStream.ts`): the run's jobs' streams joined in order under `===
+ <stage> (job <id>) ===`; the verdict is the first job not `done`; a job of the run that ends `cancelled`
+ cancels the jobs after it (the console's Cancel is the run's — else it would wait on a stage queued behind
+ something else).
+- **The actions** (`sites/lib/publishActions.ts`, `"use server"`, over `publishCore.ts`): `publishNowAction`,
+ `buildAllStaleAction`, `updateIndexAction`, `buildTargetAction`, `deployTargetAction`,
+ `buildAndDeployTargetAction`, and the lane's `start|stop|drainPublishLaneAction`. Every one enqueues a plan
+ through `enqueuePublishRun` under one run id; a manual build or deploy is forced, and a build carries the index
+ update first when the index is not fresh. A deploy is refused before any job in the deploy stage's own words:
+ steps 1–3 of `runDeployStage` moved unchanged into `resolveDeployRequest` (`publish/deployStage.ts`), which the
+ stage and the surfaces both ask ("no build of X in <dir> — archilyzer publish build X" is one sentence).
+- **/operations/publish** (static route beside `[id]`): the runner pill, Start / Drain / Stop (`Start publish
+ lane` …), the hold (`pause publishing` / `resume publishing`), lane on/held/quiet, a pass due or not and why,
+ last check / next check / last pass / last decision, the runner's job log, the plan a pass would run, and
+ `settings.publish`'s form (`savePublishSettingsAction`: enabled, check/refresh minutes, quiet hours, runner,
+ preview branch — refused with the deploy buttons' sentence —, hub and homepage policies). The page refreshes
+ itself every 5 s. `setLaneHeld` revalidates it and /sites for the publish lane; the operations board links it.
+- **SiteForm**: "Publish policy" (Off / Build / Preview / Production) writes `site.json` `publish.auto`;
+ `writeSite`'s refusal of a deploying policy with no Pages project is the form's error. A save no longer drops
+ the key (the form rebuilt the site without it).
+- **ops API**: `POST /api/ops/publish` `{verb: index | build | deploy | hub | homepage | now | stale, …}` →
+ `{ok, runId, jobs: [{target, kind, jobId, previewUrl?, existing?}], skipped, refused}` (+ `jobId` with one job;
+ every request refused → 400), a key a verb does not take is a 400; `GET` → the publish status. The eight old
+ routes are aliases with their bodies and answers (`build-index` ignores `queueKey`, `build-site` ignores
+ `skipData`; `build-deploy` `all` = every deployable site to production; per site the DEPLOY job is the one
+ reported). Shared adapter code in `api/ops/_publish.ts`. `pnpm ops publish` and `pnpm ops get publish`.
+- **CLI**: `deploy hub` / `deploy homepage` print and run `publish hub|homepage --deploy-only` (new flag) through
+ the deploy stage (S2 review I2). `build.ts` loses `buildSite`, `deploySite`, `buildAll`, `deployHub`,
+ `deployHomepage`, `runDockerBuildAllPhase`, `runDockerDeployAllPhase` and what only they used
+ (`runDeployIntoLog`, `runPagesDeployIntoLog`, `homepageDeployArgs`, the outcome types): 1452 → ~980 lines.
+- **A stage ends "Done"**: `[stage] <kind> <target>: Done — …` / `Done (no-op) — …` (was "done"/"no-op"); the
+ e2e `buildIndex()` helper waits for that line.
+- **e2e seams**: `EXPORT_NEXT_BIN` (`build.ts nextBuildStep`, ENVIRONMENT.md) runs `<bin> build` in place of
+ `pnpm exec next build` for a site's and the hub's build — the test server points it at
+ `e2e/fixtures/bin/fake-next.mjs`, which copies the composed public dir to export/out (compose runs for real;
+ the export app is never rebuilt beside its dev server); `ARCHILYZER_BRANCH=main`; a dummy
+ `CLOUDFLARE_API_TOKEN` (the preflight; the fake never sends it); the fake wrangler's mode sidecar
+ `<exportBuildsDir>/.fake-wrangler-mode.json` `{"authFail": true}`.
+
+**Deviations from the plan** (one sentence each):
+1. Built on a branch of the integration worktree (`r18/surfaces`), not in `r18-publish-surfaces`: this session
+ could run git only in its own worktree.
+2. A manual Build (row, tab, hub, homepage) enqueues the index update first when the index is not fresh — the
+ data phase a build used to run — so "Build & deploy" is two jobs only when the index is fresh.
+3. Manual deploys are forced (the plan's "--force (manual buttons only)"); `POST publish {verb: "deploy"}` forces
+ only with `force: true`.
+4. `EXPORT_NEXT_BIN` and the fake `next` are new: the plan's publish.spec needed a real build, and a real `next
+ build` of the export app inside the editor's e2e would race its dev server.
+5. A cancel of a run cancels its later jobs (publishRunStream) — the plan did not say; without it the hub and
+ homepage rows' consoles never settled.
+6. The /sites "Hub" heading is "Hub config" (the panel's row is "Hub"); the homepage's own section is gone (its
+ row is in the panel).
+7. GET /api/ops/publish answers `{ok: true, …status}` (the status at the top level, so `lane.held` reads directly).
+8. sites-homepage.spec's "run Build index" line is the row's chips now ("no index yet", "update the index first"), as
+ planned.
+9. `writeSite` (e2e helper) passes `audience` through.
+10. Files beyond the slice's list: `videoChoreCards.test.ts` (main's red pin, on integration), `source.test.ts`
+ (the manifest refusal kept a test), two comments in `builtExport.test.ts`.
+
+**Found and fixed on the way.**
+- main was red: `videoChoreCards.test.ts` pinned every import from `./cards` as a chore card, and main's
+ multi-track merge added `TranscriptTracksReader` there (`85a38e92`, on integration).
+- The site form dropped `site.json` `publish` on every save (S3 added the key, the form never knew it).
+
+- The e2e build stage left the worktree's `export/out` linked to a test bundle that resetData then deleted, and a
+ bare `next build` of the export app failed on it (`5a77682e`: the suite's setup and teardown drop such a link).
+
+**Found and left** — in "Follow-ups carried over" above.
+
+| commit | what |
+|---|---|
+| `f2fd11a7` | (integration) merge `main` `edadc712` — multi-track captions, en-track fallback, Wayback, Odysee/BitChute spacing; the changelog keeps both sides |
+| `85a38e92` | (integration) main's red pin: the chore-card test leaves the transcript reader out |
+| `195e54f8` | publish: the index judged by `settingsSig`, not the settings file's mtime (step 1) |
+| `2b533c6c` | publish: `resolveDeployRequest`; a stage ends "Done" |
+| `a30581f7` | editor: the /sites Publish panel, the site Publish tab, `POST|GET /api/ops/publish` and the eight aliases |
+| `e360f7da` | editor: /operations/publish; the site form's publish policy |
+| `ce20b387` | cli: `deploy hub|homepage` → `publish hub|homepage --deploy-only` |
+| `4cb8e81b` | ops: `pnpm ops publish`, `pnpm ops get publish` |
+| `514e9674` | publish: build.ts loses the pre-stage entry points (helper agent) |
+| `fce11b60` | e2e seams (`EXPORT_NEXT_BIN`, the mode sidecar, `ARCHILYZER_BRANCH`), the moved labels' specs |
+| `4813bafd` | e2e: publish.spec, publish-lane.spec, ops-api / jobs / duplicate-shorts |
+| `6882b507` | e2e: alerts past Next's route announcer; the job page's first compile; the changelog fold |
+| `5a77682e` | e2e: setup / teardown drop a test `export/out` link |
+| `6a697701` | the review's fixes (below) |
+
+**Gates** (worktree root; logs `$T/s4-*.log`, `$T/s4f-*.log`): tsc clean at every commit; common **3394 passed**,
+**3395** after the review round (9 tests went with the deleted build.ts code; new: settingsSig 2, `EXPORT_NEXT_BIN`
+1, the source manifest refusal 1, the CLI flags 2); editor unit **142**; `test:scripts` **599 + 3 skipped**, **600 + 2
+skipped** after the round (the ops client's publish test new); mcp
+**292**; export unit **116**; homepage unit **23**; `pnpm --filter editor exec next build` ok (47 s); `pnpm --filter
+export exec next build` ok — first FAILED (`stat export/out` ENOENT: the e2e build stage's link left dangling by
+resetData; fixed by `5a77682e`), then ok over the committed fixture compose linked into the worktree's
+`export/public`; `pnpm --filter homepage run build:nodata` ok (15 s); umtool's capped build ok (19 s, link removed).
+e2e (editor suite, `$T/s4-specs.txt`: publish, publish-lane, ops-api, build, deploy-page, site-scope,
+site-publish-preview, sites-homepage, duplicate-shorts, sites-crud, digest, jobs, lane-runner): first run **104
+passed, 4 failed, 9.6 min** — two spec bugs (Next's route announcer is an alert too), `jobs.spec` "tails its log"
+and `site-scope.spec` "charts is a site's tab" (both pass alone: **5 passed, 0 failed, 46 s**); after the fixes and the
+review round **109 passed, 0 failed, 8.8 min**. The whole suite runs once at the end of the release (step 4).
+
+**Review** (`$T/s4-review.md`, a separate Opus agent, read-only): **SHIP AFTER FIXES**, nine findings, all fixed in
+`6a697701`:
+
+| # | finding | fix |
+|---|---|---|
+| 1 | a console's Cancel cancelled only the run's first job — a no-op once it had ended, so a queued production deploy could not be stopped from the console | `cancelPublishRunAction`: every live stage with the console job's run id, newest first; every publish console uses it (`JobLane` takes `cancelAction`) |
+| 2 | a part another run had queued (`existing`) could be the console's job, be cascaded, and was not waited on | `RunPart.existing`; never the console's job, never cascaded |
+| 3 | publish-lane.spec's queue holder started its clock before the page compiled | taken after the page is up, 45 s |
+| 4 | a preview with no branch name fell through to production | refused; `wantedPlan` throws on it |
+| 5 | `POST publish {build, runner: docker}` skipped the stale index | the index update first, `indexAfter` on the containers' build |
+| 6 | the hub/homepage verbs dropped a preview on a local deploy | refused with the deploy verb's sentence |
+| 7 | `build-site` / `build-deploy` `all` lost the top-level `jobId` | the run's last job; comments and the changelog say so |
+| 8 | `--deploy-only --force` dropped the force; `--deploy --deploy-only` passed | force reaches the deploy; the pair is a usage error |
+| 9 | one target's throw was the whole request's 500 | that target's refusal |
+
+Cleanups taken: `fanOutSiteJobs` deleted; `followRun` refuses an empty run; `enqueueRun` releases its kept branches on a
+throw; the plan's list keys carry the preview; saving the lane on starts its runner; the foreground/background window
+noted in `publishStages.ts`. From the S6 drafts' readings, in the same commit: the charts page no longer points at
+"Build stats dataset"; the source gate's refusal names `archilyzer publish homepage` (only a stage stamps what a
+deploy ships); `build hub` stays a raw, unstamped build and says so (e2e:2origin's `build:hub`); three stale comments.
+
## Rollout
(Steps 1–7 above; "### As it went" is written as the rollout runs.)
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -88,11 +88,14 @@ const GETTERS = {
// single tag's assignments, each carrying the provenance of the pin.
tags: (tag) =>
tag ? `/api/ops/tags?tag=${encodeURIComponent(tag)}` : "/api/ops/tags",
+ // The publish status (release 18): the index, the lane, a row per site, the
+ // hub and the homepage with their chips, and the plan Publish now would run.
+ publish: () => "/api/ops/publish",
};
// Nouns whose read takes no argument. `get channel` without a slug is a
// mistake; `get tags` without one is the whole vocabulary.
-const GET_ARG_OPTIONAL = new Set(["tags"]);
+const GET_ARG_OPTIONAL = new Set(["tags", "publish"]);
const ACTIONS = [
"channel-priority",
@@ -117,15 +120,19 @@ const ACTIONS = [
"fetch-posts",
// A screenshot and the attached media of specific archived posts.
"capture-posts",
+ // Publishing as stages (release 18): {"verb": "index" | "build" | "deploy" |
+ // "hub" | "homepage" | "now" | "stale", …} — one run of stages on the
+ // editor's publish queue. The eight rows below are its aliases.
+ "publish",
"build-index",
"build-deploy",
"build-site",
- // Deploy the ALREADY-BUILT export/out — build-deploy's other half, and the
- // one a preview is for: build once, look at the preview, then ship the same
- // bundle to production without rebuilding it.
+ // Deploy the site's ALREADY-BUILT bundle — build-deploy's other half, and
+ // the one a preview is for: build once, look at the preview, then ship the
+ // same bundle to production without rebuilding it.
"deploy-site",
- // The HUB (the export app in hub mode, into the same export/out a site
- // build uses) and its deploy to homepage.json's Pages project.
+ // The HUB (the export app in hub mode, into its own bundle) and its deploy
+ // to homepage.json's Pages project.
"build-hub",
"deploy-hub",
// The HOMEPAGE (the `homepage` package, Archilyzer's own site, into
@@ -321,6 +328,7 @@ export function usage() {
" [--wait-timeout <seconds>] [--quiet]",
" pnpm ops get channel <slug> [--counts]",
" pnpm ops get tags [<tagId>]",
+ " pnpm ops get publish",
" pnpm ops list",
"",
`Actions: ${ACTIONS.join(", ")}`,
@@ -331,6 +339,22 @@ export function usage() {
"--wait-timeout <seconds> gives up and exits 1 instead of waiting forever.",
" Default: no timeout — the queue may legitimately hold a job for hours.",
"",
+ 'publish runs publish stages on the editor\'s publish queue, one at a time,',
+ ' under one run id: {"verb": …}. "index" updates the index; "build" builds',
+ ' "siteId"/"siteIds" (forced; the index first when stale; "runner":',
+ ' "docker" builds every site in containers); "deploy" ships their built',
+ ' bundles (production, "preview": "<branch>", or "to": "local"; "force"',
+ ' redeploys a bundle already shipped there); "hub" / "homepage" build',
+ ' them, {"deploy": true} deploys after; "now" is Publish now (the stale',
+ ' index, then each policy target); "stale" builds every stale site. The',
+ ' answer lists every job ({target, kind, jobId}) and --wait follows them',
+ ' all. A site never built is refused: "no build of <id> in <dir> —',
+ ' archilyzer publish build <id>". `get publish` is the status.',
+ "",
+ 'build-index, build-site, build-deploy, deploy-site, build-hub, deploy-hub,',
+ ' build-homepage and deploy-homepage are publish\'s aliases, with their old',
+ ' bodies and answers ("skipData" is accepted and ignored).',
+ "",
'build-site, build-deploy and deploy-site all take "siteId" (one) or',
' "siteIds" (a list).',
"",
@@ -390,7 +414,7 @@ export function usage() {
" alone. The alias is printed after the response. Lowercase letters,",
' digits and dashes, up to 28 characters; "main" is refused.',
"",
- 'build-hub builds the hub into export/out; {"deploy": true} deploys it',
+ 'build-hub builds the hub into its bundle; {"deploy": true} deploys it',
' after, and deploy-hub ships the one already built. Both deploy to the',
" Pages project set on /sites under Hub, and take \"preview\" too.",
"",
diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs
@@ -359,7 +359,24 @@ test("build-hub and deploy-hub are POSTs to their own routes, named in the usage
assert.equal(deploy.path, "/api/ops/deploy-hub");
assert.deepEqual(deploy.body, {});
assert.match(usage(), /Actions:.*build-hub, deploy-hub/);
- assert.match(usage(), /build-hub builds the hub into export\/out/);
+ assert.match(usage(), /build-hub builds the hub into its bundle/);
+});
+
+// Publishing as stages (release 18): one route, a verb in the body; the status
+// is a read.
+test("publish is a POST to its route, and get publish the status, named in the usage", () => {
+ const p = parseArgs(["publish", "--json", '{"verb":"deploy","siteId":"jer","preview":"r18"}', "--wait"]);
+ assert.equal(p.method, "POST");
+ assert.equal(p.path, "/api/ops/publish");
+ assert.deepEqual(p.body, { verb: "deploy", siteId: "jer", preview: "r18" });
+ assert.equal(p.wait, true);
+ const g = parseArgs(["get", "publish"]);
+ assert.equal(g.method, "GET");
+ assert.equal(g.path, "/api/ops/publish");
+ assert.match(usage(), /Actions:.*capture-posts, publish, build-index/);
+ assert.match(usage(), /pnpm ops get publish/);
+ assert.match(usage(), /"now" is Publish now/);
+ assert.match(usage(), /publish's aliases, with their old/);
});
// The homepage's pair (release 11 slice O4), the hub's twins: a POST each, the