commit b4322ab1cd3b4f67e0ed1ec7a4d4ccec5a1c2218
parent 2baea15e823029add6e25dc281eaaba8575c09c9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 10 Oct 2026 02:39:16 -0400
publish: the ops build verb takes allowMissingMedia; `publish build <id> --out <dir>` (release 19 A8)
POST /api/ops/publish {verb: "build", allowMissingMedia: true} carries the
flag onto the queued build stage (`--allow-missing-media`, as `build site`
already could), local builds only — the docker runner refuses it.
`archilyzer publish build` takes --allow-missing-media too, and --out <dir>:
after the build (or the no-op of a fresh one) the site's bundle is laid out
in <dir> for a server that is not Pages — hard links into the bundle where
the filesystem allows (a build installs a new bundle, never edits one), else
copies; the destination is emptied first and refused, before any build,
where a local deploy's would be. Not a deploy: nothing recorded. The publish
lane over `pnpm ops lane` shipped in A4.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 272 insertions(+), 12 deletions(-)
diff --git a/COMMANDS.md b/COMMANDS.md
@@ -18,7 +18,7 @@ Run from the repo root (in the container: `docker compose exec editor pnpm archi
| `archilyzer compose hub` | | compose the hub's export/public (hub-sites.json, corpus.json, …) |
| `archilyzer compose homepage` | | compose homepage/public (whole-pool stats + landing summary) |
| `archilyzer publish index` | | update the index: the LMDB index, the stats datasets and the chart templates in one child (8 GB heap), then the index stamp every build reads |
-| `archilyzer publish build` | `<id\|all> [--runner local\|docker\|auto] [--force] [--skip-archives]` | build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force |
+| `archilyzer publish build` | `<id\|all> [--runner local\|docker\|auto] [--force] [--skip-archives] [--allow-missing-media] [--out <dir>]` | build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force; --allow-missing-media lets a report citation with no prepared media through compose; --out <dir> then lays the site's bundle out in <dir> for a server that is not Pages (hard links where it can, else copies; emptied first and guarded as a local deploy's destination; not a deploy, nothing recorded) |
| `archilyzer publish deploy` | `<id\|all> [--preview <branch>] [--to local] [--force]` | ship a site's bundle to its Pages project (a preview with --preview), or with --to local into ARCHILYZER\_SITE\_OUT; a bundle already deployed there is a no-op without --force |
| `archilyzer publish hub` | `[--deploy \| --deploy-only] [--preview <branch>] [--force]` | build the hub into its bundle <exportBuildsDir>/\_hub/out, then (--deploy) ship it; --deploy-only ships the bundle as built |
| `archilyzer publish homepage` | `[--deploy \| --deploy-only] [--preview <branch>] [--to local] [--force]` | build homepage/out (source mirror included), then (--deploy) ship it; --deploy-only ships it as built |
@@ -99,7 +99,7 @@ Drives a running editor over HTTP (`/api/ops/*`, the same actions its pages run)
| `transcribe-bucket` | transcribe-bucket transcribes a channel's "downloaded, not transcribed" bucket on the transcription queue, as the channel page's Transcribe button does: {"slug"}. "ids": \[...\] narrows it the same way as on retry-bucket. | |
| `fetch-posts` | fetch-posts fetches a social channel's new posts: {"slug"}. "full": true re-walks the whole timeline; "older": true walks back from the oldest archived post through search (X; needs a login), saving its place for the next run, down to "floor": "YYYY-MM-DD" when given; "from": "YYYY-MM-DD" starts the walk afresh there, replacing its saved place (and a "complete") — for a gap above one surviving old post. "limit": N caps the posts one run reads; "pages": N caps the pages (a forum thread: its latest N pages). "full" and "older" together are refused. An older walk over an account that shows no posts (nothing archived, and the last timeline fetch read none) is refused unless "force": true. A drained fetch stops at its next resume point and the next run resumes. | |
| `capture-posts` | capture-posts captures archived posts of a social channel (X, forum): a screenshot of each through the connected X profile (a forum thread: its host's forum profile), and its attached media through gallery-dl (a forum thread: the same profile), into the channel's posts-media/<id>/: {"slug", "ids": \[...\]}. Every id must be in the channel's posts archive. "shots": false or "media": false skips that half; posts already captured are skipped unless "force": true. A post that links to an X Article also gets the article (article.json, .md, .png and its images) unless "articles": false; both halves off with "articles": true reads only the articles. Paced like a post fetch, on its queue. | |
-| `publish` | publish runs publish stages on the editor's publish queue, one at a time, under one run id: {"verb": …}. "index" updates the index; "build" builds "siteId"/"siteIds" (forced; the index first when stale; "runner": "docker" builds every site in containers); "deploy" ships their built bundles (production, "preview": "<branch>", or "to": "local"; "force" redeploys a bundle already shipped there); "hub" / "homepage" build them, {"deploy": true} deploys after; "now" is Publish now (the stale index, then each policy target); "stale" builds every stale site. The answer lists every job ({target, kind, jobId}) and --wait follows them all. A site never built is refused: "no build of <id> in <dir> — archilyzer publish build <id>". `get publish` is the status. | |
+| `publish` | publish runs publish stages on the editor's publish queue, one at a time, under one run id: {"verb": …}. "index" updates the index; "build" builds "siteId"/"siteIds" (forced; the index first when stale; "runner": "docker" builds every site in containers; "allowMissingMedia": true lets a report citation with no prepared media through compose, local builds only); "deploy" ships their built bundles (production, "preview": "<branch>", or "to": "local"; "force" redeploys a bundle already shipped there); "hub" / "homepage" build them, {"deploy": true} deploys after; "now" is Publish now (the stale index, then each policy target); "stale" builds every stale site. The answer lists every job ({target, kind, jobId}) and --wait follows them all. A site never built is refused: "no build of <id> in <dir> — archilyzer publish build <id>". `get publish` is the status. | |
| `build-index`, `build-site`, `build-deploy`, `deploy-site`, `build-hub`, `deploy-hub`, `build-homepage`, `deploy-homepage` | build-index, build-site, build-deploy, deploy-site, build-hub, deploy-hub, build-homepage and deploy-homepage are publish's aliases, with their old bodies and answers ("skipData" is accepted and ignored). | `pnpm ops build-deploy --json '{"siteIds":["anilyzer","jeralyzer"]}' --wait`<br>`pnpm ops build-site --json '{"siteId":"anilyzer"}' --wait`<br>`pnpm ops deploy-site --json '{"siteId":"anilyzer","preview":"tags-exclude"}' --wait`<br>`pnpm ops build-hub --wait`<br>`pnpm ops build-hub --json '{"deploy":true}' --wait`<br>`pnpm ops deploy-hub --wait`<br>`pnpm ops build-homepage --json '{"deploy":true}' --wait`<br>`pnpm ops deploy-homepage --json '{"preview":"refresh"}' --wait` |
| `build-site`, `build-deploy`, `deploy-site` | build-site, build-deploy and deploy-site all take "siteId" (one) or "siteIds" (a list). | |
| `build-hub` | build-hub builds the hub into its bundle; {"deploy": true} deploys it after, and deploy-hub ships the one already built. Both deploy to the Pages project set on /sites under Hub, and take "preview" too. | |
diff --git a/OPERATING.md b/OPERATING.md
@@ -156,11 +156,17 @@ pnpm ops publish --json '{"verb":"now"}' --wait
pnpm ops publish --json '{"verb":"build","siteId":"example-site"}' --wait
pnpm ops publish --json '{"verb":"deploy","siteId":"example-site","preview":"check"}' --wait
pnpm archilyzer publish status
+pnpm archilyzer publish build example-site --out /srv/example-private
```
- Publishing is stages on one queue: update the index, build a site into its bundle, deploy the bundle, each
checked live. `now` runs what Publish now runs (the stale index, then each site's policy). Offline, under the same
lock: `pnpm archilyzer publish index`, `publish build <id>`, `publish deploy <id> --preview <branch>`.
+- A private build served by something other than Pages: `publish build <id> --out <dir>` lays the bundle out in
+ `<dir>` (hard links where it can; the directory is emptied first and guarded like a local deploy's). A report
+ citation whose media is not prepared yet passes with `--allow-missing-media` (`"allowMissingMedia": true` on the
+ ops `build` verb).
+- The publish lane: `pnpm ops lane --json '{"lane":"publish","action":"start"}'` (`"held": true` pauses it).
- Details, policies and the hub and homepage: [PUBLISH.md](PUBLISH.md).
## Research with the MCP
diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts
@@ -436,7 +436,14 @@ test("the stage row: kind and target, the flags the child's argv carries, nothin
test("the publish rows and their flags (status and now are S3's)", () => {
const row = (...p: string[]) => resolveCommand(COMMANDS, p)?.command;
assert.deepEqual(row("publish", "index")?.flags ?? {}, {});
- assert.deepEqual(row("publish", "build", "jer")?.flags, { runner: "string", force: "boolean", "skip-archives": "boolean" });
+ assert.deepEqual(row("publish", "build", "jer")?.flags, {
+ runner: "string",
+ force: "boolean",
+ "skip-archives": "boolean",
+ // release 19 A8
+ "allow-missing-media": "boolean",
+ out: "string",
+ });
assert.equal(row("publish", "build", "all")?.maxPositionals, 1);
assert.deepEqual(row("publish", "deploy", "all")?.flags, { preview: "string", to: "string", force: "boolean" });
assert.deepEqual(row("publish", "hub")?.flags, {
diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -86,8 +86,14 @@ export const COMMANDS: Command[] = [
{
path: ["publish", "build"],
usage:
- "<id|all> [--runner local|docker|auto] [--force] [--skip-archives] build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force",
- flags: { runner: "string", force: "boolean", "skip-archives": "boolean" },
+ "<id|all> [--runner local|docker|auto] [--force] [--skip-archives] [--allow-missing-media] [--out <dir>] build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force; --allow-missing-media lets a report citation with no prepared media through compose; --out <dir> then lays the site's bundle out in <dir> for a server that is not Pages (hard links where it can, else copies; emptied first and guarded as a local deploy's destination; not a deploy, nothing recorded)",
+ flags: {
+ runner: "string",
+ force: "boolean",
+ "skip-archives": "boolean",
+ "allow-missing-media": "boolean",
+ out: "string",
+ },
maxPositionals: 1,
run: async ({ positionals, flags }) => {
const [target] = positionals;
@@ -96,11 +102,17 @@ export const COMMANDS: Command[] = [
console.error("publish build: give <id|all> [--runner local|docker|auto]");
return 2;
}
+ if (flags.out !== undefined && (typeof flags.out !== "string" || !flags.out.trim())) {
+ console.error("publish build: --out needs a directory");
+ return 2;
+ }
return (await import("./publish")).publishBuild({
target,
runner: runner as "local" | "docker" | "auto" | undefined,
force: flags.force === true,
skipArchives: flags["skip-archives"] === true,
+ allowMissingMedia: flags["allow-missing-media"] === true,
+ ...(typeof flags.out === "string" ? { out: flags.out } : {}),
});
},
},
diff --git a/common/bin/publish-out.test.ts b/common/bin/publish-out.test.ts
@@ -0,0 +1,97 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test bin/publish-out.test.ts
+//
+// `archilyzer publish build <id> --out <dir>` (release 19 A8): the row's
+// flags, the refusals that come before any build, and the lay-out itself over
+// a hand-made bundle — hard links into it, its symlinks kept, the destination
+// emptied, nothing recorded. The build stage is the stage tests'.
+
+const ROOT = mkdtempSync(path.join(os.tmpdir(), "publish-out-"));
+process.env.TRANSCRIPTS_DIR = path.join(ROOT, "transcripts");
+process.env.EXPORT_BUILDS_DIR = path.join(ROOT, "builds");
+mkdirSync(path.join(ROOT, "transcripts", "sites", "demo-site"), { recursive: true });
+writeFileSync(path.join(ROOT, "transcripts", "sites", "demo-site", "site.json"), JSON.stringify({ siteId: "demo-site" }));
+
+const { COMMANDS } = await import("./archilyzer");
+const { resolveCommand } = await import("./_cli");
+const { layOutBundle, publishBuild } = await import("./publish");
+const { getPaths } = await import("../lib/paths");
+const { writeBuiltStamp } = await import("../publish/stamps");
+
+function capture() {
+ const lines: string[] = [];
+ const errors: string[] = [];
+ return { out: { log: (s: string) => lines.push(s), error: (s: string) => errors.push(s) }, lines, errors };
+}
+
+test("the publish build row takes --allow-missing-media and --out <dir>", () => {
+ const row = resolveCommand(COMMANDS, ["publish", "build"])?.command;
+ assert.equal(row?.flags?.["allow-missing-media"], "boolean");
+ assert.equal(row?.flags?.out, "string");
+});
+
+test("--out with all, an unknown site, or a guarded destination is refused before any build", async () => {
+ const paths = getPaths();
+ const all = capture();
+ assert.equal(await publishBuild({ target: "all", out: path.join(ROOT, "x"), paths }, all.out), 2);
+ assert.match(all.errors.join(""), /--out lays out ONE site's bundle/);
+ const unknown = capture();
+ assert.equal(await publishBuild({ target: "nope", out: path.join(ROOT, "x"), paths }, unknown.out), 2);
+ assert.match(unknown.errors.join(""), /no site "nope"/);
+ const guarded = capture();
+ assert.equal(await publishBuild({ target: "demo-site", out: paths.transcriptsDir, paths }, guarded.out), 2);
+ assert.match(guarded.errors.join(""), /--out .* holds the corpus .* --out empties its destination/);
+ const notBundle = path.join(ROOT, "busy");
+ mkdirSync(notBundle);
+ writeFileSync(path.join(notBundle, "keep.txt"), "x");
+ const busy = capture();
+ assert.equal(await publishBuild({ target: "demo-site", out: notBundle, paths }, busy.out), 2);
+ assert.match(busy.errors.join(""), /holds no index\.html/);
+});
+
+test("the lay-out hard-links the bundle into an emptied destination and records nothing", async () => {
+ const paths = getPaths();
+ const empty = capture();
+ assert.equal(await layOutBundle(paths, "demo-site", path.join(ROOT, "none"), empty.out), 3);
+ assert.match(empty.errors.join(""), /no build of demo-site/);
+
+ const bundle = path.join(paths.exportBuildsDir, "demo-site", "out");
+ mkdirSync(path.join(bundle, "channels"), { recursive: true });
+ writeFileSync(path.join(bundle, "index.html"), "<html></html>");
+ writeFileSync(path.join(bundle, "channels", "a.json"), "{}");
+ symlinkSync("index.html", path.join(bundle, "home.html"));
+ await writeBuiltStamp(paths, {
+ v: 1,
+ stampId: "b-1",
+ target: "demo-site",
+ kind: "site",
+ indexStampId: null,
+ inputSig: "sig",
+ builtAt: 1,
+ commit: null,
+ branch: null,
+ runner: "local",
+ audience: "private",
+ corpusGeneratedAt: null,
+ files: 2,
+ bytes: 10,
+ archivesStaged: 0,
+ } as never);
+ const dest = path.join(ROOT, "serve");
+ mkdirSync(dest);
+ writeFileSync(path.join(dest, "index.html"), "old");
+ writeFileSync(path.join(dest, "stale.html"), "old");
+ const c = capture();
+ assert.equal(await layOutBundle(paths, "demo-site", dest, c.out), 0);
+ assert.match(c.lines.join(""), /build b-1 laid out in .* — 2 hard-linked, 0 copied, 1 symlinks \(not a deploy: nothing recorded\)/);
+ assert.equal(statSync(path.join(dest, "index.html")).ino, statSync(path.join(bundle, "index.html")).ino);
+ assert.equal(readFileSync(path.join(dest, "channels", "a.json"), "utf8"), "{}");
+ assert.equal(readFileSync(path.join(dest, "home.html"), "utf8"), "<html></html>");
+ assert.throws(() => statSync(path.join(dest, "stale.html")));
+ assert.throws(() => statSync(path.join(paths.exportBuildsDir, "demo-site", "deployed.json")));
+});
diff --git a/common/bin/publish.ts b/common/bin/publish.ts
@@ -4,6 +4,7 @@
//
// publish index update-index (as a child: its heap cap)
// publish build <id|all> [--runner local|docker|auto] [--force] [--skip-archives]
+// [--allow-missing-media] [--out <dir>]
// publish deploy <id|all> [--preview <b>] [--to local] [--force]
// publish hub [--deploy] [--preview <b>] [--force]
// publish homepage [--deploy] [--preview <b>] [--to local] [--force]
@@ -23,6 +24,8 @@ import { newStampId } from "../publish/stamps";
import { STAGE_EXIT, runStage, stageCommand, stageMain } from "../publish/stageRun";
import { parseStageArgs, type StageRequest } from "../publish/stages";
import type { CommandContext } from "./_cli";
+import path from "node:path";
+import { copyFile, link, lstat, mkdir, readdir, readlink, rm, symlink } from "node:fs/promises";
type Out = { log: (s: string) => void; error: (s: string) => void };
@@ -109,6 +112,9 @@ export type BuildArgs = {
force?: boolean;
skipArchives?: boolean;
allowMissingMedia?: boolean;
+ // A private build served elsewhere (release 19 A8): after the build (or the
+ // no-op of a fresh one), the bundle is laid out in this directory.
+ out?: string;
runId?: string;
paths?: Paths;
signal?: AbortSignal;
@@ -116,7 +122,25 @@ export type BuildArgs = {
export async function publishBuild(a: BuildArgs, out: Out = console): Promise<number> {
const all = a.target === "all";
+ if (all && a.out !== undefined) {
+ out.error("publish build: --out lays out ONE site's bundle — give a site id, not all");
+ return STAGE_EXIT.usage;
+ }
if (!all && !knownSite(a.target, "publish build", out, a.paths)) return STAGE_EXIT.usage;
+ if (a.out !== undefined) {
+ // Refused before the build, so a typo'd destination costs no build.
+ const paths = a.paths ?? getPaths();
+ const { bundleDir } = await import("../publish/build");
+ const { localDestProblem } = await import("../publish/deployStage");
+ const problem = await localDestProblem(path.resolve(a.out), bundleDir(paths, a.target), paths);
+ if (problem) {
+ out.error(`publish build: --out ${problem.replace(/a local deploy empties/g, "--out empties")}`);
+ return STAGE_EXIT.usage;
+ }
+ const code = await publishBuild({ ...a, out: undefined }, out);
+ if (code !== 0) return code;
+ return layOutBundle(paths, a.target, path.resolve(a.out), out);
+ }
const signal = a.signal ?? interrupted();
let runner: "local" | "docker" = "local";
if (a.runner === "docker" || a.runner === "auto") {
@@ -146,6 +170,64 @@ export async function publishBuild(a: BuildArgs, out: Out = console): Promise<nu
);
}
+// --- publish build --out --------------------------------------------------------
+
+// THE SITE'S BUNDLE, LAID OUT IN `dest` for a server that is not Pages (a
+// private build served elsewhere). Cheap: every file is a HARD LINK into the
+// bundle when the two share a filesystem — a build never edits a bundle in
+// place, it installs a new one (publish/build.ts installBundle), so the links
+// keep the files of the build they were made from — else a copy. `dest` is
+// emptied first (its contents, never the directory: it may be a mount) and
+// guarded as a local deploy's destination is (localDestProblem). Not a deploy:
+// nothing is recorded in deployed.json.
+export async function layOutBundle(paths: Paths, target: string, dest: string, out: Out = console): Promise<number> {
+ const { bundleDir } = await import("../publish/build");
+ const { readBuiltStamp } = await import("../publish/stamps");
+ const src = bundleDir(paths, target);
+ const built = await readBuiltStamp(paths, target);
+ if (!built) {
+ out.error(`publish build: no build of ${target} in ${src} — nothing to lay out`);
+ return STAGE_EXIT.precondition;
+ }
+ await mkdir(dest, { recursive: true });
+ for (const e of await readdir(dest)) await rm(path.join(dest, e), { recursive: true, force: true });
+ const counts = { linked: 0, copied: 0, links: 0 };
+ const walk = async (from: string, to: string): Promise<void> => {
+ for (const e of await readdir(from, { withFileTypes: true })) {
+ const s = path.join(from, e.name);
+ const d = path.join(to, e.name);
+ if (e.isDirectory()) {
+ await mkdir(d, { recursive: true });
+ await walk(s, d);
+ } else if (e.isSymbolicLink()) {
+ await symlink(await readlink(s), d);
+ counts.links++;
+ } else {
+ try {
+ await link(s, d);
+ counts.linked++;
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code !== "EXDEV" && code !== "EPERM" && code !== "EMLINK") throw err;
+ await copyFile(s, d);
+ counts.copied++;
+ }
+ }
+ }
+ };
+ const st = await lstat(src).catch(() => null);
+ if (!st?.isDirectory()) {
+ out.error(`publish build: the bundle ${src} is missing`);
+ return STAGE_EXIT.precondition;
+ }
+ await walk(src, dest);
+ out.log(
+ `[out] ${target}: build ${built.stampId} laid out in ${dest} — ${counts.linked} hard-linked, ` +
+ `${counts.copied} copied${counts.links ? `, ${counts.links} symlinks` : ""} (not a deploy: nothing recorded).`,
+ );
+ return 0;
+}
+
export type DeployArgs = {
target: string; // a site id or "all"
preview?: string;
diff --git a/editor/app/api/ops/publish/route.test.ts b/editor/app/api/ops/publish/route.test.ts
@@ -66,3 +66,30 @@ test("GET publish is the publish status: the index, the lane, a row per target",
const anon = await callGet(GET as unknown as Parameters<typeof callGet>[0], undefined, {}, {});
assert.equal(anon.status, 401);
});
+
+test("build takes allowMissingMedia (a local build's), carried onto the queued stage", async () => {
+ const docker = await callPost(POST, { verb: "build", runner: "docker", allowMissingMedia: true });
+ assert.equal(docker.status, 400);
+ assert.match(String(docker.body.error), /"allowMissingMedia" is a local build's/);
+ const notBool = await callPost(POST, { verb: "build", siteId: "pubsite", allowMissingMedia: "yes" });
+ assert.equal(notBool.status, 400);
+ assert.match(String(notBool.body.error), /"allowMissingMedia" must be a boolean/);
+ const deploy = await callPost(POST, { verb: "deploy", siteId: "pubsite", allowMissingMedia: true });
+ assert.equal(deploy.status, 400);
+ assert.match(String(deploy.body.error), /verb "deploy" takes .* — not allowMissingMedia/);
+
+ const before = new Set(await corpus.listJobIds());
+ const queued = await callPost(POST, { verb: "build", siteId: "pubsite", allowMissingMedia: true });
+ assert.equal(queued.status, 200, JSON.stringify(queued.body));
+ const jobs = queued.body.jobs as { kind: string; target: string; jobId: string }[];
+ const build = jobs.find((j) => j.target === "pubsite");
+ assert.ok(build, JSON.stringify(jobs));
+ const { readFile } = await import("node:fs/promises");
+ const path = await import("node:path");
+ const fresh = (await corpus.listJobIds()).filter((f) => !before.has(f));
+ const metas = await Promise.all(
+ fresh.map((f) => readFile(path.join(corpus.transcripts, ".jobs", f), "utf8")),
+ );
+ const mine = metas.find((m) => m.includes(build!.jobId)) ?? metas.join("\n");
+ assert.match(mine, /allowMissingMedia|allow-missing-media/, mine);
+});
diff --git a/editor/app/api/ops/publish/route.ts b/editor/app/api/ops/publish/route.ts
@@ -23,11 +23,14 @@ type Verb = (typeof VERBS)[number];
// POST { verb: index | build | deploy | hub | homepage | now | stale,
// siteId | siteIds?, preview?, to?: "pages" | "local", force?,
-// runner?: "local" | "docker", deploy?, skipArchives? }
+// runner?: "local" | "docker", deploy?, skipArchives?,
+// allowMissingMedia? }
//
// index the index update (a no-op when nothing changed)
// build each named site's build, forced — the index first when stale;
-// `runner: "docker"` builds every site in containers (host only)
+// `runner: "docker"` builds every site in containers (host only);
+// `allowMissingMedia: true` lets a report citation with no
+// prepared media through compose (local builds only)
// deploy each named site's built bundle: production, `preview`, or
// `to: "local"` (ARCHILYZER_SITE_OUT); `force` redeploys a
// bundle already shipped there
@@ -44,7 +47,18 @@ type Verb = (typeof VERBS)[number];
//
// GET → the publish status (`readPublishStatus`): the index, the lane, a row
// per target with its chips, and the plan Publish now would run.
-const KEYS = ["verb", "siteId", "siteIds", "preview", "to", "force", "runner", "deploy", "skipArchives"];
+const KEYS = [
+ "verb",
+ "siteId",
+ "siteIds",
+ "preview",
+ "to",
+ "force",
+ "runner",
+ "deploy",
+ "skipArchives",
+ "allowMissingMedia",
+];
function only(body: OpsBody, verb: Verb, allowed: string[]): void {
const extra = Object.keys(body).filter((k) => k !== "verb" && !allowed.includes(k));
@@ -75,9 +89,13 @@ export async function POST(request: Request) {
return publishResponse(await enqueuePlan(plan));
}
case "build": {
- only(body, verb, ["siteId", "siteIds", "force", "runner", "skipArchives"]);
+ only(body, verb, ["siteId", "siteIds", "force", "runner", "skipArchives", "allowMissingMedia"]);
const runner = body.runner === undefined ? "local" : oneOf(body, "runner", ["local", "docker"] as const);
+ const allowMissingMedia = optBool(body, "allowMissingMedia");
if (runner === "docker") {
+ if (allowMissingMedia !== undefined) {
+ throw new OpsInputError('"allowMissingMedia" is a local build\'s — the docker runner does not take it');
+ }
if (body.siteId !== undefined || body.siteIds !== undefined) {
throw new OpsInputError('"runner": "docker" builds every site — send no "siteId"/"siteIds"');
}
@@ -109,7 +127,12 @@ export async function POST(request: Request) {
}
const ids = reqSiteIds(body);
return publishResponse(
- await enqueueAsks(ids.map((target) => ({ target, build: { force: force !== false, skipArchives } }))),
+ await enqueueAsks(
+ ids.map((target) => ({
+ target,
+ build: { force: force !== false, skipArchives, ...(allowMissingMedia ? { allowMissingMedia: true } : {}) },
+ })),
+ ),
);
}
case "deploy": {
diff --git a/editor/app/sites/lib/publishCore.ts b/editor/app/sites/lib/publishCore.ts
@@ -43,7 +43,10 @@ export type DeployWhere = "production" | "preview" | "local";
export type TargetAsk = {
// A site id, "_hub" or "_homepage".
target: string;
- build?: { force?: boolean; skipArchives?: boolean };
+ // allowMissingMedia: a report citation with no prepared media is let
+ // through compose (`archilyzer publish build --allow-missing-media`; the ops
+ // publish route's build verb, release 19 A8).
+ build?: { force?: boolean; skipArchives?: boolean; allowMissingMedia?: boolean };
deploy?: { where: DeployWhere; preview?: string; force?: boolean };
};
@@ -135,6 +138,7 @@ export function wantedPlan(
reason: "asked",
...(ask.build.force !== false ? { force: true } : {}),
...(ask.build.skipArchives ? { skipArchives: true } : {}),
+ ...(ask.build.allowMissingMedia ? { allowMissingMedia: true } : {}),
...(indexStep ? { indexAfter: runStart } : {}),
});
}
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -710,7 +710,9 @@ export function usage() {
'publish runs publish stages on the editor\'s publish queue, one at a time,',
' under one run id: {"verb": …}. "index" updates the index; "build" builds',
' "siteId"/"siteIds" (forced; the index first when stale; "runner":',
- ' "docker" builds every site in containers); "deploy" ships their built',
+ ' "docker" builds every site in containers; "allowMissingMedia": true lets',
+ ' a report citation with no prepared media through compose, local builds',
+ ' only); "deploy" ships their built',
' bundles (production, "preview": "<branch>", or "to": "local"; "force"',
' redeploys a bundle already shipped there); "hub" / "homepage" build',
' them, {"deploy": true} deploys after; "now" is Publish now (the stale',