perf(build): keep test suites out of the image and cache CI builds durably
CI / check (push) Successful in 30s
CI / tests-unit (push) Successful in 1m53s
CI / tests-integration (push) Successful in 1m54s
CI / tests-imaging-browsers (push) Successful in 2m26s
CI / tests-quality (push) Successful in 2m19s
CI / tests-ui (push) Successful in 4m31s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 3m9s

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.
This commit is contained in:
openhands committed 2026-10-21 19:29:50 +02:00
1 parent 8ca538f99f
commit ecd8e5852c
8 files changed
+148 -2

No files matched your search

+24
View File
@@ -29,6 +29,7 @@ function simulate(
scenario: string,
previous = "epicnext-cms-app",
both = false,
extraEnv: Record<string, string> = {},
) {
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;
+22 -1
View File
@@ -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<string, string> = {}) {
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);
+20
View File
@@ -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");
});
+23 -1
View File
@@ -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:");