Archilyzer · Source

archilyzer

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

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:
MSETTINGS.md | 35+++++++++++++++++++++++++++++++++++
MSITE.md | 17++++++++++++++++-
Mcommon/lib/autoQueueTypes.ts | 11+++++++++++
Mcommon/lib/fileSchemaDocs.ts | 7+++++--
Mcommon/lib/pauseGates.test.ts | 22+++++++++++++++++++++-
Mcommon/lib/pauseGates.ts | 16+++++++++++++---
Mcommon/lib/settingsDocs.ts | 8++++++++
Mcommon/lib/settingsSchema.test.ts | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/settingsSchema.ts | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/site.ts | 5+++++
Mcommon/lib/siteSchema.test.ts | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/siteSchema.ts | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/components/lanes/pauseControl.tsx | 15+++++++++++++++
Meditor/app/operations/actions.ts | 4+++-
Msettings.json.example | 11+++++++++++
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" } }