commit a587f4b3b7f0d8868bc24e1382df14858360ff37
parent 9ebc5d241b7a5436b33e6dc8075c9c219bd9caa7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 08:56:35 -0400
ops: a deploy-site route, and preview on both deploy routes
Previewing is only useful if you can then ship the SAME bundle to production
without rebuilding it, and build-deploy could not: it always builds. deploy-site
is the other half, over the already-built export/out. The fan-out loop the two
now share moved into _lib rather than being copied — the careful parts (one
site's throw not costing the others their job ids; no jobs being a 400, not a
cheerful empty list) are exactly the parts a copy gets wrong.
`preview` is judged by the same previewBranchProblem the UI and the action use,
and judged here so the refusal precedes every job. It is refused with `all`,
because the all-sites runner has nowhere to put a branch and building every
site to ship it to production is the worst possible reading of the request.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 164 insertions(+), 48 deletions(-)
diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken";
import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels";
+import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
import { isValidSiteId } from "yt-dlp-transcript-common/lib/site";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
import type { QueueOutcome } from "../../channels/lib/queueForSlugs";
@@ -200,6 +201,80 @@ export function reqSiteIds(body: OpsBody): string[] {
return ids;
}
+// A PREVIEW BRANCH, JUDGED BEFORE ANY JOB STARTS. `previewBranchProblem` is the
+// same function the server action and the browser control use — this is not a
+// second opinion, it is the one opinion asked earlier. Earlier matters: on
+// build-deploy the action's own check happens before its build, but a `siteIds`
+// fan-out would otherwise walk every site to refuse each in turn and answer
+// with a joined list of the same sentence.
+export function optPreviewBranch(body: OpsBody): string | undefined {
+ const raw = body.preview;
+ if (raw === undefined) return undefined;
+ const problem = previewBranchProblem(raw);
+ if (problem) throw new OpsInputError(problem);
+ return (raw as string).trim();
+}
+
+// The site fan-out the deploy routes run: start one managed job per site,
+// keeping every id, and refuse with a 400 when NONE started.
+//
+// SHARED BECAUSE THE CAREFUL PART IS EASY TO GET WRONG TWICE. One site's throw
+// cannot cost the others their job ids — an exception out of the loop becomes a
+// 500 carrying no `jobs` at all, while the jobs already queued run on with
+// nobody holding their ids. And asking for deploys and getting NONE is a 400
+// carrying the reason, not a cheerful `{ ok: true, jobs: [] }`: `--wait` would
+// exit 0 on that and report success about a deploy that never started.
+//
+// `decorate` adds per-site keys to a job entry (the preview alias); they are
+// repeated at the top level in the single-job case, exactly as `jobId` is, so a
+// one-site caller never has to index into `jobs`.
+export async function fanOutSiteJobs(
+ siteIds: string[],
+ start: (siteId: string) => Promise<StreamActionResult>,
+ decorate?: (siteId: string) => Record<string, unknown>,
+): Promise<NextResponse> {
+ const jobs: Record<string, unknown>[] = [];
+ const extras: Record<string, unknown>[] = [];
+ const skipped: { siteId: string; reason: string }[] = [];
+ let info = false;
+ for (const siteId of siteIds) {
+ let result: StreamActionResult;
+ try {
+ result = await start(siteId);
+ } catch (e) {
+ skipped.push({ siteId, reason: (e as Error).message });
+ continue;
+ }
+ if (!result.ok) {
+ skipped.push({ siteId, reason: result.error });
+ info = info || result.info === true;
+ continue;
+ }
+ // The stream is cancelled, never returned — see this file's header.
+ void result.stream.cancel();
+ const extra = decorate?.(siteId) ?? {};
+ jobs.push({ siteId, jobId: result.jobId, ...extra });
+ extras.push(extra);
+ }
+ if (jobs.length === 0) {
+ // One site asked for, one reason: the bare sentence the action gave,
+ // exactly as jobResponse has always returned it.
+ return opsFail(
+ skipped.length === 1
+ ? skipped[0].reason
+ : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "),
+ 400,
+ info ? { info: true } : undefined,
+ );
+ }
+ return NextResponse.json({
+ ok: true,
+ jobs,
+ skipped,
+ ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...extras[0] } : {}),
+ });
+}
+
export function oneOf<T extends string>(
body: OpsBody,
key: string,
diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts
@@ -1,23 +1,27 @@
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,
- opsFail,
optBool,
+ optPreviewBranch,
reqSiteIds,
} from "../_lib";
export const dynamic = "force-dynamic";
-// POST { siteId: string | siteIds: string[], skipArchives? }
+// POST { siteId: string | siteIds: string[], skipArchives?, preview? }
// | { all: true, skipArchives? }
-// -> { ok: true, jobs: [{ siteId, jobId }], skipped: [{ siteId, reason }],
-// jobId? }
+// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }],
+// skipped: [{ siteId, reason }], jobId?, previewUrl? }
//
// 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.
@@ -31,63 +35,52 @@ export const dynamic = "force-dynamic";
//
// 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.
+// 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.
export async function POST(request: Request) {
return ops(
request,
- ["siteId", "siteIds", "all", "skipArchives"],
+ ["siteId", "siteIds", "all", "skipArchives", "preview"],
async (body) => {
const skipArchives = optBool(body, "skipArchives");
+ const preview = optPreviewBranch(body);
if (optBool(body, "all")) {
if (body.siteId !== undefined || body.siteIds !== undefined) {
throw new OpsInputError(
'send either "siteId"/"siteIds" or "all", not both',
);
}
- return jobResponse(await buildAndDeployAllSitesAction(skipArchives));
- }
- const jobs: { siteId: string; jobId: string }[] = [];
- const skipped: { siteId: string; reason: string }[] = [];
- let info = false;
- for (const siteId of reqSiteIds(body)) {
- // ONE SITE'S THROW CANNOT COST THE OTHERS THEIR JOB IDS. An exception
- // out of here becomes a 500 carrying no `jobs` at all, while the builds
- // already queued run on with nobody holding their ids. reqSiteIds
- // rejects the malformed-id case before any job starts; this catches
- // whatever else the action can raise.
- let result: Awaited<ReturnType<typeof buildAndDeployAction>>;
- try {
- result = await buildAndDeployAction(siteId, skipArchives);
- } catch (e) {
- skipped.push({ siteId, reason: (e as Error).message });
- continue;
- }
- if (!result.ok) {
- skipped.push({ siteId, reason: result.error });
- info = info || result.info === true;
- continue;
+ if (preview !== undefined) {
+ throw new OpsInputError(
+ '"preview" is not supported with "all" — name the sites to preview with "siteIds"',
+ );
}
- // The stream is cancelled, never returned — see _lib's header.
- void result.stream.cancel();
- jobs.push({ siteId, jobId: result.jobId });
- }
- if (jobs.length === 0) {
- // One site asked for, one reason: the bare sentence the action gave,
- // exactly as jobResponse has always returned it.
- return opsFail(
- skipped.length === 1
- ? skipped[0].reason
- : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "),
- 400,
- info ? { info: true } : undefined,
- );
+ return jobResponse(await buildAndDeployAllSitesAction(skipArchives));
}
- return NextResponse.json({
- ok: true,
- jobs,
- skipped,
- ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}),
- });
+ 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,
+ );
},
);
}
diff --git a/editor/app/api/ops/deploy-site/route.ts b/editor/app/api/ops/deploy-site/route.ts
@@ -0,0 +1,48 @@
+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";
+
+export const dynamic = "force-dynamic";
+
+// POST { siteId: string | siteIds: string[], preview? }
+// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }],
+// skipped: [{ siteId, reason }], jobId?, previewUrl? }
+//
+// 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.
+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,
+ );
+ });
+}
+
+export function GET() {
+ return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 });
+}