commit ef3fc94e99f3810b6b3009d284da24b54a93e630
parent a899a505ed1839480c53d8d04c28648fdccded20
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 12:26:52 -0400
editor: the /sites Publish panel, the site Publish tab and the ops API as publish stages
/sites: "Build all sites" and the hub's and homepage's build sections become
one Publish panel — a row per site, the hub and the homepage with the four
chips (index | built | deployed | live), Build / Deploy preview / Deploy
production / Deploy local, Publish now and Build all stale with the plan
Publish now would run. The Pool's Build index is the index stage; Build stats
dataset is gone (stats are part of the index update). A site's Publish tab
reads its own built.json / deployed.json for "Last deployed". Every button is
one run of stages on the publish queue (sites/lib/publishActions.ts →
publishCore.ts → enqueuePublishRun), followed in one console
(publishRunStream.ts); a deploy is refused before any job in the deploy
stage's own words (resolveDeployRequest).
POST /api/ops/publish {verb: index|build|deploy|hub|homepage|now|stale} and
GET (the status); the eight old routes stay as aliases with their bodies and
answers. buildAction.ts keeps only the Pool's normalize/archive actions;
hubActions.ts, homepageDeployActions.ts, deployAction.ts and the four old
components are deleted.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
28 files changed, 1550 insertions(+), 1599 deletions(-)
diff --git a/editor/app/api/ops/_publish.ts b/editor/app/api/ops/_publish.ts
@@ -0,0 +1,88 @@
+// 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) {
+ const why = await refusal(paths, ask);
+ 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,20 @@
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.
export async function POST(request: Request) {
return ops(
request,
@@ -50,37 +22,50 @@ export async function POST(request: Request) {
async (body) => {
const skipArchives = optBool(body, "skipArchives");
const preview = optPreviewBranch(body);
+ const paths = getPaths();
+ let ids: string[];
+ const skippedAll: { siteId: string; reason: string }[] = [];
if (optBool(body, "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("; "));
+ }
+ 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/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,46 @@
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.
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 }));
+ return NextResponse.json({
+ ok: true,
+ runId: run.runId,
+ jobs,
+ skipped,
+ ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}),
+ });
},
);
}
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,152 @@
+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 } 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"');
+ }
+ return publishResponse(
+ await enqueuePlan({
+ runStart: Date.now(),
+ steps: [
+ {
+ kind: "build-site",
+ target: ALL_TARGET,
+ runner: "docker",
+ reason: "asked",
+ ...(force !== false ? { force: true } : {}),
+ ...(skipArchives ? { skipArchives: true } : {}),
+ },
+ ],
+ 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');
+ }
+ 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/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/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 { 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)}
+ trigger={() => updateIndexAction()}
cancelAction={cancelJobAction}
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,7 +2,7 @@
import { useState } from "react";
import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { buildAndDeployAction } from "../lib/buildAction";
+import { buildAndDeployTargetAction } from "../lib/publishActions";
import { cancelJobAction } from "../../jobs/actions";
type Props = {
@@ -14,8 +14,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,7 +45,7 @@ export function BuildDeployButton({
return (
<StreamActionLog
- trigger={() => buildAndDeployAction(siteId, skipArchives)}
+ trigger={() => buildAndDeployTargetAction(siteId, { where: "production", skipArchives })}
cancelAction={cancelJobAction}
buttonLabel="Build & deploy"
runningLabel="Building & deploying…"
diff --git a/editor/app/sites/components/BuildExportButton.tsx b/editor/app/sites/components/BuildExportButton.tsx
@@ -2,29 +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 { buildTargetAction } from "../lib/publishActions";
import { cancelJobAction } from "../../jobs/actions";
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)
- }
+ trigger={() => buildTargetAction(siteId, { skipArchives })}
cancelAction={cancelJobAction}
buttonLabel="Build static export"
runningLabel="Building static export…"
@@ -36,18 +32,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 +41,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
@@ -8,7 +8,7 @@ import {
previewBranchProblem,
} from "yt-dlp-transcript-common/lib/pagesDeploy";
import { cancelJobAction } from "../../jobs/actions";
-import { deployExportAction } from "../lib/deployAction";
+import { deployTargetAction } from "../lib/publishActions";
type Props = {
// The site whose Publish tab this is — always a real site.
@@ -19,22 +19,25 @@ 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)}
+ trigger={() => deployTargetAction(siteId, { where: "production" })}
cancelAction={cancelJobAction}
buttonLabel="Deploy to production"
runningLabel="Deploying…"
@@ -48,6 +51,7 @@ export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) {
<PreviewDeploy
siteId={siteId}
cloudflareProject={cloudflareProject ?? null}
+ initialBranch={previewBranch || DEFAULT_PREVIEW_BRANCH}
/>
</div>
);
@@ -56,11 +60,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,7 +94,7 @@ function PreviewDeploy({
</p>
</div>
<StreamActionLog
- trigger={() => deployExportAction(siteId, { previewBranch: branch })}
+ trigger={() => deployTargetAction(siteId, { where: "preview", preview: branch })}
cancelAction={cancelJobAction}
buttonLabel="Deploy preview"
runningLabel="Deploying preview…"
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/PublishPanel.tsx b/editor/app/sites/components/PublishPanel.tsx
@@ -0,0 +1,508 @@
+"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,
+ 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 }[];
+ 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}`}>
+ <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()}
+ />
+ )}
+ </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()}
+ />
+ )}
+ </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()}
+ />
+ )}
+ </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/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,152 @@
+"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 { 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 {
+ 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 },
+ },
+ ]);
+}
+
+// --- 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,202 @@
+// 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;
+ if (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;
+ 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 };
+ };
+ const run = await enqueuePublishRun(paths, plan, { runId: opts.runId ?? newPublishRunId(), enqueue });
+ const parts: RunPart[] = run.jobs.map((j) => ({
+ label: stepLabel(j),
+ jobId: j.jobId,
+ ...(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,83 @@
+// 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`.
+//
+// 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 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>;
+};
+
+/** 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 }> {
+ const first = 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;
+ }
+ 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,55 @@ 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 })),
+ 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 +224,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,
+ };
+}