From 21b60647c882dba09e9fb922da8d5319340e2af2 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Mon, 3 Aug 2026 23:30:41 -0500 Subject: [PATCH] fix(docker): the images could build green and ship without an entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by deploying the Core to kind: the container met CrashLoopBackOff with `Cannot find module '/repo/src/apps/core-api/dist/main'` from an image whose build had exited 0. The repository had NO `.dockerignore`. All three service Dockerfiles do `COPY src ./src` and then build inside the image with `npx tsc -b`, which is a COMPOSITE build: it reads `.tsbuildinfo` to decide what still needs emitting. The working tree's `.tsbuildinfo` files rode into the context, `tsc -b` concluded the output was already current, and emitted nothing. The image carried a `dist/` with most of its files and no `main.js`. CI never saw it, and could not: CI builds from a fresh checkout, which has no `.tsbuildinfo`. The inverse of the usual failure — green in CI, broken on every developer machine that had built before. Two changes, because the first alone would only close the trigger anyone happens to know about: - `.dockerignore` excludes `**/*.tsbuildinfo` (the cause) and `**/dist`, `**/node_modules` and the usual noise. Every runner stage copies `--from=builder`, so a host `dist` was never the shipped artifact — letting it into the context only risked a stale build passing as fresh. - each Dockerfile asserts its own entrypoint after building, so an image without one fails the BUILD rather than a cluster. Verified by measurement, and the assertion was WATCHED failing rather than assumed able to: - with 11 `.tsbuildinfo` present in the tree, all three images now build and each carries its `dist/main.js`; - pointed at a path that does not exist, the assertion turns the build red and prints its diagnosis; - with the `**/*.tsbuildinfo` line commented out, the build fails too — at `tsc -b` (exit 2) rather than at the assertion, because `**/dist` is excluded as well, so the silent no-op becomes a loud error. Both doors are shut; only one of them was the original. Guards: governance suite 17/17, 56-validate-docker-workspace-closure OK (4 images, 663 sources), gitleaks clean. Co-Authored-By: Claude Opus 5 --- .dockerignore | 70 +++++++++++++++++++++++++++ src/apps/agent-runtime-api/Dockerfile | 10 ++++ src/apps/core-api/Dockerfile | 15 ++++++ src/packages/mcp-server/Dockerfile | 10 ++++ 4 files changed, 105 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..9a1a0c568 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Build context exclusions. Until 2026-08-03 this file did not exist, and its +# absence produced a silently broken image. +# +# ## The defect it closes +# +# All three service Dockerfiles do `COPY src ./src` and then build INSIDE the +# image with `npx tsc -b tsconfig.json`. `tsc -b` is a COMPOSITE build: it reads +# `.tsbuildinfo` to decide what still needs emitting. With no `.dockerignore`, +# the working tree's `.tsbuildinfo` files were copied into the build context, so +# `tsc -b` concluded everything was already built — and emitted nothing. +# +# The build exited 0. The image had a `dist/` directory with most files in it and +# no `dist/main.js`. The failure surfaced far downstream, at runtime, as +# +# Error: Cannot find module '/repo/src/apps/core-api/dist/main' +# +# in CrashLoopBackOff. Reproduced and then fixed by measurement on 2026-08-03: +# delete the `.tsbuildinfo` files, rebuild, `main.js` appears. +# +# CI never saw it because CI builds from a fresh checkout, which has no +# `.tsbuildinfo` — the inverse of the usual failure, green in CI and broken on +# every developer machine that had run a build before. +# +# The Dockerfiles also assert their entrypoint exists after building, so a +# regression here fails the BUILD instead of reaching a cluster. +# +# ## Note on `dist` +# +# Excluded for the same reason and one more: every runner stage copies +# `--from=builder`, so a host `dist` is never the artifact that ships. Letting it +# into the context only risks a stale local build masquerading as a fresh one. + +# ── The incremental-build state that caused the defect ────────────────────── +**/*.tsbuildinfo + +# ── Build output: always produced inside the image, never taken from the host ── +**/dist +**/build +**/out + +# ── Reinstalled in the image by `npm ci`; copying the host tree is both slow +# and wrong (native modules are built for the host platform) ────────────── +**/node_modules + +# ── Test, coverage and tooling noise: not needed to build, and every megabyte +# is context upload time ────────────────────────────────────────────────── +**/coverage +**/.nyc_output +**/*.log +**/.turbo +**/.cache + +# ── VCS and editor ────────────────────────────────────────────────────────── +.git +.github +.gitignore +**/.DS_Store +.vscode +.idea +.obsidian + +# ── Local infrastructure and evidence: not inputs to a service image ──────── +**/.evidence +product/infra/kind +docker-compose*.yml + +# ── Secrets must never enter a build context, even though none are tracked ── +**/.env +**/.env.* +!**/.env.example diff --git a/src/apps/agent-runtime-api/Dockerfile b/src/apps/agent-runtime-api/Dockerfile index 47819dca2..3f4a508ef 100644 --- a/src/apps/agent-runtime-api/Dockerfile +++ b/src/apps/agent-runtime-api/Dockerfile @@ -25,6 +25,16 @@ RUN npm ci --legacy-peer-deps RUN npm run build:policy && \ npx tsc -b tsconfig.json +# `tsc -b` is incremental and exits 0 when a `.tsbuildinfo` claims the output is +# current — emitting nothing. That shipped a core-api image with no +# `dist/main.js`, found only at runtime as CrashLoopBackOff. `.dockerignore` now +# keeps that state out of the context; this asserts the outcome regardless of +# cause, so a missing entrypoint fails the BUILD instead of a cluster. +RUN test -f src/apps/agent-runtime-api/dist/main.js || { \ + echo "FATAL: tsc -b exited 0 but src/apps/agent-runtime-api/dist/main.js was not emitted."; \ + echo " Check that .dockerignore still excludes **/*.tsbuildinfo."; \ + exit 1; } + # ── Runner ────────────────────────────────────────────────────────────────── FROM node:20-alpine AS runner diff --git a/src/apps/core-api/Dockerfile b/src/apps/core-api/Dockerfile index 6a8fbe2f9..d1828ffc1 100644 --- a/src/apps/core-api/Dockerfile +++ b/src/apps/core-api/Dockerfile @@ -25,6 +25,21 @@ RUN npm ci --legacy-peer-deps RUN npm run build:policy && \ npx tsc -b tsconfig.json +# The build above can succeed and emit NOTHING. `tsc -b` is incremental: given a +# `.tsbuildinfo` that claims the output is current, it exits 0 without writing a +# single file. That is how this image shipped without `dist/main.js` and met +# CrashLoopBackOff with `Cannot find module '/repo/src/apps/core-api/dist/main'` +# — a runtime failure whose cause was three layers upstream. +# +# `.dockerignore` now keeps `.tsbuildinfo` out of the context, which removes the +# known trigger. This line is the assertion that does not depend on knowing the +# trigger: whatever the reason, an image without its entrypoint fails HERE. +RUN test -f src/apps/core-api/dist/main.js || { \ + echo "FATAL: tsc -b exited 0 but src/apps/core-api/dist/main.js was not emitted."; \ + echo " An incremental build believed the output was current. Check that"; \ + echo " .dockerignore still excludes **/*.tsbuildinfo."; \ + exit 1; } + # ── Runner ────────────────────────────────────────────────────────────────── FROM node:20-alpine AS runner diff --git a/src/packages/mcp-server/Dockerfile b/src/packages/mcp-server/Dockerfile index 1a7d56830..109684b25 100644 --- a/src/packages/mcp-server/Dockerfile +++ b/src/packages/mcp-server/Dockerfile @@ -30,6 +30,16 @@ RUN npm run build:policy # same as `npm run build`). Building the whole graph avoids per-image ordering bugs. RUN npx tsc -b tsconfig.json +# `tsc -b` is incremental and exits 0 when a `.tsbuildinfo` claims the output is +# current — emitting nothing. That shipped a core-api image with no +# `dist/main.js`, found only at runtime as CrashLoopBackOff. `.dockerignore` now +# keeps that state out of the context; this asserts the outcome regardless of +# cause, so a missing entrypoint fails the BUILD instead of a cluster. +RUN test -f src/packages/mcp-server/dist/main.js || { \ + echo "FATAL: tsc -b exited 0 but src/packages/mcp-server/dist/main.js was not emitted."; \ + echo " Check that .dockerignore still excludes **/*.tsbuildinfo."; \ + exit 1; } + # ── Runner ────────────────────────────────────────────────────────────────── FROM node:20-alpine AS runner