// 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; // allowMissingMedia: a report citation with no prepared media is let // through compose (`archilyzer publish build --allow-missing-media`; the ops // publish route's build verb, release 19 A8). build?: { force?: boolean; skipArchives?: boolean; allowMissingMedia?: 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 { const bad = targetProblem(paths, ask.target); if (bad) return bad; if (!ask.deploy) return null; // A preview is asked BY NAME: a preview with none is refused, never let fall // through to production (a plan step with no `preview` is production). if (ask.deploy.where === "preview" || ask.deploy.preview !== undefined) { const p = previewBranchProblem(ask.deploy.preview ?? ""); if (p) return p; } const r = await resolveDeployRequest(paths, { kind: stageKindsOf(ask.target).deploy as "deploy-site" | "deploy-hub" | "deploy-homepage", target: ask.target, preview: ask.deploy.where === "preview" ? ask.deploy.preview : undefined, to: ask.deploy.where === "local" ? "local" : "pages", }); if (!("problem" in r)) return null; if (ask.build && r.problem.startsWith("no build of ")) return null; return r.problem; } /** * What the asks enqueue, in order: the index update when `withIndex` says so * and the index is not fresh; then per ask its build (forced) and its deploy. */ export function wantedPlan( status: Pick, 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 } : {}), ...(ask.build.allowMissingMedia ? { allowMissingMedia: true } : {}), ...(indexStep ? { indexAfter: runStart } : {}), }); } if (ask.deploy) { const preview = ask.deploy.where === "preview" ? ask.deploy.preview?.trim() : undefined; if (ask.deploy.where === "preview" && !preview) { throw new Error(`a preview deploy of ${ask.target} names no branch`); } const project = preview ? projectOf(paths, ask.target) : null; steps.push({ kind: kinds.deploy, target: ask.target, reason: "asked", ...(preview ? { preview } : {}), ...(ask.deploy.where === "local" ? { to: "local" as const } : {}), ...(ask.deploy.force ? { force: true } : {}), ...(ask.build ? { builtAfter: runStart } : {}), ...(preview && project ? { previewUrl: previewAliasUrl(project, preview) } : {}), }); } } return { runStart, steps, skipped: [] }; } /** The plan for the Pool's Build index: the index update, always (a no-op when fresh). */ export function indexPlan(runStart = Date.now()): PublishPlan { return { runStart, steps: [{ kind: "update-index", target: INDEX_TARGET, reason: "asked" }], skipped: [] }; } export type EnqueuedRun = PublishRunResult & { // With `keepStreams`: each enqueued job's stream, in plan order. parts: RunPart[]; }; /** * Enqueue a plan under one run id. With `keepStreams` every new job's stream * is teed before enqueuePublishRun releases it, so one console can follow the * run (publishRunStream.ts followRun). */ export async function enqueueRun( paths: Paths, plan: PublishPlan, opts: { keepStreams?: boolean; runId?: string } = {}, ): Promise { const kept = new Map; done: Promise }>(); const enqueue: typeof enqueueStage = async (p, req, o) => { const res = await enqueueStage(p, req, o); if (!res.ok || !opts.keepStreams) return res; const [released, keep] = res.stream.tee(); kept.set(res.jobId, { stream: keep, done: res.done }); return { ...res, stream: released }; }; let run: PublishRunResult; try { run = await enqueuePublishRun(paths, plan, { runId: opts.runId ?? newPublishRunId(), enqueue }); } catch (e) { // The jobs already enqueued run on (their logs are on disk); a kept branch // nobody will read would buffer its job's whole log in memory. for (const k of kept.values()) void k.stream.cancel().catch(() => {}); throw e; } const parts: RunPart[] = run.jobs.map((j) => ({ label: stepLabel(j), jobId: j.jobId, ...(j.existing ? { existing: true } : {}), ...(kept.get(j.jobId) ?? {}), })); return { ...run, parts }; } /** One line per skipped target of a plan ("skipped hub: its policy is off"). */ export function skippedLines(skipped: PlanSkip[]): string[] { return skipped.map((s) => `[publish] skipped ${stepLabel(s)}: ${s.reason}`); } /** The status every surface reads (re-exported for the server pages). */ export async function publishStatus(paths: Paths = getPaths()): Promise { return readPublishStatus(paths); }