From ecd8e5852c65880fe0181b4386e4c77e0cda8fdc Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 21 Oct 2026 19:29:50 +0200 Subject: [PATCH] perf(build): keep test suites out of the image and cache CI builds durably Three build-time improvements, all measured on this host. The build context no longer ships the test suites. `next build` type-checks the whole project, so `e2e/` and `integration/` in the context made the image build fail on a type error in test code and pulled @playwright/test, vitest, msw, testcontainers and drizzle-kit into the image. The CI runs those suites on the host. Type-check time in the build drops from 2.2s to 1.3s, and a contract test now fails if application code ever imports from those folders. The CI now exports the build cache to a directory outside the runner workspace: /var/cache/epicnext-cms-buildcache for the release and a separate map for branch preflights, so a branch can never feed the release cache. The workspace is wiped after every job and `docker builder prune` runs after every release, so the next build started with an empty cache and downloaded all 493 packages again (measured: reused 0, downloaded 493). With the durable export the frozen dependency layer is reused (measured: reused 493, downloaded 0) even after a full prune. A cache directory that is not writable only produces a warning; the release is never blocked. Investigated the remaining ~30s of Next.js build tracing: excluding the dev-only packages with `outputFileTracingExcludes` made it slower (35s vs 30s), because the traced output was already minimal (5 packages). The cost is Next's own page-config collection and file tracing over 329 routes, not the dependency set, so no configuration was kept. --- .dockerignore | 7 +++++++ README.md | 18 ++++++++++++++++++ scripts/ci-deploy.sh | 17 +++++++++++++++++ scripts/ci-preflight.sh | 17 +++++++++++++++++ src/lib/ci-deploy.test.ts | 24 ++++++++++++++++++++++++ src/lib/ci-preflight.test.ts | 23 ++++++++++++++++++++++- src/lib/deploy-workflow-contract.test.ts | 20 ++++++++++++++++++++ src/lib/docker-build-contract.test.ts | 24 +++++++++++++++++++++++- 8 files changed, 148 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0220e222..c0bc15fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,6 +26,13 @@ bun.lockb public/nitro-assets public/swf +# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e), +# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van +# `next build` meebepalen en de image van @playwright/test, vitest, msw, +# testcontainers en drizzle-kit afhankelijk maken. +e2e/ +integration/ + # Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext. # Ze maakten elke context groot en invalideerden bij elke rotatie de # `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide. diff --git a/README.md b/README.md index 0d788cb4..cecfd7b9 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,24 @@ Two caches make repeated builds cheap: daemon cache to 4 GB after every deploy. The directory is ignored by both Git and the build context; delete it to force a cold build. +The CI has the same problem on a bigger scale: the runner workspace is wiped +after every job and `scripts/docker-prune.sh` trims the daemon cache after +every release, so the next build started with an empty cache and downloaded all +493 packages again (measured: `reused 0, downloaded 493`). `scripts/ci-deploy.sh` +and `scripts/ci-preflight.sh` therefore export the build cache to a directory +outside the workspace — `/var/cache/epicnext-cms-buildcache` for the release and +`/var/cache/epicnext-cms-preflight-cache` for branch preflights, so a branch can +never feed the release cache. With that export in place the same build re-uses +the frozen dependency layer (`reused 493, downloaded 0`) even after a full +`docker builder prune`. Set `CMS_BUILD_CACHE_DIR` to another path to move the +cache, or to an empty value to switch it off. + +The build context excludes `e2e/` and `integration/`. `next build` type-checks +the whole project, so shipping the test suites inside the image made a type +error in test code break the image build and pulled `@playwright/test`, `vitest`, +`msw` and `testcontainers` into it. The CI runs those suites on the host +(`pnpm test:ui`, `pnpm test:integration`). + ### Volumes Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist: diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index 1dd095a5..dd0bb174 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -416,9 +416,26 @@ pnpm install --frozen-lockfile pnpm exec playwright install chromium firefox webkit echo "Building $image" +# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job +# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele +# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft +# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit. +build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}" +build_cache_flags=() +if [ -n "$build_cache_dir" ]; then + if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then + build_cache_flags=( + --cache-from "type=local,src=$build_cache_dir" + --cache-to "type=local,dest=$build_cache_dir,mode=max" + ) + else + echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2 + fi +fi # No --network=host: BuildKit only grants it via --allow=network.host, and the # build needs nothing but outbound internet (apk, pnpm, next/font/google). DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \ + ${build_cache_flags[@]+"${build_cache_flags[@]}"} \ --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . check_current # Read reports from the already-built image; do not start an extra application. diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh index 5dc86682..e95e427f 100644 --- a/scripts/ci-preflight.sh +++ b/scripts/ci-preflight.sh @@ -39,6 +39,23 @@ pnpm exec playwright install chromium export NEWS_E2E_IMAGE="$image" export NEWS_E2E_RELEASE="$sha" build_attempted=1 +# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een +# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen +# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache +# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit. +build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}" +build_cache_flags=() +if [ -n "$build_cache_dir" ]; then + if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then + build_cache_flags=( + --cache-from "type=local,src=$build_cache_dir" + --cache-to "type=local,dest=$build_cache_dir,mode=max" + ) + else + echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2 + fi +fi DOCKER_BUILDKIT=1 docker build --progress=plain \ + ${build_cache_flags[@]+"${build_cache_flags[@]}"} \ --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts \ No newline at end of file diff --git a/src/lib/ci-deploy.test.ts b/src/lib/ci-deploy.test.ts index 170fbc2b..6fe846e9 100644 --- a/src/lib/ci-deploy.test.ts +++ b/src/lib/ci-deploy.test.ts @@ -29,6 +29,7 @@ function simulate( scenario: string, previous = "epicnext-cms-app", both = false, + extraEnv: Record = {}, ) { const dir = mkdtempSync(join(tmpdir(), "cms-deploy-test-")); try { @@ -58,6 +59,9 @@ function simulate( TEST_SHA: sha, SCENARIO: scenario, DEPLOY_BRANCH: "main", + // Standaard uit: de tests rimen geen cache-map aan op de host. + CMS_BUILD_CACHE_DIR: "", + ...extraEnv, }, }); if (result.error) throw result.error; @@ -180,6 +184,24 @@ describe("deployment transaction", () => { expect(result.calls).not.toContain("pnpm db:migrate"); expect(result.calls).not.toContain("docker run"); }); + // `docker builder prune` (na elke release) gooit de buildcache weg. Zonder + // duurzame export downloadt de volgende build alle packages opnieuw. + it("bouwt met een duurzame buildcache buiten de werkboom", () => { + const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-deploy-cache-")); + try { + const cache = join(cacheDirectory, "cache"); + const result = simulate("success", "epicnext-cms-app", false, { + CMS_BUILD_CACHE_DIR: cache, + }); + expect(result.status, result.output).toBe(0); + expect(result.calls).toContain(`--cache-from type=local,src=${cache}`); + expect(result.calls).toContain( + `--cache-to type=local,dest=${cache},mode=max`, + ); + } finally { + rmSync(cacheDirectory, { recursive: true, force: true }); + } + }); }); describe("legacy and CI container coexistence", () => { @@ -248,6 +270,8 @@ function simulateBlueGreen(scenario: string, livePort = 3002) { TEST_SHA: sha, SCENARIO: scenario, DEPLOY_BRANCH: "main", + // Standaard uit: de tests rimen geen cache-map aan op de host. + CMS_BUILD_CACHE_DIR: "", }, }); if (result.error) throw result.error; diff --git a/src/lib/ci-preflight.test.ts b/src/lib/ci-preflight.test.ts index c35fb79a..11924c4c 100644 --- a/src/lib/ci-preflight.test.ts +++ b/src/lib/ci-preflight.test.ts @@ -25,7 +25,7 @@ const bash = .find(existsSync) ?? "bash") : "bash"; const sha = "d".repeat(40); -function simulate(scenario: string) { +function simulate(scenario: string, extraEnv: Record = {}) { const directory = mkdtempSync(join(tmpdir(), "cms-preflight-test-")); try { writeFileSync( @@ -48,6 +48,9 @@ function simulate(scenario: string) { SCENARIO: scenario, CMS_DEPLOY_DIR: "/must-not-read-production", DATABASE_URL: "must-not-use-production", + // Standaard uit: de tests rimen geen cache-map aan op de host. + CMS_BUILD_CACHE_DIR: "", + ...extraEnv, }, }); if (result.error) throw result.error; @@ -103,6 +106,24 @@ describe("isolated branch preflight", () => { expect(result.remaining).toEqual([]); }); + it("hergebruikt een duurzame buildcache buiten de tijdelijke werkmap", () => { + const cacheDirectory = mkdtempSync(join(tmpdir(), "cms-preflight-cache-")); + try { + const cache = join(cacheDirectory, "cache"); + const result = simulate("success", { CMS_BUILD_CACHE_DIR: cache }); + expect(result.status, result.output).toBe(0); + expect(result.calls).toContain(`--cache-from type=local,src=${cache}`); + expect(result.calls).toContain( + `--cache-to type=local,dest=${cache},mode=max`, + ); + // De cache mag nooit in de tijdelijke werkmap van de preflight + // belanden: die wordt na de run geleegd. + expect(result.remaining).toEqual([]); + } finally { + rmSync(cacheDirectory, { recursive: true, force: true }); + } + }); + it("stops after a failed build and cleans only its own attempted image", () => { const result = simulate("build-failure"); expect(result.status).not.toBe(0); diff --git a/src/lib/deploy-workflow-contract.test.ts b/src/lib/deploy-workflow-contract.test.ts index 0d1cc2e0..e7c61881 100644 --- a/src/lib/deploy-workflow-contract.test.ts +++ b/src/lib/deploy-workflow-contract.test.ts @@ -80,3 +80,23 @@ it("no longer publishes container images in CI", () => { expect(workflow).not.toContain("publish-container"); expect(workflow).not.toContain("publish-container.sh"); }); + +it("keeps een duurzame buildcache buiten de CI-werkboom", () => { + const preflight = readFileSync("scripts/ci-preflight.sh", "utf8"); + for (const script of [deploy, preflight]) { + // `docker builder prune` gooit de buildcache weg en de CI-werkboom wordt + // na elke job gewist. Zonder duurzame export downloadt de volgende build + // alle 493 packages opnieuw; gemeten: reused 0 / downloaded 493. + expect(script).toContain('--cache-from "type=local,src=$build_cache_dir"'); + expect(script).toContain( + '--cache-to "type=local,dest=$build_cache_dir,mode=max"', + ); + expect(script).toContain("CMS_BUILD_CACHE_DIR"); + // Zonder schrijftoegang bouwen we gewoon zonder cache; dat mag de + // release niet blokkeren. + expect(script).toContain("build zonder hergebruikte cache"); + } + // Eigen map voor de preflight: een branch mag de release-cache nooit voeden. + expect(deploy).toContain("/var/cache/epicnext-cms-buildcache"); + expect(preflight).toContain("/var/cache/epicnext-cms-preflight-cache"); +}); diff --git a/src/lib/docker-build-contract.test.ts b/src/lib/docker-build-contract.test.ts index c5800bf5..214c8ca7 100644 --- a/src/lib/docker-build-contract.test.ts +++ b/src/lib/docker-build-contract.test.ts @@ -1,5 +1,6 @@ // @ts-nocheck -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; const dockerfile = readFileSync("Dockerfile", "utf8"); @@ -128,6 +129,27 @@ it("keeps volatile and regenerated files out of the build context", () => { expect(ignored).toMatch(/^public\/vendor\/?$/m); }); +it("keeps de testsuites buiten de buildcontext", () => { + const ignored = readFileSync(".dockerignore", "utf8"); + // `next build` typecheckt het hele project. Met e2e/ en integration/ in de + // context faalt de image-build op een typefout in testcode en sleurt hij + // @playwright/test, vitest, msw en testcontainers mee. De CI draait die + // suites op de host (pnpm test:ui / test:integration). + expect(ignored).toMatch(/^e2e\/?$/m); + expect(ignored).toMatch(/^integration\/?$/m); + // Geen applicatiecode mag naar die mappen importeren: dan zou de build in + // de image breken zodra ze uit de context verdwijnen. + const offenders: string[] = []; + for (const entry of readdirSync("src", { recursive: true })) { + if (!/\.tsx?$/.test(entry)) continue; + const source = readFileSync(join("src", entry), "utf8"); + if (/from\s+["'][^"']*\/(?:e2e|integration)\//.test(source)) { + offenders.push(entry); + } + } + expect(offenders).toEqual([]); +}); + it("keeps a local build cache that survives the daemon cache prune", () => { const compose = readFileSync("docker-compose.yml", "utf8"); expect(compose).toContain("cache_from:");