From d20da0375e52580856d8a9f19a416e712bea05b1 Mon Sep 17 00:00:00 2001 From: kOaDT Date: Tue, 27 Oct 2026 19:48:49 +0100 Subject: [PATCH 1/2] build: decide in the Dockerfile what ships in the image --- .dockerignore | 10 +++++----- Dockerfile | 11 ++++++++++- lib/discussions.ts | 3 ++- tests/unit/challenge-parity.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0bbe8c9..e352374 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,6 @@ +# Filters a local build context only: BuildKit skips this file for a Git +# context, which the published image builds from. A tracked path excluded here +# must be excluded by the Dockerfile too, or the two images drift. node_modules/ .next/ .git/ @@ -17,11 +20,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* docs/ -# ...except the curriculum, which the app itself imports: `lib/discussions.ts` -# reads challenge titles from it so a discussion thread is named the same -# whether it was opened from the app or from a walkthrough. Keep this file -# free of imports — nothing else under docs/ ships in the image, so any -# dependency it grows would break the Docker build and only the Docker build. +# ...except the curriculum, which the image ships on its own. !docs/src/data/roadmap.ts packages/create-oss-store/ tests/ @@ -34,3 +33,4 @@ prisma/*.pem .claude/ .plan/ .venv/ +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile index bfb315b..9a92c29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1 + # Keep this default in sync with .nvmrc; tests/unit/node-version-parity.test.ts # fails the build if they drift. ARG NODE_VERSION=22 @@ -13,7 +15,14 @@ COPY package*.json ./ # binary its postinstall would otherwise download. RUN CYPRESS_INSTALL_BINARY=0 npm ci && npm cache clean --force -COPY . . +# The published image builds from a Git context, which BuildKit never filters +# through .dockerignore: these exclusions decide what ships. +COPY --exclude=docs --exclude=tests --exclude=cypress --exclude=.cursor \ + --exclude=.husky --exclude=packages/create-oss-store . . +# The app imports the curriculum to title discussion threads the way the +# walkthroughs do. Keep it free of imports: nothing else under docs/ ships, so +# a dependency it grows would break the Docker build and only the Docker build. +COPY docs/src/data/roadmap.ts docs/src/data/roadmap.ts # Prisma v6 requires DATABASE_URL even during generate; use a throwaway value RUN DATABASE_URL=file:/tmp/build.db npx prisma generate diff --git a/lib/discussions.ts b/lib/discussions.ts index a32f343..1da6d1a 100644 --- a/lib/discussions.ts +++ b/lib/discussions.ts @@ -18,7 +18,8 @@ import { formatSlug } from "./format"; * cannot import from here. * * This import is the one thing the app pulls out of `docs/`, which the Docker - * image otherwise excludes wholesale — see the exception in `.dockerignore`. + * image otherwise excludes wholesale: the Dockerfile copies the curriculum on + * its own. */ const TITLES = new Map( CURRICULUM.flatMap((chapter) => chapter.challenges).map( diff --git a/tests/unit/challenge-parity.test.ts b/tests/unit/challenge-parity.test.ts index c81bdf7..bbc156f 100644 --- a/tests/unit/challenge-parity.test.ts +++ b/tests/unit/challenge-parity.test.ts @@ -244,8 +244,8 @@ describe("docs/src/data/roadmap.ts", () => { }); it("stays free of imports so the Docker image can ship it alone", () => { - /* `lib/discussions.ts` imports this file, and `.dockerignore` re-includes - * it as the single exception to excluding `docs/`. An import added here + /* `lib/discussions.ts` imports this file, and the Dockerfile copies it as + * the single exception to excluding `docs/`. An import added here * would resolve everywhere except inside the image, where it fails at * `next build` — a break invisible to every check that runs before it. */ const source = read("docs", "src", "data", "roadmap.ts"); From f0402a7d58d42e994cfbdeeb21e27f5ccd79080e Mon Sep 17 00:00:00 2001 From: kOaDT Date: Tue, 27 Oct 2026 19:49:07 +0100 Subject: [PATCH 2/2] test: keep the Dockerfile exclusions in step with .dockerignore --- tests/unit/docker-context-parity.test.ts | 51 ++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/unit/docker-context-parity.test.ts diff --git a/tests/unit/docker-context-parity.test.ts b/tests/unit/docker-context-parity.test.ts new file mode 100644 index 0000000..adf6c0d --- /dev/null +++ b/tests/unit/docker-context-parity.test.ts @@ -0,0 +1,51 @@ +import { execFileSync } from "child_process"; +import { readFileSync } from "fs"; +import { join } from "path"; + +/** + * The published image builds from a Git context, which BuildKit never filters + * through `.dockerignore`, while a local build does. A tracked path excluded in + * one place only ships in one image and not the other, and nothing else + * notices: both images build and start. This suite keeps the two lists in step. + */ + +const ROOT = join(__dirname, "..", ".."); +const read = (...segments: string[]) => + readFileSync(join(ROOT, ...segments), "utf-8"); + +const DOCKERFILE = read("Dockerfile"); +const IGNORE_ENTRIES = read(".dockerignore") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("#")); + +const isTracked = (path: string) => + execFileSync("git", ["ls-files", "--", path], { + cwd: ROOT, + encoding: "utf-8", + }).trim() !== ""; + +describe("Docker context parity", () => { + it("excludes from the image every tracked path the local context leaves out", () => { + const excluded = [...DOCKERFILE.matchAll(/--exclude=(\S+)/g)].map( + (match) => match[1] + ); + const missing = IGNORE_ENTRIES.filter((entry) => !entry.startsWith("!")) + .map((entry) => entry.replace(/\/$/, "")) + .filter((entry) => isTracked(entry) && !excluded.includes(entry)); + expect(missing).toEqual([]); + }); + + it("copies into the image every tracked path the local context re-includes", () => { + const missing = IGNORE_ENTRIES.filter((entry) => entry.startsWith("!")) + .map((entry) => entry.slice(1)) + .filter( + (entry) => + isTracked(entry) && + !DOCKERFILE.split("\n").some((line) => + line.startsWith(`COPY ${entry} `) + ) + ); + expect(missing).toEqual([]); + }); +});