commit befef15c84dca98ff2ec22d57e9ee3f38f06f44e parent 6426e30ebe8eef316fc631af5eb05b92c0c9492f Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Tue, 6 Oct 2026 13:14:46 -0400 publish: the S4 review's fixes — a console's Cancel is its run's; another run's job is never this run's; the docker build updates a stale index first; the ops and CLI edges Review `$T/s4-review.md` (SHIP AFTER FIXES), all nine findings: 1. cancelPublishRunAction cancels every live stage carrying the console job's run id (newest first); every publish console uses it (JobLane takes a cancelAction) — Cancel used to cancel only the run's first job, a no-op once it had ended, so a queued production deploy could not be stopped. 2. A part another run queued (`existing`) is never the console's job, never cascaded, never waited on. 3. publish-lane.spec takes its queue holder after the page is up (45 s). 4. A preview with no branch name is refused, never let fall to production. 5. POST publish {build, runner: docker} puts the index update first when the index is not fresh (indexAfter on the containers' build). 6. The hub/homepage verbs refuse a local deploy with a preview. 7. build-site / build-deploy with `all` keep a top-level jobId (the run's last); comments and the changelog say what changed. 8. `publish hub|homepage --deploy-only --force` re-ships; --deploy with --deploy-only is a usage error; the changelog says the aliases no-op. 9. One target's throw is that target's refusal, not the request's 500. Cleanups: fanOutSiteJobs deleted; followRun refuses an empty run; enqueueRun releases kept branches on a throw; plan keys carry the preview; saving the lane on starts its runner; the foreground/background window noted in publishStages.ts. From the S6 drafts: the charts page no longer points at "Build stats dataset"; the source gate's refusal names `archilyzer publish homepage` (only a stage stamps the build a deploy ships); `build hub` stays a raw build (e2e:2origin's build:hub) and says it is unstamped; three stale comments. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Diffstat:
30 files changed, 244 insertions(+), 116 deletions(-)
diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -483,6 +483,15 @@ test("build site, build all and deploy site are printed aliases of the publish r } }); +test("publish hub|homepage refuse --deploy with --deploy-only (usage, nothing run)", async () => { + const { publishHub, publishHomepage } = await import("./publish"); + const lines: string[] = []; + const out = { log: (l: string) => lines.push(l), error: (l: string) => lines.push(l) }; + assert.equal(await publishHub({ deploy: true, deployOnly: true }, out), 2); + assert.equal(await publishHomepage({ deploy: true, deployOnly: true }, out), 2); + assert.match(lines.join("\n"), /--deploy-only ships the bundle as built — one of them/); +}); + test("publish hub / homepage refuse a deploy's flag without --deploy (usage, nothing run)", async () => { const { publishHub, publishHomepage } = await import("./publish"); const errors: string[] = []; diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -221,7 +221,11 @@ export const COMMANDS: Command[] = [ }, { path: ["build", "hub"], - usage: "compose:hub + INSTANCE_MODE=hub next build into export/out", + // Kept RAW (export's `build:hub`, which e2e:2origin runs — an alias of + // `publish index` would index whatever corpus is visible). Unstamped: + // `deploy hub` ships the bundle `publish hub` builds, never this. + usage: + "compose:hub + INSTANCE_MODE=hub next build into export/out — a raw build, unstamped; to deploy the hub build it with `publish hub`", run: async () => { const { buildHub } = await import("../publish/build"); const code = await buildHub({ signal: interrupted() }); diff --git a/common/bin/publish.ts b/common/bin/publish.ts @@ -207,6 +207,10 @@ export async function publishHub( }, out: Out = console, ): Promise<number> { + if (a.deploy && a.deployOnly) { + out.error("publish hub: --deploy builds then deploys, --deploy-only ships the bundle as built — one of them"); + return STAGE_EXIT.usage; + } const deploy = a.deploy || a.deployOnly; if (a.preview && !deploy) { out.error("publish hub: --preview is a deploy — add --deploy"); @@ -218,7 +222,19 @@ export async function publishHub( const built = await run({ kind: "build-hub", target: "_hub", runId, ...(a.force ? { force: true } : {}) }, signal, a.paths); if (built !== 0 || !deploy) return built; } - return run({ kind: "deploy-hub", target: "_hub", runId, ...(a.preview ? { preview: a.preview } : {}) }, signal, a.paths); + return run( + { + kind: "deploy-hub", + target: "_hub", + runId, + ...(a.preview ? { preview: a.preview } : {}), + // --deploy-only --force re-ships the bundle as built; with a build in the + // run, --force was the build's. + ...(a.deployOnly && a.force ? { force: true } : {}), + }, + signal, + a.paths, + ); } export async function publishHomepage( @@ -235,6 +251,10 @@ export async function publishHomepage( }, out: Out = console, ): Promise<number> { + if (a.deploy && a.deployOnly) { + out.error("publish homepage: --deploy builds then deploys, --deploy-only ships it as built — one of them"); + return STAGE_EXIT.usage; + } const deploy = a.deploy || a.deployOnly; if ((a.preview || a.to) && !deploy) { out.error("publish homepage: --preview and --to are a deploy's — add --deploy"); @@ -257,6 +277,7 @@ export async function publishHomepage( runId, ...(a.preview ? { preview: a.preview } : {}), ...(a.to ? { to: a.to } : {}), + ...(a.deployOnly && a.force ? { force: true } : {}), }, signal, a.paths, diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts @@ -624,7 +624,9 @@ const JOB_KINDS: Record<string, JobKindMeta> = { // THE HUB'S AND THE HOMEPAGE'S BUILD AND DEPLOY (release 13 slice W1). They // ran from /sites — the hub since release 7, the homepage since release 11 — // with no entry here, so /jobs showed their raw machine kinds. The labels - // are the lanes' own titles on /sites (HubBuildButtons, HomepageBuildButtons). + // were the lanes' own titles on /sites; since release 18 those buttons are + // the Publish panel's and run `publish-*` stages, and these kinds are only + // read back from the history. // // Queue: BUILD_QUEUE for a build, DEPLOY_QUEUE for a deploy or a // build-and-deploy (the actions' `queueKey`), hence "custom". Neither diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -412,9 +412,10 @@ function corpusSiteIdIn(outDir: string): string | null { * Why `outDir` may not be deployed as the HUB, as one sentence — or null when * it holds a hub build. * - * The hub is the export app built with INSTANCE_MODE=hub into the SAME - * export/out a site build uses, so the two overwrite each other. A hub build - * names itself by what it carries and what it does not: compose-hub writes + * The hub is the export app built with INSTANCE_MODE=hub into export/out, the + * directory a site's build uses too (release 18 moves each build into its own + * bundle afterwards, `_hub/out`), so the two are told apart by content. A hub + * build names itself by what it carries and what it does not: compose-hub writes * `hub-sites.json` and the hub build removes `site.json` first, while a site's * compose removes `hub-sites.json` and writes `site.json`. A site.json here is * therefore a site's bundle, whatever else is beside it. diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -24,9 +24,10 @@ import { getSettings } from "../lib/settings"; import { getSite, type Site } from "../lib/site"; import { builtStampPath } from "./stamps"; -// Where the basic (host) build writes the static bundle to deploy: the fixed -// export/out, composed one site at a time. The docker fan-out writes per-site -// out/ dirs instead — see dockerSiteOutDir. +// Where `next build` writes a site's or the hub's static export: the fixed +// export/out, one build at a time (the publish lock). The build stage then +// moves it into the target's bundle (`bundleDir`) and makes export/out a link +// to it; the docker runner writes per-site out/ dirs instead — dockerSiteOutDir. export function resolveOutDir(_siteId: string, paths: Paths): string { return path.join(paths.exportDir, "out"); } diff --git a/common/publish/publishStages.ts b/common/publish/publishStages.ts @@ -15,7 +15,13 @@ // ORDER IS ON DISK, NOT IN MEMORY: a run's builds carry `indexAfter` and its // deploys `builtAfter` (the plan sets them), and a child whose precondition is // not met when it starts exits 3 — the job ends `failed` with the sentence in -// its log. That is intended: nothing here chains one job to another. +// its log. That is intended: nothing here chains one job to another. One +// window it leaves: a click's run (foreground) that finds the lane's index +// update already QUEUED (background — behind a job on another queue's slot, +// say) reuses it as `existing`, and its own build, foreground, can then start +// first and end "waiting for the index update this run started". The lane +// yields while any publish stage is queued, so the window is narrow; Retry +// re-enqueues the build. // // The spec IS the request: `{kind: "publish-<stage>", slug: target, params: // {runId, …req}}` (`parseJobSpec` requires `slug`; there is no `channelSlug`), diff --git a/common/publish/source.test.ts b/common/publish/source.test.ts @@ -547,7 +547,7 @@ test("round trip: --check writes nothing; publish; a dumb clone of the mirror is writeFileSync(path.join(repo, "later.txt"), "x\n"); gitIn(repo, "add", "-A"); gitIn(repo, "commit", "-q", "-m", "later"); - assert.match((await publishedSourceProblem(checkPaths, out, check))!, /main is now [0-9a-f]{12} — run `archilyzer build homepage`/); + assert.match((await publishedSourceProblem(checkPaths, out, check))!, /main is now [0-9a-f]{12} — run `archilyzer publish homepage`/); // A REFUSAL WITHDRAWS THE PUBLISH (M1): deny a literal the mirror holds. // The deploy check refuses first (other rules), then the publish refuses @@ -727,7 +727,7 @@ test("history: published with the source — the allowlist at /source/git/, the const outLog = path.join(out, "source", "git", "log.html"); const was = readFileSync(outLog); writeFileSync(outLog, Buffer.concat([was, Buffer.from(" ")])); - assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited — run `archilyzer build homepage`/); + assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited — run `archilyzer publish homepage`/); rmSync(path.join(out, "source", "git"), { recursive: true }); assert.match((await publishedSourceProblem(o.paths!, out, check))!, /history pages \(\/source\/git\/\) are not the ones that were audited/); cpSync(path.join(pub, "source", "git"), path.join(out, "source", "git"), { recursive: true }); diff --git a/common/publish/source.ts b/common/publish/source.ts @@ -1301,7 +1301,9 @@ export async function publishedSourceProblem( ): Promise<string | null> { const outSource = path.join(outDir, "source"); const outTarball = path.join(outDir, "downloads", TARBALL_NAME); - const rebuild = "run `archilyzer build homepage` (it re-audits), then deploy"; + // The stage, not the bare `build homepage`: only a stage stamps the build + // (`_homepage/built.json`), and a deploy ships what is stamped. + const rebuild = "run `archilyzer publish homepage` (it re-audits), then deploy"; // The /source/ PAGE is always in a finished build (it renders its empty // state without a publish). Without it, the build's source step refused — // it took out/source away — or the build predates the page. diff --git a/common/publish/stages.ts b/common/publish/stages.ts @@ -9,7 +9,8 @@ // run(ctx, req) the body, in that child or in the CLI's own process // // `needs()` reads a `NeedsInput` — the minimal PublishStatus-shaped input -// defined here. S3's status view (common/views/publishStatus.ts) satisfies it +// defined here. S3's status view (publish/publishPlan.ts, re-exported by +// views/publishStatus.ts) satisfies it // from the stamps plus the job metas (`changedChannels`) and config mtimes; the // stage child builds one from disk alone (`readNeedsInput`, stageBodies.ts), // because ordering is enforced ON DISK: a stage whose precondition is not met diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -5,10 +5,10 @@ - **One index for every site.** The index is updated once and every site, the hub and the homepage are built from it; `archilyzer publish status` says, per site, whether its build is current — "stale: 3 channels changed (a, b, c)" as soon as a download, transcription or digest on one of its channels finishes, before any index runs; "stale: data changed" once the index has run and the site's data moved; "stale: config changed" after its site.json, tags or aliases changed — and whether what is deployed is that build, with a build made by older code marked "code newer" but not stale. - **Deploys are pinned and checked live.** wrangler is an exact dependency of the workspace (4.147.0), so a deploy runs the version installed with the code instead of whatever `pnpm dlx` fetched that day, and every deploy names its branch: production is `--branch main`, never taken from the checkout it ran in (where a "production" deploy from a feature branch used to land as a preview). The publish stages' deploy (release 18) refuses before wrangler runs when there is no Cloudflare credential at all — "set CLOUDFLARE_API_TOKEN in .env" — and says "REFUSED by Cloudflare — the API token was not accepted" when Cloudflare rejects one; it refuses a production deploy of a build made from a branch other than `main`. After each deploy it reads `corpus.json` at the site's address twice, as a visitor would and cache-busted, and records the verdict: ok, stale-edge (the deployment is right, Cloudflare's edge still serves an older copy), mismatch, or unreachable. A verdict short of ok is a warning in the log; the deploy itself succeeded. What each target last shipped, where, and how it read is kept in `deployed.json` beside its build. - **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel a public site carries, with an empty posts manifest; a channel only on a private site, or on no site, is never named on the hub. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back. -- **Publishing is stages, from the command line: `archilyzer publish`.** `publish index` updates the index — the LMDB index, the stats datasets and the chart templates, in one child process with an 8 GB heap — and writes an index stamp (`export/.export-index/stamp.json`) naming, for each site, a signature of everything that site's build reads. `publish build <id|all>` builds a site from that index (no data phase of its own) into its own bundle, `export/.export-builds/<id>/out`, and stamps it (`built.json`); a site whose bundle already matches the index is a no-op unless `--force`. `publish deploy <id|all> [--preview <branch>] [--to local]` ships that bundle — to Cloudflare Pages, or with `--to local` into the directory the docker `site` service serves — and records the deploy (`deployed.json`); deploying the same build again is a no-op unless `--force`. `all` passes over private sites and, to Pages, sites with no Pages project; any other site it cannot deploy is a failure, said after the rest are tried. `publish hub [--deploy]` and `publish homepage [--deploy]` do the same for the hub (`_hub/out`) and the homepage. A stage whose input is not there says so and exits 3: "update the index first", "no build of jeralyzer — archilyzer publish build jeralyzer". Production refuses a bundle built on a branch other than `main`, or with no branch recorded (a detached checkout; an image sets `ARCHILYZER_BRANCH`) — a preview of it is fine. Exit codes: 0 done or nothing to do, 1 failed, 2 usage, 3 precondition not met, 130 cancelled. In the editor, **/sites has a Publish panel** in place of "Build all sites" and the hub's and the homepage's build sections: a row per site, the hub and the homepage, each with four chips — index, built, deployed, live — and Build, Deploy preview, Deploy production (and Deploy local where the container serves one); **Publish now** runs what the lane would, **Build all stale** builds every stale site whatever its policy, and the plan Publish now would run is listed above the rows. Every button is stages on the `publish` queue, followed in one log, and a manual Build or Deploy always runs (the index is updated first when it is stale). The Pool's **Build index** is the index stage, and **Build stats dataset is gone**: the stats are part of it. A site's Publish tab is stages too, and its "Last deployed" is what that site last shipped and how its live check read. Over HTTP, `pnpm ops publish` takes `{"verb": "index" | "build" | "deploy" | "hub" | "homepage" | "now" | "stale"}` and `pnpm ops get publish` is the status; `build-index`, `build-site`, `build-deploy`, `deploy-site`, `build-hub`, `deploy-hub`, `build-homepage` and `deploy-homepage` still answer as before, as stages. +- **Publishing is stages, from the command line: `archilyzer publish`.** `publish index` updates the index — the LMDB index, the stats datasets and the chart templates, in one child process with an 8 GB heap — and writes an index stamp (`export/.export-index/stamp.json`) naming, for each site, a signature of everything that site's build reads. `publish build <id|all>` builds a site from that index (no data phase of its own) into its own bundle, `export/.export-builds/<id>/out`, and stamps it (`built.json`); a site whose bundle already matches the index is a no-op unless `--force`. `publish deploy <id|all> [--preview <branch>] [--to local]` ships that bundle — to Cloudflare Pages, or with `--to local` into the directory the docker `site` service serves — and records the deploy (`deployed.json`); deploying the same build again is a no-op unless `--force`. `all` passes over private sites and, to Pages, sites with no Pages project; any other site it cannot deploy is a failure, said after the rest are tried. `publish hub [--deploy]` and `publish homepage [--deploy]` do the same for the hub (`_hub/out`) and the homepage. A stage whose input is not there says so and exits 3: "update the index first", "no build of jeralyzer — archilyzer publish build jeralyzer". Production refuses a bundle built on a branch other than `main`, or with no branch recorded (a detached checkout; an image sets `ARCHILYZER_BRANCH`) — a preview of it is fine. Exit codes: 0 done or nothing to do, 1 failed, 2 usage, 3 precondition not met, 130 cancelled. In the editor, **/sites has a Publish panel** in place of "Build all sites" and the hub's and the homepage's build sections: a row per site, the hub and the homepage, each with four chips — index, built, deployed, live — and Build, Deploy preview, Deploy production (and Deploy local where the container serves one); **Publish now** runs what the lane would, **Build all stale** builds every stale site whatever its policy, and the plan Publish now would run is listed above the rows. Every button is stages on the `publish` queue, followed in one log, and a manual Build or Deploy always runs (the index is updated first when it is stale). The Pool's **Build index** is the index stage, and **Build stats dataset is gone**: the stats are part of it. A site's Publish tab is stages too, and its "Last deployed" is what that site last shipped and how its live check read. Over HTTP, `pnpm ops publish` takes `{"verb": "index" | "build" | "deploy" | "hub" | "homepage" | "now" | "stale"}` and `pnpm ops get publish` is the status; `build-index`, `build-site`, `build-deploy`, `deploy-site`, `build-hub`, `deploy-hub`, `build-homepage` and `deploy-homepage` still answer as before, as stages — except that `build-site` and `build-deploy` with `"all": true` answer a job per site (their `jobId` is the run's last), and a deploy-only of a build already deployed there is a no-op. A console's **Cancel** cancels its whole run. - **One publish at a time on a machine.** Every stage takes `export/.export-builds/.publish.lock`; a second one — an `archilyzer publish` beside the editor, say — waits for it, saying once whom it waits for, and Ctrl-C ends the wait. A lock left by a process that is gone is taken over. A cancelled stage takes the whole process tree it started with it (`next build`'s workers, wrangler, docker). - **`export/out` is now a link to the bundle built last.** Each site, and the hub, keeps its own bundle, so building one site no longer replaces another's; `export/out` points at whichever was built most recently, so `serve out` and anything else that read it keeps working. -- **`build site`, `build all` and `deploy site` are aliases of the publish commands** and print what they run: `build site <id>` is `publish index` (skipped with `--nodata`) then `publish build <id> --force`; `build all` is `publish index` then `publish build all --runner auto` (containers when an engine answers, else one site at a time on the host); `deploy site <id>` is `publish deploy <id>`, which now ships the site's own bundle and refuses a site never built that way; `deploy hub` and `deploy homepage` are `publish hub --deploy-only` and `publish homepage --deploy-only`. `publish build all --runner docker` builds every stale site in containers on a Linux host and refuses with "the docker runner needs an engine on this host" where there is none. +- **`build site`, `build all` and `deploy site` are aliases of the publish commands** and print what they run: `build site <id>` is `publish index` (skipped with `--nodata`) then `publish build <id> --force`; `build all` is `publish index` then `publish build all --runner auto` (containers when an engine answers, else one site at a time on the host); `deploy site <id>` is `publish deploy <id>`, which now ships the site's own bundle and refuses a site never built that way; `deploy hub` and `deploy homepage` are `publish hub --deploy-only` and `publish homepage --deploy-only` (a no-op when that build is already deployed there, unless `--force`; a refusal exits 3). `publish build all --runner docker` builds every stale site in containers on a Linux host and refuses with "the docker runner needs an engine on this host" where there is none. - **Substitute your own yt-dlp in Docker.** Point `YTDLP_BIN` at a zipapp you built, or set `YTDLP_SOURCE_HOST_DIR` to a yt-dlp checkout and start with `docker-compose.ytdlp.yml`: the image runs it with its own python, and nothing is rebuilt. Every editor boot logs `yt-dlp: <path> <version> (image|override)` (`MISSING` when it does not run; the editor still starts), and `YTDLP_AUTO_UPDATE` updates the image's yt-dlp only, warning instead of touching yours. - **The Docker image can publish.** It carries python, `pipx` and a pinned `git-filter-repo`, so the homepage's `/source` mirror builds in the container; `docker-compose.source.yml` mounts your repository read-only for it, and the scrub rules and denylist live in the config volume (`/data/config/archilyzer`). Cloudflare and R2 credentials come from `.env`. Run publish commands with `docker compose exec editor pnpm archilyzer …`, not `run --rm`. The `homepage` service serves a local deploy from the builds volume once there is one. RUNNING_IN_DOCKER.md has a Windows checklist. - **`archilyzer doctor` checks what a publish needs.** Which yt-dlp runs (the image's, the host's or an override, and whether it runs), whether the Cloudflare token and the R2 keys are set (never their values; R2 only when a bucket is configured) — judged exactly as a deploy judges them —, the wrangler a deploy runs (the pinned one or your `WRANGLER_BIN`, and that it starts and is the expected major), free space for the site bundles, the publish lock (free, held by a running stage, or left by one that is gone — with the command to clear it; never cleared for you), the index stamp's age and which sites were built from an older one, the repository the source mirror reads, the private config dir, and whether this Node is new enough for the pinned wrangler (deploys need 22). diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -293,66 +293,6 @@ export function optPreviewBranch(body: OpsBody): string | undefined { return (raw as string).trim(); } -// The site fan-out the deploy routes run: start one managed job per site, -// keeping every id, and refuse with a 400 when NONE started. -// -// SHARED BECAUSE THE CAREFUL PART IS EASY TO GET WRONG TWICE. One site's throw -// cannot cost the others their job ids — an exception out of the loop becomes a -// 500 carrying no `jobs` at all, while the jobs already queued run on with -// nobody holding their ids. And asking for deploys and getting NONE is a 400 -// carrying the reason, not a cheerful `{ ok: true, jobs: [] }`: `--wait` would -// exit 0 on that and report success about a deploy that never started. -// -// `decorate` adds per-site keys to a job entry (the preview alias); they are -// repeated at the top level in the single-job case, exactly as `jobId` is, so a -// one-site caller never has to index into `jobs`. -export async function fanOutSiteJobs( - siteIds: string[], - start: (siteId: string) => Promise<StreamActionResult>, - decorate?: (siteId: string) => Record<string, unknown>, -): Promise<NextResponse> { - const jobs: Record<string, unknown>[] = []; - const extras: Record<string, unknown>[] = []; - const skipped: { siteId: string; reason: string }[] = []; - let info = false; - for (const siteId of siteIds) { - let result: StreamActionResult; - try { - result = await start(siteId); - } catch (e) { - skipped.push({ siteId, reason: (e as Error).message }); - continue; - } - if (!result.ok) { - skipped.push({ siteId, reason: result.error }); - info = info || result.info === true; - continue; - } - // The stream is cancelled, never returned — see this file's header. - void result.stream.cancel(); - const extra = decorate?.(siteId) ?? {}; - jobs.push({ siteId, jobId: result.jobId, ...extra }); - extras.push(extra); - } - if (jobs.length === 0) { - // One site asked for, one reason: the bare sentence the action gave, - // exactly as jobResponse has always returned it. - return opsFail( - skipped.length === 1 - ? skipped[0].reason - : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "), - 400, - info ? { info: true } : undefined, - ); - } - return NextResponse.json({ - ok: true, - jobs, - skipped, - ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...extras[0] } : {}), - }); -} - export function oneOf<T extends string>( body: OpsBody, key: string, diff --git a/editor/app/api/ops/_publish.ts b/editor/app/api/ops/_publish.ts @@ -41,7 +41,14 @@ export async function enqueueAsks(asks: TargetAsk[]): Promise<OpsRun> { const ok: TargetAsk[] = []; const preRefused: OpsRefusal[] = []; for (const ask of asks) { - const why = await refusal(paths, ask); + // One target's throw (an unreadable site.json) is that target's refusal, + // never the whole request's 500: the others still run. + let why: string | null; + try { + why = await refusal(paths, ask); + } catch (e) { + why = (e as Error).message; + } if (why) { const kinds = stageKindsOf(ask.target); preRefused.push({ target: ask.target, kind: ask.deploy ? kinds.deploy : kinds.build, error: why }); diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts @@ -14,7 +14,9 @@ export const dynamic = "force-dynamic"; // `all` = every DEPLOYABLE site to production (an explicit request: the // sites' policies are not asked); a private or project-less site is skipped // with its reason. → { ok, jobs: [{siteId, jobId, previewUrl?}], skipped } -// with each site's DEPLOY job (the last of its run), as before. +// with each site's DEPLOY job (the last of its run), as before. `all` used to +// answer ONE job (build-deploy-all); it answers the per-site list now, and its +// top-level `jobId` is the run's last job (`--wait` follows `jobs` anyway). export async function POST(request: Request) { return ops( request, @@ -25,7 +27,8 @@ export async function POST(request: Request) { const paths = getPaths(); let ids: string[]; const skippedAll: { siteId: string; reason: string }[] = []; - if (optBool(body, "all")) { + const all = optBool(body, "all") === true; + if (all) { if (body.siteId !== undefined || body.siteIds !== undefined) { throw new OpsInputError('send either "siteId"/"siteIds" or "all", not both'); } @@ -59,12 +62,17 @@ export async function POST(request: Request) { if (jobs.length === 0) { return opsFail(skipped.length === 1 ? skipped[0].reason : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; ")); } + const lastOfRun = all ? run.jobs.at(-1)?.jobId : undefined; return NextResponse.json({ ok: true, runId: run.runId, jobs, skipped, - ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...(jobs[0].previewUrl ? { previewUrl: jobs[0].previewUrl } : {}) } : {}), + ...(jobs.length === 1 + ? { jobId: jobs[0].jobId, ...(jobs[0].previewUrl ? { previewUrl: jobs[0].previewUrl } : {}) } + : lastOfRun + ? { jobId: lastOfRun } + : {}), }); }, ); diff --git a/editor/app/api/ops/build-site/route.ts b/editor/app/api/ops/build-site/route.ts @@ -12,7 +12,9 @@ export const dynamic = "force-dynamic"; // // `skipData` is accepted and ignored (the data is the index stage, shared by // every site); `all` builds every site. → { ok, jobs: [{siteId, jobId}], -// skipped: [{siteId, reason}] } (+ `jobId` when one site), as before. +// skipped: [{siteId, reason}] } (+ `jobId` when one site), as before. `all` +// used to answer ONE job (build-all); it answers the per-site list now, and +// its top-level `jobId` is the run's LAST job (`--wait` follows `jobs` anyway). export async function POST(request: Request) { return ops( request, @@ -34,12 +36,14 @@ export async function POST(request: Request) { return j ? [{ siteId, jobId: j.jobId }] : []; }); const skipped = refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error })); + const all = optBool(body, "all") === true; + const top = jobs.length === 1 ? jobs[0].jobId : all ? run.jobs.at(-1)?.jobId : undefined; return NextResponse.json({ ok: true, runId: run.runId, jobs, skipped, - ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}), + ...(top ? { jobId: top } : {}), }); }, ); diff --git a/editor/app/api/ops/publish/route.ts b/editor/app/api/ops/publish/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { planPublishRun } from "yt-dlp-transcript-common/publish/publishPlan"; import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; -import { ALL_TARGET, HOMEPAGE_TARGET, HUB_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import { ALL_TARGET, HOMEPAGE_TARGET, HUB_TARGET, INDEX_TARGET } from "yt-dlp-transcript-common/publish/stamps"; import { indexPlan } from "../../../sites/lib/publishCore"; import { enqueueAsks, enqueuePlan, publishResponse } from "../_publish"; import { @@ -81,10 +81,18 @@ export async function POST(request: Request) { if (body.siteId !== undefined || body.siteIds !== undefined) { throw new OpsInputError('"runner": "docker" builds every site — send no "siteId"/"siteIds"'); } + // As every build: the index update first when it is not fresh, and + // the containers wait on disk for it (indexAfter). + const runStart = Date.now(); + const status = await readPublishStatus(getPaths()); + const withIndex = !status.index.fresh; return publishResponse( await enqueuePlan({ - runStart: Date.now(), + runStart, steps: [ + ...(withIndex + ? [{ kind: "update-index" as const, target: INDEX_TARGET, reason: "the index is not fresh" }] + : []), { kind: "build-site", target: ALL_TARGET, @@ -92,6 +100,7 @@ export async function POST(request: Request) { reason: "asked", ...(force !== false ? { force: true } : {}), ...(skipArchives ? { skipArchives: true } : {}), + ...(withIndex ? { indexAfter: runStart } : {}), }, ], skipped: [], @@ -130,6 +139,9 @@ export async function POST(request: Request) { if (!deploy && (preview !== undefined || body.to !== undefined)) { throw new OpsInputError('"preview" and "to" need "deploy": true — a build alone deploys nothing'); } + if (to === "local" && preview !== undefined) { + throw new OpsInputError("a local deploy has no preview branch — deploy locally or as a preview, not both."); + } const where = to === "local" ? "local" : preview ? "preview" : "production"; return publishResponse( await enqueueAsks([ diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts @@ -323,6 +323,12 @@ export async function savePublishSettingsAction( } catch (e) { return { ok: false, error: (e as Error).message, values }; } + // Switched on: the runner starts (idempotent — one already up is kept). + // Switched off: a running runner notices at its next tick and ends. + if (next.enabled && !current.enabled) { + const { startPublishRunner } = await import("yt-dlp-transcript-common/publish/publishRunner"); + await startPublishRunner(); + } revalidatePath("/operations/publish"); revalidatePath("/sites"); return { ok: true }; diff --git a/editor/app/sites/components/BuildButtons.tsx b/editor/app/sites/components/BuildButtons.tsx @@ -16,7 +16,7 @@ import { normalizeLiveChatAction, normalizeTranscriptsAction, } from "../lib/buildAction"; -import { updateIndexAction } from "../lib/publishActions"; +import { cancelPublishRunAction, updateIndexAction } from "../lib/publishActions"; import { ArchiveOptionsControl } from "./ArchiveOptionsControl"; type Props = { @@ -66,7 +66,7 @@ export function BuildButtons({ existingQueues }: Props) { publish queue runs its stages one at a time. */} <StreamActionLog trigger={() => updateIndexAction()} - cancelAction={cancelJobAction} + cancelAction={cancelPublishRunAction} buttonLabel="Build index" runningLabel="Building index…" /> diff --git a/editor/app/sites/components/BuildDeployButton.tsx b/editor/app/sites/components/BuildDeployButton.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import { buildAndDeployTargetAction } from "../lib/publishActions"; -import { cancelJobAction } from "../../jobs/actions"; +import { cancelPublishRunAction, buildAndDeployTargetAction } from "../lib/publishActions"; type Props = { // The site whose Publish tab this is. Always a real site now — the control @@ -46,7 +45,7 @@ export function BuildDeployButton({ return ( <StreamActionLog trigger={() => buildAndDeployTargetAction(siteId, { where: "production", skipArchives })} - cancelAction={cancelJobAction} + cancelAction={cancelPublishRunAction} buttonLabel="Build & deploy" runningLabel="Building & deploying…" label="Build and deploy" diff --git a/editor/app/sites/components/BuildExportButton.tsx b/editor/app/sites/components/BuildExportButton.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import { buildTargetAction } from "../lib/publishActions"; -import { cancelJobAction } from "../../jobs/actions"; +import { cancelPublishRunAction, buildTargetAction } from "../lib/publishActions"; type Props = { // The site whose Publish tab this is — always a real site. @@ -21,7 +20,7 @@ export function BuildExportButton({ siteId, siteTitle }: Props) { return ( <StreamActionLog trigger={() => buildTargetAction(siteId, { skipArchives })} - cancelAction={cancelJobAction} + cancelAction={cancelPublishRunAction} buttonLabel="Build static export" runningLabel="Building static export…" extraControls={ diff --git a/editor/app/sites/components/DeployButton.tsx b/editor/app/sites/components/DeployButton.tsx @@ -7,8 +7,7 @@ import { previewAliasUrl, previewBranchProblem, } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { cancelJobAction } from "../../jobs/actions"; -import { deployTargetAction } from "../lib/publishActions"; +import { cancelPublishRunAction, deployTargetAction } from "../lib/publishActions"; type Props = { // The site whose Publish tab this is — always a real site. @@ -38,7 +37,7 @@ export function DeployButton({ siteId, siteTitle, cloudflareProject, previewBran <div className="flex flex-col gap-6"> <StreamActionLog trigger={() => deployTargetAction(siteId, { where: "production" })} - cancelAction={cancelJobAction} + cancelAction={cancelPublishRunAction} buttonLabel="Deploy to production" runningLabel="Deploying…" label="Deploy export" @@ -95,7 +94,7 @@ function PreviewDeploy({ </div> <StreamActionLog trigger={() => deployTargetAction(siteId, { where: "preview", preview: branch })} - cancelAction={cancelJobAction} + cancelAction={cancelPublishRunAction} buttonLabel="Deploy preview" runningLabel="Deploying preview…" label="Deploy preview" diff --git a/editor/app/sites/components/EditorChartsClient.tsx b/editor/app/sites/components/EditorChartsClient.tsx @@ -33,8 +33,8 @@ function Inner({ return ( <p className="text-sm text-destructive"> Failed to load the stats dataset ({error.message}). Run{" "} - <strong>Build index</strong> then <strong>Build stats dataset</strong>{" "} - under Pool on the Sites page first. + <strong>Build index</strong> under Pool on the Sites page first — it + builds the stats datasets too. </p> ); } diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx @@ -26,6 +26,9 @@ type Props = { // e.g. to refresh a server-rendered "built <when>" line after a build. Not // called when the lane is unmounted first (another launch replaced it). onSettled?: (outcome: LaneOutcome) => void; + // What Cancel calls with the lane's job id (default: that one job). A + // publish run's lanes pass cancelPublishRunAction: the run's every stage. + cancelAction?: (jobId: string) => Promise<{ ok: boolean }>; }; const CHIP: Record<LaneStatus, { label: string; cls: string }> = { @@ -43,7 +46,7 @@ const CHIP: Record<LaneStatus, { label: string; cls: string }> = { // the terminal status. A light poll surfaces queued/running + queue position // before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in // miniature so several lanes can run at once on the batch panel. -export function JobLane({ title, subtitle, trigger, onSettled }: Props) { +export function JobLane({ title, subtitle, trigger, onSettled, cancelAction }: Props) { const [status, setStatus] = useState<LaneStatus>("starting"); const [log, setLog] = useState(""); const [error, setError] = useState<string | null>(null); @@ -182,7 +185,7 @@ export function JobLane({ title, subtitle, trigger, onSettled }: Props) { if (!jobId) return; setCancelling(true); try { - await cancelJobAction(jobId); + await (cancelAction ?? cancelJobAction)(jobId); } finally { setCancelling(false); } diff --git a/editor/app/sites/components/PublishPanel.tsx b/editor/app/sites/components/PublishPanel.tsx @@ -13,6 +13,7 @@ import { buildAllStaleAction, buildAndDeployTargetAction, buildTargetAction, + cancelPublishRunAction, deployTargetAction, publishNowAction, } from "../lib/publishActions"; @@ -51,7 +52,7 @@ export type PanelRow = { }; export type PanelPlan = { - steps: { kind: string; target: string; reason: string }[]; + steps: { kind: string; target: string; reason: string; preview?: string }[]; skipped: { kind: string; target: string; reason: string }[]; }; @@ -151,7 +152,7 @@ export function PublishPanel({ rows, index, lane, plan, previewBranch, siteOut, <p className="text-muted-foreground">Publish now would run, in order:</p> <ol className="ml-5 list-decimal"> {plan.steps.map((s) => ( - <li key={`${s.kind}:${s.target}`}> + <li key={`${s.kind}:${s.target}:${s.preview ?? ""}`}> <code> {s.kind} {s.target} </code>{" "} @@ -186,6 +187,7 @@ export function PublishPanel({ rows, index, lane, plan, previewBranch, siteOut, subtitle={head.subtitle} trigger={head.run} onSettled={() => router.refresh()} + cancelAction={cancelPublishRunAction} /> )} </div> @@ -372,6 +374,7 @@ function SiteRow({ subtitle={lane.subtitle} trigger={lane.run} onSettled={() => router.refresh()} + cancelAction={cancelPublishRunAction} /> )} </div> @@ -493,6 +496,7 @@ function FamilyRow({ row, localOut, hydrated }: { row: PanelRow; localOut: boole subtitle={lane.subtitle} trigger={lane.run} onSettled={() => router.refresh()} + cancelAction={cancelPublishRunAction} /> )} </div> diff --git a/editor/app/sites/lib/publishActions.ts b/editor/app/sites/lib/publishActions.ts @@ -15,6 +15,7 @@ // is a child (`archilyzer stage …`), so a heavy build cannot starve the editor. import { revalidatePath } from "next/cache"; +import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import { planPublishRun, type PublishPlan } from "yt-dlp-transcript-common/publish/publishPlan"; @@ -25,6 +26,7 @@ import { stopPublishRunner, } from "yt-dlp-transcript-common/publish/publishRunner"; import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; +import { PUBLISH_QUEUE } from "yt-dlp-transcript-common/publish/publishStages"; import { enqueueRun, indexPlan, @@ -127,6 +129,38 @@ export async function buildAndDeployTargetAction( ]); } +/** + * A publish console's Cancel: the RUN, not the one job the console holds. Every + * queued or running stage carrying that job's run id is cancelled, newest + * first (so a queued stage is gone before the slot ahead of it frees and + * nothing is promoted). A job that is not a publish stage, or carries no run + * id, is cancelled alone. Another run's jobs — another tab's, the lane's — carry + * another id and are never touched. + */ +export async function cancelPublishRunAction(jobId: string): Promise<{ ok: boolean }> { + const registry = getRegistry(); + const record = registry.get(jobId); + const runId = record?.spec?.params?.runId; + let ok = false; + if (typeof runId === "string" && record?.queueKey === PUBLISH_QUEUE) { + const live = registry + .list() + .filter( + (r) => + r.queueKey === PUBLISH_QUEUE && + (r.status === "queued" || r.status === "running") && + r.spec?.params?.runId === runId, + ) + .sort((a, b) => b.queuedAt - a.queuedAt); + for (const r of live) ok = registry.cancel(r.id) || ok; + } else { + ok = registry.cancel(jobId); + } + revalidatePath("/jobs"); + revalidatePublish(); + return { ok }; +} + // --- the publish lane's runner (/operations/publish) ------------------------- export type LaneControlResult = { ok: boolean; error?: string }; diff --git a/editor/app/sites/lib/publishCore.ts b/editor/app/sites/lib/publishCore.ts @@ -89,8 +89,10 @@ export async function refusal(paths: Paths, ask: TargetAsk): Promise<string | nu const bad = targetProblem(paths, ask.target); if (bad) return bad; if (!ask.deploy) return null; - if (ask.deploy.preview !== undefined) { - const p = previewBranchProblem(ask.deploy.preview); + // A preview is asked BY NAME: a preview with none is refused, never let fall + // through to production (a plan step with no `preview` is production). + if (ask.deploy.where === "preview" || ask.deploy.preview !== undefined) { + const p = previewBranchProblem(ask.deploy.preview ?? ""); if (p) return p; } const r = await resolveDeployRequest(paths, { @@ -138,6 +140,9 @@ export function wantedPlan( } if (ask.deploy) { const preview = ask.deploy.where === "preview" ? ask.deploy.preview?.trim() : undefined; + if (ask.deploy.where === "preview" && !preview) { + throw new Error(`a preview deploy of ${ask.target} names no branch`); + } const project = preview ? projectOf(paths, ask.target) : null; steps.push({ kind: kinds.deploy, @@ -182,10 +187,19 @@ export async function enqueueRun( kept.set(res.jobId, { stream: keep, done: res.done }); return { ...res, stream: released }; }; - const run = await enqueuePublishRun(paths, plan, { runId: opts.runId ?? newPublishRunId(), enqueue }); + let run: PublishRunResult; + try { + run = await enqueuePublishRun(paths, plan, { runId: opts.runId ?? newPublishRunId(), enqueue }); + } catch (e) { + // The jobs already enqueued run on (their logs are on disk); a kept branch + // nobody will read would buffer its job's whole log in memory. + for (const k of kept.values()) void k.stream.cancel().catch(() => {}); + throw e; + } const parts: RunPart[] = run.jobs.map((j) => ({ label: stepLabel(j), jobId: j.jobId, + ...(j.existing ? { existing: true } : {}), ...(kept.get(j.jobId) ?? {}), })); return { ...run, parts }; diff --git a/editor/app/sites/lib/publishRunStream.ts b/editor/app/sites/lib/publishRunStream.ts @@ -5,10 +5,14 @@ // line, and settles when the last one does: the run's verdict is its first // job that did not end `done`, else `done`. // -// A CANCEL IS THE RUN'S. The console's Cancel cancels the run's first job (the -// id it holds); a job of the run that ends `cancelled` cancels every job -// after it, queued or running — else the console would wait on a build still -// queued behind something else, for a run its operator called off. +// A CANCEL IS THE RUN'S. The console's Cancel is `cancelPublishRunAction` +// (publishActions.ts), which cancels every live job carrying the run's id; and +// here, a job of the run that ends `cancelled` cancels the run's jobs after it, +// queued or running — else the console would wait on a build still queued +// behind something else, for a run its operator called off. A part that was +// ALREADY QUEUED by someone else (another tab's run, the lane's pass) is never +// cancelled from here, never the console's job id, and not waited on: it is +// another run's. // // Nothing here chains one job to another — the order is on disk (a build // carries `indexAfter`, a deploy `builtAfter`; publishStages.ts). A deploy @@ -27,6 +31,8 @@ export type RunPart = { // Absent for a stage that was already queued (its own console has it). stream?: ReadableStream<string>; done?: Promise<JobDoneResult>; + // Another run's job this run found queued (enqueuePublishRun `existing`). + existing?: boolean; }; /** The parts as one stream + one verdict (the first job's id is the run's). */ @@ -34,7 +40,10 @@ export function followRun( parts: RunPart[], preface: string[] = [], ): Extract<StreamActionResult, { ok: true }> { - const first = parts[0]; + if (parts.length === 0) throw new Error("followRun: a run with no jobs"); + // The console's job: the first this run enqueued (its Cancel cancels the + // run by that job's run id), else — every part another run's — the first. + const first = parts.find((p) => !p.existing) ?? parts[0]; let resolveDone!: (r: JobDoneResult) => void; const done = new Promise<JobDoneResult>((r) => { resolveDone = r; @@ -71,7 +80,7 @@ export function followRun( const term = part.done ? await part.done : null; if (term && term.status !== "done" && verdict === null) verdict = term; if (term?.status === "cancelled") { - for (const later of parts.slice(i + 1)) getRegistry().cancel(later.jobId); + for (const later of parts.slice(i + 1)) if (!later.existing) getRegistry().cancel(later.jobId); } } resolveDone(verdict ?? { status: "done", jobId: first.jobId }); diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -171,7 +171,12 @@ export default async function SitesPage() { index={status.index.chip} lane={status.lane.chip} plan={{ - steps: status.plan.steps.map((x) => ({ kind: x.kind, target: x.target, reason: x.reason })), + steps: status.plan.steps.map((x) => ({ + kind: x.kind, + target: x.target, + reason: x.reason, + ...(x.preview ? { preview: x.preview } : {}), + })), skipped: status.plan.skipped, }} previewBranch={status.settings.previewBranch} diff --git a/editor/e2e/publish-lane.spec.ts b/editor/e2e/publish-lane.spec.ts @@ -85,13 +85,14 @@ test("enabled and started, the lane updates the index; a hold mid-stage stops th buildArchives: false, publish: { enabled: true, checkEveryMinutes: 1, refreshEveryMinutes: 0 }, }); + await openLane(page); // Hold the publish queue for a while, so the lane's first stage (the index // update: there is no stamp) queues behind the holder and is IN FLIGHT from - // the lane's view while the hold is thrown. The holder lets go by itself. - const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish&releaseAfterMs=20000`); + // the lane's view while the hold is thrown. The holder lets go by itself — + // its clock starts here, after the page has compiled and hydrated, and runs + // long enough for the dispatch poll and the click below. + const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish&releaseAfterMs=45000`); expect(hold.ok()).toBe(true); - - await openLane(page); await page.getByRole("button", { name: "Start publish lane" }).click(); await expect(page.getByText("Runner running")).toBeVisible({ timeout: 15_000 }); diff --git a/editor/e2e/publish.spec.ts b/editor/e2e/publish.spec.ts @@ -19,6 +19,7 @@ import { lstat, readdir, readlink, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { test, expect, type Page } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; import { buildIndex, pathExists, @@ -240,6 +241,42 @@ test("a site's Build & deploy is one run: its build and its production deploy, o await expect(last).toContainText("live check skipped"); }); +test("a console's Cancel cancels its own run — every stage of it — and never another run's", async ({ + page, + request, +}) => { + await setup(); + // Every stage below only queues: the publish queue is held, never released. + const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`); + expect(hold.ok()).toBe(true); + const status = async (kind: string) => (await publishMetas()).filter((m) => m.kind === kind).map((m) => m.status); + + // Run 1: the Pool's Build index — the index update, queued. + await page.goto("/sites"); + await page.getByText("Pool jobs", { exact: true }).click(); + await page.getByRole("button", { name: "Build index" }).click(); + await expect.poll(() => status("publish-update-index")).toEqual(["queued"]); + + // Run 2: the row's Build — the index update is run 1's (already queued), the + // build is run 2's own. Its console's Cancel takes the build, not the index. + await openSites(page); + await row(page).getByRole("button", { name: "Build", exact: true }).click(); + await expect.poll(() => status("publish-build-site")).toEqual(["queued"]); + await row(page).getByRole("button", { name: `Cancel Build ${SITE}` }).click(); + await expect.poll(() => status("publish-build-site")).toEqual(["cancelled"]); + expect(await status("publish-update-index")).toEqual(["queued"]); + + // Run 3: the site tab's Build & deploy — its build AND its deploy, both + // queued; one Cancel takes the run, whichever of its jobs the console holds. + await page.goto(`/sites/${SITE}/publish`); + await page.getByRole("button", { name: "Build & deploy", exact: true }).click(); + await expect.poll(() => status("publish-deploy-site")).toEqual(["queued"]); + await page.getByRole("button", { name: "Cancel Build and deploy" }).click(); + await expect.poll(() => status("publish-deploy-site")).toEqual(["cancelled"]); + expect(await status("publish-build-site")).toEqual(["cancelled", "cancelled"]); + expect(await status("publish-update-index")).toEqual(["queued"]); +}); + test("the site form's publish policy: preview without a Pages project is refused, with the reason", async ({ page, }) => {