// THE PUBLISH STATUS AND THE PUBLISH PLAN (release 18) — pure. // // One state builder (the plan's "Surfaces"): `publishState.ts` reads the // stamps, the sites, the settings, the config mtimes, the ingest job metas and // the live publish jobs into a `PublishInputs`; `buildPublishStatus(inputs, // now)` folds them into the `PublishStatus` every surface reads — the /sites // Publish panel, `archilyzer publish status`, `GET /api/ops/publish` and the // lane; `planPublishRun(status, opts)` says what a run would enqueue — what // Publish now enqueues all at once, and what the lane dispatches one stage at // a time. // // PURE: no fs, no clock, no singleton. `now` is an argument. Every freshness // question is the stage's own `needs()` (publish/stages.ts) over the // `NeedsInput` built here — the status never re-implements a stage's rule; it // only adds the two signals a stage child cannot see (`changedChannels` from // the job metas, and the config mtimes) and the chips' words. // // WHY HERE AND NOT IN views/: the lane's runner (publish/publishRunner.ts) // plans from it, and the publish layer may not import views/ (common/ // architecture.test.ts). `views/publishStatus.ts` re-exports it as the view // layer's name for it. SERVER-SIDE VALUES: it imports stages.ts, which imports // the stamp readers; a client component imports its TYPES only. import type { PublishPolicy, PublishSettings } from "../lib/settingsSchema"; import { previewAliasUrl } from "../lib/pagesDeploy"; import { isInQuietHours } from "../jobs/syncScheduler"; import { STAGES, builtCheckedAt, deployKindOf, type Freshness, type NeedsInput, type StageKind, type StageRequest, type TargetState, } from "./stages"; import { ALL_TARGET, HOMEPAGE_TARGET, HUB_TARGET, INDEX_TARGET, deployRecordFor, type BuiltStamp, type DeployKind, type DeployRecord, type DeployedFile, type IndexStamp, type LiveCheck, } from "./stamps"; // --------------------------------------------------------------------------- // Inputs (what publishState.ts reads) // --------------------------------------------------------------------------- /** A publish stage job, queued or running, on the `publish` queue. */ export type PublishJob = { id: string; kind: StageKind; target: string; status: "queued" | "running"; runId: string | null; queuedAt: number; startedAt?: number; preview?: string; to?: "pages" | "local"; }; /** The newest publish stage job of a kind + target that ENDED. */ export type PublishJobEnded = { id: string; kind: StageKind; target: string; status: "done" | "failed" | "cancelled"; exitCode: number | null; endedAt: number; runId: string | null; // The run's preconditions the job carried (a refusal for want of one reads // "waiting for …"). indexAfter?: number; builtAfter?: number; preview?: string; to?: "pages" | "local"; }; /** The lane runner's live state (publishLaneState.ts), as the editor holds it. */ export type PublishLaneLive = { // Whether this process can know (the CLI cannot: the lane is the editor's). known: boolean; running: boolean; jobId: string | null; passRunning: boolean; lastCheckAt: number | null; nextCheckAt: number | null; lastPassAt: number | null; lastPassSummary: string | null; lastDecision: string | null; }; export type PublishTargetInput = { built: BuiltStamp | null; deployed: DeployedFile | null; bundleProblem: string | null; // Never deployed anywhere (a private site), or null. deployProblem: string | null; // Cannot go to Cloudflare Pages (no project), or null. pagesProblem: string | null; // The newest mtime of a config file this target's build reads. configChangedAt: number | null; policy: PublishPolicy; // The Pages project, for a preview's alias URL. cloudflareProject: string | null; // The public URL the live check reads. url: string | null; }; export type PublishSiteInput = PublishTargetInput & { siteId: string; title: string; private: boolean; listed: boolean; // Member channel slugs (site.json `channels`). members: string[]; }; export type PublishInputs = { index: { stamp: IndexStamp | null; // When the newest ingest ended (ms), or null. lastIngestDoneAt: number | null; // The newest mtime of an index input config file, or null. configChangedAt: number | null; // indexSettingsSig of the settings now (stages.ts NeedsInput). settingsSig?: string; }; // Per channel: when its newest ingest ended (a job meta, or its report's // regeneration — see publishState.ts). ingestByChannel: Record; // The code this editor (or CLI) runs: a build of another commit reads // "code newer" — never stale. commit: string | null; sites: PublishSiteInput[]; hub: PublishTargetInput; homepage: PublishTargetInput & { mainHead: string | null }; settings: PublishSettings; jobs: PublishJob[]; ended: PublishJobEnded[]; lane: PublishLaneLive; }; // --------------------------------------------------------------------------- // The status // --------------------------------------------------------------------------- export type ChipTone = "ok" | "stale" | "blocked" | "busy" | "warn" | "off"; export type Chip = { tone: ChipTone; text: string }; export type BuildStale = { reason: "channels" | "data" | "config"; changedChannels: string[]; }; export type PublishTargetStatus = { target: string; kind: "site" | "hub" | "homepage"; title: string; indexFresh: boolean; built: BuiltStamp | null; // The bundle is what the CURRENT index would build (same signature). builtFromCurrentIndex: boolean; // The build stage's own answer (needs()). buildFreshness: Freshness; buildStale?: BuildStale; // Built by another commit than the one running: says so, never stale. codeNewer: boolean; deployed: DeployedFile | null; // Where the policy deploys (null: it does not), and that record. deployKind: DeployKind | null; deployRecord: DeployRecord | null; // The record the chips read is of the bundle on disk. deployedIsBuilt: boolean; previewUrl?: string; liveCheck?: LiveCheck; policy: PublishPolicy; // The Pages project (a preview's alias URL is built from it), or null. cloudflareProject: string | null; // The public URL the live check reads, or null. url: string | null; deployProblem?: string; bundleProblem?: string; // What happens next, in words ("build, then deploy to preview", …). next: string; running?: PublishJob; queued: PublishJob[]; // The newest ended build / deploy job (for a refusal's words). lastBuild?: PublishJobEnded; lastDeploy?: PublishJobEnded; chips: { index: Chip; built: Chip; deployed: Chip; live: Chip }; }; export type PublishLaneStatus = { enabled: boolean; held: boolean; quietNow: boolean; busy: boolean; live: PublishLaneLive; // Whether a pass would run now, and why (or why not). due: boolean; reason: string; // Why the lane will not dispatch at all right now (off, held, quiet hours, // a publish job queued or running), or null. blockedReason: string | null; chip: Chip; }; export type PlanStep = Omit & { reason: string; previewUrl?: string; }; export type PlanSkip = { kind: StageKind; target: string; reason: string }; export type PublishPlan = { runStart: number; steps: PlanStep[]; skipped: PlanSkip[]; }; export type PublishStatus = { now: number; commit: string | null; index: { stamp: IndexStamp | null; freshness: Freshness; fresh: boolean; running?: PublishJob; queued: PublishJob[]; lastRun?: PublishJobEnded; chip: Chip; }; sites: PublishTargetStatus[]; hub: PublishTargetStatus; homepage: PublishTargetStatus; // Any publish stage queued or running. busy: boolean; jobs: PublishJob[]; settings: PublishSettings; lane: PublishLaneStatus; // What Publish now would enqueue (deploys by policy). plan: PublishPlan; // What the stages' needs() read. needs: NeedsInput; }; const MIN = 60_000; function namesList(slugs: string[], max = 4): string { const shown = slugs.slice(0, max).join(", "); return slugs.length > max ? `${shown}, …` : shown; } /** "3 min ago", "2 h ago", "4 d ago" — the chips' one clock word. */ export function agoText(then: number, now: number): string { const ms = Math.max(0, now - then); if (ms < MIN) return "just now"; if (ms < 60 * MIN) return `${Math.floor(ms / MIN)} min ago`; if (ms < 48 * 60 * MIN) return `${Math.floor(ms / (60 * MIN))} h ago`; return `${Math.floor(ms / (24 * 60 * MIN))} d ago`; } function changedSince( members: Iterable, byChannel: Record, since: number, ): string[] { const out = new Set(); for (const slug of members) { const at = byChannel[slug]; if (at !== undefined && at > since) out.add(slug); } return [...out].sort(); } function targetState(t: PublishTargetInput, changedChannels: string[]): TargetState { return { built: t.built, deployed: t.deployed, changedChannels, configChangedAt: t.configChangedAt, bundleProblem: t.bundleProblem, deployProblem: t.deployProblem, pagesProblem: t.pagesProblem, }; } /** The NeedsInput the stages' needs() read, with the two signals a child cannot see. */ export function needsInputOf(i: PublishInputs): NeedsInput { const sites: Record = {}; for (const s of i.sites) { const changed = s.built ? changedSince(s.members, i.ingestByChannel, builtCheckedAt(s.built)) : []; sites[s.siteId] = targetState(s, changed); } const listedMembers = new Set(i.sites.filter((s) => s.listed).flatMap((s) => s.members)); const hubChanged = i.hub.built ? changedSince(listedMembers, i.ingestByChannel, builtCheckedAt(i.hub.built)) : []; const homeChanged = i.homepage.built ? changedSince(Object.keys(i.ingestByChannel), i.ingestByChannel, builtCheckedAt(i.homepage.built)) : []; return { index: { ...i.index }, sites, hub: targetState(i.hub, hubChanged), homepage: { ...targetState(i.homepage, homeChanged), mainHead: i.homepage.mainHead }, }; } function req(kind: StageKind, target: string, extra: Partial = {}): StageRequest { return { kind, target, runId: "status", ...extra }; } /** The build stage that builds `kind`'s target. */ function buildKindOf(kind: PublishTargetStatus["kind"]): StageKind { return kind === "site" ? "build-site" : kind === "hub" ? "build-hub" : "build-homepage"; } function deployStageOf(kind: PublishTargetStatus["kind"]): StageKind { return kind === "site" ? "deploy-site" : kind === "hub" ? "deploy-hub" : "deploy-homepage"; } /** Where a policy deploys: production, the preview branch, or nowhere. */ export function policyDeploy( policy: PublishPolicy, previewBranch: string, ): { to: DeployKind; preview?: string } | null { if (policy === "production") return { to: "production" }; if (policy === "preview") return { to: "preview", preview: previewBranch }; return null; } // The deploy record the chips read: the policy's destination when it deploys, // else production, else local, else the newest preview. function primaryRecord( deployed: DeployedFile | null, dest: { to: DeployKind; preview?: string } | null, ): DeployRecord | null { if (!deployed) return null; if (dest) return deployRecordFor(deployed, dest.to, dest.preview); if (deployed.production) return deployed.production; if (deployed.local) return deployed.local; const previews = Object.values(deployed.previews).sort((a, b) => b.at - a.at); return previews[0] ?? null; } function previewUrlOf(t: PublishTargetInput, previewBranch: string): string | undefined { const rec = t.deployed?.previews[previewBranch] ?? null; if (rec?.alias) return rec.alias; if (rec?.url) return rec.url; if (t.policy === "preview" && t.cloudflareProject) return previewAliasUrl(t.cloudflareProject, previewBranch); return undefined; } function buildStaleOf( t: PublishTargetInput, state: TargetState, sigMatches: boolean, ): BuildStale | undefined { if (!t.built) return undefined; if (state.changedChannels.length > 0) return { reason: "channels", changedChannels: state.changedChannels }; if (t.configChangedAt !== null && t.configChangedAt > builtCheckedAt(t.built)) { return { reason: "config", changedChannels: [] }; } if (!sigMatches) return { reason: "data", changedChannels: [] }; return undefined; } function liveChip(check: LiveCheck | undefined): Chip { if (!check) return { tone: "off", text: "not checked" }; switch (check.verdict) { case "ok": return { tone: "ok", text: "live ok" }; case "stale-edge": return { tone: "warn", text: "live: the edge serves an older build" }; case "mismatch": return { tone: "warn", text: "live: a different build answers" }; case "unreachable": return { tone: "warn", text: "live: unreachable" }; default: return { tone: "off", text: "live check skipped" }; } } // The words for a stage job that ended refused (exit 3) or failed. function endedWords(e: PublishJobEnded, what: "build" | "deploy"): Chip | null { if (e.status === "done") return null; if (e.status === "cancelled") return { tone: "warn", text: `last ${what} cancelled` }; if (e.exitCode === 3) { if (what === "deploy" && e.builtAfter !== undefined) return { tone: "blocked", text: "waiting for its build" }; if (what === "build" && e.indexAfter !== undefined) return { tone: "blocked", text: "waiting for the index" }; return { tone: "blocked", text: `last ${what} refused: precondition not met` }; } return { tone: "warn", text: `last ${what} failed` }; } function pickJobs(jobs: PublishJob[], kinds: StageKind[], target: string): { running?: PublishJob; queued: PublishJob[] } { const mine = jobs.filter( (j) => kinds.includes(j.kind) && (j.target === target || (j.target === ALL_TARGET && kinds.includes("build-site"))), ); return { running: mine.find((j) => j.status === "running"), queued: mine.filter((j) => j.status === "queued").sort((a, b) => a.queuedAt - b.queuedAt), }; } function newestEnded(ended: PublishJobEnded[], kind: StageKind, target: string): PublishJobEnded | undefined { return ended .filter((e) => e.kind === kind && (e.target === target || (kind === "build-site" && e.target === ALL_TARGET))) .sort((a, b) => b.endedAt - a.endedAt)[0]; } function targetStatus( i: PublishInputs, needs: NeedsInput, now: number, indexFresh: boolean, indexChip: Chip, kind: PublishTargetStatus["kind"], target: string, title: string, t: PublishTargetInput, state: TargetState, ): Omit { const stamp = needs.index.stamp; const buildKind = buildKindOf(kind); const buildFreshness = STAGES[buildKind].needs(needs, req(buildKind, target)); let sigMatches = true; let builtFromCurrentIndex = false; if (t.built && stamp) { if (kind === "site") sigMatches = stamp.sites[target] !== undefined && t.built.inputSig === stamp.sites[target].inputSig; else if (kind === "hub") sigMatches = t.built.inputSig === stamp.hubSig; else sigMatches = t.built.indexStampId === stamp.stampId; builtFromCurrentIndex = sigMatches; } const buildStale = buildStaleOf(t, state, sigMatches); const codeNewer = Boolean(t.built?.commit && i.commit && t.built.commit !== i.commit); const dest = policyDeploy(t.policy, i.settings.previewBranch); const deployRecord = primaryRecord(t.deployed, dest); const deployedIsBuilt = Boolean(t.built && deployRecord && deployRecord.builtStampId === t.built.stampId); const build = pickJobs(i.jobs, [buildKind], target); const deploy = pickJobs(i.jobs, [deployStageOf(kind)], target); const running = build.running ?? deploy.running; const queued = [...build.queued, ...deploy.queued]; const lastBuild = newestEnded(i.ended, buildKind, target); const lastDeploy = newestEnded(i.ended, deployStageOf(kind), target); // --- built chip let built: Chip; if (build.running) built = { tone: "busy", text: "building…" }; else if (build.queued.length > 0) built = { tone: "busy", text: "build queued" }; else if (buildFreshness.state === "blocked") built = { tone: "blocked", text: buildFreshness.reason }; else if (!t.built) built = { tone: "stale", text: "never built" }; else if (buildStale?.reason === "channels") { const n = buildStale.changedChannels.length; built = { tone: "stale", text: `stale: ${n} channel${n === 1 ? "" : "s"} changed (${namesList(buildStale.changedChannels)})` }; } else if (buildStale?.reason === "config") built = { tone: "stale", text: "stale: config changed" }; else if (buildStale?.reason === "data") built = { tone: "stale", text: "stale: data changed" }; else if (t.bundleProblem) built = { tone: "warn", text: `bundle: ${t.bundleProblem}` }; else built = { tone: "ok", text: `built ${agoText(t.built.builtAt, now)}` }; if (built.tone !== "busy" && lastBuild && (!t.built || lastBuild.endedAt > t.built.builtAt)) { const w = endedWords(lastBuild, "build"); if (w && built.tone !== "ok") built = { tone: w.tone, text: `${built.text} · ${w.text}` }; } if (codeNewer && t.built) built = { ...built, text: `${built.text} · code newer` }; // --- deployed chip let deployed: Chip; if (deploy.running) deployed = { tone: "busy", text: "deploying…" }; else if (deploy.queued.length > 0) deployed = { tone: "busy", text: "deploy queued" }; else if (t.deployProblem) deployed = { tone: "off", text: "never deployed (private)" }; else if (!deployRecord) { deployed = dest ? { tone: "stale", text: dest.to === "preview" ? `not on preview "${dest.preview}"` : "never deployed" } : { tone: "off", text: "not deployed" }; } else { const where = deployRecord.kind === "preview" ? `preview ${deployRecord.branch ?? ""}`.trim() : deployRecord.kind; deployed = deployedIsBuilt ? { tone: "ok", text: `${where} · ${agoText(deployRecord.at, now)}` } : { tone: "stale", text: `${where}: a newer build is not deployed` }; } if (deployed.tone !== "busy" && lastDeploy && (!deployRecord || lastDeploy.endedAt > deployRecord.at)) { const w = endedWords(lastDeploy, "deploy"); if (w) deployed = w.tone === "blocked" ? w : { tone: w.tone, text: `${deployed.text} · ${w.text}` }; } const liveCheck = deployRecord?.liveCheck ?? undefined; return { target, kind, title, indexFresh, built: t.built, builtFromCurrentIndex, buildFreshness, ...(buildStale ? { buildStale } : {}), codeNewer, deployed: t.deployed, deployKind: dest?.to ?? null, deployRecord, deployedIsBuilt, ...(previewUrlOf(t, i.settings.previewBranch) ? { previewUrl: previewUrlOf(t, i.settings.previewBranch) } : {}), ...(liveCheck ? { liveCheck } : {}), policy: t.policy, cloudflareProject: t.cloudflareProject, url: t.url, ...(t.deployProblem ? { deployProblem: t.deployProblem } : {}), ...(t.bundleProblem ? { bundleProblem: t.bundleProblem } : {}), ...(running ? { running } : {}), queued, ...(lastBuild ? { lastBuild } : {}), ...(lastDeploy ? { lastDeploy } : {}), chips: { index: indexChip, built, deployed, live: liveChip(liveCheck) }, }; } // --------------------------------------------------------------------------- // The lane's decision // --------------------------------------------------------------------------- export type PassDecision = { run: boolean; reason: string }; /** * Whether the lane may dispatch at all now: off, held, quiet hours, or a * publish stage someone else queued. Null when it may. */ export function laneBlockedReason( settings: PublishSettings, busy: boolean, now: number, ): string | null { if (!settings.enabled) return "the lane is off"; if (settings.held) return "held"; if (settings.quietHours && isInQuietHours(now, settings.quietHours.start, settings.quietHours.end)) { return "quiet hours"; } if (busy) return "a publish stage is queued or running"; return null; } /** * Is a pass due? When the lane may dispatch AND: there is no index stamp; or * the index is stale and its last update is at least `refreshEveryMinutes` * old; or the policies have work left (a target stale since the last update — * a failed stage, a policy just switched on) and the last pass is at least * that old. */ export function publishPassDecision(a: { settings: PublishSettings; stamp: IndexStamp | null; indexFresh: boolean; busy: boolean; now: number; lastPassAt: number | null; policyWork: boolean; }): PassDecision { const blocked = laneBlockedReason(a.settings, a.busy, a.now); if (blocked) return { run: false, reason: blocked }; if (!a.stamp) return { run: true, reason: "no index stamp yet" }; const refreshMs = a.settings.refreshEveryMinutes * MIN; if (!a.indexFresh) { const age = a.now - a.stamp.builtAt; if (age >= refreshMs) return { run: true, reason: "the index is stale" }; return { run: false, reason: `the index is stale; the next update is due ${agoText(a.now - (refreshMs - age), a.now).replace(" ago", "")} from now`, }; } if (a.policyWork) { if (a.lastPassAt === null || a.now - a.lastPassAt >= refreshMs) { return { run: true, reason: "a target's policy has work left" }; } return { run: false, reason: "a target's policy has work left; waiting out the refresh interval" }; } return { run: false, reason: "nothing to do: the index and every policy target are current" }; } // --------------------------------------------------------------------------- // buildPublishStatus // --------------------------------------------------------------------------- export function buildPublishStatus(i: PublishInputs, now: number): PublishStatus { const needs = needsInputOf(i); const indexFreshness = STAGES["update-index"].needs(needs, req("update-index", INDEX_TARGET)); const indexFresh = indexFreshness.state === "fresh"; const indexJobs = pickJobs(i.jobs, ["update-index"], INDEX_TARGET); const lastIndex = newestEnded(i.ended, "update-index", INDEX_TARGET); const stamp = i.index.stamp; let indexChip: Chip; if (indexJobs.running) indexChip = { tone: "busy", text: "updating…" }; else if (indexJobs.queued.length > 0) indexChip = { tone: "busy", text: "update queued" }; else if (!stamp) indexChip = { tone: "stale", text: "no index yet" }; else if (indexFreshness.state !== "fresh") indexChip = { tone: "stale", text: `stale: ${indexFreshness.reason}` }; else indexChip = { tone: "ok", text: `fresh · updated ${agoText(stamp.builtAt, now)}` }; if (indexChip.tone !== "busy" && lastIndex && lastIndex.status !== "done" && (!stamp || lastIndex.endedAt > stamp.builtAt)) { indexChip = { tone: "warn", text: `${indexChip.text} · last update ${lastIndex.status}` }; } const sites = [...i.sites] .sort((a, b) => a.siteId.localeCompare(b.siteId)) .map((s) => targetStatus(i, needs, now, indexFresh, indexChip, "site", s.siteId, s.title, s, needs.sites[s.siteId]), ); const hub = targetStatus(i, needs, now, indexFresh, indexChip, "hub", HUB_TARGET, "Hub", { ...i.hub, policy: i.settings.hub }, needs.hub); const homepage = targetStatus( i, needs, now, indexFresh, indexChip, "homepage", HOMEPAGE_TARGET, "Homepage", { ...i.homepage, policy: i.settings.homepage }, needs.homepage, ); const busy = i.jobs.length > 0; const partial = { now, commit: i.commit, index: { stamp, freshness: indexFreshness, fresh: indexFresh, ...(indexJobs.running ? { running: indexJobs.running } : {}), queued: indexJobs.queued, ...(lastIndex ? { lastRun: lastIndex } : {}), chip: indexChip, }, busy, jobs: i.jobs, settings: i.settings, needs, }; // The plan reads the targets without their `next` (it writes it). const draft = { ...partial, sites: sites.map((s) => ({ ...s, next: "" })), hub: { ...hub, next: "" }, homepage: { ...homepage, next: "" }, lane: undefined as unknown as PublishLaneStatus, plan: { runStart: now, steps: [], skipped: [] } as PublishPlan, } satisfies PublishStatus; const plan = planPublishRun(draft, { deploys: "policy" }); const withNext = (t: PublishTargetStatus): PublishTargetStatus => ({ ...t, next: nextWords(t, plan) }); const policyWork = plan.steps.some((s) => s.kind !== "update-index"); const blockedReason = laneBlockedReason(i.settings, busy, now); const decision = publishPassDecision({ settings: i.settings, stamp, indexFresh, busy, now, lastPassAt: i.lane.lastPassAt, policyWork, }); const quietNow = Boolean( i.settings.quietHours && isInQuietHours(now, i.settings.quietHours.start, i.settings.quietHours.end), ); const lane: PublishLaneStatus = { enabled: i.settings.enabled, held: i.settings.held, quietNow, busy, live: i.lane, due: decision.run, reason: decision.reason, blockedReason, chip: laneChip(i, blockedReason, decision), }; return { ...partial, sites: draft.sites.map(withNext), hub: withNext(draft.hub), homepage: withNext(draft.homepage), lane, plan, }; } function laneChip(i: PublishInputs, blocked: string | null, d: PassDecision): Chip { if (!i.settings.enabled) return { tone: "off", text: "lane off" }; if (i.settings.held) return { tone: "warn", text: "lane held" }; if (i.lane.passRunning) return { tone: "busy", text: "lane: pass running" }; if (i.lane.known && !i.lane.running) return { tone: "warn", text: "lane on, runner not running" }; if (blocked) return { tone: "off", text: `lane waiting: ${blocked}` }; return d.run ? { tone: "busy", text: "lane: pass due" } : { tone: "ok", text: "lane idle" }; } function nextWords(t: PublishTargetStatus, plan: PublishPlan): string { if (t.running) return `${t.running.kind} running`; if (t.queued.length > 0) return `${t.queued.map((j) => j.kind).join(", ")} queued`; const mine = plan.steps.filter((s) => s.target === t.target || (s.target === ALL_TARGET && t.kind === "site")); if (mine.length > 0) { return mine .map((s) => s.kind.startsWith("build") ? "build" : s.to === "local" ? "deploy locally" : s.preview ? `deploy to preview "${s.preview}"` : "deploy to production", ) .join(", then "); } const skip = plan.skipped.find((s) => s.target === t.target); if (skip) return skip.reason; if (t.buildFreshness.state !== "fresh" && t.policy === "off") return "stale — the policy is off (Build rebuilds it)"; return "up to date"; } // --------------------------------------------------------------------------- // planPublishRun // --------------------------------------------------------------------------- export type PlanOptions = { // "policy": deploy where the target's policy says (Publish now, the lane); // "none": build only. deploys?: "policy" | "none"; // "policy": build targets whose policy is not off; "stale": every stale // site, whatever its policy ("Build all stale"). The hub and the homepage // follow their policies either way. builds?: "policy" | "stale"; // The run's start (the on-disk preconditions); defaults to status.now. runStart?: number; // Step keys (`stepKey`) to leave out: what the lane already ran this pass. exclude?: ReadonlySet; // Leave the update-index step out (the lane, after its index stage). skipIndex?: boolean; }; /** A step's identity within a run: kind, target and destination. */ export function stepKey(s: Pick): string { const dest = s.kind.startsWith("deploy") ? `:${deployKindOf(s)}${s.preview ? `:${s.preview}` : ""}` : ""; return `${s.kind}:${s.target}${dest}`; } /** * What a run enqueues, in order: update-index when the index is stale; then * per site (by id) its build — when it has changed channels, its signature no * longer matches the index, it was never built, its config changed or its * bundle has a problem — and its deploy where the policy says; then the hub; * then the homepage. NEVER forced: a fresh target is not in the plan, and a * stage whose child finds it fresh is a no-op. * * Preconditions are on disk (stages.ts): a build carries `indexAfter = * runStart` when update-index is in the run; a deploy `builtAfter = runStart` * when its build is. */ export function planPublishRun(status: PublishStatus, opts: PlanOptions = {}): PublishPlan { const runStart = opts.runStart ?? status.now; const exclude = opts.exclude ?? new Set(); const needs = status.needs; const settings = status.settings; const steps: PlanStep[] = []; const skipped: PlanSkip[] = []; const add = (s: PlanStep) => { if (!exclude.has(stepKey(s))) steps.push(s); }; const indexStep = !opts.skipIndex && !status.index.fresh && !exclude.has(stepKey({ kind: "update-index", target: INDEX_TARGET })); if (indexStep) { add({ kind: "update-index", target: INDEX_TARGET, reason: status.index.freshness.state === "fresh" ? "the index is stale" : status.index.freshness.reason, }); } const afterIndex = indexStep ? { indexAfter: runStart } : {}; const noStamp = !status.index.stamp; // Whether a target's build belongs in the run, and why. const buildWanted = ( kind: StageKind, t: PublishTargetStatus, state: TargetState, ): { want: boolean; reason: string } => { const f = STAGES[kind].needs(needs, req(kind, t.target)); if (f.state === "stale") return { want: true, reason: f.reason }; if (f.state === "blocked") { // No stamp yet: the index this run updates comes first. if (indexStep && noStamp) return { want: true, reason: "built after the index's first update" }; return { want: false, reason: f.reason }; } return { want: false, reason: "fresh" }; }; // The deploy a policy asks for, after (or without) its build in this run. const deployFor = (kind: StageKind, t: PublishTargetStatus, state: TargetState, building: boolean) => { if (opts.deploys === "none") return; const dest = policyDeploy(t.policy, settings.previewBranch); if (!dest) return; const where = dest.preview ? { preview: dest.preview } : {}; const previewUrl = dest.preview && t.cloudflareProject ? { previewUrl: previewAliasUrl(t.cloudflareProject, dest.preview) } : {}; if (building) { // Never deployable by configuration: said here, not left to the child. const never = t.deployProblem ?? state.pagesProblem ?? null; if (never) { skipped.push({ kind, target: t.target, reason: never }); return; } add({ kind, target: t.target, ...where, builtAfter: runStart, reason: "its build is in this run", ...previewUrl }); return; } const f = STAGES[kind].needs(needs, req(kind, t.target, where)); if (f.state === "stale") add({ kind, target: t.target, ...where, reason: f.reason, ...previewUrl }); else if (f.state === "blocked") skipped.push({ kind, target: t.target, reason: f.reason }); }; // --- sites: each one's build, then its deploy (by id) const rows: { t: PublishTargetStatus; state: TargetState; build: PlanStep | null; deploys: boolean }[] = []; for (const t of status.sites) { const state = needs.sites[t.target]; const policyOn = t.policy !== "off"; const w = buildWanted("build-site", t, state); const wantByPolicy = opts.builds === "stale" ? true : policyOn; if (!wantByPolicy) { if (w.want) skipped.push({ kind: "build-site", target: t.target, reason: "stale, and its policy is off" }); continue; } if (!w.want && w.reason !== "fresh") skipped.push({ kind: "build-site", target: t.target, reason: w.reason }); rows.push({ t, state, build: w.want ? { kind: "build-site", target: t.target, ...afterIndex, reason: w.reason } : null, deploys: policyOn, }); } const builds = rows.filter((r) => r.build && !exclude.has(stepKey(r.build))); if (settings.runner === "docker" && builds.length > 0) { // The fan-out builds every stale site in containers, in ONE stage. add({ kind: "build-site", target: ALL_TARGET, runner: "docker", ...afterIndex, reason: `${builds.length} site${builds.length === 1 ? "" : "s"} to build (${namesList(builds.map((r) => r.t.target))}); the docker runner builds every stale site`, }); for (const r of rows) if (r.deploys) deployFor("deploy-site", r.t, r.state, r.build !== null); } else { for (const r of rows) { if (r.build) add(r.build); if (r.deploys) deployFor("deploy-site", r.t, r.state, r.build !== null); } } // --- the hub, then the homepage: their policies are settings.publish's for (const [kind, deployKind, t, state] of [ ["build-hub", "deploy-hub", status.hub, needs.hub], ["build-homepage", "deploy-homepage", status.homepage, needs.homepage], ] as const) { if (t.policy === "off") continue; let w = buildWanted(kind, t, state); // Fresh by the current index, but the index this run updates moves the // hub (its signature carries the stamp id) when a listed site's channels // changed — and the homepage when any did. if (!w.want && w.reason === "fresh" && indexStep && state.changedChannels.length > 0) { const n = state.changedChannels.length; w = { want: true, reason: `${n} channel${n === 1 ? "" : "s"} changed` }; } if (w.want) add({ kind, target: t.target, ...afterIndex, reason: w.reason }); else if (w.reason !== "fresh") skipped.push({ kind, target: t.target, reason: w.reason }); deployFor(deployKind, t, state, w.want); } return { runStart, steps, skipped }; }