// The publish layer's build primitives: one site's host build, the docker // per-site container, the R2 archive upload, the hub and homepage builds and the // per-target bundles. The publish stages (stageBodies.ts, deployStage.ts) are // their callers; the deploy itself is the deploy stage's (deployStage.ts). // // These take an `onLog` callback and an AbortSignal, so they CANNOT live in a // "use server" module (every export there becomes a server action, which // forbids non-serializable args). Keep them as plain helpers. import path from "node:path"; import { cp, lstat, mkdir, readdir, readFile, rename, rm, stat, symlink } from "node:fs/promises"; import { createReadStream, existsSync } from "node:fs"; import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { runChildIntoLog } from "../jobs/runChild"; import { builtBundleProblem, builtHubProblem, builtScopeProblem, citedBuildProblem, } from "../lib/builtExport"; import { getPaths, type Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; import { getSite, type Site } from "../lib/site"; import { builtStampPath } from "./stamps"; // 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"); } // Where a docker per-site container writes its built bundle (mounted as /site/out // inside the container). Isolated per site so parallel builds never collide. export function dockerSiteOutDir(paths: Paths, siteId: string): string { return path.join(paths.exportBuildsDir, siteId, "out"); } // Where a docker per-site container stages oversize archives for R2 upload. The // container's EXPORT_PUBLIC_DIR is /site/public (host exportBuildsDir// // public), so compose writes .r2-staging as its sibling under exportBuildsDir. export function dockerSiteStagingDir(paths: Paths, siteId: string): string { return path.join(paths.exportBuildsDir, siteId, ".r2-staging", siteId, "archives"); } // One child process of a build: what to run, where, with which environment. export type BuildStep = { command: string; args: string[]; cwd: string; env: NodeJS.ProcessEnv; }; // The basic (host) build of one site, as the child processes it runs, in order: // the pool-wide data phase (index + stats + chart templates) unless `skipData`, // then compose:site for THIS site, then `next build` — all in export/, all with // the same environment. // // These used to be ONE child, `pnpm run build` (or `build:nodata`), whose data // phase was npm's `prebuild` lifecycle hook: two scripts with identical bodies // that differed only by name, so the hook fired for one and not the other. The // hook is gone and the data phase is an explicit step here, which is what lets // export's `build` script BE this function (`archilyzer build site`) without // running itself. `skipData` still assumes a prior full build's // .export-index staging. // // `baseEnv` is the environment the steps inherit (process.env by default); // passing one is how the test pins the argv without the host's. export function buildSiteSteps(opts: { siteId: string; paths: Paths; skipData?: boolean; skipArchives?: boolean; // Let a report citation with no prepared media through compose. allowMissingMedia?: boolean; baseEnv?: NodeJS.ProcessEnv; }): BuildStep[] { const { paths } = opts; const env: NodeJS.ProcessEnv = { ...(opts.baseEnv ?? process.env), NODE_ENV: "production", TRANSCRIPTS_DIR: paths.transcriptsDir, EXPORT_PUBLIC_DIR: paths.exportPublicDir, SITE_ID: opts.siteId, // Per-build opt-out for the bulk-download archive zips. BUILD_ARCHIVES=0 // makes compose-site skip generation this build regardless of the // global/site flags. ...(opts.skipArchives ? { BUILD_ARCHIVES: "0" } : {}), // compose-site.ts ALLOW_MISSING_MEDIA_ENV (`--allow-missing-media`). ...(opts.allowMissingMedia ? { REPORTS_ALLOW_MISSING_MEDIA: "1" } : {}), }; const step = (args: string[]): BuildStep => ({ command: "pnpm", args, cwd: paths.exportDir, env, }); return [ ...(opts.skipData ? [] : [step(["run", "build:data"])]), step(["run", "compose:site"]), nextBuildStep(paths, env), ]; } /** * The export app's `next build`: `pnpm exec next build` in export/, or * ` build` when that is set — the editor's e2e suite points * it at a fake that copies the composed public dir to out/ (a real build there * would rebuild the app beside its running dev server). */ export function nextBuildStep(paths: Paths, env: NodeJS.ProcessEnv): BuildStep { const bin = env.EXPORT_NEXT_BIN?.trim(); return bin ? { command: bin, args: ["build"], cwd: paths.exportDir, env } : heavyGated(paths, { command: "pnpm", args: ["exec", "next", "build"], cwd: paths.exportDir, env, }); } /** * A real `next build` run through the HEAVY SLOT (scripts/queue-lock.mjs * --heavy, the same gate as `pnpm heavy -- `): one heavy job — an e2e * run, a build, a render — at a time machine-wide, started only once * MemAvailable is at least HEAVY_MIN_FREE_MB (6000). Two concurrent builds, or a * build beside an e2e suite, is how this machine OOMed. The wait is logged * ("waiting for the heavy slot — held by …") into the stage's own log, and a * Cancel still stops it: the gate forwards SIGTERM to the build. * * Inside a docker runner container the slot is the container's own (no shared * lock); the floor still reads the host's /proc/meminfo, which throttles a * fan-out when the host runs low. HEAVY=0 bypasses both; a checkout without * the gate script (a test's temp root) runs the step as it was. */ export function heavyGated(paths: Pick, step: BuildStep): BuildStep { const gate = path.join(paths.monorepoRoot ?? "", "scripts", "queue-lock.mjs"); if (!paths.monorepoRoot || !existsSync(gate)) return step; return { ...step, command: process.execPath, args: [gate, "--heavy", "--", step.command, ...step.args], }; } // Run a list of child steps in order, streaming into `onLog`, stopping at the // first non-zero exit (or a cancel). Returns the exit code. async function runSteps( onLog: (line: string) => void, signal: AbortSignal, steps: BuildStep[], ): Promise { for (const s of steps) { if (signal.aborted) return 1; const code = await runChildIntoLog(onLog, signal, s); if (code !== 0) return code; } return signal.aborted ? 1 : 0; } // Run the basic (host) build phase for one site, streaming into `onLog`, // returning the exit code: buildSiteSteps, in export/ (serialized upstream on // the build queue, since the export/ tree is shared). buildSiteBundle runs it, // for the local runner and inside the docker per-site container alike. export async function runBuildPhase( onLog: (line: string) => void, signal: AbortSignal, siteId: string, paths: Paths, opts?: { skipData?: boolean; skipArchives?: boolean; allowMissingMedia?: boolean }, ): Promise { // Skipping the data rebuild composes from the existing .export-index staging // (see buildSiteSteps). const skipData = opts?.skipData === true; if (skipData) { onLog( "[notice] Skipping data rebuild (index/stats/charts) — composing from " + "existing .export-index staging.\n", ); } const skipArchives = opts?.skipArchives === true; if (skipArchives) { onLog("[notice] Skipping archive-zip generation for this build.\n"); } const code = await runSteps( onLog, signal, buildSiteSteps({ siteId, paths, skipData, skipArchives, allowMissingMedia: opts?.allowMissingMedia === true, }), ); if (code !== 0) return code; // A CITED site's build must hold nothing but its reports, their moments and // the shell (lib/builtExport.ts citedBuildProblem) — and must BE a cited // build. Failing here is loud and early; every deploy path asks again. const outDir = resolveOutDir(siteId, paths); const scopeProblem = citedBuildProblem(outDir) ?? builtScopeProblem(getSite(siteId, paths), outDir); if (scopeProblem) { onLog(`[build] REFUSED — ${scopeProblem}.\n`); return 1; } return 0; } // Where the basic (host) compose staged this site's oversize archives for R2 // upload. Kept outside export/public so they never ship as Pages assets. Mirrors // the path composeArchives writes to in common/bin/compose-site.ts. The docker // fan-out uses dockerSiteStagingDir instead, passed explicitly. function archiveStagingDir(siteId: string, paths: Paths): string { return path.join( path.dirname(paths.exportPublicDir), ".r2-staging", siteId, "archives", ); } // Said once at the top of every preview deploy, because the one thing a preview // does NOT isolate is the archive bucket: R2 has no per-branch namespace, so a // preview's oversize archives overwrite the keys production's manifest points // at. That is cheap and harmless in practice — the upload skips any object R2 // already holds at the same size, and an unchanged channel re-zips byte-stable // — but "harmless because of a size check" is exactly the kind of thing an // operator should be told rather than left to discover. export const PREVIEW_SHARES_ARCHIVES_NOTICE = "[notice] A preview shares the production R2 archive bucket — unchanged " + "archives are skipped, so this is cheap, but a CHANGED archive replaces the " + "one production links to.\n"; // Cache-Control set on every uploaded archive. Served through a Cloudflare custom // domain, this lets the CDN absorb repeated/abusive downloads at the edge instead // of hitting R2 (each origin GET is a billable Class B op), which is the main cost // defense for public archives — see PUBLISH.md. 1h balances flood // absorption against re-deployed archives (stable filenames, overwritten in place) // going stale; raise it if your archives rarely change. const ARCHIVE_CACHE_CONTROL = "public, max-age=3600"; // MIME type stored on each uploaded archive. Archives are `.zip` bundles. const ARCHIVE_CONTENT_TYPE = "application/zip"; // Upload this site's staged oversize archives to the configured R2 bucket, so the // remote URLs the served manifest points at actually resolve. Uploads go through // R2's S3 API with the AWS SDK's multipart uploader (`@aws-sdk/lib-storage`) — // `wrangler r2 object put` caps single-file uploads at 300 MiB, which real // live-chat archives blow past, whereas multipart streams any size. Keys match // what composeArchives wrote into the manifest: `/archives/`. Must // run BEFORE the Pages deploy so the manifest never points at a missing object. // // No-op (returns 0) when overflow storage isn't configured or nothing was staged. // Credentials come from the host env (NOT the settings JSON — secrets don't // belong there): R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and CLOUDFLARE_ACCOUNT_ID // (for the S3 endpoint). If a bucket is configured and files are staged but the // credentials are missing, we fail (return non-zero) so the deploy aborts rather // than shipping a manifest that points at un-uploaded objects. export async function runArchiveUploadIntoLog( onLog: (line: string) => void, signal: AbortSignal, site: Site, paths: Paths, // Where the oversize archives were staged. Defaults to the basic host location; // the deploy stage passes dockerSiteStagingDir, where every runner's bundle // keeps them (stageSiteArchives). stagingDirOverride?: string, ): Promise { const bucket = getSettings().archiveStorage?.bucket?.trim(); if (!bucket) return 0; const stagingDir = stagingDirOverride ?? archiveStagingDir(site.siteId, paths); let files: string[]; try { files = (await readdir(stagingDir)).filter((f) => !f.startsWith(".")); } catch { // No staging dir → nothing oversize this build. return 0; } if (files.length === 0) return 0; const accessKeyId = process.env.R2_ACCESS_KEY_ID?.trim(); const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY?.trim(); const accountId = process.env.CLOUDFLARE_ACCOUNT_ID?.trim(); if (!accessKeyId || !secretAccessKey || !accountId) { onLog( `[archives] ${files.length} oversize archive(s) need uploading to R2 bucket ` + `"${bucket}", but R2 S3 credentials are missing. Set R2_ACCESS_KEY_ID, ` + `R2_SECRET_ACCESS_KEY, and CLOUDFLARE_ACCOUNT_ID in the environment — see ` + `PUBLISH.md ("Download archives and R2"). Aborting before deploy so the site never links to ` + `missing files.\n`, ); return 1; } const client = new S3Client({ region: "auto", endpoint: `https://${accountId}.r2.cloudflarestorage.com`, credentials: { accessKeyId, secretAccessKey }, }); onLog( `[archives] uploading ${files.length} oversize archive(s) to R2 bucket "${bucket}" via the S3 API…\n`, ); try { let uploaded = 0; let skipped = 0; for (const file of files) { if (signal.aborted) return 1; const key = `${site.siteId}/archives/${file}`; const filePath = path.join(stagingDir, file); const { size } = await stat(filePath); // Skip the re-upload when R2 already holds an object of the same size for // this key. The archive cache makes an unchanged channel's zip byte-stable // build-to-build, so a same-size object is the same object; a changed // channel re-zips to a different size. Avoids re-streaming unchanged // multi-MB archives on every deploy. HeadObject is a cheap metadata call. try { const head = await client.send( new HeadObjectCommand({ Bucket: bucket, Key: key }), ); if (head.ContentLength === size) { skipped++; onLog(`[archives] ${key} unchanged — already in R2, skipping.\n`); continue; } } catch { // Not found (or HEAD not permitted) → fall through and upload. } onLog(`[archives] ${key} (${(size / 1e6).toFixed(1)} MB)…\n`); uploaded++; const upload = new Upload({ client, params: { Bucket: bucket, Key: key, Body: createReadStream(filePath), ContentType: ARCHIVE_CONTENT_TYPE, CacheControl: ARCHIVE_CACHE_CONTROL, }, }); const onAbort = () => { upload.abort().catch(() => {}); }; signal.addEventListener("abort", onAbort, { once: true }); try { await upload.done(); } finally { signal.removeEventListener("abort", onAbort); } onLog(`[archives] ${key} done.\n`); } onLog( `[archives] R2 upload complete (${uploaded} uploaded, ${skipped} unchanged).\n`, ); return 0; } catch (err) { onLog( `[archives] R2 upload failed: ${err instanceof Error ? err.message : String(err)}\n`, ); return 1; } finally { client.destroy(); } } // --------------------------------------------------------------------------- // Docker export pipeline: the parts of the build-all stage's docker runner // (stageBodies.ts, buildAllDocker — see PUBLISH.md, "Building every site in // containers"): the host's build:archives warm (runHostScript), the image // (ensureBuildImage), then each site's compose + next build in its own // container (runDockerBuildOne), up to maxParallelBuilds at once // (runWithConcurrency), read-only over the shared caches and writing only its // per-site out/ under exportBuildsDir/. // --------------------------------------------------------------------------- // The container engine binary. Defaults to `docker`; podman is a CLI drop-in // (and rootless podman yields host-owned outputs without needing `-u`). // `archilyzer doctor` asks the same engine, from the environment it was given. export function dockerBin(env: NodeJS.ProcessEnv = process.env): string { return env.DOCKER_BIN?.trim() || "docker"; } // The build image's `docker build` argv, run from the monorepo root: the one // spelling of it. ensureBuildImage runs it; `archilyzer doctor` prints it as the // command that rebuilds an absent or stale image. export function buildImageArgs(pipeline: { dockerImage: string; dockerfile: string; }): string[] { return ["build", "-f", pipeline.dockerfile, "-t", pipeline.dockerImage, "."]; } // Cheap probe: is the container engine installed and its daemon reachable? Used // to fall back to serial host builds when docker isn't available. export async function dockerAvailable(signal: AbortSignal): Promise { const code = await runChildIntoLog(() => {}, signal, { command: dockerBin(), args: ["version"], cwd: process.cwd(), }); return code === 0; } // Run a host-side export pnpm script (build:data / build:archives) before the fan-out. // These are pool-wide: no SITE_ID, and no EXPORT_PUBLIC_DIR override so the // shared index/staging land at their canonical export/.export-index location // (exactly what the fan-out containers mount read-only). export async function runHostScript( onLog: (line: string) => void, signal: AbortSignal, paths: Paths, script: string, extraEnv?: Record, ): Promise { return runChildIntoLog(onLog, signal, { command: "pnpm", args: ["run", script], cwd: paths.exportDir, env: { ...process.env, NODE_ENV: "production", TRANSCRIPTS_DIR: paths.transcriptsDir, ...extraEnv, }, }); } // Build (or reuse cached layers of) the per-site build image. Runs before every // fan-out, so a fan-out never meets an image older than the checkout: a code // change re-runs only `COPY . .` onward, but a Dockerfile or lockfile change // re-installs every dependency first, inside this job. `archilyzer doctor` // warns ahead of that when the image is absent or older than the Dockerfile. export async function ensureBuildImage( onLog: (line: string) => void, signal: AbortSignal, paths: Paths, ): Promise { const pipeline = getSettings().buildPipeline; onLog( `[docker] building image "${pipeline.dockerImage}" from ${pipeline.dockerfile} (cached layers reused)`, ); return runChildIntoLog(onLog, signal, { command: dockerBin(), args: buildImageArgs(pipeline), cwd: paths.monorepoRoot, }); } // Run ONE site's build in a container. Mounts the corpus, the shared LMDB index, // and the .export-index staging read-only; mounts the per-site output dir rw. // Streams with a [siteId] prefix. Network is left ENABLED — `next build` uses // next/font/google, which fetches the site's fonts from Google at build time; // `--network=none` would fail the build. Isolation still comes from the per-site // output dir, the read-only shared mounts, and the non-root `-u` user. export async function runDockerBuildOne( onLog: (line: string) => void, signal: AbortSignal, siteId: string, paths: Paths, opts?: { skipArchives?: boolean }, ): Promise { const { dockerImage } = getSettings().buildPipeline; const siteDir = path.join(paths.exportBuildsDir, siteId); // Pre-create the mount target as the host user so container-written files are // host-owned (paired with `-u` below), not created root-owned by the daemon. await mkdir(siteDir, { recursive: true }); const args = ["run", "--rm", "--init"]; const uid = typeof process.getuid === "function" ? process.getuid() : null; const gid = typeof process.getgid === "function" ? process.getgid() : null; if (uid !== null && gid !== null) args.push("-u", `${uid}:${gid}`); // Optional resource caps so N parallel builds (each next build can use ~8 GB) // don't OOM the host. Sized by the operator; see PUBLISH.md. const mem = process.env.DOCKER_BUILD_MEMORY?.trim(); const cpus = process.env.DOCKER_BUILD_CPUS?.trim(); if (mem) args.push("--memory", mem); if (cpus) args.push("--cpus", cpus); args.push( "-v", `${paths.transcriptsDir}:/data/transcripts:ro`, "-v", `${paths.exportIndexDir}:/data/export/.export-index:ro`, "-v", `${siteDir}:/site`, "-e", `SITE_ID=${siteId}`, // The container's `build site` is `publish build --force` (release 18): // its publish lock lives inside the container, never on the host's // mount — the host's lock is held by the fan-out stage itself. "-e", `EXPORT_BUILDS_DIR=${CONTAINER_BUILDS_DIR}`, ); // Mount the host settings.json fresh (build config: archive storage, size caps) // rather than relying on a possibly-stale copy — it is NOT baked into the image. if (existsSync(paths.settingsFile)) { args.push( "-v", `${paths.settingsFile}:/data/settings.json:ro`, "-e", "SETTINGS_FILE=/data/settings.json", ); } // Mount the entrypoint fresh over the baked copy so a script tweak takes effect // without an image rebuild (the image still bakes it as a fallback). const entrypoint = path.join(paths.monorepoRoot, "docker", "build-site.sh"); if (existsSync(entrypoint)) { args.push("-v", `${entrypoint}:/repo/docker/build-site.sh:ro`); } if (opts?.skipArchives) args.push("-e", "BUILD_ARCHIVES=0"); args.push(dockerImage); return runChildIntoLog(onLog, signal, { command: dockerBin(), args, cwd: paths.monorepoRoot, label: `[${siteId}] `, }); } // Bounded-concurrency map over a fixed work set, preserving input order in the // results. No external dep; a fresh worker pulls the next index until exhausted. export async function runWithConcurrency( items: T[], limit: number, worker: (item: T) => Promise, ): Promise { const results: R[] = new Array(items.length); let next = 0; const width = Math.max(1, Math.min(limit, items.length)); const runners = Array.from({ length: width }, async () => { while (true) { const i = next++; if (i >= items.length) break; results[i] = await worker(items[i]); } }); await Promise.all(runners); return results; } // --------------------------------------------------------------------------- // Named entry points: one call per thing the stages build, over the parts // above. Their options default to the terminal and a never-aborted signal. // --------------------------------------------------------------------------- export type PublishOpts = { paths?: Paths; // Default: the terminal. onLog?: (line: string) => void; // Default: never aborted. signal?: AbortSignal; }; // runChildIntoLog hands onLog lines WITHOUT their newline and the actions' own // notices end in one; the terminal gets exactly one either way. export function terminalLog(line: string): void { process.stdout.write(line.endsWith("\n") ? line : `${line}\n`); } function resolved(opts: PublishOpts): { paths: Paths; onLog: (line: string) => void; signal: AbortSignal; } { return { paths: opts.paths ?? getPaths(), onLog: opts.onLog ?? terminalLog, signal: opts.signal ?? new AbortController().signal, }; } // --------------------------------------------------------------------------- // The hub and the homepage — two apps, two Pages projects (decision // 2026-09-25). // // The HUB is the export app built with INSTANCE_MODE=hub: a federating shell // over every site that has a public URL, branded by homepage.json and deployed // to homepage.json's `cloudflareProject` (`archilyzer-hub`). It builds into the // same export/out as a site — the two overwrite each other, and // builtHubProblem / builtSiteProblem refuse to deploy the wrong one. // // The HOMEPAGE is the `homepage` package: the software's own site (docs, the // source download), deployed to the constant project `archilyzer` // (https://archilyzer.pages.dev, PROJECT_URL). It is never the hub. // --------------------------------------------------------------------------- /** The homepage package's Pages project. Constant: it is the product's site. */ export const HOMEPAGE_PAGES_PROJECT = "archilyzer"; /** * Why `project` may not be the hub's deploy target, as one sentence — or null. * The homepage's project is refused by name: homepage.json said `archilyzer` * until the hub got a deploy path, and a hub deployed there would replace the * software's own site. */ export function hubProjectProblem(project: string | undefined): string | null { const p = project?.trim(); if (!p) { return "The hub has no Cloudflare Pages project configured — set it on /sites under Hub."; } if (p === HOMEPAGE_PAGES_PROJECT) { return ( `The hub's Cloudflare Pages project is "${p}", which is the Archilyzer ` + `homepage's — set the hub's own project (for example "archilyzer-hub") ` + `on /sites under Hub.` ); } return null; } /** * The hub build, as the children it runs: compose:hub (hub-sites.json, * hub-summary.json when there is an index, corpus.json, llms.txt, robots.txt, * _headers, sw.js into export/public), then * `next build` with INSTANCE_MODE=hub — both in export/. */ export function buildHubSteps(opts: { paths: Paths; baseEnv?: NodeJS.ProcessEnv; }): BuildStep[] { const { paths } = opts; const env: NodeJS.ProcessEnv = { ...(opts.baseEnv ?? process.env), NODE_ENV: "production", TRANSCRIPTS_DIR: paths.transcriptsDir, EXPORT_PUBLIC_DIR: paths.exportPublicDir, }; return [ { command: "pnpm", args: ["run", "compose:hub"], cwd: paths.exportDir, env }, nextBuildStep(paths, { ...env, INSTANCE_MODE: "hub" }), ]; } /** Compose the hub's export/public, as a child. Returns the exit code. */ export async function composeHub(opts: PublishOpts = {}): Promise { const { paths, onLog, signal } = resolved(opts); return runSteps(onLog, signal, buildHubSteps({ paths }).slice(0, 1)); } /** * Build the hub into export/out. Removes public/site.json first — a site's * compose left it there, and a hub bundle carrying one would read as that * site's (builtExport.ts). compose-hub then removes every other per-site entry * a site's compose left in public/ (SITE_ONLY_PUBLIC_ENTRIES), so the hub * never ships a site's data. Returns the exit code. */ export async function buildHub(opts: PublishOpts = {}): Promise { const { paths, onLog, signal } = resolved(opts); await rm(path.join(paths.exportPublicDir, "site.json"), { force: true }); return runSteps(onLog, signal, buildHubSteps({ paths })); } function homepageDir(paths: Paths): string { return path.join(paths.monorepoRoot, "homepage"); } /** * Where buildHomepage writes and the deploy-homepage stage ships: `homepage/out` of this * checkout. Exported for the build-homepage and deploy stages, which judge * whether anything is built there (builtHomepageProblem). */ export function homepageOutDir(paths: Paths): string { return path.join(homepageDir(paths), "out"); } function homepageEnv(paths: Paths): NodeJS.ProcessEnv { return { ...process.env, NODE_ENV: "production", TRANSCRIPTS_DIR: paths.transcriptsDir, }; } /** * Compose homepage/public (whole-pool stats, channel → sites map, landing * summary), as a child. Reads the LMDB index as it stands: run `archilyzer * index` first when it is stale. Returns the exit code. */ export async function composeHomepage(opts: PublishOpts = {}): Promise { const { paths, onLog, signal } = resolved(opts); return runSteps(onLog, signal, [ { command: "pnpm", args: ["run", "compose"], cwd: homepageDir(paths), env: homepageEnv(paths) }, ]); } /** * composeHomepage, then `archilyzer source publish` (the git mirror, the raw * tree and the tarball into homepage/public — source.ts), then `next build` in * homepage/ (→ homepage/out). * * A source REFUSAL fails the build before `next build`, and withdraws the * source twice over: the step itself removes the last publish from * homepage/public, and this removes the last BUILD's copy from homepage/out * (`out/source`, the tarball, `snapshot.json`), so a deploy-only cannot ship * a source today's rules were never applied to (the deploy stage checks too). * The audit report is in the log. * * `skipSource` (the CLI's `--no-source`) REMOVES the published source instead: * a copy left from an earlier publish was audited against the rules of ITS * day, so a build that skips the gate ships none — the pages show their empty * states. A checkout with no git repository (the docker runtime, a tarball * install) builds with the empty state too. `publishSource` / `clearSource` * are the test's seams. */ export async function buildHomepage( opts: PublishOpts & { skipSource?: boolean; publishSource?: (o: PublishOpts & { noRepository?: "refuse" | "empty" }) => Promise; clearSource?: (o: PublishOpts) => Promise; } = {}, ): Promise { const { paths, onLog, signal } = resolved(opts); const code = await composeHomepage({ paths, onLog, signal }); if (code !== 0) return code; // Loaded lazily: the source step pulls nothing the other publish entry // points need, and it imports this module's types. if (opts.skipSource) { const clear = opts.clearSource ?? (await import("./source")).clearPublishedSource; await clear({ paths, onLog, signal }); } else { const publish = opts.publishSource ?? (await import("./source")).publishSource; const sourceCode = await publish({ paths, onLog, signal, noRepository: "empty" }); if (sourceCode !== 0 || signal.aborted) { await withdrawBuiltSource(paths, onLog); return sourceCode || 1; } } return runSteps(onLog, signal, [ heavyGated(paths, { command: "pnpm", args: ["exec", "next", "build"], cwd: homepageDir(paths), env: homepageEnv(paths), }), ]); } // The last build's copy of the source, out of homepage/out: a refused source // step must not leave it for a deploy-only to ship. async function withdrawBuiltSource(paths: Paths, onLog: (line: string) => void): Promise { const out = homepageOutDir(paths); const had = existsSync(path.join(out, "source", "manifest.json")) || existsSync(path.join(out, "downloads", "archilyzer-source.tar.gz")); await rm(path.join(out, "source", "manifest.json"), { force: true }); await rm(path.join(out, "source"), { recursive: true, force: true }); await rm(path.join(out, "downloads", "archilyzer-source.tar.gz"), { force: true }); await rm(path.join(out, "downloads", "snapshot.json"), { force: true }); if (had) { onLog("[source] the last build's source was removed from homepage/out too, so a deploy-only ships none.\n"); } } // --------------------------------------------------------------------------- // Per-target bundles (release 18). `//out` is THE // bundle every deploy of `target` ships, whichever runner built it: a site's // id, `_hub`, or (stamps only) `_homepage` — the homepage's bundle stays // homepage/out, where the source gate withdraws from. // // The local runner builds into the shared export/out (Next's `distDir` may not // leave the project, and `public/` is copied into `out/` — the plan's // "Decided"), then INSTALLS it: moved to `/out.next`, swapped in // (`out → out.prev`, `out.next → out`, `out.prev` removed). A leftover // `out.next` is deleted first. On one filesystem the move is a rename; across // two (the container: export/out is an image layer, the builds dir a volume) // rename fails EXDEV and the bundle is copied, then the source removed. // export/out is then a SYMLINK to the bundle built last, so older readers and // `serve out` keep working; `next build` removes the link itself (an `rm` of // the path, recursive — never its target) before it writes a real out/. // --------------------------------------------------------------------------- // Inside the docker per-site build container: where `publish build` keeps its // lock (runDockerBuildOne passes it as EXPORT_BUILDS_DIR). export const CONTAINER_BUILDS_DIR = "/tmp/archilyzer-builds"; /** `//out` — the target's bundle (= dockerSiteOutDir for a site). */ export function bundleDir(paths: Pick, target: string): string { return path.join(paths.exportBuildsDir, target, "out"); } /** export/out: where `next build` writes, and afterwards a link to the last bundle. */ export function exportOutPath(paths: Pick): string { return path.join(paths.exportDir, "out"); } // The filesystem calls a bundle move makes, injectable so the EXDEV path is // tested without two filesystems. export type BundleFs = { rename: typeof rename; cp: typeof cp; rm: typeof rm; mkdir: typeof mkdir; }; const realBundleFs: BundleFs = { rename, cp, rm, mkdir }; async function lexists(p: string): Promise { return lstat(p).then( () => true, () => false, ); } // Move a directory: rename, or across filesystems a copy then a remove. async function moveDir(src: string, dest: string, fsOps: BundleFs): Promise<"rename" | "copy"> { try { await fsOps.rename(src, dest); return "rename"; } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; await fsOps.cp(src, dest, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true }); await fsOps.rm(src, { recursive: true, force: true }); return "copy"; } } /** * A crash between installBundle's two renames leaves `out.prev` and no `out`: * the last bundle is whole, under the wrong name. Put it back. Asked first by * every build and every install, before anything else touches the target. * Answers whether it restored one. */ export async function recoverInterruptedInstall( destOut: string, fsOps: BundleFs = realBundleFs, ): Promise { const prev = `${destOut}.prev`; if ((await lexists(destOut)) || !(await lexists(prev))) return false; await fsOps.rename(prev, destOut); return true; } /** * Install the build at `src` as the bundle `destOut` (see the section header): * `src` is gone afterwards and `destOut` holds it. Answers how it moved. */ export async function installBundle( src: string, destOut: string, fsOps: BundleFs = realBundleFs, ): Promise<"rename" | "copy"> { const next = `${destOut}.next`; const prev = `${destOut}.prev`; await recoverInterruptedInstall(destOut, fsOps); await fsOps.mkdir(path.dirname(destOut), { recursive: true }); await fsOps.rm(next, { recursive: true, force: true }); const how = await moveDir(src, next, fsOps); await fsOps.rm(prev, { recursive: true, force: true }); // Siblings: these two renames never cross a filesystem. if (await lexists(destOut)) await fsOps.rename(destOut, prev); await fsOps.rename(next, destOut); await fsOps.rm(prev, { recursive: true, force: true }); return how; } /** * Before a build: export/out must not still point at an older bundle, or a * build that failed before `next build` reached it would leave that bundle * readable at export/out. A link is removed; a real directory is left for * `next build` to replace. */ export async function unlinkExportOut(paths: Pick): Promise { const link = exportOutPath(paths); const st = await lstat(link).catch(() => null); if (st?.isSymbolicLink()) await rm(link, { force: true }); } /** After a build: export/out becomes a (relative) link to `bundle`, replaced atomically. */ export async function pointExportOutAt(paths: Pick, bundle: string): Promise { const link = exportOutPath(paths); const st = await lstat(link).catch(() => null); if (st && !st.isSymbolicLink()) await rm(link, { recursive: true, force: true }); const tmp = `${link}.link-${process.pid}`; await rm(tmp, { force: true }); await symlink(path.relative(path.dirname(link), bundle), tmp, "dir"); await rename(tmp, link); } /** * The host compose stages a site's oversize archives beside export/public * (`.r2-staging//archives`); the bundle's are `dockerSiteStagingDir` for * both runners. Moves this build's (replacing the last build's) and answers * how many were staged. A no-op where the two are one place. */ export async function stageSiteArchives( paths: Pick, siteId: string, fsOps: BundleFs = realBundleFs, ): Promise { const src = path.join(path.dirname(paths.exportPublicDir), ".r2-staging", siteId); const dest = path.join(paths.exportBuildsDir, siteId, ".r2-staging", siteId); if (path.resolve(src) !== path.resolve(dest)) { await fsOps.rm(dest, { recursive: true, force: true }); if (await lexists(src)) { await fsOps.mkdir(path.dirname(dest), { recursive: true }); await moveDir(src, dest, fsOps); } } const staged = await readdir(dockerSiteStagingDir(paths as Paths, siteId)).catch(() => [] as string[]); return staged.filter((f) => !f.startsWith(".")).length; } /** Files and bytes under `dir` (links not followed). */ export async function bundleCounts(dir: string): Promise<{ files: number; bytes: number }> { let files = 0; let bytes = 0; const walk = async (d: string): Promise => { const ents = await readdir(d, { withFileTypes: true }).catch(() => []); for (const e of ents) { const p = path.join(d, e.name); if (e.isDirectory()) await walk(p); else if (e.isFile()) { files++; bytes += (await stat(p)).size; } } }; await walk(dir); return { files, bytes }; } /** The bundle's corpus.json `generatedAt`, or null. */ export async function corpusGeneratedAtIn(outDir: string): Promise { try { const v = JSON.parse(await readFile(path.join(outDir, "corpus.json"), "utf8")) as { generatedAt?: unknown }; return typeof v.generatedAt === "string" ? v.generatedAt : null; } catch { return null; } } /** * Build one site and install it as its bundle (the build-site stage's local * body): export/out unlinked, buildSiteSteps with `skipData` (the index stage * ran the data phase), the cited/scope checks of runBuildPhase, * builtBundleProblem, then the install, the archive staging and the link. * Returns the exit code; on 0 the bundle is `bundleDir(paths, siteId)`. * * `inPlace` (the docker per-site container) stops after the checks: the * container hands export/out back itself, and the host stamps it. */ export async function buildSiteBundle( siteId: string, opts: PublishOpts & { skipArchives?: boolean; allowMissingMedia?: boolean; inPlace?: boolean } = {}, ): Promise<{ code: number; archivesStaged: number }> { const { paths, onLog, signal } = resolved(opts); if (!opts.inPlace) { if (await recoverInterruptedInstall(bundleDir(paths, siteId))) { onLog(`[build] ${siteId}: restored the bundle an interrupted install left as out.prev\n`); } await unlinkExportOut(paths); } const code = await runBuildPhase(onLog, signal, siteId, paths, { skipData: true, skipArchives: opts.skipArchives, allowMissingMedia: opts.allowMissingMedia, }); if (code !== 0 || signal.aborted) return { code: code || 1, archivesStaged: 0 }; const out = exportOutPath(paths); const problem = builtBundleProblem(out, siteId); if (problem) { onLog(`[build] REFUSED — ${problem}.\n`); return { code: 1, archivesStaged: 0 }; } if (opts.inPlace) return { code: 0, archivesStaged: 0 }; // The old stamp must never describe the new bundle: it goes first, and the // stage writes the new one as its LAST step, once the bundle is in place. await rm(builtStampPath(paths, siteId), { force: true }); const how = await installBundle(out, bundleDir(paths, siteId)); const archivesStaged = await stageSiteArchives(paths, siteId); await pointExportOutAt(paths, bundleDir(paths, siteId)); onLog( `[build] ${siteId}: bundle installed at ${bundleDir(paths, siteId)} (${how === "rename" ? "moved" : "copied across filesystems"})` + (archivesStaged ? `, ${archivesStaged} archive(s) staged for R2` : "") + "\n", ); return { code: 0, archivesStaged }; } /** * Build the hub and install it as `_hub/out` (the build-hub stage's body). * Returns the exit code. */ export async function buildHubBundle(opts: PublishOpts = {}): Promise { const { paths, onLog, signal } = resolved(opts); if (await recoverInterruptedInstall(bundleDir(paths, "_hub"))) { onLog("[build] hub: restored the bundle an interrupted install left as out.prev\n"); } await unlinkExportOut(paths); const code = await buildHub({ paths, onLog, signal }); if (code !== 0 || signal.aborted) return code || 1; const out = exportOutPath(paths); const problem = builtHubProblem(out); if (problem) { onLog(`[build] REFUSED — ${problem}.\n`); return 1; } const dest = bundleDir(paths, "_hub"); await rm(builtStampPath(paths, "_hub"), { force: true }); const how = await installBundle(out, dest); await pointExportOutAt(paths, dest); onLog(`[build] hub: bundle installed at ${dest} (${how === "rename" ? "moved" : "copied across filesystems"})\n`); return 0; }