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
36 changes: 34 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ permissions:
contents: read

jobs:
# 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)
name: Typecheck & Test (node 22.12.0)
runs-on: ubuntu-latest
timeout-minutes: 10

Expand All @@ -23,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
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
Expand All @@ -36,3 +40,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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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 |
| --- | --- | --- |
Expand Down
116 changes: 116 additions & 0 deletions scripts/ci-start-check.mjs
Original file line number Diff line number Diff line change
@@ -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",
);
Loading