Archilyzer · Source

archilyzer

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

commit cd8c0d76f58321f3522c851c2e3425be2ab20f2c
parent 2863c1b9a83e5b7d0c840d4b3f559fdd89edd566
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 02:17:30 -0400

Merge main (O4 merged, eb677ac7) into r11/runner-lows

Conflicts: editor/CHANGELOG.md (one [Unreleased]: O4's bullets, then O3's)
and plans/release-11.md (both records under Record: O4, then O3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/builtExport.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/builtExport.ts | 32+++++++++++++++++++++++++++++++-
Mcommon/publish/build.test.ts | 7+++++++
Mcommon/publish/build.ts | 13++++++++++++-
Meditor/CHANGELOG.md | 2++
Aeditor/app/api/ops/build-homepage/route.ts | 33+++++++++++++++++++++++++++++++++
Aeditor/app/api/ops/deploy-homepage/route.ts | 32++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/HomepageBuildButtons.tsx | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/components/JobLane.tsx | 29+++++++++++++++++++++--------
Aeditor/app/sites/lib/homepageDeployActions.ts | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/page.tsx | 44+++++++++++++++++++++++++++++++++++++-------
Meditor/e2e/ops-api.spec.ts | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aeditor/e2e/sites-homepage.spec.ts | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/homepage-deploy-from-ui.md | 29+++++++++++++++++++++++++++++
Mplans/release-11.md | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/archilyzer-ops.mjs | 12++++++++++++
Mscripts/archilyzer-ops.test.mjs | 17+++++++++++++++++
17 files changed, 1027 insertions(+), 20 deletions(-)

diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -1,9 +1,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { builtHubProblem, builtSiteIdIn, builtSiteProblem } from "./builtExport"; +import { + builtHomepageAt, + builtHomepageProblem, + builtHubProblem, + builtSiteIdIn, + builtSiteProblem, +} from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { const dir = mkdtempSync(path.join(tmpdir(), "built-export-")); @@ -113,3 +119,44 @@ test("builtHubProblem accepts only a hub bundle", () => { none.cleanup(); } }); + +// The homepage package builds into its own homepage/out, which nothing else +// writes, so "built" is one question: is there an index.html? The deploy-only +// action refuses before any job on exactly the file deployHomepage checks +// inside it. +test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => { + const built = tempOut(); + const empty = tempOut(); + try { + writeFileSync(path.join(built.dir, "index.html"), "<!doctype html>"); + assert.equal(builtHomepageProblem(built.dir), null); + + const problem = "homepage/out holds no build — build the homepage first"; + assert.equal(builtHomepageProblem(empty.dir), problem); + // No directory at all is the same answer, not a throw. + assert.equal(builtHomepageProblem(path.join(tmpdir(), "no-such-homepage-out-ever")), problem); + // A stray file that is not the page is still no build. + writeFileSync(path.join(empty.dir, "404.html"), "<!doctype html>"); + assert.equal(builtHomepageProblem(empty.dir), problem); + } finally { + built.cleanup(); + empty.cleanup(); + } +}); + +test("builtHomepageAt is index.html's mtime, and null with no build", () => { + const built = tempOut(); + const empty = tempOut(); + try { + const index = path.join(built.dir, "index.html"); + writeFileSync(index, "<!doctype html>"); + const when = new Date("2026-09-28T00:53:00Z"); + utimesSync(index, when, when); + assert.equal(builtHomepageAt(built.dir), when.getTime()); + assert.equal(builtHomepageAt(empty.dir), null); + assert.equal(builtHomepageAt(path.join(tmpdir(), "no-such-homepage-out-ever")), null); + } finally { + built.cleanup(); + empty.cleanup(); + } +}); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -13,7 +13,7 @@ // public/ into out/. So the check is a file read, and it is cheap enough to do // before every deploy. -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; /** @@ -80,3 +80,33 @@ export function builtHubProblem(outDir: string): string | null { } return null; } + +/** + * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as + * the homepage, as one sentence, or null when it holds a build. + * + * Unlike export/out nothing else ever builds into homepage/out, so the only + * question is whether a build is there at all. `index.html` is the file + * deployHomepage checks inside its job, so the refusal an operator gets before + * the job and the one the job would give agree on what "built" means. + */ +export function builtHomepageProblem(outDir: string): string | null { + if (!existsSync(path.join(outDir, "index.html"))) { + return "homepage/out holds no build — build the homepage first"; + } + return null; +} + +/** + * When the homepage build in `outDir` was made (its `index.html`'s mtime, in + * ms), or null when builtHomepageProblem would refuse it. What `/sites` shows + * as "built <when>" beside Deploy homepage, so the operator sees how old the + * bundle a deploy-only would ship is. + */ +export function builtHomepageAt(outDir: string): number | null { + try { + return statSync(path.join(outDir, "index.html")).mtimeMs; + } catch { + return null; + } +} diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -7,6 +7,7 @@ import { buildSiteSteps, hubProjectProblem, homepageDeployArgs, + homepageOutDir, deployHomepage, dockerSiteOutDir, dockerSiteStagingDir, @@ -136,6 +137,12 @@ test("homepageDeployArgs: production is branch main; a preview is its own branch assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " r8-home "), [...base, "--branch", "r8-home"]); }); +// The editor's homepage actions judge "is there a build?" on this path before +// any job, so it must be the directory deployHomepage ships. +test("homepageOutDir is homepage/out of the checkout", () => { + assert.equal(homepageOutDir({ monorepoRoot: "/repo" } as Paths), "/repo/homepage/out"); +}); + test("deployHomepage refuses a bad preview branch before it looks for a build", async () => { const noBuild = { monorepoRoot: "/nonexistent-repo" } as Paths; await assert.rejects( diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -939,6 +939,16 @@ function homepageDir(paths: Paths): string { return path.join(paths.monorepoRoot, "homepage"); } +/** + * Where buildHomepage writes and deployHomepage ships: `homepage/out` of this + * checkout. Exported for the editor's homepage actions, which refuse a + * deploy-only before any job when nothing is built there + * (builtHomepageProblem) and show when it was built. + */ +export function homepageOutDir(paths: Paths): string { + return path.join(homepageDir(paths), "out"); +} + function homepageEnv(paths: Paths): NodeJS.ProcessEnv { return { ...process.env, @@ -1006,7 +1016,8 @@ export async function deployHomepage( if (problem) throw new Error(problem); } const branch = opts.previewBranch?.trim() || undefined; - const outDir = path.join(homepageDir(paths), "out"); + // The directory the editor's actions judged "built" (builtHomepageProblem). + const outDir = homepageOutDir(paths); if (!existsSync(path.join(outDir, "index.html"))) { throw new Error("homepage/out holds no build — run archilyzer build homepage first"); } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## [Unreleased] +- **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage. +- **`pnpm ops build-homepage` and `pnpm ops deploy-homepage`.** The same two jobs over HTTP: `build-homepage` takes `{"deploy": true}` to deploy after a successful build, and both take `{"preview": "<branch>"}` for a preview (`build-homepage` only with `deploy`). `deploy-homepage` answers with the preview's address, and refuses a bad preview name or a missing build before any job starts. - **"Persist source video" or a whole-recording fetch that cannot get the source no longer marks the video's download failed.** When YouTube's subtitles came down but the source video did not, the video page said "Download failed" over a transcript that is fine. The download now keeps the subtitle pass's result and records only the failed media attempt, with yt-dlp's reason. The run itself now ends failed with that reason; it used to end done with no file, so `fetch_clip` could only say the job "finished but named no file". A partial source file is left for a retry to resume, and the run's log names it. - **A bucket's retry keeps its log when it empties the bucket.** On a channel's Download stage, "Download with cookies", the partial-download resume and the missing-transcript retry could lose their run log part-way: the video they fetched left the bucket, the page refreshed, and the card disappeared with the log in it. The card now stays, with its log and its button disabled, until the page is reloaded. Retry buttons elsewhere behave the same way. - **A release cut whose commit fails still refreshes the pages.** When the changelog's new heading was written but the commit after it failed, `pnpm ops cut-release` answered as if nothing had happened and no page showed the new heading until a reload. It now says the file was written and refreshes the changelog pages. Every refused cut's answer says whether anything was written (`untouched`), and a cut of both changelogs that stopped half-way names the one already cut as well as the failure. diff --git a/editor/app/api/ops/build-homepage/route.ts b/editor/app/api/ops/build-homepage/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { + buildAndDeployHomepageAction, + buildHomepageAction, +} from "../../../sites/lib/homepageDeployActions"; +import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { deploy?: boolean, preview? } -> { ok: true, jobId } +// +// Builds the HOMEPAGE (the `homepage` package, Archilyzer's own site) into +// homepage/out. `deploy: true` makes it the one-job build-then-deploy (`/sites` +// → Homepage, "Deploy after build") to the Pages project `archilyzer`, which +// deploys only when the build exits 0; `preview` needs `deploy`. +export async function POST(request: Request) { + return ops(request, ["deploy", "preview"], async (body) => { + const deploy = optBool(body, "deploy") === true; + const preview = optPreviewBranch(body); + if (preview !== undefined && !deploy) { + throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing'); + } + return jobResponse( + deploy + ? await buildAndDeployHomepageAction(preview ? { previewBranch: preview } : undefined) + : await buildHomepageAction(), + ); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/api/ops/deploy-homepage/route.ts b/editor/app/api/ops/deploy-homepage/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { HOMEPAGE_PAGES_PROJECT } from "yt-dlp-transcript-common/publish/build"; +import { deployHomepageAction } from "../../../sites/lib/homepageDeployActions"; +import { jobResponse, ops, optPreviewBranch } from "../_lib"; + +export const dynamic = "force-dynamic"; + +// POST { preview? } -> { ok: true, jobId, previewUrl? } +// +// DEPLOY ONLY — the homepage already built in homepage/out, to its constant +// Pages project `archilyzer`: production (branch `main`), or with `preview` a +// branch alias, knowable before the job runs. Refused before any job when the +// preview name is bad or homepage/out holds no build. +export async function POST(request: Request) { + return ops(request, ["preview"], async (body) => { + const preview = optPreviewBranch(body); + const res = jobResponse( + await deployHomepageAction(preview ? { previewBranch: preview } : undefined), + ); + if (!preview || res.status !== 200) return res; + const payload = (await res.json()) as Record<string, unknown>; + return NextResponse.json({ + ...payload, + previewUrl: previewAliasUrl(HOMEPAGE_PAGES_PROJECT, preview), + }); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); +} diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { + MAX_PREVIEW_BRANCH, + previewAliasUrl, + previewBranchProblem, +} from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { PROJECT_URL } from "yt-dlp-transcript-common/lib/project"; +import { + buildAndDeployHomepageAction, + buildHomepageAction, + deployHomepageAction, +} from "../lib/homepageDeployActions"; +import { useHydrated } from "../../operations/components/useOperationsStatus"; +import { JobLane } from "./JobLane"; + +type Lane = { + kind: "build" | "build-deploy" | "deploy"; + key: number; + // The preview branch AS LAUNCHED. Editing the field afterwards must not + // change what a running lane says it is deploying to. + preview: string | undefined; +}; + +const TITLE: Record<Lane["kind"], string> = { + build: "Build homepage", + "build-deploy": "Build & deploy homepage", + deploy: "Deploy homepage", +}; + +type Props = { + // The homepage's Pages project — the constant HOMEPAGE_PAGES_PROJECT, passed + // down because it lives beside the deploy code (server-only), not copied. + project: string; + // When homepage/out/index.html was built, formatted by the server; null + // when there is no build there. + builtAt: string | null; +}; + +// The homepage's build and deploy — the `homepage` package, built into +// homepage/out and deployed to its own Pages project. HubBuildButtons' shape +// exactly: one lane at a time (each launch replaces the previous lane; its job +// keeps running and stays on /jobs), and "Deploy after build" starts +// unchecked, because a homepage deploy replaces the software's public site. +// +// Plus a preview branch, which the hub's buttons do not have: empty is +// production, a name is a Cloudflare Pages preview of the same project. The +// name is judged by previewBranchProblem, the function the action and the ops +// route refuse with, so the buttons grey out on exactly the names the server +// would reject. +export function HomepageBuildButtons({ project, builtAt }: Props) { + const [deploy, setDeploy] = useState(false); + const [preview, setPreview] = useState(""); + const [lane, setLane] = useState<Lane | null>(null); + const [run, setRun] = useState(0); + const hydrated = useHydrated(); + + const branch = preview.trim(); + const problem = branch ? previewBranchProblem(branch) : null; + // Knowable before any deploy: project + branch and nothing else. + const alias = branch && problem === null ? previewAliasUrl(project, branch) : null; + + function launch(kind: Lane["kind"]) { + const next = run + 1; + setRun(next); + setLane({ kind, key: next, preview: branch || undefined }); + } + + function trigger(l: Lane) { + const opts = l.preview ? { previewBranch: l.preview } : undefined; + return l.kind === "build" + ? buildHomepageAction() + : l.kind === "build-deploy" + ? buildAndDeployHomepageAction(opts) + : deployHomepageAction(opts); + } + + return ( + // data-hydrated: a TESTING AFFORDANCE, as on the operation lanes. A click + // that lands before React hydrates fires nothing at all, so a spec waits + // for this before it clicks Build homepage. + <div + role="group" + aria-label="Homepage build" + className="flex flex-col gap-3" + data-hydrated={hydrated ? "true" : undefined} + > + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + onClick={() => launch(deploy ? "build-deploy" : "build")} + // A build alone deploys nothing, so only a build that will deploy + // is held back by a bad preview name. + disabled={deploy && problem !== null} + className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + Build homepage + </button> + <label className="flex items-center gap-2 text-sm text-muted-foreground"> + <input + type="checkbox" + checked={deploy} + onChange={(e) => setDeploy(e.target.checked)} + /> + Deploy after build + </label> + <button + type="button" + onClick={() => launch("deploy")} + disabled={problem !== null} + className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50" + > + Deploy homepage + </button> + <label className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground">Preview branch</span> + <input + type="text" + aria-label="preview branch" + placeholder="none: production" + value={preview} + // Twice the limit, so an over-long name is refused with the + // sentence that says WHY rather than silently truncated. + maxLength={MAX_PREVIEW_BRANCH * 2} + onChange={(e) => setPreview(e.target.value)} + className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm" + /> + </label> + </div> + {problem !== null && ( + <p + role="status" + // Not "preview branch problem": a name that CONTAINS the input's + // would make every by-label lookup for the input ambiguous. + aria-label="preview problem" + className="text-sm text-destructive" + > + {problem} + </p> + )} + <p className="text-xs text-muted-foreground" data-testid="homepage-ships"> + {builtAt ? ( + <> + Deploy homepage ships <code>homepage/out</code>, built {builtAt}, + </> + ) : ( + <> + <code>homepage/out</code> holds no build yet, so Deploy homepage has + nothing to ship. A deploy goes + </> + )}{" "} + to <code>{project}</code>{" "} + {!branch ? ( + <> + (production): <UrlLink url={PROJECT_URL} />. + </> + ) : alias ? ( + <> + (preview <code>{branch}</code>): <UrlLink url={alias} />; the live + site is left alone. + </> + ) : ( + <> + (preview <code>{branch}</code>). + </> + )} + </p> + <p className="text-xs text-muted-foreground"> + The homepage reads the search index as it stands: run Build index + (under Pool jobs, below) first when its numbers should move. + </p> + {lane && ( + <JobLane + key={lane.key} + title={TITLE[lane.kind]} + subtitle={ + lane.kind === "build" + ? "homepage/out" + : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}` + } + trigger={() => trigger(lane)} + /> + )} + </div> + ); +} + +function UrlLink({ url }: { url: string }) { + return ( + <a className="font-mono underline" href={url} target="_blank" rel="noreferrer"> + {url} + </a> + ); +} diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx @@ -44,12 +44,26 @@ export function JobLane({ title, subtitle, trigger }: Props) { const [cancelling, setCancelling] = useState(false); const preRef = useRef<HTMLPreElement | null>(null); const startedRef = useRef(false); + // Whether the lane is mounted, kept by its OWN effect. It used to be a flag + // local to the launch effect below, set by that effect's cleanup — and + // Strict Mode (on under `next dev`) runs mount → cleanup → mount: the + // cleanup set the flag, the second run returned early on startedRef, and + // every update of the one launch there is was dropped. The job ran; the lane + // sat on "Starting…" forever. Declared first, so on the remount it is true + // again before anything reads it. Production mounts once and never saw it. + const aliveRef = useRef(false); + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); useEffect(() => { // Strict-mode mounts effects twice in dev; guard so the job launches once. if (startedRef.current) return; startedRef.current = true; - let cancelled = false; + const unmounted = () => !aliveRef.current; let stopped = false; let pollTimer: ReturnType<typeof setTimeout> | null = null; @@ -64,7 +78,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { status: string; queuePosition?: number; }; - if (!cancelled) { + if (!unmounted()) { if (data.status === "queued") { setStatus("queued"); setQueuePos( @@ -81,7 +95,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { } catch { /* transient; keep polling */ } - if (!cancelled && !stopped) pollTimer = setTimeout(() => poll(id), 1000); + if (!unmounted() && !stopped) pollTimer = setTimeout(() => poll(id), 1000); } (async () => { @@ -89,13 +103,13 @@ export function JobLane({ title, subtitle, trigger }: Props) { try { result = await trigger(); } catch (e) { - if (!cancelled) { + if (!unmounted()) { setStatus("error"); setError((e as Error).message); } return; } - if (cancelled) return; + if (unmounted()) return; if (!result.ok) { setStatus("error"); setError(result.error); @@ -110,7 +124,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { while (true) { const { value, done } = await reader.read(); if (done) break; - if (value && !cancelled) setLog((prev) => prev + value); + if (value && !unmounted()) setLog((prev) => prev + value); } } catch { /* stream tear-down */ @@ -118,7 +132,7 @@ export function JobLane({ title, subtitle, trigger }: Props) { const term = await result.done; stopped = true; if (pollTimer) clearTimeout(pollTimer); - if (!cancelled) { + if (!unmounted()) { setQueuePos(null); setStatus( term.status === "done" @@ -131,7 +145,6 @@ export function JobLane({ title, subtitle, trigger }: Props) { })(); return () => { - cancelled = true; if (pollTimer) clearTimeout(pollTimer); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/editor/app/sites/lib/homepageDeployActions.ts b/editor/app/sites/lib/homepageDeployActions.ts @@ -0,0 +1,104 @@ +"use server"; + +import { builtHomepageProblem } from "yt-dlp-transcript-common/lib/builtExport"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; +import { + runManagedFunction, + type StreamActionResult, +} from "yt-dlp-transcript-common/jobs/streamCommand"; +import { + buildHomepage, + deployHomepage, + homepageOutDir, +} from "yt-dlp-transcript-common/publish/build"; + +// The homepage's build and deploy, as jobs — the `homepage` package, Archilyzer's +// own site, deployed to the constant Pages project `archilyzer` +// (HOMEPAGE_PAGES_PROJECT). The same three shapes as the hub's (hubActions.ts) +// and the same bodies `archilyzer build homepage` / `deploy homepage` run. +// +// The build writes homepage/out, not export/out, so it cannot race a site +// build for export/ — but `next build` is heavy, and the build queue is what +// serialises heavy builds. The deploys take the deploy queue. +const BUILD_QUEUE = "build"; +const DEPLOY_QUEUE = "deploy"; + +// Refusals a deploy can give BEFORE any job starts: a bad preview name and — +// for a deploy-only — no build in homepage/out. No project check: the project +// is a constant. deployHomepage checks both again when its job runs, because a +// queued deploy can start after the build it was going to ship is gone. +function deployRefusal( + paths: Paths, + previewBranch: string | undefined, + checkBuilt: boolean, +): string | null { + if (previewBranch !== undefined) { + const problem = previewBranchProblem(previewBranch); + if (problem) return problem; + } + if (checkBuilt) return builtHomepageProblem(homepageOutDir(paths)); + return null; +} + +export async function buildHomepageAction(): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "build-homepage", + queueKey: BUILD_QUEUE, + paths, + fn: async (onLog, signal) => { + const code = await buildHomepage({ paths, onLog, signal }); + if (signal.aborted) return; + if (code !== 0) throw new Error(`Homepage build failed (exit ${code})`); + }, + }); +} + +// Deploy the homepage already built in homepage/out: production (branch +// `main`), or with `opts.previewBranch` a Cloudflare Pages PREVIEW of the +// `archilyzer` project. +export async function deployHomepageAction(opts?: { + previewBranch?: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + const refusal = deployRefusal(paths, opts?.previewBranch, true); + if (refusal) return { ok: false, error: refusal }; + const branch = opts?.previewBranch?.trim(); + return runManagedFunction({ + kind: "deploy-homepage", + queueKey: DEPLOY_QUEUE, + paths, + fn: (onLog, signal) => + deployHomepage({ paths, onLog, signal, previewBranch: branch }), + }); +} + +// Build the homepage and, only if that succeeds, deploy it — one job, one log, +// one Cancel, on the deploy queue (as build-deploy-hub is for the hub). A failed +// build never reaches the deploy: homepage/out would still hold the PREVIOUS +// build, and deploying that after a failure would ship old news as new. +export async function buildAndDeployHomepageAction(opts?: { + previewBranch?: string; +}): Promise<StreamActionResult> { + const paths = getPaths(); + // Before the build: learning the preview name is wrong after a build wastes it. + const refusal = deployRefusal(paths, opts?.previewBranch, false); + if (refusal) return { ok: false, error: refusal }; + const branch = opts?.previewBranch?.trim(); + return runManagedFunction({ + kind: "build-deploy-homepage", + queueKey: DEPLOY_QUEUE, + paths, + fn: async (onLog, signal) => { + onLog("=== Build homepage ===\n"); + const code = await buildHomepage({ paths, onLog, signal }); + if (signal.aborted) return; + if (code !== 0) { + throw new Error(`Homepage build failed (exit ${code}) — not deploying.`); + } + onLog("\n=== Deploy homepage ===\n"); + await deployHomepage({ paths, onLog, signal, previewBranch: branch }); + }, + }); +} diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -8,10 +8,15 @@ import { suggestNextVersion, } from "yt-dlp-transcript-common/lib/changelog"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { builtHomepageAt } from "yt-dlp-transcript-common/lib/builtExport"; import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { listSites } from "yt-dlp-transcript-common/lib/site"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { + HOMEPAGE_PAGES_PROJECT, + homepageOutDir, +} from "yt-dlp-transcript-common/publish/build"; import { liveJobRows } from "../jobs/active/buildActiveJobs"; import { RunningJobsList } from "../jobs/components/RunningJobsList"; import { BuildAllSitesButton } from "./components/BuildAllSitesButton"; @@ -19,6 +24,7 @@ import { BuildButtons } from "./components/BuildButtons"; import { BuildModeToggle } from "./components/BuildModeToggle"; import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; +import { HomepageBuildButtons } from "./components/HomepageBuildButtons"; import { HomepageConfigForm } from "./components/HomepageConfigForm"; import { HubBuildButtons } from "./components/HubBuildButtons"; import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions"; @@ -30,8 +36,9 @@ export const metadata: Metadata = { title: "Sites" }; // buttons enqueue (BuildButtons.tsx → sites/lib/buildAction.ts), and only // those. build-export and build-deploy are a site's Publish tab's, build-all // and build-deploy-all are the batch panel's above, build-hub / deploy-hub / -// build-deploy-hub the Hub section's — each has its own console and is not -// repeated here. +// build-deploy-hub the Hub section's, build-homepage / deploy-homepage / +// build-deploy-homepage the Homepage section's — each has its own console and +// is not repeated here. const BUILD_KINDS = new Set([ "build-index", "build-stats", @@ -76,6 +83,7 @@ export default async function SitesPage() { // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind)); const hubConfig = getHomepageConfig(paths); + const homepageBuiltAt = builtHomepageAt(homepageOutDir(paths)); return ( <div className="flex flex-col gap-8"> @@ -192,17 +200,39 @@ export default async function SitesPage() { site that has a public URL, reading each archive where it is published. This config names and brands it, and its Public URL and Cloudflare Pages project are the hub&rsquo;s own (for example{" "} - <code>archilyzer-hub</code>). The <code>homepage</code> package — - Archilyzer&rsquo;s own site, with the docs and the source download - — shares these social links and links to the hub, but deploys to - its own project, <code>archilyzer</code>, with{" "} - <code>archilyzer deploy homepage</code>. + <code>archilyzer-hub</code>). </p> </div> <HomepageConfigForm config={hubConfig} /> <HubBuildButtons project={hubConfig.cloudflareProject ?? null} /> </section> + {/* The software's own site: the `homepage` package, never the hub. It + has no config of its own here — it shares the hub form's social + links — so the section is only its build and deploy. */} + <section className="flex flex-col gap-3 border-t border-border pt-6"> + <div> + <h2 className="text-lg font-semibold">Homepage</h2> + <p className="mt-1 text-sm text-muted-foreground"> + The <code>homepage</code> package is Archilyzer&rsquo;s own site, + with the docs and the source download. It shares the Hub + form&rsquo;s social links and links to the hub, but it builds into{" "} + <code>homepage/out</code> and deploys to its own Pages project,{" "} + <code>{HOMEPAGE_PAGES_PROJECT}</code>. The buttons run the same code + as <code>archilyzer build homepage</code> and{" "} + <code>archilyzer deploy homepage</code>, as jobs. + </p> + </div> + <HomepageBuildButtons + project={HOMEPAGE_PAGES_PROJECT} + builtAt={ + homepageBuiltAt === null + ? null + : new Date(homepageBuiltAt).toLocaleString() + } + /> + </section> + {/* The shared pool: corpus-wide, no site involved. */} <section className="flex flex-col gap-3 border-t border-border pt-6"> <div> diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -864,6 +864,24 @@ async function listJobIds(): Promise<string[]> { .sort(); } +// Hold the `build` AND `deploy` queues with fabricated running jobs +// (/api/test/stuck-job; never released by the caller), so any build or deploy +// job that a refusal under test failed to stop can only QUEUE, never run. BOTH +// queues, because a regression can turn a refused call into a job on either +// one: a dropped "preview needs deploy" guard makes a build, a dropped preview +// check makes a deploy. The fabricated holders write no .meta.json, so they are +// invisible to listJobIds; take `before` after this. The next resetData +// cancels jobs newest first, so a queued job is removed before a holder's slot +// is freed and nothing is ever promoted. +async function holdBuildAndDeployQueues(request: APIRequestContext) { + for (const queue of ["build", "deploy"]) { + const hold = await request.get( + `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`, + ); + expect(hold.ok(), queue).toBe(true); + } +} + test("build-site and build-deploy each take siteId or siteIds, and refuse both or neither", async ({ request, }) => { @@ -1092,12 +1110,15 @@ test("build-site with a bare siteId starts one build-export job", async ({ // The hub's deploy path (release 7). Every refusal here is answered BEFORE a // job exists, which is what lets a runbook's `pnpm ops deploy-hub --wait` fail -// fast instead of queueing a deploy that can only fail. +// fast instead of queueing a deploy that can only fail. Both queues are held +// (release 11, O4 review): a regression of a refusal below would otherwise +// start a real hub build into export/out, or a real deploy. test("deploy-hub refuses no project, the homepage's project, and a bundle that is not the hub", async ({ request, }) => { await resetData("title-filter-channel"); await settings(); + await holdBuildAndDeployQueues(request); const before = await listJobIds(); // The fixture has no homepage.json at all: no project. @@ -1148,3 +1169,68 @@ test("deploy-hub refuses no project, the homepage's project, and a bundle that i expect(await listJobIds()).toEqual(before); }); + +// The homepage's deploy path (release 11, slice O4). Every refusal is answered +// BEFORE a job exists, like deploy-hub's. +// +// NOTHING HERE CAN REACH WRANGLER, and three separate things make sure of it: +// 1. `homepage/out` is the CHECKOUT's (homepageOutDir = <repo>/homepage/out, +// not fixture-controlled — the primary checkout holds the live homepage's +// build), so the missing-build call is made only when this checkout has no +// `homepage/out/index.html`, as deploy-hub's is (211d4666); +// 2. that call asks for a PREVIEW, never production; +// 3. the whole test runs with BOTH the `build` and the `deploy` queue held +// by fabricated jobs (holdBuildAndDeployQueues). A build or deploy job +// that a refusal failed to stop — `build-homepage {preview}` is a build +// if the "preview needs deploy" guard regresses — would only QUEUE; the +// listJobIds assertion then fails, and the next resetData cancels jobs +// newest first, so the queued job is removed before a holder's slot is +// freed. +test("deploy-homepage refuses a bad preview name and a missing build; build-homepage refuses a preview without a deploy", async ({ + request, +}) => { + await resetData("empty"); + await settings(); + await holdBuildAndDeployQueues(request); + const before = await listJobIds(); + + // A bad preview name is judged before homepage/out is even looked at, so + // these hold in any checkout. + const production = await ops(request, "deploy-homepage", { preview: "main" }); + expect(production.status).toBe(400); + expect(production.body.error).toBe( + '"main" is the production branch; a preview needs another name.', + ); + const shape = await ops(request, "deploy-homepage", { preview: "Not_Valid" }); + expect(shape.status).toBe(400); + expect(shape.body.error).toMatch(/not a valid preview branch name/); + // And on the build-then-deploy, before anything is built. + const buildDeploy = await ops(request, "build-homepage", { + deploy: true, + preview: "main", + }); + expect(buildDeploy.status).toBe(400); + expect(buildDeploy.body.error).toContain("is the production branch"); + + // build-homepage: a preview without a deploy is a mistake, not a build. + const previewOnly = await ops(request, "build-homepage", { preview: "home-check" }); + expect(previewOnly.status).toBe(400); + expect(previewOnly.body.error).toContain('"preview" needs "deploy": true'); + + // No build in homepage/out. + if (await pathExists("../homepage/out/index.html")) { + test.info().annotations.push({ + type: "subcase-not-exercisable", + description: + "homepage/out holds a build in this checkout, so the unbuilt refusal cannot fire (and the call would deploy it)", + }); + } else { + const unbuilt = await ops(request, "deploy-homepage", { preview: "home-check" }); + expect(unbuilt.status).toBe(400); + expect(unbuilt.body.error).toBe( + "homepage/out holds no build — build the homepage first", + ); + } + + expect(await listJobIds()).toEqual(before); +}); diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts @@ -0,0 +1,208 @@ +// /sites → Homepage: the `homepage` package's build and deploy (release 11, +// slice O4), the twin of the Hub section's buttons. +// +// NOTHING HERE DEPLOYS, AND NOTHING HERE BUILDS. Wrangler has no fake in +// e2e/fixtures/bin, and `homepage/out` is the CHECKOUT's own directory — in the +// primary checkout it holds the live homepage's build — so a deploy clicked +// from a spec would be a real one. So: +// - no spec clicks Deploy homepage, and none ticks Deploy after build and +// then clicks Build homepage; +// - the one spec that clicks Build homepage first holds BOTH the `build` and +// the `deploy` queue with fabricated jobs (/api/test/stuck-job, never +// released here), so the job it starts only ever QUEUES — the +// build-homepage it expects on `build`, and equally a build-deploy-homepage +// on `deploy` if a regression turned the click into a build-and-deploy — +// and it is cancelled from its own lane while still queued. Its start +// function never runs: no log file, no child, nothing written to +// homepage/public or homepage/out. If the spec fails before its Cancel, the +// next resetData cancels jobs newest first, so the queued job is removed +// before a holder's slot is freed. + +import { readdir } from "node:fs/promises"; +import { test, expect, type Page } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; +import { pathExists, readJson, resetData, resolvePath } from "./helpers"; + +const group = (page: Page) => + page.getByRole("group", { name: "Homepage build" }); + +// EXACT: a live lane's Cancel button is named "Cancel Build homepage", which +// a substring match for "Build homepage" would also find. +const buildButton = (page: Page) => + group(page).getByRole("button", { name: "Build homepage", exact: true }); +const deployButton = (page: Page) => + group(page).getByRole("button", { name: "Deploy homepage", exact: true }); +// Scoped: /sites has three other "Deploy after build" checkboxes (the batch, +// the specific-sites panel, the hub). +const deployAfter = (page: Page) => group(page).getByLabel("Deploy after build"); +const previewInput = (page: Page) => + group(page).getByRole("textbox", { name: "preview branch", exact: true }); + +// A click or a keystroke that lands before React hydrates is lost without a +// trace; the group stamps data-hydrated once its mount effect has run. +async function openSites(page: Page) { + await page.goto("/sites"); + await expect(group(page)).toHaveAttribute("data-hydrated", "true", { + timeout: 15_000, + }); +} + +type Meta = { + id: string; + kind: string; + queueKey?: string; + status: string; + startedAt?: number; +}; + +async function metasOfKind(kind: string): Promise<Meta[]> { + const files = ( + await readdir(resolvePath("test-transcripts/.jobs")).catch(() => []) + ).filter((f) => f.endsWith(".meta.json")); + const metas: Meta[] = []; + for (const f of files) { + const meta = await readJson<Meta>(`test-transcripts/.jobs/${f}`).catch( + () => null, + ); + if (meta?.kind === kind) metas.push(meta); + } + return metas; +} + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("the Homepage section follows Hub, with the build and deploy controls and what a deploy ships", async ({ + page, +}) => { + await page.goto("/sites"); + + const hub = page.getByRole("heading", { name: "Hub", exact: true }); + const home = page.getByRole("heading", { name: "Homepage", exact: true }); + await expect(home).toBeVisible(); + expect((await hub.boundingBox())!.y).toBeLessThan((await home.boundingBox())!.y); + + await expect(buildButton(page)).toBeEnabled(); + await expect(deployButton(page)).toBeEnabled(); + // Unticked, as the hub's: a homepage deploy replaces a public site, so it is + // never the default of a build. + await expect(deployAfter(page)).not.toBeChecked(); + // Empty is production. + await expect(previewInput(page)).toHaveValue(""); + + // One line: what homepage/out holds, the project, production, the live URL. + const ships = group(page).getByTestId("homepage-ships"); + if (await pathExists("../homepage/out/index.html")) { + await expect(ships).toContainText(/Deploy homepage ships homepage\/out, built /); + } else { + await expect(ships).toContainText( + "homepage/out holds no build yet, so Deploy homepage has nothing to ship.", + ); + } + await expect(ships).toContainText("to archilyzer (production)"); + await expect( + ships.getByRole("link", { name: "https://archilyzer.pages.dev", exact: true }), + ).toHaveAttribute("href", "https://archilyzer.pages.dev"); + await expect(group(page)).toContainText( + "The homepage reads the search index as it stands: run Build index", + ); + + // The homepage's prose moved out of the Hub section and into its own. + const hubSection = page.locator("section").filter({ has: hub }); + const homeSection = page.locator("section").filter({ has: home }); + await expect(hubSection).not.toContainText("archilyzer deploy homepage"); + await expect(homeSection).toContainText("archilyzer deploy homepage"); + await expect(homeSection).toContainText("homepage/out"); +}); + +test("a bad preview name greys out every deploy and says why; a good one shows its alias", async ({ + page, +}) => { + await openSites(page); + const ships = group(page).getByTestId("homepage-ships"); + + await previewInput(page).fill("main"); + await expect(deployButton(page)).toBeDisabled(); + await expect( + group(page).getByRole("status", { name: "preview problem" }), + ).toHaveText('"main" is the production branch; a preview needs another name.'); + // A build alone deploys nothing, so it stays available… + await expect(buildButton(page)).toBeEnabled(); + // …until it would deploy. + await deployAfter(page).check(); + await expect(buildButton(page)).toBeDisabled(); + + await previewInput(page).fill("Main"); + await expect( + group(page).getByRole("status", { name: "preview problem" }), + ).toContainText("not a valid preview branch name"); + await expect(buildButton(page)).toBeDisabled(); + + // A good name re-arms both and names the alias before anything runs. + await previewInput(page).fill("home-check"); + await expect(buildButton(page)).toBeEnabled(); + await expect(deployButton(page)).toBeEnabled(); + await expect( + group(page).getByRole("status", { name: "preview problem" }), + ).toHaveCount(0); + await expect(ships).toContainText("to archilyzer (preview home-check)"); + await expect( + ships.getByRole("link", { + name: "https://home-check.archilyzer.pages.dev", + exact: true, + }), + ).toBeVisible(); + + // Cleared, it is production again. + await previewInput(page).fill(""); + await expect(ships).toContainText("to archilyzer (production)"); + + // Nothing above was clicked: no homepage job of any kind exists. + for (const kind of ["build-homepage", "deploy-homepage", "build-deploy-homepage"]) { + expect(await metasOfKind(kind), kind).toEqual([]); + } +}); + +test("Build homepage starts a build-homepage job on the build queue (held there, then cancelled from its lane)", async ({ + page, + request, +}) => { + // See the header: with both queues held, whatever the click starts can only + // queue. + for (const queue of ["build", "deploy"]) { + const hold = await request.get( + `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`, + ); + expect(hold.ok(), queue).toBe(true); + } + + await openSites(page); + await expect(deployAfter(page)).not.toBeChecked(); + await buildButton(page).click(); + + // The lane: queued behind the holder — not refused, not running. + await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); + await expect(group(page).getByRole("alert")).toHaveCount(0); + + // One build-homepage job, on the build queue, queued — and nothing else. + await expect.poll(async () => (await metasOfKind("build-homepage")).length).toBe(1); + const [job] = await metasOfKind("build-homepage"); + expect(job.queueKey).toBe("build"); + expect(job.status).toBe("queued"); + expect(await metasOfKind("build-deploy-homepage")).toEqual([]); + expect(await metasOfKind("deploy-homepage")).toEqual([]); + + await group(page).getByRole("button", { name: "Cancel Build homepage" }).click(); + await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible(); + + // It never started: the registry says cancelled, and start() — which opens + // the job's log file before it runs anything — never ran. + const res = await request.get( + `${baseUrl}/api/jobs/${encodeURIComponent(job.id)}/log?from=0`, + ); + const log = (await res.json()) as { status: string; content: string }; + expect(log.status).toBe("cancelled"); + expect(log.content).toBe(""); + expect(await pathExists(`test-transcripts/.jobs/${job.id}.log`)).toBe(false); +}); diff --git a/plans/homepage-deploy-from-ui.md b/plans/homepage-deploy-from-ui.md @@ -116,3 +116,32 @@ Numbers tool: none. The live :3001 editor must be restarted to show the section, which fits the next editor restart. After it: Build homepage with Deploy after build unticked, then Deploy homepage, and the job's log ends with the Pages URL. + +## As shipped (2026-09-28, release 11 slice O4) + +Built as planned; record: [`release-11.md`](release-11.md), "Slice O4, as shipped". What the +build corrected here: +- **The ops preview is a body key, not a flag.** `pnpm ops deploy-homepage [--preview <branch>]` + (decision 4) is `pnpm ops deploy-homepage --json '{"preview":"<branch>"}'`: the ops client has no + per-verb flags, and `deploy-hub` takes its preview the same way. +- **No job-kind registry entries.** The hub's three kinds have none in `common/jobs/jobKinds.ts`, + and neither does any publish kind (`build-export`, `deploy-export`, `build-deploy`, `build-all`, + `build-deploy-all`). A kind with no entry shows its raw name on `/jobs` and is not drainable, not + replayable and needs no media, which is right for all three. Mirrored: the kinds are declared + where the hub's are, in the action's `kind` and in `/sites`' `BUILD_KINDS` comment. +- **`homepageOutDir(paths)` is exported from `common/publish/build.ts`.** The editor needs + `homepage/out` for `builtHomepageProblem` and for "built <when>", and `homepageDir` was private. + After review, `deployHomepage` takes its directory from `homepageOutDir` too, with no behaviour + change. Otherwise `buildHomepage` and `deployHomepage` are unchanged, so the job's own refusal + keeps its sentence ("… — run archilyzer build homepage first"). The refusal before the job says + "… — build the homepage first". +- **`JobLane` needed a fix to be testable at all.** Under `next dev`, Strict Mode's + mount → cleanup → mount dropped every update of a lane's one launch, so it sat on "Starting…" + while its job ran. Production mounts once and never showed it; no spec had clicked a lane + button before. +- **How the e2e avoids a real `next build` and wrangler.** There is no fake wrangler, and the hub's + buttons have no spec. The Build homepage spec holds the `build` and `deploy` queues with + `/api/test/stuck-job`, so its job only queues, and it cancels the job from the lane before it + starts. The deploy-homepage refusals run with both queues held the same way (after review; each + test held only one queue at first). The missing-build refusal is asked only + when the checkout has no `homepage/out/index.html`, and asked as a preview. diff --git a/plans/release-11.md b/plans/release-11.md @@ -36,6 +36,157 @@ merges `main` once O1–O5 have landed, then does its `E2E_` rename. Then one in ## Record +### Slice O4, as shipped — deploy the homepage from /sites (2026-09-28) + +The operator's ask (2026-09-26): **"Is there a way to redeploy archilyzer.pages.dev from the UI?"** +There was not: `/sites` built and deployed the hub, and the homepage was a CLI +(`archilyzer build|deploy homepage`) with no job, no `/jobs` row and no `pnpm ops` verb. Plan: +[`homepage-deploy-from-ui.md`](homepage-deploy-from-ui.md), decisions 1–5, now with an "As shipped" +note. Branch `r11/homepage-deploy-ui` off `main` `2162db92`, worktree +`/home/user/Projects/r11-homepage-deploy-ui`, one Opus implementer. Scratch files `o4-*` in the +job's `tmp/overnight`. + +**What shipped.** +- **A Homepage section on `/sites`, after Hub.** The Hub paragraph lost its homepage sentence, and + the new section says it instead: the `homepage` package, `homepage/out`, the project `archilyzer`, + and the same code as `archilyzer build|deploy homepage`. Its group, **Homepage build**, is + `HubBuildButtons`' shape: **Build homepage**, **Deploy after build** (unticked), **Deploy + homepage**, one `JobLane` at a time (**Build homepage** / **Build & deploy homepage** / + **Deploy homepage**). +- **A preview branch box** (textbox "preview branch", empty = production). It is judged by + `previewBranchProblem`, the function the action and the route refuse with. A bad name greys out + Deploy homepage, and Build homepage when Deploy after build is ticked, and shows the sentence + (`role="status"`, "preview problem"). +- **One line saying what a deploy ships:** "Deploy homepage ships `homepage/out`, built <when>, to + `archilyzer` (production): https://archilyzer.pages.dev." (`PROJECT_URL`). With no build it says + "`homepage/out` holds no build yet …". With a preview it gives the alias and says "the live site + is left alone". Under it: the homepage reads the index as it stands, so run Build index first. +- **Three job kinds,** via `runManagedFunction` in `editor/app/sites/lib/homepageDeployActions.ts`: + - `build-homepage` on queue `build`; + - `deploy-homepage` and `build-deploy-homepage` on queue `deploy`; + - the bodies are `buildHomepage` / `deployHomepage`, unchanged. The build-deploy deploys only + on exit 0. +- **Refusals before any job:** a bad preview name, and for a deploy-only, + `builtHomepageProblem(homepageOutDir(paths))` ("homepage/out holds no build — build the homepage + first"). `deployHomepage` re-checks both inside the job with its own sentences, untouched. +- **Ops:** + - `/api/ops/build-homepage` `{deploy?, preview?}`: a preview without deploy is a 400. + - `/api/ops/deploy-homepage` `{preview?}` answers `previewUrl` = + `previewAliasUrl(HOMEPAGE_PAGES_PROJECT, preview)`. + - `pnpm ops build-homepage` / `deploy-homepage`, in the verb list, the header examples and the + usage. +- **common:** `builtHomepageProblem` and `builtHomepageAt` (index.html's mtime) beside + `builtHubProblem` in `builtExport.ts`. `homepageOutDir(paths)` exported from `publish/build.ts`, + so the editor does not re-derive the path. +- **Found and fixed: `JobLane` under Strict Mode.** The launch effect gated its updates on a flag + local to it, set by its cleanup. `next dev`'s mount → cleanup → mount set that flag, and the + second run returned early on `startedRef`, so the job ran while the lane sat on "Starting…" + forever. Liveness is now a ref kept by its own effect. Production mounts once and is unchanged. + No spec had ever clicked a lane button (batch, specific sites, hub), which is how it went unseen. + +**Corrections to the plan** (also in its "As shipped" note): +- The ops preview is `--json '{"preview":"<b>"}'`, not a `--preview` flag. +- No `jobKinds.ts` entries: the hub's kinds have none, and neither does any publish kind. +- `homepageOutDir` had to be exported. + +**How nothing in e2e builds or deploys.** There is no fake wrangler, `homepage/out` is the +checkout's own directory (the primary's holds the live homepage's build), and the hub's buttons have +no spec to copy. So: +- **No spec clicks Deploy homepage.** None ticks Deploy after build and then clicks Build. +- **Build homepage is clicked only with the `build` and `deploy` queues both held** by + `/api/test/stuck-job`, which is never released in the spec. The `build-homepage` job only + queues, and so would a `build-deploy-homepage` if the click regressed into one. The spec cancels + it from its lane. Its `start()` never runs: no log file, no child, nothing written under + `homepage/`. +- **If that spec fails before its Cancel,** the next `resetData` cancels live jobs newest first + (`registry.list()` sorts by `queuedAt` descending). The queued build is removed before the + holder's slot is freed, so nothing is ever promoted. +- **The deploy-homepage refusals run with both queues held the same way** (`holdBuildAndDeployQueues` + in `ops-api.spec`). A build or deploy that a refusal failed to stop would only queue, and the + spec's job-list check would fail. +- **The missing-build refusal is asked only when the checkout has no `homepage/out/index.html`,** as + `deploy-hub`'s is (`211d4666`), and it is asked as a preview. +- **Checked after every run:** the worktree has no `homepage/out`, and `homepage/public` holds only + its tracked `_headers`. Its gitignored data was not copied, because nothing here builds the + homepage. + +| sha | what | +|---|---| +| `b047b1be` | `common:` `builtHomepageProblem` + `builtHomepageAt` (`builtExport.ts`), `homepageOutDir` (`publish/build.ts`); +3 unit tests | +| `7ba41164` | `sites:` `homepageDeployActions.ts`, `HomepageBuildButtons.tsx`, the Homepage section; the Hub paragraph loses its homepage sentence | +| `54021099` | `ops:` `/api/ops/build-homepage`, `/api/ops/deploy-homepage`; `archilyzer-ops.mjs` verbs + usage (+1 test) | +| `ba4ff0bb` | `e2e:` `ops-api.spec` +1 (the refusals), `sites-homepage.spec` (3) | +| `d52d017d` | `sites:` `JobLane` survives Strict Mode | +| `d9218cb7` | `sites:` the group stamps `data-hydrated`; the specs wait for it | +| `40b6a0d7` | `changelog:` `[Unreleased]` above `[0.9.4]` in `editor/CHANGELOG.md` | +| `ff8b9382` | `plans:` this record; the plan's "As shipped" note | +| `72175134` | `e2e:` review fix — the homepage refusal test, the Build homepage test and the hub's refusal test hold both the `build` and the `deploy` queue | +| `275c0fa6` | `common:` review fix — `deployHomepage` ships `homepageOutDir(paths)` (no behaviour change) | +| _this_ | `plans:` the review fixes in this record and the plan's note | + +**Gates** (logs `o4-*.log`): +- **tsc** clean before every code commit (the last run on `d9218cb7`'s tree). +- **common 2,041/2,041** (2,038 + 3), editor unit **85/85**, `test:scripts` **175 + 1 skipped** + (174 + 1, +1), mcp **269/269**. +- **Editor build** ok (59 s); `/api/ops/build-homepage` and `/api/ops/deploy-homepage` are in the + route list. +- **e2e:** + - Run 1 on `ba4ff0bb` (`ops-api`, `sites-homepage`, `sites-crud`, `site-publish-preview`, + `deploy-page`, `build`, `settings`): **63 passed, 1 failed** (2.8 min). The failure was + `sites-homepage`'s Build homepage test: the lane stayed on "Starting…", which is the `JobLane` + bug above. + - `sites-homepage` ×3 with the old `JobLane`: **6 passed, 3 failed**. + - The same ×3 on `d9218cb7`: **9 passed** (27 s). + - The final list on `d9218cb7` (the same seven specs): **64 passed** (1.8 min). + - After the review fixes, on `275c0fa6`: `ops-api` + `sites-homepage` **26 passed** (40.5 s). + tsc was clean, common 2,041/2,041 and `build.test.ts` 10/10. The worktree still has no + `homepage/out`. + - No spec here greps `deploy-hub|build-hub|HubBuild` except `ops-api`, which is in the list. +- **Numbers tool:** none. + +**They bite:** +- **Unit, against `2162db92`'s source:** + - `builtExport.test.ts` and `build.test.ts` fail to load (no `builtHomepageAt` / + `homepageOutDir` export). + - A mutation, `builtHomepageProblem` checking the directory instead of `index.html`, fails 1 of 8. + - `archilyzer-ops.test.mjs` against the old script: 1 of 33 fails, the new test. +- **e2e, against `2162db92`'s `page.tsx`, `builtExport.ts` and `build.ts`,** with the new action, + component and routes moved out: **4 of 4 new tests fail.** `ops-api` hits a 404 page ("Unexpected + token '<'"), and `sites-homepage` finds no Homepage group. The tree was restored by a trap + afterwards. +- **The `JobLane` fix:** the Build homepage test fails 3 of 3 with the old lane and passes 3 of 3 + with the fix. + +**Review fixes** (review: SHIP AFTER FIXES, `o4-review.md`): +- **Should-fix 1, one queue held where a regression could reach the other.** The deploy-homepage + refusal test held only `deploy`. A regressed "preview needs deploy" guard would have run a real + `build-homepage` there: in the primary checkout, compose writes fixture numbers into + `homepage/public`, then `next build` runs. The Build homepage test held only `build`, so a click + turned into build-and-deploy would have reached `deploy`. Both tests now hold both queues. So does + the hub's `deploy-hub` / `build-hub` refusal test (the review's low 4). That test **predates this + slice** (release 7) and held nothing. In `ops-api.spec` the holds sit in one helper, + `holdBuildAndDeployQueues`, and `before` is taken after them. +- **Low 2:** `deployHomepage` now takes its directory from `homepageOutDir(paths)`. This is a pure + refactor, and what it ships is exactly the directory the editor judged "built". +- **Low 3** (the "built <when>" line does not refresh after an in-page build) is left as recorded + below. + +**Found and left** +- **A job cancelled while still queued keeps `"status":"queued"` in its `.meta.json`.** The + `onCancel` path never rewrites the sidecar. The registry says cancelled, and the boot pass settles + the sidecar. This is pre-existing and was not touched. +- **The "built <when>" line is read when `/sites` renders,** so a lane that finishes a build does + not refresh it. The Hub's lanes do not refresh the page either. +- **`/jobs` shows the three kinds by their raw names,** as it does the hub's. +- **Nothing has been built or deployed through the new jobs:** no build and no deploy tonight, by + rule. The proof is the rollout below. + +**Rollout (owed):** the section and the routes exist only on an editor built from this code, so it +waits on the :3001 restart. After it: +1. **Build homepage** with Deploy after build unticked, or `pnpm ops build-homepage --wait`. +2. **Deploy homepage**, or `pnpm ops deploy-homepage --wait`. The job's log ends with + `[deployed] https://<hash>.archilyzer.pages.dev`. + ### Slice O3, as shipped — runner lows (2026-09-28) Branch `r11/runner-lows` off `main` `2162db92`, worktree `/home/user/Projects/r11-runner-lows`, one diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -37,6 +37,8 @@ // pnpm ops build-hub --wait // pnpm ops build-hub --json '{"deploy":true}' --wait // pnpm ops deploy-hub --wait +// pnpm ops build-homepage --json '{"deploy":true}' --wait +// pnpm ops deploy-homepage --json '{"preview":"refresh"}' --wait // pnpm ops get channel the-quartering // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' // pnpm ops tag-videos --file ids.json @@ -106,6 +108,10 @@ const ACTIONS = [ // build uses) and its deploy to homepage.json's Pages project. "build-hub", "deploy-hub", + // The HOMEPAGE (the `homepage` package, Archilyzer's own site, into + // homepage/out) and its deploy to the constant Pages project `archilyzer`. + "build-homepage", + "deploy-homepage", "relocate", "relocate-back", "evict-clips", @@ -309,6 +315,12 @@ export function usage() { ' after, and deploy-hub ships the one already built. Both deploy to the', " Pages project set on /sites under Hub, and take \"preview\" too.", "", + 'build-homepage builds the homepage package into homepage/out;', + ' {"deploy": true} deploys it after (only if the build succeeded), and', + " deploy-homepage ships the one already built. Both deploy to the Pages", + " project archilyzer (https://archilyzer.pages.dev), production unless", + ' "preview" is given.', + "", 'cut-release turns a changelog\'s [Unreleased] into "## [<version>] - <date>":', ' {"workspace": "editor" | "export" | "all",', ' "version": "X.Y.Z" | "next" | "next-minor",', diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -361,3 +361,20 @@ test("build-hub and deploy-hub are POSTs to their own routes, named in the usage assert.match(usage(), /Actions:.*build-hub, deploy-hub/); assert.match(usage(), /build-hub builds the hub into export\/out/); }); + +// The homepage's pair (release 11 slice O4), the hub's twins: a POST each, the +// body passed through untouched — the routes judge it. +test("build-homepage and deploy-homepage are POSTs to their own routes, named in the usage", () => { + const build = parseArgs(["build-homepage", "--json", '{"deploy":true}', "--wait"]); + assert.equal(build.method, "POST"); + assert.equal(build.path, "/api/ops/build-homepage"); + assert.deepEqual(build.body, { deploy: true }); + assert.equal(build.wait, true); + const deploy = parseArgs(["deploy-homepage", "--json", '{"preview":"refresh"}']); + assert.equal(deploy.method, "POST"); + assert.equal(deploy.path, "/api/ops/deploy-homepage"); + assert.deepEqual(deploy.body, { preview: "refresh" }); + assert.match(usage(), /Actions:.*build-homepage, deploy-homepage/); + assert.match(usage(), /build-homepage builds the homepage package into homepage\/out/); + assert.match(usage(), /project archilyzer \(https:\/\/archilyzer\.pages\.dev\)/); +});