From 57820ab48d0134b83f019599ae01e5f9a925671f Mon Sep 17 00:00:00 2001 From: Ogden-2688 Date: Wed, 28 Oct 2026 16:38:35 -0400 Subject: [PATCH 1/2] CI: the whole suite once at Node 22.8.0, and install and start at 22.0.0 and 24 The workflow ran npm test on Linux at whatever Node 22 release setup-node picked. It now runs: - the typecheck and the whole suite once, on Linux at Node 22.8.0 exactly: npm test's --experimental-test-isolation flag needs 22.8, and 22.0.0 refuses it ("bad option"). - an install and start check at Node 22.0.0, engines' floor, and at the newest Node 24: npm ci, the build, and scripts/ci-start-check.mjs. The matrix doesn't fail fast, so each Node gets its own verdict. The start check runs the built server with --stdio under a throwaway configuration (a generated App key, one example repository, polling off, no webhook), so it needs no credentials and reaches nothing. It sends one MCPL initialize and requires a JSON-RPC 2.0 result carrying the mcpl capability, then closes stdin and requires exit 0. Anything else fails it by name within 30 s and 10 s, with the server's stderr: no answer, a line that isn't a JSON object, an answer without that result, an exit before answering, an exit other than 0 after it, or no exit. Every ending kills the server if it still runs and removes the key's temporary directory. A write to a server that has already exited fails with EPIPE; the check ignores that error, since the exit itself is what it reports. README's development section said Node 22; npm test needs 22.8 or newer since the test files began to share one process. --- .github/workflows/ci.yml | 35 ++++++++++- README.md | 6 +- scripts/ci-start-check.mjs | 116 +++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 scripts/ci-start-check.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 649ff0f..7fbbb5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,11 @@ permissions: contents: read jobs: + # The whole suite runs once, at the oldest Node npm test runs on: its + # --experimental-test-isolation flag needs Node 22.8. engines' floor, + # 22.0.0, and the newest Node line get the install and start check below. typecheck-and-test: - name: Typecheck & Test (node 22) + name: Typecheck & Test (node 22.8.0) runs-on: ubuntu-latest timeout-minutes: 10 @@ -23,7 +26,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - node-version: 22 + node-version: 22.8.0 # npm ci installs the lock verbatim: npm >= 11 'npm install' silently # re-resolves platform packages missing from the lock, so only npm ci @@ -36,3 +39,31 @@ jobs: - name: Test run: npm test + + install-and-start: + name: Install & Start (node ${{ matrix.node }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node: ['22.0.0', '24'] + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies (strict lockfile) + run: npm ci + + - name: Build + run: npm run build + + - name: Start + run: node scripts/ci-start-check.mjs diff --git a/README.md b/README.md index dfa2ec7..7fc8050 100644 --- a/README.md +++ b/README.md @@ -461,9 +461,9 @@ npm test npm run build # tsc into dist/ ``` -Node 22, ESM, TypeScript. Stdout carries only JSON-RPC: every diagnostic goes -through the `Logger`, which writes to stderr. No test in this package reaches -the network. +Node 22 (`npm test` needs 22.8 or newer), ESM, TypeScript. Stdout carries +only JSON-RPC: every diagnostic goes through the `Logger`, which writes to +stderr. No test in this package reaches the network. | File | Work package | Role | | --- | --- | --- | diff --git a/scripts/ci-start-check.mjs b/scripts/ci-start-check.mjs new file mode 100644 index 0000000..55ec7c7 --- /dev/null +++ b/scripts/ci-start-check.mjs @@ -0,0 +1,116 @@ +import { spawn } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SERVER = fileURLToPath(new URL("../dist/src/index.js", import.meta.url)); +const ANSWER_MS = 30_000; +const EXIT_MS = 10_000; + +const dir = mkdtempSync(join(tmpdir(), "github-mcpl-start-")); +const keyFile = join(dir, "app.pem"); +writeFileSync( + keyFile, + generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }).privateKey, + { mode: 0o600 }, +); + +const child = spawn(process.execPath, [SERVER, "--stdio"], { + env: { + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_FILE: keyFile, + GITHUB_REPOS: "example/repo", + GITHUB_BOT_LOGIN: "example[bot]", + GITHUB_POLL_INTERVAL_MS: "0", + }, + stdio: ["pipe", "pipe", "pipe"], +}); + +let stdout = ""; +let stderr = ""; +let answered = false; +let finished = false; +let timer = setTimeout(() => finish(1, `no answer to initialize within ${ANSWER_MS / 1000} s`), ANSWER_MS); + +function finish(code, message) { + if (finished) return; + finished = true; + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + rmSync(dir, { recursive: true, force: true }); + const line = `ci-start-check (Node ${process.version}): ${message}`; + if (code === 0) console.log(line); + else { + console.error(line); + if (stderr.trim()) console.error(`server stderr:\n${stderr.trimEnd()}`); + } + process.exitCode = code; +} + +function clip(text, max) { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function onAnswer(line) { + let msg; + try { + msg = JSON.parse(line); + } catch { + finish(1, `the server wrote a line that isn't JSON: ${clip(line, 200)}`); + return; + } + if (msg === null || typeof msg !== "object" || Array.isArray(msg)) { + finish(1, `the server wrote a line that isn't a JSON-RPC message: ${clip(line, 200)}`); + return; + } + if (msg.id !== 1) return; + const mcpl = msg.result?.capabilities?.experimental?.mcpl; + if (msg.jsonrpc !== "2.0" || "error" in msg || mcpl === null || typeof mcpl !== "object") { + finish(1, `initialize was answered without an MCPL result: ${clip(line, 300)}`); + return; + } + answered = true; + clearTimeout(timer); + timer = setTimeout(() => finish(1, `the server didn't exit within ${EXIT_MS / 1000} s of its stdin closing`), EXIT_MS); + child.stdin.end(); +} + +child.stdout.setEncoding("utf8"); +child.stdout.on("data", (chunk) => { + stdout += chunk; + let nl; + while (!answered && !finished && (nl = stdout.indexOf("\n")) >= 0) { + const line = stdout.slice(0, nl).trim(); + stdout = stdout.slice(nl + 1); + if (line) onAnswer(line); + } +}); +child.stderr.setEncoding("utf8"); +child.stderr.on("data", (chunk) => (stderr += chunk)); +child.on("error", (err) => finish(1, `couldn't start the server: ${err.message}`)); +child.on("close", (code, signal) => { + if (finished) return; + if (!answered) finish(1, `the server exited (${signal ?? `code ${code}`}) before answering initialize`); + else if (code !== 0) finish(1, `the server exited (${signal ?? `code ${code}`}) after answering initialize, not with 0`); + else finish(0, "the built server answered initialize over --stdio and exited 0"); +}); + +child.stdin.on("error", () => {}); +child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: { experimental: { mcpl: { version: "0.5" } } }, + clientInfo: { name: "ci-start-check", version: "0" }, + }, + }) + "\n", +); From b899b4b090cdf487a523bbfb4df26d26dd6ed905 Mon Sep 17 00:00:00 2001 From: Ogden-2688 Date: Wed, 28 Oct 2026 17:13:48 -0400 Subject: [PATCH 2/2] CI: run the whole suite at Node 22.12.0, where it loads The workflow's first run had the whole-suite job at Node 22.8.0, the first release that takes npm test's --experimental-test-isolation=none. Node took the flag, but all 18 test files failed to load with 'TypeError: Unknown file extension ".ts"', so no test ran. The job moves to Node 22.12.0, where the whole suite has run in one process with the same verdicts as on Node 24. The install and start legs stay at 22.0.0, engines' floor, and 24. README's development line said npm test needs 22.8 or newer; it now says it runs on 22.12 or newer. --- .github/workflows/ci.yml | 11 ++++++----- README.md | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fbbb5d..5c0bd1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,11 +10,12 @@ permissions: contents: read jobs: - # The whole suite runs once, at the oldest Node npm test runs on: its - # --experimental-test-isolation flag needs Node 22.8. engines' floor, - # 22.0.0, and the newest Node line get the install and start check below. + # The whole suite runs once, at Node 22.12.0. Node 22.8.0 takes npm test's + # --experimental-test-isolation=none, but there every test file fails to + # load with 'Unknown file extension ".ts"'. engines' floor, 22.0.0, and the + # newest Node line get the install and start check below. typecheck-and-test: - name: Typecheck & Test (node 22.8.0) + name: Typecheck & Test (node 22.12.0) runs-on: ubuntu-latest timeout-minutes: 10 @@ -26,7 +27,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - node-version: 22.8.0 + node-version: 22.12.0 # npm ci installs the lock verbatim: npm >= 11 'npm install' silently # re-resolves platform packages missing from the lock, so only npm ci diff --git a/README.md b/README.md index 7fc8050..aa9670d 100644 --- a/README.md +++ b/README.md @@ -461,9 +461,9 @@ npm test npm run build # tsc into dist/ ``` -Node 22 (`npm test` needs 22.8 or newer), ESM, TypeScript. Stdout carries -only JSON-RPC: every diagnostic goes through the `Logger`, which writes to -stderr. No test in this package reaches the network. +Node 22 (`npm test` runs on 22.12 or newer), ESM, TypeScript. Stdout +carries only JSON-RPC: every diagnostic goes through the `Logger`, which +writes to stderr. No test in this package reaches the network. | File | Work package | Role | | --- | --- | --- |