Harden dependency isolation check and run it in CI
npm pack runs the prepare script for directory specs even with --ignore-scripts and its output can pollute stdout, so locate the packed tarball on disk instead of parsing --json output. Run npm with a minimal environment so caller npm config cannot leak into the synthetic consumers, and require exact versions for the consumers' dependencies. Add a CI job that runs the check on Node 22.
This commit is contained in:
parent
90123733bb
commit
f28f5ddb3c
2 changed files with 57 additions and 18 deletions
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
|
|
@ -114,6 +114,21 @@ jobs:
|
||||||
grep -q "Ready" /tmp/mcpb-smoke.log
|
grep -q "Ready" /tmp/mcpb-smoke.log
|
||||||
working-directory: examples/pdf-server
|
working-directory: examples/pdf-server
|
||||||
|
|
||||||
|
dependency-isolation:
|
||||||
|
name: Dependency isolation
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- run: npm run test:dependency-isolation
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
|
|
@ -25,33 +26,56 @@ for (const role of [client, server]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact version for a synthetic consumer dependency. Read from
|
||||||
|
* devDependencies so the consumers exercise the same SDK version the
|
||||||
|
* repository tests against; the check would silently drift if a range were
|
||||||
|
* allowed here.
|
||||||
|
*/
|
||||||
|
function exactDevDependency(name) {
|
||||||
|
const version = packageJson.devDependencies?.[name];
|
||||||
|
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version ?? "")) {
|
||||||
|
throw new Error(
|
||||||
|
`devDependencies["${name}"] must be an exact version, got ${JSON.stringify(version)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
|
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
|
||||||
try {
|
try {
|
||||||
|
// Minimal environment: nothing inherited from the caller's npm config
|
||||||
|
// (registry overrides, auth tokens, npm_config_* set by an outer `npm run`)
|
||||||
|
// can leak into the synthetic consumers.
|
||||||
const npmEnvironment = {
|
const npmEnvironment = {
|
||||||
...process.env,
|
PATH: process.env.PATH ?? process.env.Path,
|
||||||
|
HOME: process.env.HOME ?? process.env.USERPROFILE,
|
||||||
npm_config_cache: join(temporaryRoot, "npm-cache"),
|
npm_config_cache: join(temporaryRoot, "npm-cache"),
|
||||||
};
|
};
|
||||||
const packOutput = JSON.parse(
|
const packDestination = join(temporaryRoot, "pack");
|
||||||
execFileSync(
|
mkdirSync(packDestination);
|
||||||
"npm",
|
// `npm pack` runs the package's `prepare` script even with --ignore-scripts
|
||||||
[
|
// (pacote's directory fetcher), and its output can pollute stdout, so do not
|
||||||
"pack",
|
// rely on `--json`: locate the tarball on disk instead.
|
||||||
"--ignore-scripts",
|
execFileSync(
|
||||||
"--json",
|
"npm",
|
||||||
"--pack-destination",
|
["pack", "--ignore-scripts", "--pack-destination", packDestination],
|
||||||
temporaryRoot,
|
{ cwd: root, stdio: "pipe", env: npmEnvironment },
|
||||||
],
|
|
||||||
{ cwd: root, encoding: "utf8", env: npmEnvironment },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const tarball = join(temporaryRoot, packOutput[0].filename);
|
const tarballs = readdirSync(packDestination).filter((name) =>
|
||||||
|
name.endsWith(".tgz"),
|
||||||
|
);
|
||||||
|
if (tarballs.length !== 1) {
|
||||||
|
throw new Error(`expected exactly one tarball, found ${tarballs}`);
|
||||||
|
}
|
||||||
|
const tarball = join(packDestination, tarballs[0]);
|
||||||
|
|
||||||
const consumers = [
|
const consumers = [
|
||||||
{
|
{
|
||||||
name: "app-only",
|
name: "app-only",
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@types/node": packageJson.devDependencies["@types/node"],
|
"@types/node": exactDevDependency("@types/node"),
|
||||||
[client]: packageJson.devDependencies[client],
|
[client]: exactDevDependency(client),
|
||||||
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
|
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
|
||||||
},
|
},
|
||||||
absent: server,
|
absent: server,
|
||||||
|
|
@ -61,9 +85,9 @@ try {
|
||||||
{
|
{
|
||||||
name: "server-only",
|
name: "server-only",
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@types/node": packageJson.devDependencies["@types/node"],
|
"@types/node": exactDevDependency("@types/node"),
|
||||||
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
|
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
|
||||||
[server]: packageJson.devDependencies[server],
|
[server]: exactDevDependency(server),
|
||||||
},
|
},
|
||||||
absent: client,
|
absent: client,
|
||||||
entry:
|
entry:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue