commit 9ebc5d241b7a5436b33e6dc8075c9c219bd9caa7
parent 1aeecab984ff4d15b430cfe7a463f3318226f815
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 08:54:34 -0400
sites: deploy can go to a preview branch instead of production
wrangler with no --branch infers the branch from the checkout, so every deploy
this app has ever run was production. Naming a branch makes it a Cloudflare
preview at a stable alias, and naming an unusable one is a refusal rather than
a quiet fall-through to production — the one mistake the feature must not make,
so build-and-deploy checks the name before it spends a build learning about a
typo. The deploy also now prints the URL on a line of its own: the streamed log
scrolls, and an operator who looked away had nowhere else to find it. Previews
say once that they share the production R2 bucket, because "harmless due to a
size check" is something to be told, not to discover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 102 insertions(+), 11 deletions(-)
diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts
@@ -15,12 +15,14 @@ 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 { 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,
dockerAvailable,
dockerSiteOutDir,
resolveOutDir,
@@ -103,15 +105,27 @@ export async function buildExportAction(
// 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);
if (!site.cloudflareProject) {
return {
@@ -133,7 +147,8 @@ export async function buildAndDeployAction(
if (buildCode !== 0) {
throw new Error(`Build failed (exit ${buildCode}) — not deploying.`);
}
- onLog("\n=== Deploy ===\n");
+ 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);
@@ -147,6 +162,7 @@ export async function buildAndDeployAction(
site,
resolveOutDir(id, paths),
paths,
+ { previewBranch: branch },
);
if (signal.aborted) return;
if (deployCode !== 0) {
diff --git a/editor/app/sites/lib/buildDeployCore.ts b/editor/app/sites/lib/buildDeployCore.ts
@@ -10,6 +10,11 @@ import { createReadStream, existsSync } from "node:fs";
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { runChildIntoLog } from "yt-dlp-transcript-common/jobs/runChild";
+import {
+ deploymentUrlIn,
+ pagesDeployArgs,
+ previewAliasUrl,
+} from "yt-dlp-transcript-common/lib/pagesDeploy";
import type { Paths } from "yt-dlp-transcript-common/lib/paths";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import type { Site } from "yt-dlp-transcript-common/lib/site";
@@ -93,6 +98,18 @@ function archiveStagingDir(siteId: string, paths: Paths): string {
);
}
+// Said once at the top of every preview deploy, because the one thing a preview
+// does NOT isolate is the archive bucket: R2 has no per-branch namespace, so a
+// preview's oversize archives overwrite the keys production's manifest points
+// at. That is cheap and harmless in practice — the upload skips any object R2
+// already holds at the same size, and an unchanged channel re-zips byte-stable
+// — but "harmless because of a size check" is exactly the kind of thing an
+// operator should be told rather than left to discover.
+export const PREVIEW_SHARES_ARCHIVES_NOTICE =
+ "[notice] A preview shares the production R2 archive bucket — unchanged " +
+ "archives are skipped, so this is cheap, but a CHANGED archive replaces the " +
+ "one production links to.\n";
+
// Cache-Control set on every uploaded archive. Served through a Cloudflare custom
// domain, this lets the CDN absorb repeated/abusive downloads at the edge instead
// of hitting R2 (each origin GET is a billable Class B op), which is the main cost
@@ -230,24 +247,42 @@ export async function runArchiveUploadIntoLog(
// Pages project, streaming into `onLog`, returning the exit code. Runs on the
// host with the host's Cloudflare credentials (process.env) — deploy never runs
// inside a container, so container wrangler auth is never needed.
+//
+// `opts.previewBranch` makes it a PREVIEW deploy: Cloudflare treats a deploy to
+// any branch but the project's production branch as a preview, reachable at the
+// branch alias. Omitting it leaves the argv byte-identical to what production
+// has always run — no `--branch`, so wrangler infers the branch from the
+// checkout, which is the long-standing behaviour (and the long-standing hazard:
+// a "production" deploy run from a non-main checkout silently becomes a
+// preview).
+//
+// Either way, the URL wrangler prints ("Take a peek over at …") earns one
+// terminal line of its own, because the streamed log scrolls and an operator
+// who looked away has nowhere else to find it. A preview also gets the stable
+// branch alias, which is knowable without reading the log at all.
export async function runDeployIntoLog(
onLog: (line: string) => void,
signal: AbortSignal,
site: Site,
outDir: string,
paths: Paths,
+ opts?: { previewBranch?: string },
): Promise<number> {
- return runChildIntoLog(onLog, signal, {
+ const project = site.cloudflareProject as string;
+ const previewBranch = opts?.previewBranch?.trim() || undefined;
+
+ // Spot the deployment URL as it streams past rather than re-reading the
+ // finished log file: the log is the operator's too, and buffering it a second
+ // time to grep it would double a big deploy's memory for one line of output.
+ let deploymentUrl: string | null = null;
+ const watch = (line: string) => {
+ if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, project);
+ onLog(line);
+ };
+
+ const code = await runChildIntoLog(watch, signal, {
command: "pnpm",
- args: [
- "dlx",
- "wrangler",
- "pages",
- "deploy",
- outDir,
- "--project-name",
- site.cloudflareProject as string,
- ],
+ args: ["dlx", ...pagesDeployArgs({ outDir, project, previewBranch })],
cwd: paths.exportDir,
env: {
...process.env,
@@ -257,6 +292,22 @@ export async function runDeployIntoLog(
SITE_ID: site.siteId,
},
});
+
+ // Only on success. A URL scraped out of a failed run points at nothing — or
+ // worse, at the deployment that is still live.
+ if (code === 0) {
+ if (previewBranch) {
+ const alias = previewAliasUrl(project, previewBranch);
+ onLog(
+ `[preview] ${alias}` +
+ (deploymentUrl ? ` (this deployment: ${deploymentUrl})` : "") +
+ "\n",
+ );
+ } else if (deploymentUrl) {
+ onLog(`[deployed] ${deploymentUrl}\n`);
+ }
+ }
+ return code;
}
// ---------------------------------------------------------------------------
diff --git a/editor/app/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts
@@ -1,12 +1,14 @@
"use server";
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 {
+ PREVIEW_SHARES_ARCHIVES_NOTICE,
resolveOutDir,
runArchiveUploadIntoLog,
runDeployIntoLog,
@@ -14,13 +16,28 @@ import {
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);
if (!site.cloudflareProject) {
return {
@@ -33,6 +50,12 @@ export async function deployExportAction(
queueKey: DEPLOY_QUEUE,
paths,
fn: async (onLog, signal) => {
+ // The production path logs no banner and gains none here: its log has
+ // always opened on wrangler's own first line.
+ if (branch) {
+ onLog(`=== Deploy (preview "${branch}") ===\n`);
+ onLog(PREVIEW_SHARES_ARCHIVES_NOTICE);
+ }
// Push oversize archives to R2 first, so the manifest URLs the Pages
// deploy publishes resolve immediately. No-op when R2 isn't configured.
const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths);
@@ -46,6 +69,7 @@ export async function deployExportAction(
site,
resolveOutDir(site.siteId, paths),
paths,
+ { previewBranch: branch },
);
if (signal.aborted) return;
if (code !== 0) throw new Error(`Deploy failed (exit ${code}).`);