commit 453644de25a6f432fdc4e2648d59469f011dccf0
parent 7dcaea61ed552981295cf907cdab8cc934c98aff
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 11:13:53 -0400
settings: settings.publish (the publish lane) and site.json publish.auto; the publish pipeline lane and its pause gate
- settings.publish {enabled, held, checkEveryMinutes, refreshEveryMinutes,
quietHours, runner, previewBranch, hub, homepage}, sanitized (clamps, a bad
preview name reads "preview", quiet hours both-or-none); SETTINGS.md and
settings.json.example regenerated
- site.json publish: {auto: off|build|preview|production}: absent = off, only
a policy other than off is written; a private site at most builds, a site
with no cloudflareProject at most builds on read; a save of a public deploy
policy with no cloudflareProject is refused, the sentence naming the key;
SITE.md regenerated
- autoQueueTypes: PIPELINE_LANES = ["publish"], not in LANES; PauseLane
widened to AutoQueueKind | "publish"; isGateHeld / withGateHeld read and
write settings.publish.held; the editor's lane pause action saves that
block, and the pause control has the publish lane's words
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
15 files changed, 459 insertions(+), 11 deletions(-)
diff --git a/SETTINGS.md b/SETTINGS.md
@@ -47,6 +47,7 @@ A copied example PINS every default it spells — including each lane's `autoQue
| [`backfill`](#backfill) | object — see below |
| [`attribution`](#attribution) | object — see below |
| [`archiveOrg`](#archiveorg) | object — see below |
+| [`publish`](#publish) | object — see below |
## `adminTitle`
@@ -821,3 +822,37 @@ Default:
"maxUploadKiBps": 0
}
```
+
+## `publish`
+
+The publish LANE (release 18): a runner that, when the index is stale, updates it and then builds — and, where a site's own `publish.auto` (site.json) says so, deploys — what changed, one stage at a time on the `publish` queue. OFF by default; the manual stages (Publish now, Build, Deploy) work either way. See PublishSettings and common/publish/publishRunner.ts.
+
+#### `publish`
+
+| Key | Default | Description |
+|---|---|---|
+| `enabled` | `false` | Whether the publish lane's runner runs (`auto-publish` on /jobs). Off by default. Turning it on starts nothing by itself until the index is stale (or there is no index stamp yet) — see `refreshEveryMinutes`. |
+| `held` | `false` | The lane's pause gate (lib/pauseGates.ts, lane `publish`). A hold stops the runner DISPATCHING: the stage in flight finishes, no next one starts. It never kills a stage. |
+| `checkEveryMinutes` | `10` | How often (minutes) the runner wakes to ask whether a pass is due. Clamped to [1, 1440]; default 10. |
+| `refreshEveryMinutes` | `360` | The least time (minutes) between two index updates the lane starts: a pass runs when the index is stale and the last update is at least this old, or when there is no index stamp. Clamped to [0, 43200]; default 360. 0 = whenever the index is stale. |
+| `quietHours` | `null` | A local-clock window in which the lane starts no pass, `{ "start": 22, "end": 6 }` (hours [0,23], `[start, end)`, may wrap midnight), or null (default). A pass already running finishes its stage and then waits. |
+| `runner` | `"local"` | Which build runner the lane's builds ask for: "local" (default; each site in turn, as a child of the editor) or "docker" (every stale site in containers — a host with a container engine only; in a container it is refused). See PUBLISH.md. |
+| `previewBranch` | `"preview"` | The Pages preview branch a `preview` policy deploys to (`wrangler pages deploy --branch <previewBranch>`). Lowercase letters, digits and dashes, never `main`; an invalid name reads as the default, "preview". |
+| `hub` | `"off"` | The hub's policy: "off" (default), "build", "preview" or "production". The hub is built when the index or the listed sites changed, and deployed as the policy says — to production only with a Pages project in homepage.json. |
+| `homepage` | `"off"` | The homepage's policy, as `hub` (default "off"). Building the homepage also publishes the source mirror (the operator's scrub and denylist files must exist). |
+
+Default:
+
+```json
+{
+ "enabled": false,
+ "held": false,
+ "checkEveryMinutes": 10,
+ "refreshEveryMinutes": 360,
+ "quietHours": null,
+ "runner": "local",
+ "previewBranch": "preview",
+ "hub": "off",
+ "homepage": "off"
+}
+```
diff --git a/SITE.md b/SITE.md
@@ -4,7 +4,7 @@
One public site: its branding, its channel grouping and which channels it exposes, persisted to `transcripts/sites/<id>/site.json` (the directory under `$SITES_DIR` when that is set). The schema is `common/lib/siteSchema.ts`. Global operational settings are `settings.json` — see [SETTINGS.md](SETTINGS.md). The PUBLIC `/site.json` a built site serves is a different file (`common/lib/siteDescriptor.ts`).
-Every key is optional on read. A missing key reads as its default, an ill-typed one as its default (or is dropped, for the optional ones), and an unknown one is dropped on the next save. A save ALWAYS writes `siteId`, `siteTitle`, `siteDescription`, `headerTitle`, `homeTagline`, `groups`, `defaultGroupId` and `channels`; every other key is written only when it differs from its default (`socialLinks` whenever it is an array, even an empty one). A save is REFUSED when there is no channel group, when `defaultGroupId` names no group, or when a social link's SVG is not safe to inline.
+Every key is optional on read. A missing key reads as its default, an ill-typed one as its default (or is dropped, for the optional ones), and an unknown one is dropped on the next save. A save ALWAYS writes `siteId`, `siteTitle`, `siteDescription`, `headerTitle`, `homeTagline`, `groups`, `defaultGroupId` and `channels`; every other key is written only when it differs from its default (`socialLinks` whenever it is an array, even an empty one). A save is REFUSED when there is no channel group, when `defaultGroupId` names no group, when a social link's SVG is not safe to inline, or when a public site's `publish.auto` deploys and it has no `cloudflareProject`.
Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`.
@@ -34,6 +34,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| [`transcriptDownloads`](#transcriptdownloads) | `true` |
| [`archiveMaxBytes`](#archivemaxbytes) | absent |
| [`hubUrl`](#huburl) | absent |
+| [`publish`](#publish) | absent |
## `siteId`
@@ -243,3 +244,17 @@ Default: absent
Per-site override for the hub this site belongs under. Absent = the family default, `settings.json` `homepageUrl`. Published on the public `/site.json` and `/corpus.json` so a hub can tell member sites from arbitrary added origins; the header does not link to it (release 14).
Default: absent
+
+## `publish`
+
+What the publish lane — and Publish now — may do with this site when it is stale (release 18): `{ "auto": "off" | "build" | "preview" | "production" }`. Absent = `off`: the lane leaves the site alone (a manual Build or Deploy still works, and "Build all stale" still builds it). `build` rebuilds its bundle; `preview` also deploys it to the Pages preview branch `settings.json` `publish.previewBranch` names; `production` deploys it to production. A private site is clamped to `build` (it is never deployed); `preview` and `production` need a `cloudflareProject` — a save without one is refused, and a file that says so anyway reads as `build`. Only a policy other than `off` is written.
+
+Default: absent
+
+#### `publish`
+
+Per entry — each entry spells its own values.
+
+| Key | Description |
+|---|---|
+| `auto` | The policy: `off` (default), `build`, `preview` or `production` — see `publish` above. |
diff --git a/common/lib/autoQueueTypes.ts b/common/lib/autoQueueTypes.ts
@@ -208,3 +208,14 @@ export const LANES = [
] as const;
export type AutoQueueKind = (typeof LANES)[number];
+
+// THE PIPELINE LANES (release 18): lanes that dispatch STAGES of a pipeline,
+// not work picked per channel — today the one publish lane. A pipeline lane
+// has a runner, a pause gate (lib/pauseGates.ts) and a console, but NO rule
+// tree: it is deliberately NOT in LANES, because nine loops compile a channel
+// tree per entry of LANES (channelPriority, channelWriters, the storage watch,
+// operationBatch, the channel snapshot, the auto-queue schema …). Its
+// settings are `settings.publish`, not an `autoQueue` policy.
+export const PIPELINE_LANES = ["publish"] as const;
+
+export type PipelineLane = (typeof PIPELINE_LANES)[number];
diff --git a/common/lib/fileSchemaDocs.ts b/common/lib/fileSchemaDocs.ts
@@ -27,6 +27,7 @@ import {
RELATED_SITE_GROUP_FIELD_DOCS,
SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS,
SITE_FIELD_DOCS,
+ SITE_PUBLISH_FIELD_DOCS,
parseSite,
type Site,
} from "./siteSchema";
@@ -50,6 +51,7 @@ const SITE_NESTED: Partial<Record<keyof Site, KeyTable[]>> = {
groups: [{ path: "groups[]", docs: CHANNEL_GROUP_FIELD_DOCS }],
channels: [{ path: "channels[]", docs: SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS }],
relatedSites: [{ path: "relatedSites[]", docs: RELATED_SITE_GROUP_FIELD_DOCS }],
+ publish: [{ path: "publish", docs: SITE_PUBLISH_FIELD_DOCS }],
};
export function renderSiteMarkdown(): string {
@@ -78,8 +80,9 @@ export function renderSiteMarkdown(): string {
"`homeTagline`, `groups`, `defaultGroupId` and `channels`; every other " +
"key is written only when it differs from its default (`socialLinks` " +
"whenever it is an array, even an empty one). A save is REFUSED when " +
- "there is no channel group, when `defaultGroupId` names no group, or " +
- "when a social link's SVG is not safe to inline.",
+ "there is no channel group, when `defaultGroupId` names no group, " +
+ "when a social link's SVG is not safe to inline, or when a public " +
+ "site's `publish.auto` deploys and it has no `cloudflareProject`.",
);
out.push("");
out.push(REGENERATE);
diff --git a/common/lib/pauseGates.test.ts b/common/lib/pauseGates.test.ts
@@ -136,7 +136,7 @@ test("the backfill lane ships held, as its inverted field always made it", () =>
assert.equal(base.autoQueue.backfill.enabled, false, "and unarmed as well");
for (const lane of LANES) {
if (lane === "backfill") continue;
- assert.equal(base.autoQueue[lane].held, false, `${lane} ships free`);
+ assert.equal(isGateHeld(base, lane), false, `${lane} ships free`);
}
});
@@ -232,3 +232,23 @@ test("pauseLaneFor answers for every catalog id", () => {
test("an id the catalog does not know has no lane", () => {
assert.equal(pauseLaneFor("not-an-operation"), null);
});
+
+// RELEASE 18: the publish lane is a PIPELINE lane, not a LANES entry, and its
+// gate is `settings.publish.held` — the one gate not on an `autoQueue` policy.
+test("the publish gate round-trips through settings.publish.held, and touches no other gate", () => {
+ const base = defaultSiteSettings();
+ assert.equal(isGateHeld(base, "publish"), false, "the publish lane ships free");
+ const held = withGateHeld(base, "publish", true);
+ assert.equal(held.publish.held, true);
+ assert.equal(isGateHeld(held, "publish"), true);
+ // The rest of the block is kept (a spread, never a rebuilt literal).
+ assert.deepEqual({ ...held.publish, held: false }, base.publish);
+ assert.equal("publish" in held.autoQueue, false, "no autoQueue.publish is invented");
+ for (const lane of LANES) {
+ assert.equal(isGateHeld(held, lane), isGateHeld(base, lane), `holding publish moved ${lane}`);
+ assert.equal(isGateHeld(withGateHeld(base, lane, true), "publish"), false, `holding ${lane} held publish`);
+ }
+ assert.equal(isGateHeld(withGateHeld(held, "publish", false), "publish"), false);
+ // A partial object (laneGuards.test casts one) answers free, never throws.
+ assert.equal(isGateHeld({} as SiteSettings, "publish"), false);
+});
diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts
@@ -1,5 +1,5 @@
import type { SiteSettings } from "./settings";
-import type { AutoQueueKind } from "./autoQueueTypes";
+import type { AutoQueueKind, PipelineLane } from "./autoQueueTypes";
import { operationCatalog } from "./operations";
import {
BACKFILL_QUEUE,
@@ -46,7 +46,11 @@ import {
// closed that gap, so this is now an alias and the two id spaces cannot drift.
// The name survives because thirteen call sites read as "which lane's gate",
// and because a gate is what this file is about.
-export type PauseLane = AutoQueueKind;
+//
+// WIDENED BY ONE in release 18: the publish lane is a PIPELINE lane
+// (autoQueueTypes.ts PIPELINE_LANES), not a LANES entry, and its gate is
+// `settings.publish.held` — the one gate that is not on an `autoQueue` policy.
+export type PauseLane = AutoQueueKind | PipelineLane;
// WHICH LANE'S GATE HOLDS THIS OPERATION — the answer to "the operator is on
// /operations/diarization and wants to hold it".
@@ -65,7 +69,7 @@ export type PauseLane = AutoQueueKind;
// OPERATION_BY_ID, which knows only the four registry entries. Sync is in that
// walk and its answer is null: the scheduler's `enabled` is its own switch, not
// a lane gate.
-export function pauseLaneFor(operationId: string): PauseLane | null {
+export function pauseLaneFor(operationId: string): AutoQueueKind | null {
const op = operationCatalog().find((o) => o.id === operationId);
if (!op) return null;
if (op.runner) return op.runner;
@@ -95,6 +99,9 @@ export function pauseLaneFor(operationId: string): PauseLane | null {
// `autoQueue` is read defensively: laneGuards.test.ts casts a partial object to
// SiteSettings, and this must answer for it the way it always has.
export function isGateHeld(settings: SiteSettings, lane: PauseLane): boolean {
+ // The publish lane's gate is its own block's (release 18): there is no
+ // `autoQueue.publish`.
+ if (lane === "publish") return settings.publish?.held === true;
return settings.autoQueue?.[lane]?.held === true;
}
@@ -115,6 +122,9 @@ export function withGateHeld(
lane: PauseLane,
held: boolean,
): SiteSettings {
+ if (lane === "publish") {
+ return { ...settings, publish: { ...settings.publish, held } };
+ }
return {
...settings,
autoQueue: {
diff --git a/common/lib/settingsDocs.ts b/common/lib/settingsDocs.ts
@@ -18,6 +18,7 @@ import {
BUILD_PIPELINE_SETTINGS_FIELD_DOCS,
DIARIZATION_SETTINGS_FIELD_DOCS,
PACING_SETTINGS_FIELD_DOCS,
+ PUBLISH_SETTINGS_FIELD_DOCS,
DIGEST_SETTINGS_FIELD_DOCS,
SAVED_VIDEO_BACKUP_SETTINGS_FIELD_DOCS,
SOCIAL_LINK_FIELD_DOCS,
@@ -244,6 +245,13 @@ export function blockTables(d: SiteSettings): Partial<Record<keyof SiteSettings,
defaults: fromObject(d.archiveOrg),
},
],
+ publish: [
+ {
+ path: "publish",
+ docs: PUBLISH_SETTINGS_FIELD_DOCS,
+ defaults: fromObject(d.publish),
+ },
+ ],
};
}
diff --git a/common/lib/settingsSchema.test.ts b/common/lib/settingsSchema.test.ts
@@ -37,6 +37,7 @@ import type {
DiarizationSettings,
DigestSettings,
PacingSettingsBlock,
+ PublishSettings,
ReportDebouncePreset,
SavedVideoBackupSettings,
SocialLink,
@@ -100,13 +101,15 @@ type PreSchemaSiteSettings = {
attribution: AttributionSettings;
// How archive.org files are fetched: BitTorrent with seeding, else direct.
archiveOrg: ArchiveOrgFetchSettings;
+ // The publish lane (release 18).
+ publish: PublishSettings;
};
// Bracketed so the conditional does not distribute (see commit 8c43231).
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const shapeUnchanged: Same<SiteSettings, PreSchemaSiteSettings> = true;
-test("SiteSettings keeps its 35 fields, in file order", () => {
+test("SiteSettings keeps its 36 fields, in file order", () => {
assert.equal(shapeUnchanged, true);
assert.deepEqual(Object.keys(siteSettingsSchema.shape), [
"adminTitle",
@@ -144,6 +147,7 @@ test("SiteSettings keeps its 35 fields, in file order", () => {
"backfill",
"attribution",
"archiveOrg",
+ "publish",
]);
// A parsed object carries every key, in that order — writeSettings writes
// exactly this, so the order is the on-disk order.
@@ -489,3 +493,76 @@ test("the retired sweep fields migrate onto a lane ONLY when the lane is absent"
assert.equal(withFile("{}").autoQueue.digest.enabled, false);
assert.equal(withFile("{}").autoQueue.backfill.enabled, false);
});
+
+// RELEASE 18: the publish lane's block.
+test("publish: defaults, and every field sanitized", () => {
+ const { sanitizePublish, defaultPublish } = S;
+ const d = defaults().publish;
+ assert.deepEqual(d, {
+ enabled: false,
+ held: false,
+ checkEveryMinutes: 10,
+ refreshEveryMinutes: 360,
+ quietHours: null,
+ runner: "local",
+ previewBranch: "preview",
+ hub: "off",
+ homepage: "off",
+ });
+ assert.deepEqual(defaultPublish(), d);
+ for (const raw of [undefined, null, 3, "x", []]) assert.deepEqual(sanitizePublish(raw), d);
+ const good = sanitizePublish({
+ enabled: true,
+ held: true,
+ checkEveryMinutes: 5,
+ refreshEveryMinutes: 0,
+ quietHours: { start: 22, end: 6 },
+ runner: "docker",
+ previewBranch: "r18",
+ hub: "preview",
+ homepage: "production",
+ });
+ assert.deepEqual(good, {
+ enabled: true,
+ held: true,
+ checkEveryMinutes: 5,
+ refreshEveryMinutes: 0,
+ quietHours: { start: 22, end: 6 },
+ runner: "docker",
+ previewBranch: "r18",
+ hub: "preview",
+ homepage: "production",
+ });
+ const bad = sanitizePublish({
+ enabled: "yes",
+ held: 1,
+ checkEveryMinutes: 0,
+ refreshEveryMinutes: 10 ** 9,
+ quietHours: { start: 3, end: 3 },
+ runner: "kubernetes",
+ previewBranch: "main",
+ hub: "always",
+ homepage: null,
+ });
+ assert.deepEqual(bad, {
+ ...d,
+ checkEveryMinutes: 1,
+ refreshEveryMinutes: 43200,
+ });
+ assert.equal(sanitizePublish({ checkEveryMinutes: 99999 }).checkEveryMinutes, 1440);
+ assert.equal(sanitizePublish({ quietHours: { start: 24, end: 6 } }).quietHours, null);
+ assert.equal(sanitizePublish({ quietHours: { start: 1 } }).quietHours, null);
+ assert.equal(sanitizePublish({ previewBranch: "Has Spaces" }).previewBranch, "preview");
+ assert.equal(sanitizePublish({ previewBranch: " r18 " }).previewBranch, "r18");
+});
+
+test("publish: an unknown key inside the block is dropped, and the block round-trips the schema", () => {
+ const parsed = siteSettingsSchema.parse({
+ publish: { enabled: true, held: true, extra: 1, previewBranch: "smoke" },
+ });
+ assert.equal(parsed.publish.enabled, true);
+ assert.equal(parsed.publish.held, true);
+ assert.equal(parsed.publish.previewBranch, "smoke");
+ assert.equal("extra" in parsed.publish, false);
+ assert.deepEqual(siteSettingsSchema.parse(JSON.parse(JSON.stringify(parsed))).publish, parsed.publish);
+});
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -96,6 +96,7 @@ import {
type DigestTimestampMode,
} from "./digest";
import type { FieldDocs } from "./fieldDocs";
+import { previewBranchProblem } from "./pagesDeploy";
import {
sanitizeSocial,
type SocialSettings,
@@ -1563,6 +1564,115 @@ export function clampPageBytes(value: unknown): number {
}
+// --- The publish lane (release 18) ------------------------------------------
+
+// What the lane (and Publish now) may do to one target when it is stale:
+// nothing, build it, build it and deploy it as a preview, or build it and
+// deploy it to production. A site carries its own in site.json
+// (`publish.auto`, lib/siteSchema.ts); the hub and the homepage carry theirs
+// here. A manual Build or Deploy button never asks it.
+export type PublishPolicy = "off" | "build" | "preview" | "production";
+
+export const PUBLISH_POLICIES: readonly PublishPolicy[] = ["off", "build", "preview", "production"];
+
+export function isPublishPolicy(v: unknown): v is PublishPolicy {
+ return v === "off" || v === "build" || v === "preview" || v === "production";
+}
+
+export type PublishQuietHours = { start: number; end: number };
+
+// Each field is documented in PUBLISH_SETTINGS_FIELD_DOCS below.
+export type PublishSettings = {
+ enabled: boolean;
+ held: boolean;
+ checkEveryMinutes: number;
+ refreshEveryMinutes: number;
+ quietHours: PublishQuietHours | null;
+ runner: "local" | "docker";
+ previewBranch: string;
+ hub: PublishPolicy;
+ homepage: PublishPolicy;
+};
+
+export const PUBLISH_SETTINGS_FIELD_DOCS: FieldDocs<PublishSettings> = {
+ enabled:
+ "Whether the publish lane's runner runs (`auto-publish` on /jobs). Off by default. Turning it on starts nothing by itself until the index is stale (or there is no index stamp yet) — see `refreshEveryMinutes`.",
+ held:
+ "The lane's pause gate (lib/pauseGates.ts, lane `publish`). A hold stops the runner DISPATCHING: the stage in flight finishes, no next one starts. It never kills a stage.",
+ checkEveryMinutes:
+ "How often (minutes) the runner wakes to ask whether a pass is due. Clamped to [1, 1440]; default 10.",
+ refreshEveryMinutes:
+ "The least time (minutes) between two index updates the lane starts: a pass runs when the index is stale and the last update is at least this old, or when there is no index stamp. Clamped to [0, 43200]; default 360. 0 = whenever the index is stale.",
+ quietHours:
+ "A local-clock window in which the lane starts no pass, `{ \"start\": 22, \"end\": 6 }` (hours [0,23], `[start, end)`, may wrap midnight), or null (default). A pass already running finishes its stage and then waits.",
+ runner:
+ "Which build runner the lane's builds ask for: \"local\" (default; each site in turn, as a child of the editor) or \"docker\" (every stale site in containers — a host with a container engine only; in a container it is refused). See PUBLISH.md.",
+ previewBranch:
+ "The Pages preview branch a `preview` policy deploys to (`wrangler pages deploy --branch <previewBranch>`). Lowercase letters, digits and dashes, never `main`; an invalid name reads as the default, \"preview\".",
+ hub:
+ "The hub's policy: \"off\" (default), \"build\", \"preview\" or \"production\". The hub is built when the index or the listed sites changed, and deployed as the policy says — to production only with a Pages project in homepage.json.",
+ homepage:
+ "The homepage's policy, as `hub` (default \"off\"). Building the homepage also publishes the source mirror (the operator's scrub and denylist files must exist).",
+};
+
+export const PUBLISH_CHECK_EVERY_DEFAULT_MINUTES = 10;
+export const PUBLISH_CHECK_EVERY_MAX_MINUTES = 1440;
+export const PUBLISH_REFRESH_EVERY_DEFAULT_MINUTES = 360;
+export const PUBLISH_REFRESH_EVERY_MAX_MINUTES = 43200;
+export const PUBLISH_DEFAULT_PREVIEW_BRANCH = "preview";
+
+export function defaultPublish(): PublishSettings {
+ return {
+ enabled: false,
+ held: false,
+ checkEveryMinutes: PUBLISH_CHECK_EVERY_DEFAULT_MINUTES,
+ refreshEveryMinutes: PUBLISH_REFRESH_EVERY_DEFAULT_MINUTES,
+ quietHours: null,
+ runner: "local",
+ previewBranch: PUBLISH_DEFAULT_PREVIEW_BRANCH,
+ hub: "off",
+ homepage: "off",
+ };
+}
+
+function sanitizeQuietHours(value: unknown): PublishQuietHours | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const r = value as Record<string, unknown>;
+ const start = clampHourOrNull(r.start);
+ const end = clampHourOrNull(r.end);
+ // Both valid hours and a non-empty window, or no window at all.
+ if (start === null || end === null || start === end) return null;
+ return { start, end };
+}
+
+// Coerce a raw settings.publish into a clean PublishSettings: every field its
+// default when missing or ill-typed, numbers clamped, the two switches true
+// only when exactly true, a bad preview name the default.
+export function sanitizePublish(value: unknown): PublishSettings {
+ const d = defaultPublish();
+ if (!value || typeof value !== "object" || Array.isArray(value)) return d;
+ const r = value as Record<string, unknown>;
+ const previewBranch =
+ typeof r.previewBranch === "string" && previewBranchProblem(r.previewBranch) === null
+ ? r.previewBranch.trim()
+ : d.previewBranch;
+ return {
+ enabled: r.enabled === true,
+ held: r.held === true,
+ checkEveryMinutes: clampPositiveInt(r.checkEveryMinutes, d.checkEveryMinutes, PUBLISH_CHECK_EVERY_MAX_MINUTES),
+ refreshEveryMinutes: clampIntAllowZero(
+ r.refreshEveryMinutes,
+ d.refreshEveryMinutes,
+ PUBLISH_REFRESH_EVERY_MAX_MINUTES,
+ ),
+ quietHours: sanitizeQuietHours(r.quietHours),
+ runner: r.runner === "docker" ? "docker" : "local",
+ previewBranch,
+ hub: isPublishPolicy(r.hub) ? r.hub : d.hub,
+ homepage: isPublishPolicy(r.homepage) ? r.homepage : d.homepage,
+ };
+}
+
// Coerce a raw settings.transcriptionApps value into a clean keyed map of
// AppInstanceConfig, dropping unknown/ill-typed fields.
export function sanitizeTranscriptionApps(
@@ -1705,6 +1815,9 @@ export const siteSettingsSchema = z.object({
archiveOrg: settingsField((v): ArchiveOrgFetchSettings => sanitizeArchiveOrg(v)).describe(
"How archive.org files are fetched (controller/archiveOrgDownload.ts). Over BitTorrent with aria2c when the item's torrent carries the file — archive.org is the torrent's web seed, so the swarm takes load off archive.org — then seeded for a while; otherwise, or when the torrent stalls, a direct download from archive.org. Either way the file is verified against archive.org's sha1/md5. No yt-dlp.",
),
+ publish: settingsField((v): PublishSettings => sanitizePublish(v)).describe(
+ "The publish LANE (release 18): a runner that, when the index is stale, updates it and then builds — and, where a site's own `publish.auto` (site.json) says so, deploys — what changed, one stage at a time on the `publish` queue. OFF by default; the manual stages (Publish now, Build, Deploy) work either way. See PublishSettings and common/publish/publishRunner.ts.",
+ ),
});
export type SiteSettings = z.infer<typeof siteSettingsSchema>;
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -18,6 +18,7 @@ import {
isValidSiteId,
parseSite,
parseSiteUrl,
+ sitePublishProblem,
siteToDisk,
type Site,
} from "./siteSchema";
@@ -245,6 +246,10 @@ export async function writeSite(
`Default group "${String(site.defaultGroupId)}" is not in the configured groups`,
);
}
+ // A public site whose publish policy deploys needs a Pages project (release
+ // 18). A private one is clamped to "build" by siteToDisk, not refused.
+ const publishProblem = sitePublishProblem(site);
+ if (publishProblem) throw new Error(publishProblem);
// undefined socialLinks = inherit the global default; only validate/persist a
// key when the site explicitly overrides (an array, even empty).
// A link whose SVG is unchanged from the file on disk is kept as it is; a new
diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts
@@ -10,11 +10,14 @@ import {
SITE_FIELD_DOCS,
SITE_KEYS,
channelsOnlyOnUnlistedSites,
+ clampSitePublishPolicy,
isCitedSite,
isListedSite,
parseSite,
parseSiteReports,
siteFieldsSchema,
+ sitePublishPolicy,
+ sitePublishProblem,
siteToDisk,
type Site,
} from "./siteSchema";
@@ -336,7 +339,9 @@ test("legacy publish: \"cited\" reads as search off and is rewritten as search:
const legacy = parseSite("s", { publish: "cited" });
assert.equal(legacy.search, false);
assert.equal(isCitedSite(legacy), true);
- assert.equal("publish" in legacy, false);
+ // `publish` is a key again since release 18 (the lane's policy), and the
+ // legacy string is not one: it reads as absent and is never written back.
+ assert.equal(legacy.publish, undefined);
const disk = siteToDisk(legacy);
assert.equal(disk.search, false);
assert.equal("publish" in disk, false);
@@ -472,3 +477,58 @@ test("patchSite applies a patch to the site on disk now, keeps every other key,
assert.equal(getSite("s", paths).siteId, "s");
assert.equal(fs.existsSync(siteConfigFile(paths, "other")), false);
});
+
+// RELEASE 18: the per-site publish policy.
+test("publish.auto: off by default, the four values read, anything else is off", () => {
+ assert.equal(parseSite("s", {}).publish, undefined);
+ assert.equal(sitePublishPolicy(parseSite("s", {})), "off");
+ const withProject = { cloudflareProject: "proj" };
+ for (const auto of ["build", "preview", "production"] as const) {
+ assert.deepEqual(parseSite("s", { ...withProject, publish: { auto } }).publish, { auto });
+ }
+ for (const bad of [{ auto: "off" }, { auto: "always" }, { auto: 3 }, "production", [], null, {}]) {
+ assert.equal(parseSite("s", { ...withProject, publish: bad }).publish, undefined, JSON.stringify(bad));
+ }
+ // The legacy report-only switch is a string: it is not a policy.
+ const legacy = parseSite("s", { publish: "cited" });
+ assert.equal(legacy.publish, undefined);
+ assert.equal(legacy.search, false);
+});
+
+test("publish.auto: a private site at most builds; no Pages project, no deploy policy", () => {
+ const priv = parseSite("s", { audience: "private", cloudflareProject: "p", publish: { auto: "production" } });
+ assert.deepEqual(priv.publish, { auto: "build" });
+ const noProject = parseSite("s", { publish: { auto: "preview" } });
+ assert.deepEqual(noProject.publish, { auto: "build" });
+ assert.deepEqual(parseSite("s", { publish: { auto: "build" } }).publish, { auto: "build" });
+ assert.equal(clampSitePublishPolicy({ audience: "private" }, "preview"), "build");
+ assert.equal(clampSitePublishPolicy({ cloudflareProject: " " }, "production"), "build");
+ assert.equal(clampSitePublishPolicy({ cloudflareProject: "p" }, "production"), "production");
+ assert.equal(clampSitePublishPolicy({}, "off"), "off");
+});
+
+test("publish.auto: only a policy other than off is written, clamped", () => {
+ const base = parseSite("s", { cloudflareProject: "p" });
+ assert.equal("publish" in siteToDisk(base), false);
+ assert.equal("publish" in siteToDisk({ ...base, publish: { auto: "off" } }), false);
+ assert.deepEqual(siteToDisk({ ...base, publish: { auto: "preview" } }).publish, { auto: "preview" });
+ assert.deepEqual(
+ siteToDisk({ ...base, audience: "private", publish: { auto: "production" } }).publish,
+ { auto: "build" },
+ );
+});
+
+test("writeSite refuses a public deploy policy with no cloudflareProject, naming the key", async () => {
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
+ const base = parseSite("s", {});
+ await assert.rejects(writeSite({ ...base, publish: { auto: "production" } }, paths), /cloudflareProject/);
+ assert.equal(sitePublishProblem({ publish: { auto: "preview" } })?.includes("publish.auto"), true);
+ assert.equal(sitePublishProblem({ publish: { auto: "build" } }), null);
+ assert.equal(sitePublishProblem({ audience: "private", publish: { auto: "production" } }), null);
+ assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false);
+ // A private site is clamped, not refused; a public one with a project saves.
+ await writeSite({ ...base, audience: "private", publish: { auto: "production" } }, paths);
+ assert.deepEqual(getSite("s", paths).publish, { auto: "build" });
+ await writeSite({ ...base, cloudflareProject: "proj", publish: { auto: "production" } }, paths);
+ assert.deepEqual(getSite("s", paths).publish, { auto: "production" });
+});
diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts
@@ -38,7 +38,12 @@ import {
} from "./channelGroups";
import { parseAccentSetting } from "./accent";
import { wordmarkLeadFor } from "./brand";
-import { parseSocialLinks, type SocialLink } from "./settingsSchema";
+import {
+ isPublishPolicy,
+ parseSocialLinks,
+ type PublishPolicy,
+ type SocialLink,
+} from "./settingsSchema";
import { settingsField } from "./settingsFieldSchemas";
import type { FieldDocs } from "./fieldDocs";
import { REPORT_ID_RE, isReportId } from "./report/schema";
@@ -157,6 +162,7 @@ export type Site = {
transcriptDownloads?: boolean;
archiveMaxBytes?: number;
hubUrl?: string;
+ publish?: SitePublish;
};
export const SITE_FIELD_DOCS: FieldDocs<Site> = {
@@ -204,8 +210,56 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
"Per-site served-file size cap in bytes: any archive larger is dropped from what is served and flagged in the manifest, so a capped host (Cloudflare Pages: 25 MB) will not reject the deploy. 0 = no cap. Absent = the global default. Negative or non-numeric values are dropped.",
hubUrl:
"Per-site override for the hub this site belongs under. Absent = the family default, `settings.json` `homepageUrl`. Published on the public `/site.json` and `/corpus.json` so a hub can tell member sites from arbitrary added origins; the header does not link to it (release 14).",
+ publish:
+ "What the publish lane — and Publish now — may do with this site when it is stale (release 18): `{ \"auto\": \"off\" | \"build\" | \"preview\" | \"production\" }`. Absent = `off`: the lane leaves the site alone (a manual Build or Deploy still works, and \"Build all stale\" still builds it). `build` rebuilds its bundle; `preview` also deploys it to the Pages preview branch `settings.json` `publish.previewBranch` names; `production` deploys it to production. A private site is clamped to `build` (it is never deployed); `preview` and `production` need a `cloudflareProject` — a save without one is refused, and a file that says so anyway reads as `build`. Only a policy other than `off` is written.",
+};
+
+// Each field is documented in SITE_PUBLISH_FIELD_DOCS below.
+export type SitePublish = { auto: PublishPolicy };
+
+export const SITE_PUBLISH_FIELD_DOCS: FieldDocs<SitePublish> = {
+ auto:
+ "The policy: `off` (default), `build`, `preview` or `production` — see `publish` above.",
};
+// The policy a site may have, given who it is for and where it can go: a
+// private site at most builds, and a site with no Pages project cannot be
+// deployed to one. Pure; parseSite and siteToDisk both apply it.
+export function clampSitePublishPolicy(
+ site: Pick<Site, "audience" | "cloudflareProject">,
+ policy: PublishPolicy,
+): PublishPolicy {
+ if (policy !== "preview" && policy !== "production") return policy;
+ if (isPrivateSite(site)) return "build";
+ if (!site.cloudflareProject?.trim()) return "build";
+ return policy;
+}
+
+// THE ONE READER of a site's publish policy: absent is "off".
+export function sitePublishPolicy(site: Pick<Site, "publish">): PublishPolicy {
+ return site.publish?.auto ?? "off";
+}
+
+// Why a save of this site's publish policy is refused, or null. A public site
+// that asks the lane to deploy needs somewhere to deploy to; the sentence
+// names the key. A private site is not refused: it is clamped to `build`.
+export function sitePublishProblem(
+ site: Pick<Site, "audience" | "cloudflareProject" | "publish">,
+): string | null {
+ const policy = site.publish?.auto;
+ if (policy !== "preview" && policy !== "production") return null;
+ if (isPrivateSite(site)) return null;
+ if (site.cloudflareProject?.trim()) return null;
+ return `publish.auto "${policy}" deploys the site, and it has no cloudflareProject — set the Pages project, or choose "build"`;
+}
+
+function parseSitePublish(v: unknown): SitePublish | undefined {
+ if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
+ const auto = (v as Record<string, unknown>).auto;
+ if (!isPublishPolicy(auto) || auto === "off") return undefined;
+ return { auto };
+}
+
// siteId shares the group-id grammar: lowercase slug, used as a directory name.
export const SITE_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
@@ -376,6 +430,9 @@ export const siteFieldsSchema = z.object({
),
archiveMaxBytes: settingsField(archiveMaxBytesOf).describe(d.archiveMaxBytes),
hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl),
+ // Clamped against `audience` and `cloudflareProject` in the object step
+ // below. The legacy `publish: "cited"` (a string) reads as absent here.
+ publish: settingsField(parseSitePublish).describe(d.publish),
});
// The whole schema: the per-key object, then the three sibling-dependent
@@ -404,6 +461,8 @@ export const siteSchema = z.preprocess(migrateLegacyPublish, siteFieldsSchema).t
}
return c;
}),
+ // A private site at most builds; no Pages project, no deploy policy.
+ publish: s.publish ? { auto: clampSitePublishPolicy(s, s.publish.auto) } : undefined,
};
const out: Partial<Record<keyof Site, unknown>> = {};
for (const key of SITE_KEYS) out[key] = resolved[key];
@@ -438,6 +497,8 @@ export function siteToDisk(site: Site): Site {
const siteUrl = parseSiteUrl(site.siteUrl);
const hubUrl = parseSiteUrl(site.hubUrl);
const archiveMaxBytes = archiveMaxBytesOf(site.archiveMaxBytes);
+ const parsedPublish = parseSitePublish(site.publish);
+ const publishPolicy = parsedPublish ? clampSitePublishPolicy(site, parsedPublish.auto) : "off";
return {
siteId: site.siteId,
siteTitle: site.siteTitle,
@@ -471,6 +532,8 @@ export function siteToDisk(site: Site): Site {
...(site.transcriptDownloads === false ? { transcriptDownloads: false } : {}),
...(archiveMaxBytes !== undefined ? { archiveMaxBytes } : {}),
...(hubUrl ? { hubUrl } : {}),
+ // Off is the default: only another policy is persisted (clamped).
+ ...(publishPolicy !== "off" ? { publish: { auto: publishPolicy } } : {}),
};
}
diff --git a/editor/app/components/lanes/pauseControl.tsx b/editor/app/components/lanes/pauseControl.tsx
@@ -76,6 +76,21 @@ const PAUSE_COPY: Record<PauseLane, { held: PauseCopy; free: PauseCopy }> = {
"Resume digest generation. The running job picks up where it left off — it was holding, not stopped.",
},
},
+ // The publish lane (release 18): a hold stops it dispatching the next stage;
+ // the stage in flight finishes.
+ publish: {
+ free: {
+ label: "Hold the lane",
+ ariaLabel: "pause publishing",
+ title:
+ "Hold the publish lane: the stage in flight finishes and no next one starts. Manual Build, Deploy and Publish now still work.",
+ },
+ held: {
+ label: "Resume the lane",
+ ariaLabel: "resume publishing",
+ title: "Resume the publish lane. Its next check decides whether a pass is due.",
+ },
+ },
backfill: {
free: {
label: "Hold the lane",
diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts
@@ -197,7 +197,9 @@ async function setLaneHeld(
try {
const cur = getSettings();
if (isGateHeld(cur, lane) !== held) {
- await saveSettings({ autoQueue: withGateHeld(cur, lane, held).autoQueue });
+ const next = withGateHeld(cur, lane, held);
+ // The publish lane's gate is its own block (release 18), not a policy.
+ await saveSettings(lane === "publish" ? { publish: next.publish } : { autoQueue: next.autoQueue });
}
} catch (e) {
// REPORTED, not swallowed. The workers page's old best-effort persist
diff --git a/settings.json.example b/settings.json.example
@@ -196,5 +196,16 @@
"maxPeers": 30,
"maxDownloadKiBps": 0,
"maxUploadKiBps": 0
+ },
+ "publish": {
+ "enabled": false,
+ "held": false,
+ "checkEveryMinutes": 10,
+ "refreshEveryMinutes": 360,
+ "quietHours": null,
+ "runner": "local",
+ "previewBranch": "preview",
+ "hub": "off",
+ "homepage": "off"
}
}