commit 9d11f5aefeede6e615c3313f6a0d4e035550600d
parent 046047adaaa165d9771ffe2db48e9bc75abf4ea6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 13:41:57 -0400
docker: build-site.sh syncs the tracked assets into a site's public/ every run — a changed one ships, a removed one stops (review L1, L2)
/site/public persists between container builds, and the no-clobber copy
never refreshed a changed svg and never dropped a removed one: an icon taken
out of the repo (the Ko-fi mark was one, dropped for anonymity) would keep
shipping from every container-built site. sync_public_assets copies each
tracked .svg over whatever is there and removes a name the previous run copied
that the repo no longer has — the names are listed in /site/.tracked-public-
assets, outside public/ so the list never ships, and only those names are ever
removed, so nothing compose wrote is touched.
common/publish/buildImage.test.ts: the function read out of build-site.sh and
run with bash over temp dirs (a changed asset ships, a removed one goes, a
composed file and an unlisted svg stay, a non-svg is never copied); and every
file git tracks in export/public is a top-level .svg — the ignore file and the
sync both assume it, and the failure says to update both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 118 insertions(+), 11 deletions(-)
diff --git a/common/publish/buildImage.test.ts b/common/publish/buildImage.test.ts
@@ -0,0 +1,87 @@
+// The container build's contract with the repo: what Dockerfile.build's
+// image bakes into export/public and how docker/build-site.sh puts it in front
+// of each site's `next build`. No docker here — the invariant is read from git,
+// and the shell function is read out of build-site.sh and run with bash.
+//
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+// The site build image bakes only export/public/*.svg (Dockerfile.build.
+// dockerignore) and docker/build-site.sh copies only those into each site's
+// public/. A tracked asset of any other kind, or in a subdirectory, would be in
+// every host build and silently missing from every container build.
+test("every tracked file in export/public is a top-level .svg, as the container build assumes", (t) => {
+ let listed: string;
+ try {
+ listed = execFileSync("git", ["-C", REPO, "ls-files", "export/public"], { encoding: "utf8" });
+ } catch {
+ t.skip("not a git checkout");
+ return;
+ }
+ const odd = listed.split("\n").filter((f) => f && !/^export\/public\/[^/]+\.svg$/.test(f));
+ assert.deepEqual(
+ odd,
+ [],
+ `export/public tracks ${odd.join(", ")} — not a top-level .svg. The container build ships only ` +
+ `those: admit the new asset in Dockerfile.build.dockerignore and copy it in docker/build-site.sh ` +
+ `(sync_public_assets), or every container-built site goes without it.`,
+ );
+});
+
+// /site/public persists between container builds, so the tracked assets are
+// synced into it every run: a changed one must ship, a removed one must stop
+// shipping (an icon dropped from the repo — the Ko-fi mark was one — must not
+// live on in a site), and nothing compose wrote may be touched. The function is
+// read out of build-site.sh itself and run with bash over temp dirs.
+test("build-site.sh's asset sync ships a changed svg, drops a removed one, and touches nothing compose wrote", () => {
+ const script = readFileSync(path.join(REPO, "docker", "build-site.sh"), "utf8");
+ const fn = /^sync_public_assets\(\) \{[\s\S]*?^\}$/m.exec(script)?.[0];
+ assert.ok(fn, "docker/build-site.sh defines sync_public_assets()");
+ const root = mkdtempSync(path.join(tmpdir(), "asset-sync-"));
+ const src = path.join(root, "image-public");
+ const dest = path.join(root, "site-public");
+ const list = path.join(root, ".tracked-public-assets");
+ const sync = () =>
+ execFileSync("bash", ["-euo", "pipefail", "-c", `${fn}\nsync_public_assets "$1" "$2" "$3"`, "sync", src, dest, list]);
+ const read = (p: string) => readFileSync(path.join(dest, p), "utf8");
+ try {
+ mkdirSync(src);
+ mkdirSync(path.join(dest, "transcripts"), { recursive: true });
+ writeFileSync(path.join(src, "globe.svg"), "<svg>v1</svg>");
+ writeFileSync(path.join(src, "kofi-symbol.svg"), "<svg>kofi</svg>");
+ writeFileSync(path.join(src, "stale.json"), "{}"); // not an asset: never copied
+ // What compose wrote (or any file not copied from the image): never touched.
+ writeFileSync(path.join(dest, "corpus.json"), '{"site":{"id":"anilyzer"}}');
+ writeFileSync(path.join(dest, "transcripts", "page-0000.json"), "[]");
+ writeFileSync(path.join(dest, "composed.svg"), "<svg>compose</svg>");
+
+ sync();
+ assert.equal(read("globe.svg"), "<svg>v1</svg>");
+ assert.equal(read("kofi-symbol.svg"), "<svg>kofi</svg>");
+ assert.equal(existsSync(path.join(dest, "stale.json")), false);
+ assert.equal(readFileSync(list, "utf8"), "globe.svg\nkofi-symbol.svg\n");
+
+ // The repo changes one asset and drops another.
+ writeFileSync(path.join(src, "globe.svg"), "<svg>v2</svg>");
+ rmSync(path.join(src, "kofi-symbol.svg"));
+ sync();
+ assert.equal(read("globe.svg"), "<svg>v2</svg>", "a changed asset is shipped");
+ assert.equal(existsSync(path.join(dest, "kofi-symbol.svg")), false, "a removed asset stops shipping");
+ assert.equal(readFileSync(list, "utf8"), "globe.svg\n");
+ assert.equal(read("corpus.json"), '{"site":{"id":"anilyzer"}}');
+ assert.equal(read("transcripts/page-0000.json"), "[]");
+ assert.equal(read("composed.svg"), "<svg>compose</svg>", "only a name the last run copied is removed");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/docker/build-site.sh b/docker/build-site.sh
@@ -40,17 +40,37 @@ rm -rf 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
+# all, or whatever site the image's build context had composed last. 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.
+#
+# The repo's tracked assets in public/ — all .svg (Dockerfile.build.dockerignore;
+# common/publish/buildImage.test.ts fails if one is not) — are synced into the
+# site's public/ first, on EVERY run, because /site/public persists between runs:
+# - each is copied over whatever copy is there, so a changed asset is shipped;
+# - one the repo no longer has is removed, so a dropped asset stops shipping.
+# Only a name the previous run copied is ever removed — they are listed in
+# <list>, outside public/ so the list never ships — so nothing compose wrote
+# is touched.
+# Nothing but the .svg assets is copied: anything else in an image's public/
+# would be data, and not this site's.
+sync_public_assets() { # <image public dir> <site public dir> <list file>
+ local src="$1" dest="$2" list="$3" f name
+ if [ -f "$list" ]; then
+ while IFS= read -r name; do
+ case "$name" in "" | */* | . | ..) continue ;; esac
+ if [ ! -e "$src/$name" ] && [ -f "$dest/$name" ]; then rm -f "$dest/$name"; fi
+ done < "$list"
+ fi
+ : > "$list.new"
+ for f in "$src"/*.svg; do
+ [ -f "$f" ] || continue
+ cp -a "$f" "$dest/"
+ printf '%s\n' "${f##*/}" >> "$list.new"
+ done
+ mv "$list.new" "$list"
+}
+sync_public_assets export/public /site/public /site/.tracked-public-assets
rm -rf export/public
ln -s /site/public export/public