commit 35814656e122ae95dd0f05d187cd91641befebef
parent 2fea61e240b83155fdd9498fd12b77c58eaba5c8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 13:13:28 -0400
docker: build-site.sh builds in the container's own .next, publishes the composed public/, and refuses a bundle that is not the site's
(A) export/.next was a symlink to /site/.next. Turbopack's server runtime
imports next's own externals (the icon routes' @vercel/og) from each chunk's
REAL path, under /site, where there is no node_modules, so every container
build died prerendering /favicon.ico — on main's image as on W3's. .next now
stays in the container. Carrying .next/cache between runs was tried and
measured to save nothing (TypeScript 30.3 s -> 32.6 s, the two-site run
90 s -> 88 s), so it is not kept.
(B) next build publishes export/public into out/, and export/public was the
copy baked into the image, not the public/ compose had just written to
/site/public: out/ carried no data, or whatever site the build context had
composed last. export/public is now a link to /site/public, after the tracked
.svg assets are copied in (never over a file there, and nothing else).
Before handing out/ back, builtBundleProblem must pass (site.json and
corpus.json both name SITE_ID); a refusal fails the build and leaves /site/out
as it was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
1 file changed, 46 insertions(+), 9 deletions(-)
diff --git a/docker/build-site.sh b/docker/build-site.sh
@@ -7,7 +7,7 @@
# mounted READ-ONLY. This container only composes ITS site's public/ and runs
# `next build`, writing everything into the per-site mount at /site.
#
-# Expected run-time mounts (see runDockerBuildAllPhase):
+# Expected run-time mounts (see runDockerBuildOne in common/publish/build.ts):
# <transcriptsDir> -> /data/transcripts (ro)
# <export/.export-index> -> /data/export/.export-index (ro)
# <exportBuildsDir>/<siteId> -> /site (rw)
@@ -24,14 +24,35 @@ export EXPORT_PUBLIC_DIR=/site/public
# never re-generate — see compose-site.ts / archiveTranscripts.ts.
export ARCHIVES_READONLY=1
-# Persist Next's build cache (.next) into the per-site mount so incremental
-# next builds stay warm across runs. A symlink is safe for .next because Next
-# reuses — never wholesale-replaces — its cache dir. The export dir was made
-# writable in the image so this symlink (and Next's next-env.d.ts) can be created
-# as an arbitrary runtime uid.
-mkdir -p /site/.next /site/out /site/public
+mkdir -p /site/out /site/public
+
+# .next stays INSIDE the container, and starts empty. It used to be a symlink to
+# /site/.next, but turbopack's server runtime imports next's own externals (the
+# icon routes' next/dist/compiled/@vercel/og) from each chunk's REAL path —
+# under /site, where there is no node_modules — so `next build` died
+# prerendering /favicon.ico. Nothing measurable is lost: a Turbopack production
+# build keeps no filesystem cache unless experimental.
+# turbopackFileSystemCacheForBuild is set (export/next.config.ts does not), and
+# carrying .next/cache (the TypeScript .tsbuildinfo, the fetch cache) between
+# runs was measured to save nothing (plans/release-13.md, W3b).
rm -rf export/.next
-ln -s /site/.next export/.next
+
+# `next build` publishes export/public into out/, so export/public must BE the
+# public/ compose writes for this site (EXPORT_PUBLIC_DIR=/site/public). It used
+# to be the copy baked into the image, which shipped as the site's data: none at
+# all, or whatever site the image's build context had composed last. The image's
+# public/ holds only the repo's tracked assets, all .svg
+# (Dockerfile.build.dockerignore); those are copied across first, never over a
+# file already there, and nothing else is — whatever else an image's public/
+# held would be data, and not this site's. The export dir was made writable in
+# the image so this link (and Next's next-env.d.ts) can be created as an
+# arbitrary runtime uid.
+for f in export/public/*.svg; do
+ [ -e "$f" ] || continue
+ [ -e "/site/public/${f##*/}" ] || cp -a "$f" /site/public/
+done
+rm -rf export/public
+ln -s /site/public export/public
echo "[build-site] building site '${SITE_ID}'"
# `build site --nodata` = compose:site + next build, WITHOUT the data phase
@@ -40,9 +61,25 @@ echo "[build-site] building site '${SITE_ID}'"
# compose through the environment.
pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build site "${SITE_ID}" --nodata
+# Refuse to hand back a bundle that is not this site's own: its site.json and
+# corpus.json must both name SITE_ID (common/lib/builtExport.ts,
+# builtBundleProblem — the check the deploy phase makes again on the host). On a
+# refusal /site/out keeps whatever it held, and the build fails.
+BUILT_OUT_DIR="$PWD/export/out" pnpm --filter yt-dlp-transcript-common exec tsx -e '
+ import("./lib/builtExport.ts").then(({ builtBundleProblem }) => {
+ const problem = builtBundleProblem(process.env.BUILT_OUT_DIR, process.env.SITE_ID);
+ if (problem) {
+ console.error(`[build-site] REFUSED: ${problem} — /site/out is left as it was`);
+ process.exit(1);
+ }
+ console.log(`[build-site] out/ is the bundle of ${process.env.SITE_ID} (site.json, corpus.json)`);
+ });
+'
+
# next build writes export/out as a FRESH real dir (it removes+recreates out, so
# a symlink there wouldn't survive) — publish it into the per-site mount. Reached
-# only when the build succeeded (set -e aborts otherwise).
+# only when the build succeeded and the bundle is the site's (set -e aborts
+# otherwise).
echo "[build-site] publishing out/ -> /site/out"
rm -rf /site/out
mkdir -p /site/out