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
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,19 +157,44 @@ 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
# backend that threw MODULE_NOT_FOUND at import time because a relative
# 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
Expand Down
13 changes: 13 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/backend/src/auth/server-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
46 changes: 46 additions & 0 deletions scripts/check-runtime-deps.mjs
Original file line number Diff line number Diff line change
@@ -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 <lockfile>
*/
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);
}
29 changes: 29 additions & 0 deletions scripts/check-runtime-deps.test.mjs
Original file line number Diff line number Diff line change
@@ -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), []);
});
Loading