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
141 changes: 139 additions & 2 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,95 @@ 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")
# 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.
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

: > 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
`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
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
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:
Expand Down Expand Up @@ -242,6 +325,60 @@ 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 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
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 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
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 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)

It used to happen on the milestone-merge branch, which is cut from `main` — so
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
Loading