Skip to content
Open
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
224 changes: 24 additions & 200 deletions scripts/e2e/fake-host.mjs

Large diffs are not rendered by default.

41 changes: 2 additions & 39 deletions scripts/e2e/host-lifecycle-check.mjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,4 @@
#!/usr/bin/env node
/**
* The one automated check on {@link ../e2e/fake-host.mjs}'s own behaviour.
*
* `npm test` covers `src/`; the end-to-end host is a script and nothing in the
* suite would catch a regression in it. Three review rounds found three
* separate ways it reported a green run over a server that had failed — a
* refusal taken for a success (round 2), a crash between commands (round 3
* finding 3), and a server that crashed *during* its own shutdown (round 3
* finding 2) — so the lifecycle it has to get right is checked here instead.
*
* It needs no credentials and no network: each case spawns the host against a
* tiny stub server written to a temp directory, which answers the MCPL
* handshake and then behaves badly in one specific way. What is asserted is
* the host's **exit status** and the verdict on its `child-exit` line, because
* those are what a scenario run is read by.
*
* node scripts/e2e/host-lifecycle-check.mjs
*
* Exits 0 when every case holds, 1 on the first that does not, naming it.
*/

import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
Expand All @@ -27,15 +7,8 @@ import * as path from 'node:path';

const HOST = path.join(import.meta.dirname, 'fake-host.mjs');
const workspace = mkdtempSync(path.join(tmpdir(), 'github-mcpl-host-check-'));
const IDLE_BEFORE_INPUT_MS = 400;

/**
* A stub server, parameterised by how it misbehaves.
*
* `onSigterm`: `'clean'` exits 0, `'fail'` exits 3, `'ignore'` does nothing at
* all so the host has to escalate, and `'default'` installs no handler so the
* signal itself ends the process. `crashWhenIdle` exits 3 shortly after the
* grant, with no command in flight.
*/
function stub(name, { onSigterm = 'clean', crashWhenIdle = false } = {}) {
const file = path.join(workspace, `${name}.mjs`);
writeFileSync(
Expand Down Expand Up @@ -63,19 +36,10 @@ setInterval(() => {}, 1000);
return file;
}

/**
* Run the host against `server`, closing its stdin the way `closing` says.
*
* `quit` sends the command; `eof` just closes the pipe. Either way the input
* is delayed, so the stub has a window to misbehave while the host sits idle
* on stdin with nothing in flight — which is the state round 3's finding 3
* was about and which a command sent immediately would hide.
*/
function run(server, closing, extra = []) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [HOST, '--server', server, '--shutdown-grace', '700', ...extra], {
stdio: ['pipe', 'pipe', 'pipe'],
// No credentials: the stub needs none, and this check must not read any.
env: { PATH: process.env.PATH },
});
let out = '';
Expand All @@ -86,7 +50,7 @@ function run(server, closing, extra = []) {
setTimeout(() => {
if (closing === 'quit') child.stdin.write('{"cmd":"quit"}\n');
child.stdin.end();
}, 400);
}, IDLE_BEFORE_INPUT_MS);
child.on('exit', (code) => {
const lines = out
.split('\n')
Expand Down Expand Up @@ -165,7 +129,6 @@ try {
if (testCase.expected !== undefined && got.childExit?.expected !== testCase.expected) {
problems.push(`child-exit.expected ${got.childExit?.expected}, wanted ${testCase.expected}`);
}
// A failing run has to say why, or the status is the only evidence there is.
if (testCase.exit === 1 && got.failures.length === 0) problems.push('exit 1 with no failed result reported');
if (testCase.exit === 0 && got.failures.length > 0) {
problems.push(`exit 0 but ${got.failures.length} failed result(s)`);
Expand Down
44 changes: 7 additions & 37 deletions scripts/release-changelog.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
// Runs as npm's `version` lifecycle hook (see package.json): at that point
// package.json already carries the new version, and files staged here are
// included in the release commit that `npm version` then creates and tags.
//
// Folds the pending fragments in changelog.d/ (one file per change,
// `<slug>.<breaking|added|changed|fixed>.md`) together with anything filed
// directly under the standing `## Unreleased` section into a new
// `## X.Y.Z — YYYY-MM-DD` section, deletes the consumed fragments, and
// leaves a fresh empty `## Unreleased` above it. Refuses to release when
// there is nothing to release, or when the input's shape is ambiguous.
// Runs as npm's `version` lifecycle hook: package.json already carries the
// new version, and files staged here go into the release commit that
// `npm version` then creates and tags.
import {
existsSync,
readdirSync,
Expand All @@ -21,7 +14,6 @@ import { fileURLToPath } from "node:url";

const CHANGELOG = "CHANGELOG.md";
const FRAGMENT_DIR = "changelog.d";
// Canonical subsection order; a fragment's category must be one of these.
const CATEGORIES = ["breaking", "added", "changed", "fixed"];
const HEADINGS = {
breaking: "Breaking",
Expand All @@ -30,32 +22,22 @@ const HEADINGS = {
fixed: "Fixed",
};

// Refusals throw and are reported at the entry point, which then lets the
// process end on its own with exitCode 1. process.exit() would race the
// stderr write when stderr is a pipe (asynchronous on macOS), leaving the
// caller a bare failure status with no reason attached.
// Refusals throw and set exitCode rather than calling process.exit(), which
// can cut off a stderr write still pending: Node writes to a pipe
// asynchronously on POSIX.
export class ReleaseError extends Error {}
const fail = (msg) => {
throw new ReleaseError(msg);
};

// Fragment grammar: every non-blank line starts a bullet or continues one
// (indented two or more spaces; nested bullets included). Headings and
// thematic breaks are refused wherever they appear — at top level a '## '
// line would splice a fake section boundary into the released changelog,
// and as item content ('- ## x', ' ## x') they still render as headings.
// A heading inside a list item still renders as a heading.
const isBulletStart = (l) => /^[-*] /.test(l);
const isContinuation = (l) => /^ {2,}\S/.test(l);
const itemContent = (l) => l.replace(/^\s*(?:[-*]\s+)?/, "");
const isHeading = (s) => /^#{1,6}(\s|$)/.test(s);
// Thematic breaks may carry interior whitespace ('- - -', '* * *').
const isRule = (s) => /^([-*_=])(\s*\1){2,}\s*$/.test(s);
const isBlockConstruct = (l) => isRule(l.trim()) || isHeading(itemContent(l));

// The directory is scanned fail-closed: anything that is not README.md or
// a well-formed fragment file aborts the release, so an entry can never be
// silently left out (e.g. a fragment created under a 'fix/' subdirectory
// because the slug was taken verbatim from a branch name).
function collectFragments(dir) {
const fragments = [];
if (!existsSync(join(dir, FRAGMENT_DIR))) return fragments;
Expand Down Expand Up @@ -100,11 +82,6 @@ function collectFragments(dir) {
return fragments;
}

// Merge fragment bullets into the Unreleased body's subsection structure.
// Directly-filed entries are kept; a fragment joins the first subsection
// whose title starts with its category (so audience-qualified headings like
// '### Breaking (module authors only)' still attract 'breaking' fragments),
// or a new canonical subsection. Output is emitted in canonical order.
function mergeFragments(body, frags) {
const preamble = [];
const parts = [];
Expand Down Expand Up @@ -147,9 +124,6 @@ export function release(dir) {
const text = readFileSync(join(dir, CHANGELOG), "utf8");
const fragments = collectFragments(dir);

// Exactly one Unreleased heading. A second one silently strands entries:
// only the first is ever cut, so anything filed under a later heading is
// never released and never reaches the GitHub release notes.
const headings = [...text.matchAll(/^## Unreleased[ \t]*$/gm)];
if (headings.length === 0) {
fail(`no '## Unreleased' section in ${CHANGELOG} — add one before releasing.`);
Expand Down Expand Up @@ -181,10 +155,6 @@ export function release(dir) {
);
}

// Spliced by index rather than string-replaced: `text.replace("## Unreleased", …)`
// would hit the first *substring* occurrence, which is not necessarily the
// heading the regex matched (an inline mention of `## Unreleased` in prose
// comes first) and would inject the version heading into the wrong place.
const date = new Date().toISOString().slice(0, 10);
writeFileSync(
join(dir, CHANGELOG),
Expand Down
Loading
Loading