From 4d884fac824c65dec0818b1d5da72c72e3a80aa2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 30 Sep 2026 18:48:26 -0400 Subject: [PATCH 1/5] docs(release): add the release-notes step and the re-cut procedure Step 3 of the release skill said only "generate the notes and publish". It is now three parts: - 3a, draft the release notes: What's Changed from the generate-notes API, the smoke-ledger line, any known issue, and a "Thanks for helping us improve" section crediting the community members whose issues the release addresses (maintainers and bots excluded by permission). The recipe reproduces 2.9.0's published Thanks section exactly. @-mentions feed the release's Contributors strip. - 3b, tag and publish: the existing UI steps, plus gh release create with the notes file. Editing published notes is safe, since every tag's main.yml fires only on release: [published]. - 3c, if the release run fails: a release runs the workflow from the tag's commit, so fix it on v2/main, merge to main, delete the Release AND its tag, and re-cut. This is what 2.9.0 needed (#2551). The AGENTS.md skills-index row for release is updated to match. Closes #2554 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 116 +++++++++++++++++++++++++++++++- AGENTS.md | 2 +- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index ceb73d77f..89ba62ab5 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -206,12 +206,89 @@ the artifact the maintainers approve the merge on. ## 3. Tag and publish the Release +### 3a. Draft the release notes + +A release's notes have four parts, in this order: + +1. **What's Changed.** GitHub's generated list of every PR since the previous tag. +2. **The smoke-ledger line**, linking the artifact from 2b. +3. **`## Known issue`**, only when there is one. It names the issue, who is + affected and the workaround. Deciding what counts as a known issue is a + maintainer judgment, so it is written by hand, never generated. +4. **`## Thanks for helping us improve`.** Credit to the community members whose + issues the release addresses. GitHub adds everyone `@`-mentioned in a + release body to that release's **Contributors** avatar strip, so the people + credited here appear there too (confirmed on 2.9.0). + +The generated list comes from the same API the UI's *Generate release notes* +button uses, so it can be produced without creating anything. The recipe below +builds parts 1 and 4. It reproduced 2.9.0's published Thanks section exactly. + +```sh +REPO=modelcontextprotocol/inspector +git fetch origin main --tags +VERSION=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version") +PREV=$(git tag -l '[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -vx "$VERSION" | head -1) +echo "$PREV → $VERSION" # sanity-check both + +# 1. What's Changed, exactly as the UI generates it. +gh api "repos/$REPO/releases/generate-notes" -f tag_name="$VERSION" \ + -f target_commitish=main -f previous_tag_name="$PREV" --jq .body > release-notes.md + +# 4. Reporter credit: the author of every issue a listed PR closes, minus +# maintainers (admin/maintain/write) and bots. +for pr in $(grep -oE 'pull/[0-9]+' release-notes.md | cut -d/ -f2 | sort -un); do + gh api graphql -F n="$pr" -f query='query($n:Int!){repository(owner:"modelcontextprotocol",name:"inspector"){pullRequest(number:$n){body closingIssuesReferences(first:20){nodes{number}}}}}' \ + --jq '.data.repository.pullRequest | ([.closingIssuesReferences.nodes[].number] + ([.body | scan("(?i)(?:closes|fixes|resolves) #([0-9]+)")[] | .[0] | tonumber])) | .[]' +done | sort -un | while read -r n; do + gh api graphql -F n="$n" -f query='query($n:Int!){repository(owner:"modelcontextprotocol",name:"inspector"){issueOrPullRequest(number:$n){... on Issue{number author{login __typename}}}}}' \ + --jq '.data.repository.issueOrPullRequest | select(.number and .author.__typename == "User") | "\(.author.login) \(.number)"' +done | while read -r who n; do + perm=$(gh api "repos/$REPO/collaborators/$who/permission" --jq .permission 2>/dev/null || echo none) + case "$perm" in admin|maintain|write) ;; *) echo "$who $n" ;; esac +done | awk '{ c[$1]++; l[$1] = l[$1] (l[$1] ? ", " : "") "#" $2 } + END { for (u in c) printf "%d\t%s\t%s\n", c[u], u, l[u] }' \ + | sort -t$'\t' -k1,1nr -k2,2f | awk -F'\t' '{ print "* @" $2 " (" $3 ")" }' > thanks.txt + +[ -s thanks.txt ] && { printf '\n## Thanks for helping us improve\n\nThis release addresses issues reported by these community members. Thank you for taking the time to file them:\n\n'; cat thanks.txt; } > thanks.md +``` + +Then assemble `release-notes.md`, the ledger line, any known issue, and +`thanks.md`, and read the result before publishing. The rules behind the recipe: + +- **An issue counts when a listed PR closes it**, through either the manual + closing link (`closingIssuesReferences`) or a `Closes / Fixes / Resolves #N` + in the PR body. So an issue older than the release still counts when this + release closed it. +- **Maintainers and bots are excluded by permission, not by name.** A + maintainer is anyone with `admin`, `maintain` or `write` on the repo. Bot + authors are dropped, which covers the issues the SDK-watch and Dependabot + sweeps file. On a public repo, anyone without a role reads as `read`, so they + are credited. +- **"Addresses", not "fixes."** The credited issues include feature requests. +- **Leave the section out** when no community reporter remains, as with 2.1.0. + +The whole step, including creating the Release from these notes, is being +scripted as a tested helper in #2550. Until that lands, this recipe is the +procedure. + +### 3b. Tag and publish + **Normally this is done by a maintainer through the GitHub UI**, after PR 2 has merged: *Releases → Draft a new release → Choose a tag → type the bare `x.y.z` -→ Create new tag on publish*, with **Target: `main`**, then generate the notes -and publish. Publishing the Release is what fires the `publish` and +→ Create new tag on publish*, with **Target: `main`**, then paste the notes from +3a and publish. Publishing the Release is what fires the `publish` and `publish-github-container-registry` jobs. +The same thing from the CLI, with the notes file from 3a: + +```sh +gh release create "$VERSION" --target main --title "$VERSION" --notes-file --latest +``` + +`--target main` and the bare `$VERSION` give the right target and tag by +construction. + The equivalent by hand, for when the UI is not an option — derive the tag from the version that just landed rather than typing one, since a hard-coded tag is either already taken (so `git tag` aborts) or, worse, wrong: @@ -242,6 +319,41 @@ publish — it would just be inconsistent with every previous release.) The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying the v2 workflow. +**Editing a published Release's notes is safe.** Every tag's `main.yml` +triggers only on `release: types: [published]` (checked for every tag from 2.0.0 +through 2.9.0), so an `edited` event never re-runs publishing. Fixing a typo or +adding a known issue after the fact needs no ceremony. + +### 3c. If the release run fails + +Check npm before anything else: `npm view @modelcontextprotocol/inspector +dist-tags`. If the new version is not there, nothing was published. A freshly +published version can also show **Validating** on npmjs.com for a few minutes +before it resolves; that is npm's automated review, not a failure. + +⚠️ **A release event runs the workflow from the tag's commit, not from +`main`.** Re-running a failed job therefore re-runs the same broken step. The +fix has to reach `main`, and the Release has to be re-cut at that commit. This +is what 2.9.0 needed (#2551): the first run's `publish` passed a bare +`release-tarball/…tgz` path, which npm read as a GitHub `owner/repo` shorthand. + +1. **Fix it on `v2/main`** through an ordinary PR, never on the merge branch. +2. **Merge `v2/main` into `main`** in a new milestone-merge PR. Its only diff + against the released `main` should be the fix. +3. **Delete the Release *and* its tag.** ⚠️ Deleting a Release in the UI leaves + the tag behind. While the old tag exists, GitHub reuses it, so the re-cut + attaches to the broken commit again, and `generate-notes` reads that commit + too. Delete the tag with `git push origin :refs/tags/$VERSION`, and confirm + it is gone with `gh api repos/$REPO/git/ref/tags/$VERSION` (expect a 404). +4. **Recreate the Release at the new `main`** with the same notes, regenerating + only the What's Changed list, which now includes the fix PRs. +5. **Record it in the ledger.** The fix could not be smoke-tested; the re-cut + run passing `publish` is its evidence. + +If npm *did* publish and something downstream failed (the GHCR image, for +example), do not re-cut: that would try to publish the same npm version again. +Fix it forward in the next release. + ## Why the bump goes on `v2/main` first (#2010) It used to happen on the milestone-merge branch, which is cut from `main` — so diff --git a/AGENTS.md b/AGENTS.md index 4e8e03be8..c186399ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ users invoke them by name. | [`board-ops`](.claude/skills/board-ops/SKILL.md) | `gh project` recipes and the field/option IDs for boards #28 and #11; the option-deletion hazard and its recovery | Model-invoked, or `/board-ops` | | [`pr-flow`](.claude/skills/pr-flow/SKILL.md) | Branch naming, DCO signoff, screenshots, opening the PR, requesting a Copilot review, responding, closing out | Model-invoked, or `/pr-flow` | | [`pre-push-gate`](.claude/skills/pre-push-gate/SKILL.md) | Running `npm run local:gate` and diagnosing a failing stage | Model-invoked, or `/pre-push-gate` | -| [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, tag `origin/main`, publish | `/release` | +| [`release`](.claude/skills/release/SKILL.md) | Cutting a release: bump on `v2/main`, milestone merge, release notes (incl. reporter thanks), tag `origin/main`, publish, re-cut on failure | `/release` | | [`security-advisory`](.claude/skills/security-advisory/SKILL.md) | A privately reported vulnerability end to end: the draft card, who owns the code path, which release lines are affected, accepting, the private fork, publishing, public tracking per line (v2 converts; v1 files) | Model-invoked, or `/security-advisory` | | [`test-servers`](.claude/skills/test-servers/SKILL.md) | Picking and running a showcase test server; the stale-build hazard | Model-invoked, or `/test-servers` | From 4c05d3b83d86082b38237d26686943a3308d85d0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 30 Sep 2026 19:02:45 -0400 Subject: [PATCH 2/5] docs(release): tighten the notes recipe and the re-cut step (Copilot) - PREV is now the highest strict x.y.z tag below VERSION. The old glob also matched 2.0.0-rc.N, x.y.z-hotfix and x.y.z-amended tags, so cutting a stable release after an RC would pick the RC and drop changes (for VERSION=2.0.0 it picked 2.0.0-rc.3; now 1.0.2). - thanks.md is truncated before the optional append, so a rerun with no eligible reporters never keeps a stale section. - A re-cut re-runs the whole 3a recipe, since a fix PR can close a community-reported issue and change the Thanks section too. - The re-cut no longer waives verification: gate and smoke a fix wherever it can be run, and only a release-only path takes the re-cut run as its first evidence. Refs #2554 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 89ba62ab5..5034d1fed 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -228,7 +228,11 @@ builds parts 1 and 4. It reproduced 2.9.0's published Thanks section exactly. REPO=modelcontextprotocol/inspector git fetch origin main --tags VERSION=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version") -PREV=$(git tag -l '[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -vx "$VERSION" | head -1) +# The highest STABLE tag below VERSION. Whole-name match: this repo also has +# 2.0.0-rc.N, x.y.z-hotfix, x.y.z-amended and v2-alpha-1 tags, and a glob +# like [0-9]*.[0-9]*.[0-9]* would pick an RC as PREV and drop changes. +PREV=$( { git tag -l | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'; echo "$VERSION"; } \ + | sort -uV | grep -B1 -x "$VERSION" | head -1 ) echo "$PREV → $VERSION" # sanity-check both # 1. What's Changed, exactly as the UI generates it. @@ -250,7 +254,8 @@ done | awk '{ c[$1]++; l[$1] = l[$1] (l[$1] ? ", " : "") "#" $2 } END { for (u in c) printf "%d\t%s\t%s\n", c[u], u, l[u] }' \ | sort -t$'\t' -k1,1nr -k2,2f | awk -F'\t' '{ print "* @" $2 " (" $3 ")" }' > thanks.txt -[ -s thanks.txt ] && { printf '\n## Thanks for helping us improve\n\nThis release addresses issues reported by these community members. Thank you for taking the time to file them:\n\n'; cat thanks.txt; } > thanks.md +: > thanks.md # truncate first, so a rerun never keeps a stale section +[ -s thanks.txt ] && { printf '\n## Thanks for helping us improve\n\nThis release addresses issues reported by these community members. Thank you for taking the time to file them:\n\n'; cat thanks.txt; } >> thanks.md ``` Then assemble `release-notes.md`, the ledger line, any known issue, and @@ -345,10 +350,16 @@ is what 2.9.0 needed (#2551): the first run's `publish` passed a bare attaches to the broken commit again, and `generate-notes` reads that commit too. Delete the tag with `git push origin :refs/tags/$VERSION`, and confirm it is gone with `gh api repos/$REPO/git/ref/tags/$VERSION` (expect a 404). -4. **Recreate the Release at the new `main`** with the same notes, regenerating - only the What's Changed list, which now includes the fix PRs. -5. **Record it in the ledger.** The fix could not be smoke-tested; the re-cut - run passing `publish` is its evidence. +4. **Recreate the Release at the new `main`.** Re-run the whole 3a recipe: + What's Changed now includes the fix PRs, and a fix PR can close a + community-reported issue, so the Thanks section can change too. Keep the + hand-written parts (the ledger line and any known issue) as they were. +5. **Verify the fix before publishing again, wherever it can be verified.** + Run the gate, and the smoke rows the fix touches, on the new tree. Only a path + that exists solely inside a release run (like #2551's publish step) has the + re-cut run as its first real evidence. Get as close as you can beforehand (a + `--dry-run` with the pinned tool version), and record in the ledger which kind + of evidence each fix has. If npm *did* publish and something downstream failed (the GHCR image, for example), do not re-cut: that would try to publish the same npm version again. From f7290553023d94449098300d6f08e2e98bd281e1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 30 Sep 2026 19:17:06 -0400 Subject: [PATCH 3/5] docs(release): fix the recipe's scan() indexing and notes-file placeholder (Copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The body-reference extraction did scan(...)[] | .[0], which with gh's jq indexes the first CHARACTER of each captured string: "Closes #2554" became issue 2. It is now scan(...) | .[0]. Verified with gh --jq: "Closes #2554 … Fixes #12 … resolves #999" gave [2,1,9] before and [2554,12,999] after. The 2.9.0 check missed it because every PR there also carried a manual closing link. - inside a shell block is input redirection, so --notes-file lost its argument. It is now a NOTES variable. Cross-checked: the fixed recipe reproduces the published Thanks lists of 2.9.0, 2.8.0, 2.5.0 and 2.4.0 exactly (26 reporters; three of those lists were built independently). Refs #2554 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 5034d1fed..b58521b96 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -243,7 +243,7 @@ gh api "repos/$REPO/releases/generate-notes" -f tag_name="$VERSION" \ # maintainers (admin/maintain/write) and bots. for pr in $(grep -oE 'pull/[0-9]+' release-notes.md | cut -d/ -f2 | sort -un); do gh api graphql -F n="$pr" -f query='query($n:Int!){repository(owner:"modelcontextprotocol",name:"inspector"){pullRequest(number:$n){body closingIssuesReferences(first:20){nodes{number}}}}}' \ - --jq '.data.repository.pullRequest | ([.closingIssuesReferences.nodes[].number] + ([.body | scan("(?i)(?:closes|fixes|resolves) #([0-9]+)")[] | .[0] | tonumber])) | .[]' + --jq '.data.repository.pullRequest | ([.closingIssuesReferences.nodes[].number] + ([.body | scan("(?i)(?:closes|fixes|resolves) #([0-9]+)") | .[0] | tonumber])) | .[]' done | sort -un | while read -r n; do gh api graphql -F n="$n" -f query='query($n:Int!){repository(owner:"modelcontextprotocol",name:"inspector"){issueOrPullRequest(number:$n){... on Issue{number author{login __typename}}}}}' \ --jq '.data.repository.issueOrPullRequest | select(.number and .author.__typename == "User") | "\(.author.login) \(.number)"' @@ -288,7 +288,8 @@ merged: *Releases → Draft a new release → Choose a tag → type the bare `x. The same thing from the CLI, with the notes file from 3a: ```sh -gh release create "$VERSION" --target main --title "$VERSION" --notes-file --latest +NOTES=release-notes-final.md # the assembled notes from 3a +gh release create "$VERSION" --target main --title "$VERSION" --notes-file "$NOTES" --latest ``` `--target main` and the bare `$VERSION` give the right target and tag by From c5a1f008027a49c64be17fe8e13f75cff6c0605a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 30 Sep 2026 19:27:18 -0400 Subject: [PATCH 4/5] docs(release): delete the stale local tag too during a re-cut (Copilot) The re-cut deleted only the remote tag. The 3b manual path creates a local tag, which would stay pointed at the broken commit: the re-tag aborts, and a later git fetch --tags refuses to clobber it. Delete it locally as well. Refs #2554 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index b58521b96..3810191ab 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -349,8 +349,11 @@ is what 2.9.0 needed (#2551): the first run's `publish` passed a bare 3. **Delete the Release *and* its tag.** ⚠️ Deleting a Release in the UI leaves the tag behind. While the old tag exists, GitHub reuses it, so the re-cut attaches to the broken commit again, and `generate-notes` reads that commit - too. Delete the tag with `git push origin :refs/tags/$VERSION`, and confirm - it is gone with `gh api repos/$REPO/git/ref/tags/$VERSION` (expect a 404). + too. Delete it on the remote **and locally**: `git push origin :refs/tags/$VERSION` + and `git tag -d "$VERSION"`. A stale local tag (the 3b manual path creates one) + makes the re-tag abort, and later makes `git fetch --tags` refuse to clobber it. + Confirm the remote tag is gone with `gh api repos/$REPO/git/ref/tags/$VERSION` + (expect a 404). 4. **Recreate the Release at the new `main`.** Re-run the whole 3a recipe: What's Changed now includes the fix PRs, and a fix PR can close a community-reported issue, so the Thanks section can change too. Keep the From 0bcc4f9b126ad77405dd9968519070190ed22764 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 30 Sep 2026 19:38:58 -0400 Subject: [PATCH 5/5] docs(release): check npm by exact version; retry transient downstream failures (Copilot) - Never infer "unpublished" from dist-tags. During the 2.9.0 re-cut, 2.9.0 existed (Validating) while dist-tags still said latest 2.8.0. Wait out validation and treat only an exact-version 404 as unpublished. - When npm published but a downstream job (such as GHCR) failed, re-run only the failed job for a transient fault, and fix forward only a defect that cannot pass on retry. Never re-cut a version npm owns. Refs #2554 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3810191ab..1260ec953 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -332,10 +332,14 @@ adding a known issue after the fact needs no ceremony. ### 3c. If the release run fails -Check npm before anything else: `npm view @modelcontextprotocol/inspector -dist-tags`. If the new version is not there, nothing was published. A freshly -published version can also show **Validating** on npmjs.com for a few minutes -before it resolves; that is npm's automated review, not a failure. +**Check npm for the exact version before anything else**, and never infer +from `dist-tags`. A freshly published version sits in **Validating** (npm's +automated review, shown on npmjs.com) for a few minutes, and during that window +`dist-tags` still shows the previous `latest`. That happened on 2.9.0. So wait +out validation, then query the version itself: +`npm view @modelcontextprotocol/inspector@$VERSION version --prefer-online`. +**Only an exact-version 404 means nothing was published.** Re-cutting a version +npm already owns cannot succeed, because the version number is immutable. ⚠️ **A release event runs the workflow from the tag's commit, not from `main`.** Re-running a failed job therefore re-runs the same broken step. The @@ -365,9 +369,15 @@ is what 2.9.0 needed (#2551): the first run's `publish` passed a bare `--dry-run` with the pinned tool version), and record in the ledger which kind of evidence each fix has. -If npm *did* publish and something downstream failed (the GHCR image, for -example), do not re-cut: that would try to publish the same npm version again. -Fix it forward in the next release. +If npm *did* publish and a downstream job failed (the GHCR image, for example), +**do not re-cut**: that would try to publish the same npm version again. +Instead: + +- **A transient failure** (a registry hiccup, a runner fault): re-run **only + the failed job** from the run page. It re-runs at the same tagged commit and + does not touch the npm job. +- **A defect in the tagged workflow** that cannot pass on retry: fix it on + `v2/main` and let it ship with the next release. ## Why the bump goes on `v2/main` first (#2010)