Files
servers/scripts/release-notes.mjs
cliffhallandClaude Opus 5.5 d3bd74d6d0 chore(scripts): one release-wide Thanks section, as the Inspector's; drop Credit lines
Thanks is now a single `## Thanks for helping us improve` list at the end
of the notes, laid out as the Inspector's release notes are, rather than a
subsection per server. The maintainer-written `Credit: @login` convention
is removed: outside-PR authors are added to the draft's Thanks by hand, as
before, and the harvest docs are back to their v2/main wording.

From Copilot's review: refuse a milestone tag that already exists (GitHub
would use its commit, not --merge-sha), and count a rename's source path
when bucketing a PR by server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-10-07 12:43:04 -04:00

742 lines
24 KiB
JavaScript

#!/usr/bin/env node
// Assemble a milestone release's notes, per server, and optionally create the
// draft GitHub Release (#5056) —
// `npm run release:notes -- --milestone vX.Y.Z --merge-sha <sha> --merge-branch <b> --ledger-url <u> [...]`.
// It replaces the shell recipe the release skill's step 5a carried, and is
// modeled on the MCP Inspector's helper (modelcontextprotocol/inspector#2550),
// adapted to a repository that publishes seven packages at seven versions.
//
// A reader of a release wants to know what changed in the server they use, so
// the notes are laid out per PACKAGE rather than as one flat list:
//
// ## Packages every package, its version, and whether
// this release publishes it (registry check)
// ## <package> <version> one per PUBLISHED package, with its
// ### What's Changed PRs
// ## Repository every PR left over (CI, docs, skills, a
// server whose version did not change), so
// no PR in the generated list is dropped
// ## New Contributors / Full Changelog GitHub's own trailer, verbatim
// Release ledger: [<branch>](<url>)
// ## Known issues only when passed in: a maintainer's call
// ## Thanks for helping us improve one release-wide list of the community
// members whose issues the PRs close, laid
// out as the Inspector's release notes are
//
// The PR list is GitHub's generated one (`releases/generate-notes`, what the
// UI's button uses) from the latest PUBLISHED Release to the merge commit. A
// PR is bucketed by the files it changes: `src/<dir>/` is that directory's
// package. Thanks credits each closed issue's author; maintainers
// (admin/maintain/write), bots and deleted accounts are never credited.
// GitHub adds everyone `@`-mentioned in a release body to its Contributors
// strip, so that section is what puts the reporters there.
//
// FAIL FAST. Every `gh` call and registry request is checked and a failure
// throws before anything is created: a permission lookup that errors must
// never read as "community" (that could credit a maintainer), a rate-limited
// lookup must never drop a PR or a reporter, and a registry that does not
// answer says nothing about what this release publishes. A permission value or
// a generated-notes line outside the known set throws for the same reason.
//
// The default run is a PREVIEW that prints the notes and creates nothing.
// `--draft` creates a draft Release at the merge SHA under the milestone tag.
// There is deliberately no `--publish`: publishing starts `release.yml`, so it
// stays a maintainer's act on the draft (the release skill, step 5b).
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import { readProject } from "./prepare-python-release.mjs";
export const REPO = "modelcontextprotocol/servers";
const [OWNER, NAME] = REPO.split("/");
// What `collaborators/{user}/permission` reports in its `permission` field.
// A public repo reports `read` for anyone without a role, so a community
// member is `read` (or `triage`/`none`), and a maintainer is anyone above it.
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const COMMUNITY_PERMISSIONS = new Set(["triage", "read", "none"]);
export const THANKS_LEAD_IN =
"This release addresses issues reported by these community members. Thank you for taking the time to file them:";
export const REPOSITORY_HEADING = "## Repository";
/**
* @typedef {{ dir: string, name: string, version: string, registry: "npm" | "PyPI" }} Package
* @typedef {Package & { published: boolean }} CheckedPackage
* @typedef {{ pr: number, line: string }} Entry
* @typedef {{ heading: string, entries: Entry[] }} Section
* @typedef {(cmd: string, args: string[], options: object) => { status: number | null, stdout?: string, stderr?: string, error?: Error }} Spawn
*/
/**
* @param {string[]} argv
*/
export function parseNotesArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
milestone: { type: "string" },
"merge-sha": { type: "string" },
"merge-branch": { type: "string" },
"ledger-url": { type: "string" },
"known-issue": { type: "string", multiple: true },
"previous-tag": { type: "string" },
draft: { type: "boolean" },
},
});
const milestone = values.milestone;
const mergeSha = values["merge-sha"];
const mergeBranch = values["merge-branch"];
const ledgerUrl = values["ledger-url"];
if (!milestone || !mergeSha || !mergeBranch || !ledgerUrl) {
throw new Error(
"--milestone, --merge-sha, --merge-branch and --ledger-url are all required",
);
}
// The tag is the milestone's name; a typo here becomes a tag on the release.
if (!/^v\d+\.\d+\.\d+$/.test(milestone)) {
throw new Error(`--milestone "${milestone}" is not vX.Y.Z`);
}
// A full SHA, never a branch name: `main` moves, and the Release must land
// on the commit the ledger verified.
if (!/^[0-9a-f]{40}$/.test(mergeSha)) {
throw new Error(`--merge-sha "${mergeSha}" is not a full 40-hex SHA`);
}
if (!/^https:\/\/\S+$/.test(ledgerUrl)) {
throw new Error(`--ledger-url "${ledgerUrl}" is not an https URL`);
}
// A Release created here always starts from the latest published Release;
// an override (a typo, a stale value) would publish the wrong range of
// changes and credits, so it is a preview-only knob.
if (values.draft && values["previous-tag"] !== undefined) {
throw new Error("--previous-tag is preview-only");
}
return {
milestone,
mergeSha,
mergeBranch,
ledgerUrl,
knownIssues: values["known-issue"] ?? [],
previousTag: values["previous-tag"],
mode: values.draft ? "draft" : "preview",
};
}
/**
* @param {Spawn} spawn
* @param {string} cmd
* @param {string[]} args
* @param {string} [input]
*/
function run(spawn, cmd, args, input) {
const result = spawn(cmd, args, { encoding: "utf8", input });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`${cmd} ${args.join(" ")} failed: ${(result.stderr ?? "").trim()}`,
);
}
return (result.stdout ?? "").trim();
}
/**
* @param {Spawn} spawn
* @param {string} query
* @param {Record<string, string | number>} variables
*/
function graphql(spawn, query, variables) {
const args = ["api", "graphql", "-f", `query=${query}`];
for (const [key, value] of Object.entries(variables)) {
// -F types a number as Int; -f keeps a cursor or an expression a String.
args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`);
}
const response = JSON.parse(run(spawn, "gh", args));
if (response.errors?.length) {
throw new Error(`gh api graphql: ${JSON.stringify(response.errors)}`);
}
return response.data;
}
const MANIFEST_QUERY = `query($expr:String!){repository(owner:"${OWNER}",name:"${NAME}"){object(expression:$expr){... on Tree{entries{name type object{... on Tree{entries{name object{... on Blob{text}}}}}}}}}}`;
/**
* Every package under `src/` at `sha`, read from GitHub rather than a
* checkout, so the table describes the commit being released whatever is on
* disk. The same identities `release-manifest.mjs` reads, in directory order,
* npm before PyPI.
*
* @param {Spawn} spawn
* @param {string} sha
* @returns {Package[]}
*/
export function packagesAt(spawn, sha) {
const tree = graphql(spawn, MANIFEST_QUERY, { expr: `${sha}:src` }).repository
.object;
if (!tree?.entries) {
throw new Error(`no src/ tree at ${sha}`);
}
/** @type {Package[]} */
const npm = [];
/** @type {Package[]} */
const pypi = [];
const dirs = tree.entries
.filter((/** @type {{ type: string }} */ e) => e.type === "tree")
.sort(
(/** @type {{ name: string }} */ a, /** @type {{ name: string }} */ b) =>
a.name.localeCompare(b.name, "en"),
);
for (const dir of dirs) {
for (const file of dir.object.entries) {
if (file.name !== "package.json" && file.name !== "pyproject.toml")
continue;
const text = file.object?.text;
if (typeof text !== "string") {
throw new Error(`src/${dir.name}/${file.name} has no text at ${sha}`);
}
const { name, version } =
file.name === "package.json" ? JSON.parse(text) : readProject(text);
if (typeof name !== "string" || name === "")
throw new Error(`src/${dir.name}/${file.name} has no package name`);
if (typeof version !== "string" || version === "")
throw new Error(`src/${dir.name}/${file.name} has no version`);
(file.name === "package.json" ? npm : pypi).push({
dir: dir.name,
name,
version,
registry: file.name === "package.json" ? "npm" : "PyPI",
});
}
}
if (npm.length + pypi.length === 0) {
throw new Error(`no package manifests under src/ at ${sha}`);
}
return [...npm, ...pypi];
}
/**
* The registry URL that answers 200 when this exact version is already
* published, and 404 when it is not.
*
* @param {Package} pkg
*/
export function registryUrl(pkg) {
return pkg.registry === "npm"
? `https://registry.npmjs.org/${pkg.name.replace("/", "%2F")}/${pkg.version}`
: `https://pypi.org/pypi/${pkg.name}/${pkg.version}/json`;
}
/**
* Whether this release publishes each package: a version the registry does
* not have yet is published, one it has is skipped. Any other answer aborts.
*
* @param {Package[]} packages
* @param {typeof fetch} fetchImpl
* @returns {Promise<CheckedPackage[]>}
*/
export async function checkRegistries(packages, fetchImpl) {
/** @type {CheckedPackage[]} */
const checked = [];
for (const pkg of packages) {
const url = registryUrl(pkg);
const response = await fetchImpl(url, { method: "GET" });
if (response.status !== 200 && response.status !== 404) {
throw new Error(
`${pkg.registry} answered HTTP ${response.status} for ${pkg.name}@${pkg.version} (${url}): UNKNOWN, not guessing`,
);
}
checked.push({ ...pkg, published: response.status === 404 });
}
return checked;
}
/** @param {CheckedPackage[]} packages */
export function formatPackages(packages) {
const rows = packages.map(
(p) =>
`| \`${p.name}\` | \`${p.version}\` | ${p.registry} | ${p.published ? "**published**" : "unchanged"} |`,
);
return [
"## Packages",
"",
"| Package | Version | Registry | In this release |",
"| --- | --- | --- | --- |",
...rows,
].join("\n");
}
const PR_LINE = new RegExp(
`^\\* .* in https://github\\.com/${REPO}/pull/(\\d+)\\s*$`,
);
/**
* Split GitHub's generated body into its PR entries and its trailer (the
* `## New Contributors` block and the `**Full Changelog**` line, kept
* verbatim). Headings, blank lines and HTML comments are dropped; any other
* line throws, so a format change on GitHub's side cannot silently drop a PR.
*
* @param {string} body
* @returns {{ entries: Entry[], trailer: string }}
*/
export function parseGenerated(body) {
/** @type {Entry[]} */
const entries = [];
const seen = new Set();
/** @type {string[]} */
const contributors = [];
let changelog = "";
let inContributors = false;
for (const raw of body.split("\n")) {
const line = raw.trimEnd();
if (/^#{2,3} /.test(line)) {
inContributors = line === "## New Contributors";
continue;
}
if (line === "" || /^<!--.*-->$/.test(line)) continue;
if (line.startsWith("**Full Changelog**")) {
changelog = line;
continue;
}
if (inContributors && line.startsWith("* ")) {
contributors.push(line);
continue;
}
const match = PR_LINE.exec(line);
if (!match) {
throw new Error(`unrecognized line in the generated notes: ${line}`);
}
const pr = Number(match[1]);
if (seen.has(pr)) continue;
seen.add(pr);
entries.push({ pr, line });
}
const trailer = [
contributors.length
? `## New Contributors\n\n${contributors.join("\n")}`
: "",
changelog,
]
.filter(Boolean)
.join("\n\n");
return { entries, trailer };
}
/**
* The package directories a PR's files fall under, of those given.
*
* @param {string[]} files
* @param {Set<string>} dirs
* @returns {string[]}
*/
export function dirsTouched(files, dirs) {
const touched = new Set();
for (const file of files) {
const match = /^src\/([^/]+)\//.exec(file);
if (match && dirs.has(match[1])) touched.add(match[1]);
}
return [...touched].sort();
}
/**
* @param {Spawn} spawn
* @param {number} pr
* @returns {string[]}
*/
export function filesOf(spawn, pr) {
const out = run(spawn, "gh", [
"api",
"--paginate",
`repos/${REPO}/pulls/${pr}/files`,
"--jq",
// A rename's source is in previous_filename: a file moved out of a
// server's directory still changed that server.
".[] | .filename, (.previous_filename // empty)",
]);
return out === "" ? [] : out.split("\n");
}
/**
* One section per published package, in table order, then Repository with
* every entry no published section took. Each entry lands somewhere.
*
* @param {Entry[]} entries
* @param {CheckedPackage[]} packages
* @param {Map<number, string[]>} touched PR → package directories it changes
* @returns {Section[]}
*/
export function bucket(entries, packages, touched) {
const published = packages.filter((p) => p.published);
/** @type {Section[]} */
const sections = published.map((p) => ({
heading: `## ${p.name} ${p.version}`,
entries: entries.filter((e) => (touched.get(e.pr) ?? []).includes(p.dir)),
}));
const placed = new Set(sections.flatMap((s) => s.entries.map((e) => e.pr)));
const rest = entries.filter((e) => !placed.has(e.pr));
if (rest.length)
sections.push({ heading: REPOSITORY_HEADING, entries: rest });
return sections;
}
/**
* Issue numbers a PR body closes by keyword — GitHub's nine closing keywords,
* with its optional colon. A bare `#N` only, so a cross-repo `owner/repo#N`
* is not mistaken for one of ours.
*
* @param {string | null | undefined} body
*/
export function closingKeywordIssues(body) {
const pattern = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi;
return [...proseOf(body ?? "").matchAll(pattern)].map((m) => Number(m[1]));
}
/**
* The text with every place GitHub ignores a closing keyword blanked out:
* HTML comments (PR templates leave `<!-- Closes #… -->` behind), fenced
* code, inline code and blockquotes. Four-space indented code is left alone:
* telling it from an indented list continuation needs a full Markdown parser,
* and masking a real `Closes` line would lose a credit. A keyword quoted in
* any of those does not close the issue, so it must not credit its author either.
*
* @param {string} body
*/
export function proseOf(body) {
return (
body
.replace(/<!--[\s\S]*?(?:-->|$)/g, " ")
// A closing fence is the opening fence's character, at least as many,
// and nothing after it but spaces or tabs; anything else is content.
.replace(
/^ {0,3}((`|~)\2{2,})[^\n]*\n[\s\S]*?(?:^ {0,3}\1\2*[ \t]*$|(?![\s\S]))/gm,
" ",
)
.replace(/(`+)[\s\S]*?\1/g, " ")
.replace(/^ {0,3}>.*$/gm, " ")
);
}
const CLOSING_QUERY = `query($n:Int!,$after:String){repository(owner:"${OWNER}",name:"${NAME}"){pullRequest(number:$n){body closingIssuesReferences(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{number repository{nameWithOwner}}}}}}`;
/**
* Every issue of this repo one PR closes: manual links (paginated) + body
* keywords.
*
* @param {Spawn} spawn
* @param {number} pr
* @returns {Set<number>}
*/
export function issuesClosedBy(spawn, pr) {
const numbers = new Set();
let body;
let after;
for (;;) {
/** @type {Record<string, string | number>} */
const variables = after === undefined ? { n: pr } : { n: pr, after };
const pull = graphql(spawn, CLOSING_QUERY, variables).repository
.pullRequest;
if (!pull) {
throw new Error(`PR #${pr} not found`);
}
body ??= pull.body;
const refs = pull.closingIssuesReferences;
for (const node of refs.nodes) {
if (node.repository.nameWithOwner === REPO) numbers.add(node.number);
}
if (!refs.pageInfo.hasNextPage) break;
after = refs.pageInfo.endCursor;
}
for (const n of closingKeywordIssues(body)) numbers.add(n);
return numbers;
}
const AUTHOR_QUERY = `query($n:Int!){repository(owner:"${OWNER}",name:"${NAME}"){issueOrPullRequest(number:$n){__typename ... on Issue{author{login __typename}}}}}`;
/**
* The login to credit for issue `n`, or null when there is nobody to credit:
* the number is a PR rather than an issue, or the author is a bot or a
* deleted account.
*
* @param {Spawn} spawn
* @param {number} n
* @returns {string | null}
*/
export function creditableAuthor(spawn, n) {
const node = graphql(spawn, AUTHOR_QUERY, { n }).repository
.issueOrPullRequest;
if (!node) {
throw new Error(`#${n} not found`);
}
if (node.__typename !== "Issue") return null;
return node.author?.__typename === "User" ? node.author.login : null;
}
/**
* True for a maintainer, false for a community member; throws otherwise.
*
* @param {Spawn} spawn
* @param {string} login
*/
export function isMaintainer(spawn, login) {
const permission = run(spawn, "gh", [
"api",
`repos/${REPO}/collaborators/${login}/permission`,
"--jq",
".permission",
]);
if (MAINTAINER_PERMISSIONS.has(permission)) return true;
if (COMMUNITY_PERMISSIONS.has(permission)) return false;
throw new Error(`unexpected permission "${permission}" for @${login}`);
}
/**
* Community reporter → the issues of theirs the listed PRs close.
*
* @param {Spawn} spawn
* @param {number[]} pulls
* @returns {Map<string, number[]>}
*/
export function collectReporters(spawn, pulls) {
const issues = new Set();
for (const pr of pulls) {
for (const n of issuesClosedBy(spawn, pr)) issues.add(n);
}
/** @type {Map<string, number[]>} */
const reporters = new Map();
/** @type {Map<string, boolean>} */
const maintainer = new Map();
for (const n of [...issues].sort((a, b) => a - b)) {
const login = creditableAuthor(spawn, n);
if (login === null) continue;
if (!maintainer.has(login)) {
maintainer.set(login, isMaintainer(spawn, login));
}
if (maintainer.get(login)) continue;
const list = reporters.get(login) ?? [];
list.push(n);
reporters.set(login, list);
}
return reporters;
}
/**
* One line per person, most issues first, then by name; "" when nobody.
*
* @param {Map<string, number[]>} reporters
*/
export function formatThanks(reporters) {
if (reporters.size === 0) return "";
const lines = [...reporters]
.sort(
([a, ia], [b, ib]) =>
ib.length - ia.length ||
a.localeCompare(b, "en", { sensitivity: "base" }),
)
.map(
([login, nums]) => `* @${login} (${nums.map((n) => `#${n}`).join(", ")})`,
);
return `## Thanks for helping us improve\n\n${THANKS_LEAD_IN}\n\n${lines.join("\n")}`;
}
/** @param {Section} section */
export function formatSection(section) {
const changed = section.entries.length
? section.entries.map((e) => e.line).join("\n")
: "No pull request in this range changed this package's directory.";
return `${section.heading}\n\n### What's Changed\n\n${changed}`;
}
/** @param {string[]} knownIssues */
export function formatKnownIssues(knownIssues) {
if (knownIssues.length === 0) return "";
const heading = knownIssues.length === 1 ? "Known issue" : "Known issues";
return `## ${heading}\n\n${knownIssues.join("\n\n")}`;
}
/**
* @param {{ packages: string, sections: string[], trailer: string, mergeBranch: string, ledgerUrl: string, knownIssues: string[], thanks: string }} parts
*/
export function assembleNotes({
packages,
sections,
trailer,
mergeBranch,
ledgerUrl,
knownIssues,
thanks,
}) {
return (
[
packages,
...sections,
trailer,
`Release ledger: [${mergeBranch}](${ledgerUrl})`,
formatKnownIssues(knownIssues),
thanks,
]
.filter(Boolean)
.join("\n\n") + "\n"
);
}
/**
* @param {string[]} [argv]
* @param {{ spawn?: Spawn, fetch?: typeof fetch }} [deps]
* @returns {Promise<string>} the notes
*/
export async function main(
argv = process.argv.slice(2),
{ spawn = spawnSync, fetch: fetchImpl = globalThis.fetch } = {},
) {
const args = parseNotesArgs(argv);
// When the tag already exists, GitHub ignores target_commitish (and
// `--target`) in favor of the tag's commit, so the generated range and the
// draft could describe a commit other than --merge-sha. Refuse in every
// mode, before anything is generated.
const existing = run(spawn, "gh", [
"api",
`repos/${REPO}/git/matching-refs/tags/${args.milestone}`,
"--jq",
".[].ref",
]).split("\n");
if (existing.includes(`refs/tags/${args.milestone}`)) {
throw new Error(
`tag ${args.milestone} already exists: GitHub would use its commit, not ${args.mergeSha}`,
);
}
if (args.mode === "draft") {
// release.yml only checks that the tagged commit is on main; refuse here
// first, before a draft exists that someone could publish.
const status = run(spawn, "gh", [
"api",
`repos/${REPO}/compare/main...${args.mergeSha}`,
"--jq",
".status",
]);
if (status !== "identical" && status !== "behind") {
throw new Error(
`${args.mergeSha} is not on main (compare status "${status}"): merge the milestone PR first`,
);
}
// A second draft under the same tag is one more draft to publish by
// mistake: edit the existing one, or delete it, instead.
const tags = run(spawn, "gh", [
"api",
"--paginate",
`repos/${REPO}/releases`,
"--jq",
".[].tag_name",
]).split("\n");
if (tags.includes(args.milestone)) {
throw new Error(
`a Release (or draft) tagged ${args.milestone} already exists`,
);
}
}
// The previous release is the latest PUBLISHED Release, asked of GitHub
// rather than sorted out of the tag list: this repo's tags mix date stamps,
// milestone names and older per-package tags, and no sort orders them.
const previousTag =
args.previousTag ??
run(spawn, "gh", [
"release",
"view",
"--repo",
REPO,
"--json",
"tagName",
"--jq",
".tagName",
]);
if (!previousTag) {
throw new Error("no previous published Release found");
}
console.error(`release notes: ${previousTag} → ${args.milestone}`);
const packages = await checkRegistries(
packagesAt(spawn, args.mergeSha),
fetchImpl,
);
const { entries, trailer } = parseGenerated(
run(spawn, "gh", [
"api",
`repos/${REPO}/releases/generate-notes`,
"-f",
`tag_name=${args.milestone}`,
"-f",
`target_commitish=${args.mergeSha}`,
"-f",
`previous_tag_name=${previousTag}`,
"--jq",
".body",
]),
);
console.error(`bucketing ${entries.length} PRs by server`);
const dirs = new Set(packages.map((p) => p.dir));
/** @type {Map<number, string[]>} */
const touched = new Map();
for (const { pr } of entries) {
touched.set(pr, dirsTouched(filesOf(spawn, pr), dirs));
}
const sections = bucket(entries, packages, touched).map(formatSection);
console.error(
`crediting reporters of issues closed by ${entries.length} PRs`,
);
const thanks = formatThanks(
collectReporters(
spawn,
entries.map((e) => e.pr),
),
);
const notes = assembleNotes({
packages: formatPackages(packages),
sections,
trailer,
mergeBranch: args.mergeBranch,
ledgerUrl: args.ledgerUrl,
knownIssues: args.knownIssues,
thanks,
});
if (args.mode === "preview") {
process.stdout.write(notes);
console.error("preview only — nothing created; re-run with --draft");
return notes;
}
const url = run(
spawn,
"gh",
[
"release",
"create",
args.milestone,
"--repo",
REPO,
"--target",
args.mergeSha,
"--title",
args.milestone,
"--notes-file",
"-",
"--draft",
],
notes,
);
console.error(
`draft created: ${url} — review it, then publish it (this starts release.yml)`,
);
return notes;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main().catch((/** @type {Error} */ error) => {
console.error(`release-notes: ${error.message}`);
process.exitCode = 1;
});