diff --git a/.changeset/dockerfile-asset-hygiene.md b/.changeset/dockerfile-asset-hygiene.md new file mode 100644 index 0000000000..2dd322a984 --- /dev/null +++ b/.changeset/dockerfile-asset-hygiene.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Build pipeline: prevent PR #4769-class outages where a runtime asset added to `server/src/**` is missing from the shipping image because the Dockerfile copied it by exact filename. `npm run build` now mirrors every allowlisted non-TypeScript file (`.json`, `.md`, `.sql`, `.txt`, `.csv`, `.yaml`/`.yml`, `.html`, `.xml`) from `server/src/**` into `dist/**` automatically (`scripts/copy-server-assets.cjs`); the Dockerfile drops three redundant per-directory `COPY` lines; and a new CI check (`scripts/copy-server-assets.cjs --check`) fails the build if `dist/` ever diverges from source. No protocol surface change. diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index e0b22466d8..40620eefd0 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -35,6 +35,15 @@ jobs: - name: Build run: npm run build + - name: Assert dist/ contains every server/src runtime asset + # PR #4769 root cause: ui-element-formats.json was added to source + # and `require()`d by task-handlers.ts, but the build script didn't + # copy it into dist/, so the runtime image crashlooped with + # `Cannot find module './ui-element-formats.json'`. Prod was down + # ~75 min. This assertion fails CI the moment a referenced asset + # would be missing in the shipping artifact. + run: node scripts/copy-server-assets.cjs --check + - name: Server unit tests run: npm run test:server-unit diff --git a/Dockerfile b/Dockerfile index 3fc8418183..979adb3452 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,12 +99,13 @@ COPY package*.json ./ # dep tree; native deps are rebuilt explicitly per-package. RUN npm ci --omit=dev --ignore-scripts && npm rebuild sharp -# Copy built files from builder +# Copy built files from builder. Runtime assets under server/src/** (JSON +# format catalogs, SQL migrations, Addie rule markdown, etc.) are mirrored +# into dist/ by scripts/copy-server-assets.cjs during `npm run build`, so +# `COPY ... /dist` is sufficient — no per-directory asset lines needed here. +# Adding an asset to server/src/** is a one-place change. COPY --from=builder /app/dist ./dist COPY --from=builder /app/server/public ./server/public -COPY --from=builder /app/server/src/db/migrations ./dist/db/migrations -COPY --from=builder /app/server/src/creative-agent/*.json ./dist/creative-agent/ -COPY --from=builder /app/server/src/addie/rules/*.md ./dist/addie/rules/ COPY --from=builder /app/static ./static COPY --from=builder /app/docs ./docs diff --git a/package.json b/package.json index fdc4374b0a..16e5c82d98 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start:stripe": "bash -c 'source <(grep CONDUCTOR_PORT .env.local | sed \"s/^/export /\") && PORT=${CONDUCTOR_PORT:-3000} && stripe listen --forward-to localhost:$PORT/api/webhooks/stripe'", "dev": "echo 'Use docker compose up --build for local development' && exit 1", "db:migrate": "DOTENV_CONFIG_PATH=.env.local tsx --import dotenv/config server/src/db/migrate.ts", - "build": "tsc --project server/tsconfig.json && mkdir -p dist/addie/rules && cp server/src/addie/rules/*.md dist/addie/rules/ && npm run build:schemas && npm run build:compliance && npm run build:protocol-tarball", + "build": "tsc --project server/tsconfig.json && node scripts/copy-server-assets.cjs && npm run build:schemas && npm run build:compliance && npm run build:protocol-tarball", "build:openapi": "tsx scripts/generate-openapi.ts", "build:addie-tools": "tsx scripts/build-addie-tool-reference.ts", "test:addie-tools": "tsx scripts/build-addie-tool-reference.ts --check", diff --git a/scripts/copy-server-assets.cjs b/scripts/copy-server-assets.cjs new file mode 100644 index 0000000000..9ac3300646 --- /dev/null +++ b/scripts/copy-server-assets.cjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +// Mirror non-TypeScript runtime assets from server/src/** into dist/**. +// `tsc` only emits .js from .ts, so any module that does +// require('./foo.json') / readFile(__dirname + '/foo.sql') +// will ENOENT at runtime in the built image unless the asset is copied here. +// Establishing this in the npm build script (rather than in the Dockerfile) +// means `node dist/index.js` works locally too, and CI exercises the same +// asset graph the runtime image ships. +// +// Extensions are an explicit allowlist: any non-TS file type that needs to +// reach dist should be added here. The allowlist is intentionally permissive +// so contributors don't have to remember to opt-in when adding a new asset. +// +// Modes: +// (default) Copy every allowlisted src asset into the matching dist path. +// --check Don't copy. Exit non-zero if any src asset is missing in dist +// (verifies the build script ran and the contract holds — the +// CI assertion that catches PR #4769-class regressions). + +const fs = require("node:fs"); +const path = require("node:path"); + +const SRC = path.join(__dirname, "..", "server", "src"); +const DST = path.join(__dirname, "..", "dist"); +const EXTENSIONS = new Set([".json", ".md", ".sql", ".txt", ".csv", ".yaml", ".yml", ".html", ".xml"]); +const checkMode = process.argv.includes("--check"); + +const assets = []; + +function walk(dir, relBase = "") { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = path.join(relBase, entry.name); + const src = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(src, rel); + } else if (entry.isFile() && EXTENSIONS.has(path.extname(entry.name))) { + assets.push({ rel, src }); + } + } +} + +walk(SRC); + +if (checkMode) { + const missing = assets.filter(({ rel }) => !fs.existsSync(path.join(DST, rel))); + if (missing.length > 0) { + console.error(`copy-server-assets --check: ${missing.length} asset(s) missing in dist/:`); + for (const { rel } of missing) console.error(` server/src/${rel} -> dist/${rel}`); + console.error("\nRun `npm run build` to refresh dist/, or update the EXTENSIONS allowlist if a new file type was introduced."); + process.exit(1); + } + console.log(`copy-server-assets --check: ${assets.length} asset(s) present in dist`); +} else { + for (const { rel, src } of assets) { + const dst = path.join(DST, rel); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + } + console.log(`copy-server-assets: ${assets.length} file(s) copied from server/src to dist`); +}