Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Meditor/app/sites/lib/buildAction.ts | 18+++++++++++++++++-
Meditor/app/sites/lib/buildDeployCore.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Meditor/app/sites/lib/deployAction.ts | 24++++++++++++++++++++++++
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}).`);