diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3fc00bf..13513662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,13 @@ jobs: - name: Verify the quoted adapter count and license wording run: node scripts/adapter-count.mjs --check + # The Docker image ships only hoisted dependencies; a nested one passes + # every test here and then crashes the container (see the script). + - name: Verify no backend dependency is nested out of the image + run: | + node --test scripts/check-runtime-deps.test.mjs + node scripts/check-runtime-deps.mjs + - name: Verify catalog.ts is up-to-date with adapter JSONs run: | node scripts/regenerate-catalog.mjs @@ -150,12 +157,36 @@ jobs: name: Docker build runs-on: ubuntu-latest needs: [backend, frontend] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Every push to main, and the pull requests that can break the image + # without breaking a unit test (decided in the first step). js-yaml 5 + # (#809) passed every PR check and only failed here, after the merge. + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Decide whether this change can break the image + id: scope + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + run: | + if [ "$EVENT" != "pull_request" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + changed=$(git diff --name-only "origin/$BASE_REF...HEAD") + if echo "$changed" | grep -qE '(^|/)package(-lock)?\.json$|^Dockerfile$|^\.dockerignore$|^start\.sh$|^packages/backend/prisma/|^packages/backend/prisma\.config\.ts$'; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "No dependency, Dockerfile or startup change: skipping the image boot." + echo "run=false" >> "$GITHUB_OUTPUT" + fi - name: Build unified image + if: steps.scope.outputs.run == 'true' run: docker build -t anythingmcp:ci . # Building proves the image assembles, not that it runs. v0.8.0 shipped a @@ -163,6 +194,7 @@ jobs: # require resolved differently under dist/ than under src/: the image # built clean, every test passed, and the container crash-looped. Boot it. - name: Boot the image and wait for /health + if: steps.scope.outputs.run == 'true' run: | set -euo pipefail docker network create amcp-ci diff --git a/Dockerfile b/Dockerfile index 56779ee2..e9231607 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,19 @@ RUN mkdir -p packages/frontend && \ RUN npm install --omit=dev --network-timeout=600000 && \ rm -rf node_modules/typescript node_modules/react-dom node_modules/react +# The runner stage ships only this stage's hoisted node_modules. A package npm +# had to nest under packages/backend/node_modules (a second version of +# something the root also needs) would be missing there, and the backend would +# load the root's version at runtime: js-yaml 5 crash-looped the image that way +# (#809). Fail the build here instead. scripts/check-runtime-deps.mjs runs the +# same check on the lockfile in CI. +RUN nested=$(ls -A packages/backend/node_modules 2>/dev/null | grep -v '^\.bin$' || true); \ + if [ -n "$nested" ]; then \ + echo "Nested backend dependencies would be missing from the image:" >&2; \ + echo "$nested" >&2; \ + exit 1; \ + fi + # ── Stage 2: Build Backend ────────────────────────────────────────────────── FROM node:${NODE_VERSION} AS backend-builder WORKDIR /app diff --git a/packages/backend/src/auth/server-picker.spec.ts b/packages/backend/src/auth/server-picker.spec.ts index d2f02862..ec31d029 100644 --- a/packages/backend/src/auth/server-picker.spec.ts +++ b/packages/backend/src/auth/server-picker.spec.ts @@ -167,8 +167,11 @@ describe('choosing what a client may reach', () => { expect(pending).toBeDefined(); expect(pending[1]).toBe('u1'); expect(pending[2]).toMatchObject({ httpOnly: true, signed: true }); - // The rendered form must not carry the user id anywhere. - expect(res.send.mock.calls[0][0]).not.toContain('u1'); + // The rendered form must not carry the user id anywhere. The CSRF token + // is random base64url and contains "u1" in about 1 run out of 100, so + // leave it out of the search. + const html: string = res.send.mock.calls[0][0]; + expect(html.replace(/name="csrf" value="[^"]*"/g, '')).not.toContain('u1'); }); it('escapes a workspace name instead of interpolating it raw', async () => { diff --git a/scripts/check-runtime-deps.mjs b/scripts/check-runtime-deps.mjs new file mode 100644 index 00000000..682400f8 --- /dev/null +++ b/scripts/check-runtime-deps.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Fails when a production dependency of the backend is nested under + * packages/backend/node_modules in package-lock.json. + * + * The Docker runtime stage copies only the hoisted /app/node_modules of the + * prod-deps stage into backend/node_modules. npm nests a package under the + * workspace when the backend needs a different version of something the root + * also needs. That copy never reaches the image: the backend then loads the + * root's version and can crash at boot, while unit tests (which resolve the + * nested copy) stay green. js-yaml 5 did exactly that on 2026-10-02 (#809). + * + * Fix it by aligning versions so npm can hoist one copy, or change the + * Dockerfile to ship nested packages (and keep the root's copy for the + * packages that need it) before allowing an exception here. + * + * node scripts/check-runtime-deps.mjs # checks ./package-lock.json + * node scripts/check-runtime-deps.mjs + */ +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const NESTED = 'packages/backend/node_modules/'; + +/** Production packages the lockfile nests under the backend workspace. */ +export function nestedRuntimeDeps(lock) { + return Object.entries(lock.packages ?? {}) + .filter(([path, meta]) => path.startsWith(NESTED) && !meta.dev) + .map(([path, meta]) => ({ name: path.slice(NESTED.length), version: meta.version })); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + const file = process.argv[2] ?? join(root, 'package-lock.json'); + const nested = nestedRuntimeDeps(JSON.parse(readFileSync(file, 'utf8'))); + if (nested.length === 0) { + console.log('No backend production dependency is nested: the runtime image gets them all.'); + process.exit(0); + } + console.error('::error::These backend production dependencies are nested under packages/backend/node_modules'); + console.error('and would be missing from the Docker image (it ships only the hoisted node_modules):'); + for (const d of nested) console.error(` - ${d.name}@${d.version}`); + console.error('Align the versions so npm hoists a single copy. See scripts/check-runtime-deps.mjs.'); + process.exit(1); +} diff --git a/scripts/check-runtime-deps.test.mjs b/scripts/check-runtime-deps.test.mjs new file mode 100644 index 00000000..a2a1b7e6 --- /dev/null +++ b/scripts/check-runtime-deps.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { nestedRuntimeDeps } from './check-runtime-deps.mjs'; + +test('flags a production package nested under the backend workspace', () => { + const lock = { + packages: { + '': {}, + 'node_modules/js-yaml': { version: '4.3.2' }, + 'packages/backend/node_modules/js-yaml': { version: '5.4.1' }, + 'packages/backend/node_modules/js-yaml/node_modules/argparse': { version: '2.0.1' }, + }, + }; + assert.deepEqual(nestedRuntimeDeps(lock).map((d) => d.name), [ + 'js-yaml', + 'js-yaml/node_modules/argparse', + ]); +}); + +test('ignores dev-only nested packages and hoisted ones', () => { + const lock = { + packages: { + 'node_modules/js-yaml': { version: '4.3.2' }, + 'packages/backend/node_modules/typescript': { version: '6.0.0', dev: true }, + 'packages/frontend/node_modules/react': { version: '19.0.0' }, + }, + }; + assert.deepEqual(nestedRuntimeDeps(lock), []); +});