Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -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/
Expand All @@ -34,3 +33,4 @@ prisma/*.pem
.claude/
.plan/
.venv/
*.tsbuildinfo
11 changes: 10 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/discussions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/challenge-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/docker-context-parity.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading