// Tests for scripts/release-notes.mjs (#5056): per-server bucketing (one
// server, several, none), sections only for published packages, the
// Repository catch-all dropping no PR, the release-wide Thanks section (laid
// out as the Inspector's) with every exclusion (maintainers by permission,
// bots, deleted accounts, PRs, other repos), keyword parsing, pagination,
// fail-fast at every lookup, and that nothing is created without --draft. Run via
// `npm run test:scripts`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
REPO,
THANKS_LEAD_IN,
assembleNotes,
bucket,
closingKeywordIssues,
dirsTouched,
formatKnownIssues,
formatPackages,
formatThanks,
main,
parseGenerated,
parseNotesArgs,
registryUrl,
} from "./release-notes.mjs";
const SHA = "a".repeat(40);
const BASE = [
"--milestone",
"v2.0.0",
"--merge-sha",
SHA,
"--merge-branch",
"v2/chore/1-release-v2.0.0",
"--ledger-url",
"https://l.example/x",
];
const PULL = (/** @type {number | string} */ n) =>
`https://github.com/${REPO}/pull/${n}`;
test("parseNotesArgs defaults to a preview and validates its inputs", () => {
assert.deepEqual(parseNotesArgs(BASE), {
milestone: "v2.0.0",
mergeSha: SHA,
mergeBranch: "v2/chore/1-release-v2.0.0",
ledgerUrl: "https://l.example/x",
knownIssues: [],
previousTag: undefined,
mode: "preview",
});
assert.equal(parseNotesArgs([...BASE, "--draft"]).mode, "draft");
assert.deepEqual(
parseNotesArgs([...BASE, "--known-issue", "a", "--known-issue", "b"])
.knownIssues,
["a", "b"],
);
assert.throws(() => parseNotesArgs([]), /all required/);
const swap = (/** @type {string} */ flag, /** @type {string} */ value) => {
const argv = [...BASE];
argv[argv.indexOf(flag) + 1] = value;
return argv;
};
assert.throws(
() => parseNotesArgs(swap("--milestone", "2.0.0")),
/not vX\.Y\.Z/,
);
assert.throws(
() => parseNotesArgs(swap("--merge-sha", "main")),
/not a full 40-hex SHA/,
);
assert.throws(
() => parseNotesArgs(swap("--ledger-url", "ftp://x")),
/not an https URL/,
);
assert.throws(
() => parseNotesArgs([...BASE, "--previous-tag", "2026.8.31", "--draft"]),
/--previous-tag is preview-only/,
);
assert.equal(
parseNotesArgs([...BASE, "--previous-tag", "2026.8.31"]).previousTag,
"2026.8.31",
);
// There is no --publish: publishing is the maintainer's act on the draft.
assert.throws(() => parseNotesArgs([...BASE, "--publish"]), /publish/);
});
test("registryUrl asks for the exact version on each registry", () => {
assert.equal(
registryUrl({
dir: "memory",
name: "@modelcontextprotocol/server-memory",
version: "1.0.0",
registry: "npm",
}),
"https://registry.npmjs.org/@modelcontextprotocol%2Fserver-memory/1.0.0",
);
assert.equal(
registryUrl({
dir: "git",
name: "mcp-server-git",
version: "2026.10.1",
registry: "PyPI",
}),
"https://pypi.org/pypi/mcp-server-git/2026.10.1/json",
);
});
test("formatPackages marks what this release publishes", () => {
assert.equal(
formatPackages([
{
dir: "a",
name: "@s/a",
version: "1.0.0",
registry: "npm",
published: true,
},
{
dir: "b",
name: "b",
version: "0.1",
registry: "PyPI",
published: false,
},
]),
[
"## Packages",
"",
"| Package | Version | Registry | In this release |",
"| --- | --- | --- | --- |",
"| `@s/a` | `1.0.0` | npm | **published** |",
"| `b` | `0.1` | PyPI | unchanged |",
].join("\n"),
);
});
test("parseGenerated splits PR entries from GitHub's trailer", () => {
const body = [
"",
"## What's Changed",
`* fix: a by @x in ${PULL(12)}`,
`* chore: b by @y in ${PULL(3)}`,
"",
"## New Contributors",
`* @y made their first contribution in ${PULL(3)}`,
"",
"**Full Changelog**: https://example/compare",
].join("\n");
assert.deepEqual(parseGenerated(body), {
entries: [
{ pr: 12, line: `* fix: a by @x in ${PULL(12)}` },
{ pr: 3, line: `* chore: b by @y in ${PULL(3)}` },
],
trailer: [
"## New Contributors",
"",
`* @y made their first contribution in ${PULL(3)}`,
"",
"**Full Changelog**: https://example/compare",
].join("\n"),
});
// A category heading (a future .github/release.yml) is fine; a repeat is not
// listed twice.
assert.deepEqual(
parseGenerated(`### Fixes\n* a in ${PULL(1)}\n* a in ${PULL(1)}`).entries,
[{ pr: 1, line: `* a in ${PULL(1)}` }],
);
assert.equal(parseGenerated(`* a in ${PULL(1)}`).trailer, "");
});
test("parseGenerated throws on a line it does not know, rather than drop it", () => {
assert.throws(
() =>
parseGenerated("## What's Changed\n* a in https://github.com/o/r/pull/1"),
/unrecognized line/,
);
assert.throws(
() => parseGenerated("## What's Changed\nsomething new"),
/unrecognized line/,
);
});
test("dirsTouched maps src/
/ to known package directories only", () => {
const dirs = new Set(["git", "memory", "time"]);
assert.deepEqual(dirsTouched(["src/git/a.py"], dirs), ["git"]);
assert.deepEqual(
dirsTouched(["src/time/x", "src/git/y", "src/git/z", "README.md"], dirs),
["git", "time"],
);
assert.deepEqual(
dirsTouched(["scripts/x.mjs", "src/README.md", "src/unknown/x"], dirs),
[],
);
});
test("bucket: one, several and no servers; unpublished and empty fall to Repository", () => {
const pkgs = [
{ dir: "git", name: "g", version: "2", registry: "PyPI", published: true },
{ dir: "time", name: "t", version: "2", registry: "PyPI", published: true },
{
dir: "memory",
name: "m",
version: "1",
registry: "npm",
published: false,
},
];
const e = (/** @type {number} */ pr) => ({ pr, line: `* ${pr}` });
const entries = [e(1), e(2), e(3), e(4), e(5)];
const touched = new Map([
[1, ["git"]],
[2, ["git", "time"]],
[3, []],
[4, ["memory"]],
[5, ["memory", "time"]],
]);
const sections = /** @type {const} */ (bucket(entries, pkgs, touched)).map(
(s) => [s.heading, s.entries.map((x) => x.pr)],
);
assert.deepEqual(sections, [
["## g 2", [1, 2]],
["## t 2", [2, 5]],
["## Repository", [3, 4]],
]);
// No leftovers → no Repository section; a published package with no PR
// still gets its section.
assert.deepEqual(
bucket([e(1)], pkgs, new Map([[1, ["git"]]])).map((s) => s.heading),
["## g 2", "## t 2"],
);
});
test("closingKeywordIssues reads every closing keyword, never a cross-repo ref", () => {
const body = [
"Closes #1",
"fixes: #2, Resolved #3",
"close #4 and FIXED #5",
"Refs #6",
"Closes other/repo#7",
"prefixes #8",
].join("\n");
assert.deepEqual(closingKeywordIssues(body), [1, 2, 3, 4, 5]);
assert.deepEqual(closingKeywordIssues(null), []);
});
test("closingKeywordIssues ignores keywords GitHub ignores: code, quotes, comments", () => {
const body = [
"Closes #1",
"",
"Documents `Closes #4` and ``fixes #5``.",
"```sh",
"Closes #6",
"```",
"> Closes #8",
"Resolves #10",
].join("\n");
assert.deepEqual(closingKeywordIssues(body), [1, 10]);
assert.deepEqual(closingKeywordIssues("Closes #1\n```\nCloses #2"), [1]);
});
test("formatThanks orders by issue count, then name case-insensitively", () => {
const reporters = new Map([
["zed", [5]],
["Amy", [9]],
["many", [1, 2, 3]],
["bob", [4]],
]);
assert.equal(
formatThanks(reporters),
[
"## Thanks for helping us improve",
"",
THANKS_LEAD_IN,
"",
"* @many (#1, #2, #3)",
"* @Amy (#9)",
"* @bob (#4)",
"* @zed (#5)",
].join("\n"),
);
assert.equal(formatThanks(new Map()), "");
});
test("formatKnownIssues pluralizes and is omitted when empty", () => {
assert.equal(formatKnownIssues([]), "");
assert.equal(formatKnownIssues(["one"]), "## Known issue\n\none");
assert.equal(formatKnownIssues(["a", "b"]), "## Known issues\n\na\n\nb");
});
test("assembleNotes: packages, ledger, sections, trailer, known issues, thanks", () => {
assert.equal(
assembleNotes({
packages: "P",
sections: ["S1", "S2"],
trailer: "T",
mergeBranch: "mb",
ledgerUrl: "https://l",
knownIssues: ["K"],
thanks: "## Thanks for helping us improve\n\nX",
}),
"P\n\nRelease ledger: [mb](https://l)\n\nS1\n\nS2\n\nT\n\n## Known issue\n\nK\n\n## Thanks for helping us improve\n\nX\n",
);
assert.equal(
assembleNotes({
packages: "P",
sections: [],
trailer: "",
mergeBranch: "mb",
ledgerUrl: "https://l",
knownIssues: [],
thanks: "",
}),
"P\n\nRelease ledger: [mb](https://l)\n",
);
});
// A fake `gh` over a small repo model, and a fake registry. `failOn` makes the
// first gh call whose joined argv includes it fail, the way a rate limit or a
// 404 would.
function world({
packages = {
git: { file: "pyproject.toml", name: "mcp-server-git", version: "2.0.0" },
time: { file: "pyproject.toml", name: "mcp-server-time", version: "2.0.0" },
memory: {
file: "package.json",
name: "@modelcontextprotocol/server-memory",
version: "1.0.0",
},
},
onRegistry = ["mcp-server-time"],
/** @type {Record, body?: string, closing?: number[], pages?: Array> }>} */
pulls = {},
/** @type {Record} */
issues = {},
/** @type {Record} */
perms = {},
/** @type {string | undefined} */
failOn = undefined,
compare = "identical",
releases = ["2026.8.31"],
/** @type {string[]} */
tagRefs = [],
} = {}) {
/** @type {Array<{ cmd: string, args: string[], input?: string }>} */
const calls = [];
const generated = [
"## What's Changed",
...Object.keys(pulls).map((n) => `* change ${n} by @dev in ${PULL(n)}`),
"",
"**Full Changelog**: https://example/compare",
].join("\n");
const ok = (/** @type {string} */ stdout) => ({
status: 0,
stdout,
stderr: "",
});
const tree = {
entries: [
{ name: "README.md", type: "blob", object: {} },
...Object.entries(packages).map(([dir, p]) => ({
name: dir,
type: "tree",
object: {
entries: [
{ name: "README.md", object: { text: "" } },
{
name: p.file,
object: {
text:
p.file === "package.json"
? JSON.stringify({ name: p.name, version: p.version })
: `[project]\nname = "${p.name}"\nversion = "${p.version}"\n`,
},
},
],
},
})),
],
};
/**
* @param {string} cmd
* @param {string[]} args
* @param {{ input?: string }} [opts]
*/
const spawn = (cmd, args, opts) => {
calls.push({ cmd, args, input: opts?.input });
const joined = args.join(" ");
if (failOn && joined.includes(failOn)) {
return { status: 1, stdout: "", stderr: "API rate limit exceeded" };
}
assert.equal(cmd, "gh");
if (args[0] === "release" && args[1] === "view") return ok(releases[0]);
if (args[0] === "release") return ok("https://github.com/r/releases/1");
if (args[1] === "graphql") {
const vars = Object.fromEntries(
args
.filter((_, i) => args[i - 1] === "-F" || args[i - 1] === "-f")
.map((kv) => kv.split(/=(.*)/s).slice(0, 2)),
);
if (vars.query.includes("Blob")) {
assert.equal(vars.expr, `${SHA}:src`);
return ok(JSON.stringify({ data: { repository: { object: tree } } }));
}
const n = Number(vars.n);
const index = vars.after ? Number(vars.after) : 0;
if (vars.query.includes("closingIssuesReferences")) {
const pr = pulls[n];
const pages = pr.pages ?? [pr.closing ?? []];
const hasNextPage = index + 1 < pages.length;
return ok(
JSON.stringify({
data: {
repository: {
pullRequest: {
body: pr.body ?? "",
closingIssuesReferences: {
pageInfo: {
hasNextPage,
endCursor: hasNextPage ? String(index + 1) : null,
},
nodes: pages[index].map((ref) =>
typeof ref === "number"
? { number: ref, repository: { nameWithOwner: REPO } }
: ref,
),
},
},
},
},
}),
);
}
return ok(
JSON.stringify({
data: { repository: { issueOrPullRequest: issues[n] ?? null } },
}),
);
}
const files = args[2]?.match(/pulls\/(\d+)\/files$/);
if (files) {
// The --jq emits each file, then its rename source when it has one.
const { files: list = [], renamedFrom = {} } = pulls[Number(files[1])];
return ok(
list
.flatMap((f) => (renamedFrom[f] ? [f, renamedFrom[f]] : [f]))
.join("\n"),
);
}
if (args[1].includes("/git/matching-refs/tags/"))
return ok(tagRefs.join("\n"));
if (args[1].endsWith("/releases/generate-notes")) return ok(generated);
if (args[1].includes("/compare/")) return ok(compare);
if (args[2] === `repos/${REPO}/releases`) return ok(releases.join("\n"));
const login = args[1].match(/collaborators\/([^/]+)\/permission/)?.[1];
assert.ok(login, `unexpected gh call: ${joined}`);
return ok(perms[login]);
};
spawn.calls = calls;
/** @type {string[]} */
const fetched = [];
/** @param {string} url */
const fetch = async (url) => {
fetched.push(url);
const name = Object.values(packages).find((p) =>
url.includes(p.name.replace("/", "%2F")),
)?.name;
return { status: name && onRegistry.includes(name) ? 200 : 404 };
};
return { spawn, fetch, fetched };
}
const user = (/** @type {string} */ login, extra = {}) => ({
__typename: "Issue",
author: { login, __typename: "User" },
...extra,
});
/** @param {import("node:test").TestContext} t */
function quiet(t) {
/** @type {string[]} */
const out = [];
t.mock.method(console, "error", () => {});
t.mock.method(process.stdout, "write", (/** @type {string} */ chunk) => {
out.push(chunk);
return true;
});
return out;
}
/** The text of one `## …` section of the notes. */
function sectionOf(/** @type {string} */ notes, /** @type {string} */ heading) {
const start = notes.indexOf(`${heading}\n`);
assert.notEqual(start, -1, `no section ${heading}`);
const next = notes.indexOf("\n## ", start + heading.length);
return notes.slice(start, next === -1 ? undefined : next);
}
test("notes: per-server sections, every PR placed, one release-wide Thanks", async (t) => {
const out = quiet(t);
const deps = world({
pulls: {
10: { files: ["src/git/a.py"], body: "Closes #1" },
11: { files: ["src/git/b.py", "src/memory/c.ts"], closing: [2] },
12: { files: [".github/workflows/x.yml"], body: "Fixes #3" },
13: { files: ["src/time/d.py"], body: "Closes #4" },
},
issues: {
1: user("reporter"),
2: user("reporter"),
3: user("docs-person"),
4: user("reporter"),
},
perms: { reporter: "read", "docs-person": "triage" },
});
const notes = await main(BASE, deps);
assert.equal(out.join(""), notes);
// time is already on the registry, so it is unchanged, gets no section,
// and its PR falls through to Repository rather than being dropped.
assert.match(
notes,
/\| `mcp-server-time` \| `2\.0\.0` \| PyPI \| unchanged \|/,
);
assert.doesNotMatch(notes, /^## mcp-server-time/m);
assert.deepEqual(notes.match(/^## .*/gm), [
"## Packages",
"## @modelcontextprotocol/server-memory 1.0.0",
"## mcp-server-git 2.0.0",
"## Repository",
"## Thanks for helping us improve",
]);
// The ledger sits directly under the Packages table (#5115), so the
// evidence for what was published is read with it, not below every PR list.
assert.match(
sectionOf(notes, "## Packages"),
/\|\n\nRelease ledger: \[v2\/chore\/1-release-v2\.0\.0\]\(https:\/\/l\.example\/x\)\n$/,
);
assert.equal(notes.split("Release ledger:").length, 2);
const git = sectionOf(notes, "## mcp-server-git 2.0.0");
assert.match(git, /change 10 .*\n\* change 11 [^\n]*\n*$/);
const memory = sectionOf(
notes,
"## @modelcontextprotocol/server-memory 1.0.0",
);
assert.match(memory, /change 11/);
assert.doesNotMatch(memory, /change 10/);
const repo = sectionOf(notes, "## Repository");
assert.match(
repo,
/change 12[^\n]*\n\* change 13 [^\n]*\n\n\*\*Full Changelog/,
);
// One Thanks section for the whole release, last, as the Inspector lays it
// out: each reporter once, with every issue of theirs across all servers.
assert.ok(
notes.endsWith(
[
"**Full Changelog**: https://example/compare",
"",
"## Thanks for helping us improve",
"",
THANKS_LEAD_IN,
"",
"* @reporter (#1, #2, #4)",
"* @docs-person (#3)",
"",
].join("\n"),
),
notes,
);
// Each PR's files, closing refs, each issue and each login: looked up once.
const count = (/** @type {string} */ s) =>
deps.spawn.calls.filter((c) => c.args.join(" ").includes(s)).length;
assert.equal(count("pulls/11/files"), 1);
assert.equal(count("/permission"), 2);
assert.equal(
deps.spawn.calls.filter(
(c) =>
c.args.join(" ").includes("issueOrPullRequest") &&
c.args.includes("n=2"),
).length,
1,
);
// The registries were asked about the exact versions on the merge SHA.
assert.deepEqual(deps.fetched, [
"https://registry.npmjs.org/@modelcontextprotocol%2Fserver-memory/1.0.0",
"https://pypi.org/pypi/mcp-server-git/2.0.0/json",
"https://pypi.org/pypi/mcp-server-time/2.0.0/json",
]);
// The preview creates nothing.
assert.equal(
deps.spawn.calls.some(
(c) => c.args[0] === "release" && c.args[1] === "create",
),
false,
);
});
test("Thanks excludes maintainers, bots, deleted accounts, PRs and other repos", async (t) => {
quiet(t);
const notes = await main(
BASE,
world({
pulls: {
10: {
files: ["src/git/a.py"],
body: "Closes #1\nFixes #2\nResolves #4, closes #5, closes #6",
closing: [
3,
{ number: 7, repository: { nameWithOwner: "other/repo" } },
],
},
},
issues: {
1: user("reporter"),
2: user("maint"),
3: user("reporter"),
4: { __typename: "Issue", author: { login: "bot", __typename: "Bot" } },
5: { __typename: "PullRequest" },
6: { __typename: "Issue", author: null },
},
perms: { reporter: "read", maint: "write" },
}),
);
assert.match(notes, /\* @reporter \(#1, #3\)\n/);
assert.doesNotMatch(notes, /@maint|@bot|#5|#7/);
});
test("the Thanks section is left out when no community reporter remains", async (t) => {
quiet(t);
const notes = await main(
BASE,
world({
pulls: { 10: { files: ["src/git/a.py"], body: "Closes #1" } },
issues: { 1: user("maint") },
perms: { maint: "maintain" },
}),
);
assert.doesNotMatch(notes, /Thanks/);
assert.match(
notes,
/## mcp-server-git 2\.0\.0\n\n### What's Changed\n\n\* change 10/,
);
});
test("closing references are followed across every page", async (t) => {
quiet(t);
const deps = world({
pulls: { 10: { files: ["src/git/a"], pages: [[1], [2], [3]] } },
issues: { 1: user("a"), 2: user("b"), 3: user("c") },
perms: { a: "read", b: "triage", c: "none" },
});
const notes = await main(BASE, deps);
assert.match(notes, /@a \(#1\)\n\* @b \(#2\)\n\* @c \(#3\)/);
const cursors = deps.spawn.calls
.filter((c) => c.args.some((a) => a.includes("closingIssuesReferences")))
.map((c) => c.args.find((a) => a.startsWith("after=")) ?? null);
assert.deepEqual(cursors, [null, "after=1", "after=2"]);
});
test("generate-notes runs from the latest published Release to the merge SHA", async (t) => {
quiet(t);
const deps = world();
await main(BASE, deps);
const call = deps.spawn.calls.find((c) =>
c.args[1]?.endsWith("/releases/generate-notes"),
);
assert.deepEqual(call?.args.slice(2), [
"-f",
"tag_name=v2.0.0",
"-f",
`target_commitish=${SHA}`,
"-f",
"previous_tag_name=2026.8.31",
"--jq",
".body",
]);
const override = world();
await main([...BASE, "--previous-tag", "2026.8.18"], override);
assert.ok(
override.spawn.calls.some((c) =>
c.args.includes("previous_tag_name=2026.8.18"),
),
);
assert.equal(
override.spawn.calls.some((c) => c.args[0] === "release"),
false,
);
});
for (const [what, failOn] of [
["the previous-release lookup", "release view"],
["the manifest lookup", ":src"],
["generate-notes", "generate-notes"],
["a PR files lookup", "/files"],
["a closing-references lookup", "closingIssuesReferences"],
["an issue lookup", "issueOrPullRequest"],
["a permission lookup", "/permission"],
["the compare check", "/compare/"],
["the existing-release check", `repos/${REPO}/releases --jq`],
["the existing-tag check", "matching-refs"],
]) {
test(`a failed ${what} aborts with nothing created`, async (t) => {
quiet(t);
const deps = world({
pulls: { 10: { files: ["src/git/a"], body: "Closes #1" } },
issues: { 1: user("maybe-maint") },
perms: { "maybe-maint": "read" },
failOn,
});
await assert.rejects(
main([...BASE, "--draft"], deps),
/rate limit exceeded/,
);
assert.equal(
deps.spawn.calls.some((c) => c.args[1] === "create"),
false,
);
});
}
test("a registry answer other than 200/404 aborts — UNKNOWN is a stop", async (t) => {
quiet(t);
const deps = world();
const fetch = async () => ({ status: 503 });
await assert.rejects(
main([...BASE, "--draft"], { spawn: deps.spawn, fetch }),
/HTTP 503 .*UNKNOWN/,
);
assert.equal(
deps.spawn.calls.some((c) => c.args[1] === "create"),
false,
);
});
test("an unknown permission value aborts rather than reading as community", async (t) => {
quiet(t);
await assert.rejects(
main(
BASE,
world({
pulls: { 10: { files: [], body: "Closes #1" } },
issues: { 1: user("who") },
perms: { who: "" },
}),
),
/unexpected permission "" for @who/,
);
});
test("GraphQL errors and missing nodes abort", async (t) => {
quiet(t);
await assert.rejects(
main(BASE, world({ pulls: { 10: { files: [], body: "Closes #99" } } })),
/#99 not found/,
);
const inner = world({ pulls: { 10: { files: [] } } });
const respond = (/** @type {string} */ stdout) =>
/** @type {import("./release-notes.mjs").Spawn} */ (
(cmd, args, opts) =>
args[1] === "graphql" &&
args.some((a) => a.includes("closingIssuesReferences"))
? { status: 0, stdout, stderr: "" }
: inner.spawn(cmd, args, opts)
);
await assert.rejects(
main(BASE, {
spawn: respond('{"errors":[{"message":"boom"}]}'),
fetch: inner.fetch,
}),
/boom/,
);
await assert.rejects(
main(BASE, {
spawn: respond('{"data":{"repository":{"pullRequest":null}}}'),
fetch: inner.fetch,
}),
/PR #10 not found/,
);
});
test("a manifest tree that is missing or malformed aborts", async (t) => {
quiet(t);
const inner = world();
const tree = (/** @type {unknown} */ object) =>
/** @type {import("./release-notes.mjs").Spawn} */ (
(cmd, args, opts) =>
args.some((a) => a.includes("Blob"))
? {
status: 0,
stdout: JSON.stringify({ data: { repository: { object } } }),
stderr: "",
}
: inner.spawn(cmd, args, opts)
);
const run = (/** @type {unknown} */ object) =>
main(BASE, { spawn: tree(object), fetch: inner.fetch });
await assert.rejects(run(null), /no src\/ tree/);
await assert.rejects(run({ entries: [] }), /no package manifests/);
const dir = (/** @type {unknown} */ file) => ({
entries: [{ name: "x", type: "tree", object: { entries: [file] } }],
});
await assert.rejects(
run(dir({ name: "package.json", object: null })),
/src\/x\/package\.json has no text/,
);
await assert.rejects(
run(dir({ name: "package.json", object: { text: '{"version":"1"}' } })),
/has no package name/,
);
await assert.rejects(
run(dir({ name: "package.json", object: { text: '{"name":"n"}' } })),
/has no version/,
);
});
test("no previous published Release aborts", async (t) => {
quiet(t);
await assert.rejects(
main(BASE, world({ releases: [""] })),
/no previous published Release/,
);
});
test("a spawn error is rethrown", async () => {
const boom = new Error("ENOENT");
await assert.rejects(
main(BASE, {
spawn: () => ({ status: null, error: boom }),
fetch: world().fetch,
}),
(e) => e === boom,
);
});
test("--draft creates a draft at the merge SHA under the milestone tag", async (t) => {
quiet(t);
const deps = world();
const notes = await main([...BASE, "--draft"], deps);
const create = deps.spawn.calls.find((c) => c.args[1] === "create");
assert.deepEqual(create?.args, [
"release",
"create",
"v2.0.0",
"--repo",
REPO,
"--target",
SHA,
"--title",
"v2.0.0",
"--notes-file",
"-",
"--draft",
]);
assert.equal(create?.input, notes);
// Never published, never marked latest: that is the maintainer's step.
assert.equal(create?.args.includes("--latest"), false);
});
test("--draft refuses a SHA that is not on main", async (t) => {
quiet(t);
for (const compare of ["ahead", "diverged"]) {
const deps = world({ compare });
await assert.rejects(
main([...BASE, "--draft"], deps),
new RegExp(`not on main \\(compare status "${compare}"\\)`),
);
assert.equal(
deps.spawn.calls.some((c) => c.args.join(" ").includes("generate-notes")),
false,
);
}
const behind = world({ compare: "behind" });
await main([...BASE, "--draft"], behind);
assert.ok(behind.spawn.calls.some((c) => c.args[1] === "create"));
});
test("--draft refuses when a Release or draft with that tag already exists", async (t) => {
quiet(t);
const deps = world({ releases: ["2026.8.31", "v2.0.0"] });
await assert.rejects(
main([...BASE, "--draft"], deps),
/tagged v2\.0\.0 already exists/,
);
assert.equal(
deps.spawn.calls.some((c) => c.args[1] === "create"),
false,
);
});
test("an existing milestone tag aborts in every mode, before notes are generated", async (t) => {
quiet(t);
for (const mode of [[], ["--draft"]]) {
const deps = world({
// A prefix match (v2.0.0-rc.1) is not the tag itself.
tagRefs: ["refs/tags/v2.0.0-rc.1", "refs/tags/v2.0.0"],
});
await assert.rejects(
main([...BASE, ...mode], deps),
/tag v2\.0\.0 already exists: GitHub would use its commit/,
);
assert.equal(
deps.spawn.calls.some(
(c) =>
c.args.join(" ").includes("generate-notes") || c.args[1] === "create",
),
false,
);
}
const prefixOnly = world({ tagRefs: ["refs/tags/v2.0.0-rc.1"] });
await main(BASE, prefixOnly);
});
test("a file renamed out of a server's directory still places the PR there", async (t) => {
quiet(t);
const notes = await main(
BASE,
world({
pulls: {
10: {
files: ["docs/moved.md"],
renamedFrom: { "docs/moved.md": "src/git/old.md" },
},
},
}),
);
assert.match(
notes,
/## mcp-server-git 2\.0\.0\n\n### What's Changed\n\n\* change 10/,
);
assert.doesNotMatch(notes, /## Repository/);
});