commit af8948946c67afbf8f6e10f11f40b81445d4b315
parent ef3fc94e99f3810b6b3009d284da24b54a93e630
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 12:29:15 -0400
editor: /operations/publish (the lane's runner, hold, settings and plan) and a site's publish policy on its form
A static route beside operations/[id]: the runner pill and Start / Drain /
Stop (startPublishLaneAction & co. in publishActions.ts), the hold
(pauseLaneControl, lane "publish"), last check / next check / last pass /
last decision, the plan a pass would run, and settings.publish's form
(savePublishSettingsAction: enabled, check/refresh minutes, quiet hours,
runner, preview branch, hub and homepage policies). setLaneHeld revalidates
/operations/publish and /sites for the publish lane; the operations board
links the page. SiteForm gains "Publish policy" (site.json publish.auto) —
writeSite's refusal of a deploying policy without a Pages project is the
form's error, and a save no longer drops the key.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 491 insertions(+), 0 deletions(-)
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -215,6 +215,10 @@ async function setLaneHeld(
revalidatePath("/jobs");
revalidatePath("/workers");
revalidatePath("/operations/[id]", "page");
+ if (lane === "publish") {
+ revalidatePath("/operations/publish");
+ revalidatePath("/sites");
+ }
return { ok: true };
}
diff --git a/editor/app/operations/components/OperationsBoard.tsx b/editor/app/operations/components/OperationsBoard.tsx
@@ -1,6 +1,7 @@
"use client";
import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/autoQueueStatus";
+import Link from "next/link";
import type { SyncRowView } from "../syncRow";
import { OperationRail } from "./OperationRail";
import { railStates } from "./railStates";
@@ -52,6 +53,17 @@ export function OperationsBoard({
selectedId={null}
sync={sync}
/>
+ {/* THE PUBLISH LANE (release 18) is a pipeline lane, not an operation of
+ the catalog: a plain row below the rail, to its own page. */}
+ <Link
+ href="/operations/publish"
+ className="flex items-baseline justify-between gap-3 rounded-md border border-border px-3 py-2 text-sm hover:bg-muted"
+ >
+ <span className="font-medium">Publishing</span>
+ <span className="text-muted-foreground">
+ the publish lane — index, builds, deploys
+ </span>
+ </Link>
</div>
);
}
diff --git a/editor/app/operations/components/PublishLaneView.tsx b/editor/app/operations/components/PublishLaneView.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "yt-dlp-transcript-common/components/ui/button";
+import type { PublishLaneStatus, PublishPlan } from "yt-dlp-transcript-common/views/publishStatus";
+import { PauseLaneButton } from "../../components/lanes/pauseControl";
+import {
+ drainPublishLaneAction,
+ startPublishLaneAction,
+ stopPublishLaneAction,
+ type LaneControlResult,
+} from "../../sites/lib/publishActions";
+import { Chip } from "../../sites/components/PublishPanel";
+import { useHydrated } from "./useOperationsStatus";
+
+// The publish lane's console (release 18): the runner and its three controls
+// (Start / Drain / Stop — the runner, as on every lane), the hold (the LANE's
+// gate: a hold stops dispatching between stages and never kills one), what the
+// runner last did and when it looks next, and the plan a pass would run now.
+//
+// The page is server-rendered from readPublishStatus; this refreshes it every
+// few seconds while it is open, and after every control.
+
+const REFRESH_MS = 5_000;
+
+function at(ms: number | null): string {
+ return ms === null ? "—" : new Date(ms).toLocaleString();
+}
+
+export function PublishLaneView({
+ lane,
+ plan,
+}: {
+ lane: PublishLaneStatus;
+ plan: Pick<PublishPlan, "steps" | "skipped">;
+}) {
+ const router = useRouter();
+ const hydrated = useHydrated();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const running = lane.live.running;
+
+ useEffect(() => {
+ const t = setInterval(() => router.refresh(), REFRESH_MS);
+ return () => clearInterval(t);
+ }, [router]);
+
+ async function control(action: () => Promise<LaneControlResult>) {
+ setBusy(true);
+ setError(null);
+ try {
+ const r = await action();
+ if (!r.ok) setError(r.error ?? "The control failed.");
+ } finally {
+ setBusy(false);
+ router.refresh();
+ }
+ }
+
+ return (
+ <div className="flex flex-col gap-4" data-hydrated={hydrated ? "true" : undefined}>
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
+ <h2 className="font-display text-lg font-semibold tracking-tight">Publish lane</h2>
+ <span
+ className={`rounded-full border px-3 py-1 text-sm ${
+ running ? "border-success/30 bg-success-soft text-success" : "border-border bg-card text-muted-foreground"
+ }`}
+ >
+ {running ? "Runner running" : "Runner stopped"}
+ </span>
+ <Chip name="lane" chip={lane.chip} />
+ <span className="ml-auto flex flex-wrap gap-2">
+ <Button
+ type="button"
+ size="sm"
+ aria-label="Start publish lane"
+ onClick={() => control(startPublishLaneAction)}
+ disabled={busy || running}
+ >
+ Start
+ </Button>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ aria-label="Drain publish lane"
+ title="Finish the stage in flight, start no next one, then stop"
+ onClick={() => control(drainPublishLaneAction)}
+ disabled={busy || !running}
+ className="border-warning/30 text-warning hover:bg-warning-soft hover:text-warning"
+ >
+ Drain
+ </Button>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ aria-label="Stop publish lane"
+ title="Stop the runner now; a stage in flight finishes as its own job (Cancel it on /jobs)"
+ onClick={() => control(stopPublishLaneAction)}
+ disabled={busy || !running}
+ >
+ Stop
+ </Button>
+ <PauseLaneButton lane="publish" held={lane.held} onChange={() => router.refresh()} />
+ </span>
+ </div>
+ {error && (
+ <p role="alert" className="text-sm text-destructive">
+ {error}
+ </p>
+ )}
+
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm" data-testid="publish-lane-facts">
+ <dt className="text-muted-foreground">Lane</dt>
+ <dd>
+ {lane.enabled ? "on" : "off"}
+ {lane.held ? " · held" : ""}
+ {lane.quietNow ? " · quiet hours" : ""}
+ {lane.blockedReason ? ` — ${lane.blockedReason}` : ""}
+ </dd>
+ <dt className="text-muted-foreground">A pass now</dt>
+ <dd>
+ {lane.due ? "due" : "not due"} — {lane.reason}
+ </dd>
+ <dt className="text-muted-foreground">Last check</dt>
+ <dd>{at(lane.live.lastCheckAt)}</dd>
+ <dt className="text-muted-foreground">Next check</dt>
+ <dd>{at(lane.live.nextCheckAt)}</dd>
+ <dt className="text-muted-foreground">Last pass</dt>
+ <dd>
+ {at(lane.live.lastPassAt)}
+ {lane.live.lastPassSummary ? ` — ${lane.live.lastPassSummary}` : ""}
+ </dd>
+ <dt className="text-muted-foreground">Last decision</dt>
+ <dd>{lane.live.lastDecision ?? "—"}</dd>
+ {lane.live.jobId && (
+ <>
+ <dt className="text-muted-foreground">Runner</dt>
+ <dd>
+ <Link href={`/jobs/${lane.live.jobId}`} className="underline underline-offset-2">
+ job log
+ </Link>
+ </dd>
+ </>
+ )}
+ </dl>
+
+ <div className="text-sm" data-testid="publish-lane-plan">
+ <h3 className="font-semibold">What a pass would run now</h3>
+ {plan.steps.length === 0 ? (
+ <p className="text-muted-foreground">Nothing: every target its policies cover is current.</p>
+ ) : (
+ <ol className="ml-5 list-decimal">
+ {plan.steps.map((s) => (
+ <li key={`${s.kind}:${s.target}:${s.preview ?? ""}`}>
+ <code>
+ {s.kind} {s.target}
+ {s.preview ? ` --preview ${s.preview}` : ""}
+ </code>{" "}
+ <span className="text-muted-foreground">— {s.reason}</span>
+ </li>
+ ))}
+ </ol>
+ )}
+ {plan.skipped.length > 0 && (
+ <ul className="mt-1 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>
+ )}
+ <p className="mt-2 text-xs text-muted-foreground">
+ The same plan is <strong>Publish now</strong> on{" "}
+ <Link href="/sites" className="underline">
+ /sites
+ </Link>
+ ; a site’s policy is set on its own page.
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/editor/app/operations/components/settings/PublishSettingsForm.tsx b/editor/app/operations/components/settings/PublishSettingsForm.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useActionState } from "react";
+import type { PublishSettings } from "yt-dlp-transcript-common/lib/settings";
+import { Field } from "../../../components/forms/Field";
+import { seedChecked, seedValue } from "../../../lib/formState";
+import { savePublishSettingsAction } from "../../settingsActions";
+import type { SaveResult } from "../../../settings/actions";
+
+// `settings.publish` (release 18), every field but `held` — the hold button
+// beside the runner writes that one. The policies of the hub and the homepage
+// live here; a SITE's policy is its own (site.json `publish.auto`, set on the
+// site's page).
+
+const POLICIES = [
+ ["off", "Off"],
+ ["build", "Build"],
+ ["preview", "Preview"],
+ ["production", "Production"],
+] as const;
+
+function PolicySelect({
+ label,
+ name,
+ initial,
+ state,
+}: {
+ label: string;
+ name: string;
+ initial: string;
+ state: SaveResult | undefined;
+}) {
+ return (
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">{label}</span>
+ <select
+ name={name}
+ defaultValue={seedValue(state, name, initial)}
+ className="w-48 rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ {POLICIES.map(([v, l]) => (
+ <option key={v} value={v}>
+ {l}
+ </option>
+ ))}
+ </select>
+ </label>
+ );
+}
+
+export function PublishSettingsForm({ initial }: { initial: PublishSettings }) {
+ const [state, formAction, pending] = useActionState<SaveResult | undefined, FormData>(
+ savePublishSettingsAction,
+ undefined,
+ );
+
+ return (
+ <form action={formAction} data-settings-block="publish" className="flex flex-col gap-3">
+ <fieldset className="flex flex-col gap-3 rounded border border-border p-3">
+ <legend className="px-1 text-sm font-medium">Publish lane settings</legend>
+ <label className="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ name="publishEnabled"
+ defaultChecked={seedChecked(state, "publishEnabled", initial.enabled)}
+ className="mt-1"
+ />
+ <span className="flex flex-col gap-1">
+ <span className="font-medium">Run the publish lane</span>
+ <span className="text-xs text-muted-foreground">
+ The runner wakes, and when the index is stale (or a policy target is left stale) it updates
+ the index, then builds and deploys what each policy says — one stage at a time on the publish
+ queue. Off, the buttons on /sites still run.
+ </span>
+ </span>
+ </label>
+ <Field
+ label="Check every (minutes)"
+ name="publishCheckEveryMinutes"
+ type="number"
+ min="1"
+ state={state}
+ defaultValue={String(initial.checkEveryMinutes)}
+ hint="How often the runner asks whether a pass is due. 1–1440."
+ />
+ <Field
+ label="Refresh every (minutes)"
+ name="publishRefreshEveryMinutes"
+ type="number"
+ min="0"
+ state={state}
+ defaultValue={String(initial.refreshEveryMinutes)}
+ hint="The least time between two index updates the lane starts; 0 = whenever the index is stale."
+ />
+ <div className="flex flex-wrap gap-3">
+ <Field
+ label="Quiet hours: start (0–23)"
+ name="publishQuietStart"
+ type="number"
+ min="0"
+ state={state}
+ defaultValue={initial.quietHours ? String(initial.quietHours.start) : ""}
+ placeholder="none"
+ />
+ <Field
+ label="Quiet hours: end (0–23)"
+ name="publishQuietEnd"
+ type="number"
+ min="0"
+ state={state}
+ defaultValue={initial.quietHours ? String(initial.quietHours.end) : ""}
+ placeholder="none"
+ hint="No pass starts in [start, end) on this machine's clock (may wrap midnight). Both empty: none."
+ />
+ </div>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Build runner</span>
+ <select
+ name="publishRunner"
+ defaultValue={seedValue(state, "publishRunner", initial.runner)}
+ className="w-48 rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="local">Local (one site at a time)</option>
+ <option value="docker">Docker (host only)</option>
+ </select>
+ <span className="text-xs text-muted-foreground">
+ Docker builds every stale site in containers — a host with a container engine only; refused in
+ a container.
+ </span>
+ </label>
+ <Field
+ label="Preview branch"
+ name="publishPreviewBranch"
+ state={state}
+ defaultValue={initial.previewBranch}
+ hint="Where a “preview” policy deploys (wrangler --branch). Lowercase letters, digits and dashes; never main."
+ />
+ <div className="flex flex-wrap gap-4">
+ <PolicySelect label="Hub policy" name="publishHub" initial={initial.hub} state={state} />
+ <PolicySelect label="Homepage policy" name="publishHomepage" initial={initial.homepage} state={state} />
+ </div>
+ <div className="flex items-center gap-3">
+ <button
+ type="submit"
+ disabled={pending}
+ className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending ? "Saving…" : "Save publish settings"}
+ </button>
+ {state?.ok === true && (
+ <span role="status" className="text-sm text-success">
+ Saved.
+ </span>
+ )}
+ {state?.ok === false && (
+ <span role="alert" className="text-sm text-destructive">
+ {state.error}
+ </span>
+ )}
+ </div>
+ </fieldset>
+ </form>
+ );
+}
diff --git a/editor/app/operations/publish/page.tsx b/editor/app/operations/publish/page.tsx
@@ -0,0 +1,44 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState";
+import { PublishLaneView } from "../components/PublishLaneView";
+import { PublishSettingsForm } from "../components/settings/PublishSettingsForm";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Publish lane" };
+
+// THE PUBLISH LANE (release 18) — a static route beside `operations/[id]`
+// (Next prefers it): the publish lane is not an operation of the catalog
+// (lib/operations.ts), whose settings switch and lane keys are the ingest
+// lanes', so it has its own page rather than a catalog entry.
+//
+// Everything on it is read through readPublishStatus — the same status the
+// /sites Publish panel, `archilyzer publish status` and `GET /api/ops/publish`
+// read.
+export default async function PublishLanePage() {
+ const status = await readPublishStatus(getPaths());
+ const settings = getSettings().publish;
+ return (
+ <div className="flex max-w-4xl flex-col gap-6">
+ <div>
+ <h1 className="text-2xl font-semibold">Publishing</h1>
+ <p className="text-sm text-muted-foreground">
+ The lane keeps the published sites current: when the index is stale it updates it, then builds
+ what changed and deploys where each target’s policy says. Every stage runs on the{" "}
+ <code>publish</code> queue, one at a time. The rows and their buttons are on{" "}
+ <Link href="/sites" className="underline">
+ /sites
+ </Link>
+ .
+ </p>
+ </div>
+ <PublishLaneView
+ lane={status.lane}
+ plan={{ steps: status.plan.steps, skipped: status.plan.skipped }}
+ />
+ <PublishSettingsForm initial={settings} />
+ </div>
+ );
+}
diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts
@@ -24,6 +24,8 @@ import {
isDigestTimestampMode,
} from "yt-dlp-transcript-common/lib/digest";
import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates";
+import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
+import { sanitizePublish } from "yt-dlp-transcript-common/lib/settingsSchema";
import type { SaveResult } from "../settings/actions";
import { formValues } from "../lib/formState";
@@ -280,3 +282,48 @@ export async function saveAttributionSettingsAction(
revalidateOperations();
return { ok: true };
}
+
+// The publish lane (release 18): `settings.publish`, saved by
+// PublishSettingsForm on /operations/publish. `held` is not on the form — the
+// hold button writes it (operations/actions.ts setLaneHeld) — so it is carried
+// from the stored block. A preview branch name the sanitizer would silently
+// replace by the default is refused instead, with the sentence the deploy
+// buttons use.
+export async function savePublishSettingsAction(
+ _prev: SaveResult | undefined,
+ formData: FormData,
+): Promise<SaveResult> {
+ const values = formValues(formData);
+ const current = getSettings().publish;
+ const text = (key: string) => String(formData.get(key) ?? "").trim();
+ const previewBranch = text("publishPreviewBranch") || current.previewBranch;
+ const branchProblem = previewBranchProblem(previewBranch);
+ if (branchProblem) return { ok: false, error: branchProblem, values };
+ const quietStart = text("publishQuietStart");
+ const quietEnd = text("publishQuietEnd");
+ if ((quietStart === "") !== (quietEnd === "")) {
+ return { ok: false, error: "Quiet hours need both a start and an end hour (or neither).", values };
+ }
+ const next = sanitizePublish({
+ ...current,
+ enabled: formData.get("publishEnabled") === "on",
+ checkEveryMinutes: num(formData, "publishCheckEveryMinutes", current.checkEveryMinutes),
+ refreshEveryMinutes: num(formData, "publishRefreshEveryMinutes", current.refreshEveryMinutes),
+ quietHours: quietStart === "" ? null : { start: Number(quietStart), end: Number(quietEnd) },
+ runner: text("publishRunner") || current.runner,
+ previewBranch,
+ hub: text("publishHub") || current.hub,
+ homepage: text("publishHomepage") || current.homepage,
+ });
+ if (quietStart !== "" && next.quietHours === null) {
+ return { ok: false, error: "Quiet hours are two different hours from 0 to 23.", values };
+ }
+ try {
+ await saveSettings({ publish: next });
+ } catch (e) {
+ return { ok: false, error: (e as Error).message, values };
+ }
+ revalidatePath("/operations/publish");
+ revalidatePath("/sites");
+ return { ok: true };
+}
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -1,5 +1,6 @@
"use server";
+import { PUBLISH_POLICIES, type PublishPolicy } from "yt-dlp-transcript-common/lib/settingsSchema";
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import {
@@ -118,6 +119,14 @@ export async function saveSiteAction(
return { ok: false, error: `Unknown audience "${audienceRaw}".`, values };
}
const isPrivate = audienceRaw === "private";
+ // The publish lane's policy for this site (release 18). writeSite refuses a
+ // deploying policy with no Pages project (sitePublishProblem) and stores a
+ // private site's as "build".
+ const publishAutoRaw = String(formData.get("publishAuto") ?? "off");
+ if (!(PUBLISH_POLICIES as readonly string[]).includes(publishAutoRaw)) {
+ return { ok: false, error: `Unknown publish policy "${publishAutoRaw}".`, values };
+ }
+ const publishAuto = publishAutoRaw as PublishPolicy;
// Search (opt-out; off = report-only) and the report list, which is not on
// this form: the stored one is carried over.
let storedReports: Pick<Site, "reports"> | undefined;
@@ -265,6 +274,7 @@ export async function saveSiteAction(
// The Site is rebuilt from the form: a key missing here is dropped on save.
...(listed ? {} : { listed: false }),
...(isPrivate ? { audience: "private" as const } : {}),
+ ...(publishAuto !== "off" ? { publish: { auto: publishAuto } } : {}),
...reportKeys,
...(hubUrl ? { hubUrl } : {}),
...(pwa ? { pwa: true } : {}),
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -361,6 +361,26 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
into while Settings keeps X posts private.
</span>
</label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Publish policy</span>
+ <SeededSelect
+ state={state}
+ name="publishAuto"
+ aria-label="Publish policy"
+ initial={initial.publish?.auto ?? "off"}
+ className="w-fit rounded border border-border bg-card px-2 py-1 text-sm"
+ >
+ <option value="off">Off — built and deployed only when you press a button</option>
+ <option value="build">Build — Publish now and the publish lane rebuild it when it is stale</option>
+ <option value="preview">Preview — and deploy it to the preview branch</option>
+ <option value="production">Production — and deploy it to production</option>
+ </SeededSelect>
+ <span className="text-xs text-muted-foreground">
+ What Publish now and the publish lane do for this site (site.json{" "}
+ <code>publish.auto</code>). Preview and Production need a Cloudflare
+ Pages project; a private site is built, never deployed.
+ </span>
+ </label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"