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
27 changes: 26 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,36 @@ jobs:

- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: npm

- run: npm ci

- run: npm run build

- run: npm test

bundle-compat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm

- run: npm ci

- run: npm run bundle

# Run the bundle on the exact minimum Node allowed by package.json
# "engines". Copy plugin/ out of the checkout first, as the plugin cache
# does, so neither node_modules nor the root package.json is visible.
- uses: actions/setup-node@v7
with:
node-version: 20.0.0

- run: |
cp -R plugin "$RUNNER_TEMP/plugin"
Comment thread
satsukies marked this conversation as resolved.
node scripts/smoke-bundle.mjs "$RUNNER_TEMP/plugin/scripts/bundle.js"
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- if: ${{ steps.release.outputs.prs_created == 'true' }}
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: npm

- if: ${{ steps.release.outputs.prs_created == 'true' }}
Expand Down Expand Up @@ -61,7 +61,7 @@ jobs:
- if: ${{ steps.release.outputs.release_created == 'true' }}
uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: npm
registry-url: 'https://registry.npmjs.org'

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ npm run dev # TypeScript watch mode
npm start # Run the MCP server directly
```

CI runs `npm run build && npm test` on every PR and push to main. `plugin/scripts/bundle.js` is regenerated and committed automatically by the release-please workflow when it opens/updates a release PR — do not run `npm run bundle` and commit the result manually. A pre-commit hook in `.githooks/` (installed by `npm install` via `prepare`) blocks accidental commits of the bundle.
CI runs `npm run build && npm test` on Node 24 on every PR and push to main. A separate `bundle-compat` job builds the bundle on Node 24 and runs `node scripts/smoke-bundle.mjs` against a copy of `plugin/` outside the checkout on Node 20.0.0, the exact minimum in `engines`, so the plugin as users install it keeps working there even though dev tooling (vitest 5) needs Node 22+. `plugin/scripts/bundle.js` is regenerated and committed automatically by the release-please workflow when it opens/updates a release PR — do not run `npm run bundle` and commit the result manually. A pre-commit hook in `.githooks/` (installed by `npm install` via `prepare`) blocks accidental commits of the bundle.

## Architecture

Expand Down
140 changes: 140 additions & 0 deletions scripts/smoke-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Smoke test for the bundled MCP server. Spawns the bundle with the current
// Node, performs the MCP handshake over stdio and checks that tools/list
// returns tools. Dependency-free so it can run on the minimum supported Node
// without installing dev dependencies.
//
// Usage: node scripts/smoke-bundle.mjs [path/to/bundle.js]

import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";

const TIMEOUT_MS = 15_000;

const bundle = resolve(process.argv[2] ?? "plugin/scripts/bundle.js");

// Point every config location at an empty directory so a token stored on the
// machine is never picked up.
const home = mkdtempSync(join(tmpdir(), "deploygate-smoke-"));

const child = spawn(process.execPath, [bundle], {
env: {
...process.env,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: home,
APPDATA: home,
},
stdio: ["pipe", "pipe", "pipe"],
});

let stderr = "";
child.stderr.on("data", (chunk) => (stderr += chunk));

const pending = new Map();
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk;
let newline;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
let message;
try {
message = JSON.parse(line);
} catch {
message = undefined;
}
if (
typeof message !== "object" ||
message === null ||
Array.isArray(message) ||
message.jsonrpc !== "2.0"
) {
fail(new Error(`bundle wrote a non JSON-RPC line to stdout: ${line}`));
return;
}
Comment thread
Copilot marked this conversation as resolved.
pending.get(message.id)?.(message);
pending.delete(message.id);
Comment thread
satsukies marked this conversation as resolved.
}
});

const exited = new Promise((_, reject) => {
child.on("exit", (code, signal) =>
reject(new Error(`bundle exited early (code=${code}, signal=${signal})`)),
);
});

function send(message) {
child.stdin.write(JSON.stringify(message) + "\n");
}

function request(id, method, params = {}) {
const response = new Promise((resolve) => pending.set(id, resolve));
send({ jsonrpc: "2.0", id, method, params });
return Promise.race([response, exited]).then((message) => {
if (message.error)
throw new Error(`${method} failed: ${JSON.stringify(message.error)}`);
return message.result;
});
}

function assert(condition, message) {
if (!condition) throw new Error(message);
}

const timer = setTimeout(
() => fail(new Error(`timed out after ${TIMEOUT_MS}ms`)),
TIMEOUT_MS,
);

function finish(code) {
clearTimeout(timer);
child.removeAllListeners("exit");
child.kill();
rmSync(home, { recursive: true, force: true });
process.exit(code);
}

function fail(error) {
console.error(
`smoke test failed on Node ${process.version}: ${error.message}`,
);
if (stderr) console.error(`bundle stderr:\n${stderr}`);
finish(1);
}

try {
const init = await request(1, "initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "smoke-bundle", version: "0.0.0" },
});
assert(
init.serverInfo?.name === "deploygate",
`unexpected serverInfo: ${JSON.stringify(init.serverInfo)}`,
);

send({ jsonrpc: "2.0", method: "notifications/initialized" });

const { tools } = await request(2, "tools/list");
assert(
Array.isArray(tools) && tools.length > 0,
"tools/list returned no tools",
);
for (const tool of tools) {
assert(
typeof tool.name === "string" && tool.inputSchema,
`malformed tool: ${JSON.stringify(tool)}`,
);
}

console.log(
`smoke test passed on Node ${process.version}: ${init.serverInfo.name} ${init.serverInfo.version}, ${tools.length} tools`,
);
finish(0);
} catch (error) {
fail(error);
}
Loading