- name
- release
- description
- Cut a milestone release: the release issue, the preparation PRs on v2/main (audit, changesets Version Packages, Python CalVer), the pure v2/main to main merge PR with its release ledger, then the GitHub Release a maintainer publishes.
- disable-model-invocation
- true
# Cutting a milestone release
How versions and publishing work, and what to do when a publish fails, is
[`RELEASING.md`](../../../RELEASING.md). This skill is the procedure for one
milestone, start to finish. It is started by name only (`/release`), because a
release is never something to begin by inference.
A release is cut from **`main`**, after the milestone's work has been merged
there from `v2/main`. Nothing publishes from `v2/main`: `release.yml` refuses a
Release whose commit is not on `main`.
**What stays human.** Publishing the GitHub Release and approving the `release`
environment deployments are a maintainer's acts. So is merging any PR. Prepare,
verify and recommend; do not perform those.
## 0. Check this session's instructions first
A release is the longest-running session and the costliest to get wrong, and
it follows this skill and `AGENTS.md` exactly as they were when the session
started. Before step 1, run the staleness check in `AGENTS.md`
**Issue-driven work style** (all work in a worktree freshly cut from
`origin/v2/main`), whatever the `SessionStart` hook printed: the hook ran when
the session began, and this check runs now.
On `STALE`, or a `STALE` line from the `SessionStart` hook, **stop** and ask
for a session started in a fresh worktree. The v2.0.0 release ran its first
three steps on a `release` skill 447 commits out of date (#5167). Re-run the
check after each preparation PR merges, since `v2/main` moves under the
session, **from the session-start revision, not `HEAD`**: this flow moves
between worktrees (step 4e moves the verification worktree onto a newer
candidate), and a newer worktree's `HEAD` says nothing about the rules this
session loaded. The hook prints that revision and the re-check command.
## The shape
One **release issue**, up to three **preparation PRs** on `v2/main`, one
**merge PR** into `main`, then the **Release**. In that order: each step needs
the one before it merged.
| | Preparation PRs | The merge PR |
| --- | --- | --- |
| Base | **`v2/main`** | **`main`** |
| Carries | The audit's forced fixes; the TypeScript version bumps; the Python version stamps | The milestone's work, arriving whole from `v2/main`. **No commits of its own** |
| References the release issue with | `Closes #N` (a cross-reference only, on `v2/main`) | `Part of #N` (**non-closing**: `main` is the default branch, where `Closes` would close the issue before anything is published) |
| Verified by | `npm run local:gate`, CI | `npm run local:gate`, **plus** `npm run pack:verify`, **plus** every milestone issue exercised, written up as the **release ledger** |
⚠️ **Do not fold them together.** A version bump made on the merge branch
exists only downstream of `v2/main`, and nothing carries it back: the develop
branch would keep reporting the previous version, and the next branch cut from
it would too.
## 1. The release issue
One per milestone, titled **`Release vX.Y.Z`** (the milestone's name), filed
through `/issue-create`: type `chore`, no server-scope label, the milestone it
releases. It is what every PR below references, so file it first.
Set its card to **In Progress** (`/board-ops`). It stays open until the Release
is published (step 6), whatever merges in between.
```sh
N=<RELEASE_ISSUE_NUMBER>
MILESTONE=vX.Y.Z
```
**Check the milestone is complete** before preparing anything. The only open
issue left in it should be the release issue itself:
```sh
gh issue list --repo modelcontextprotocol/servers --milestone "$MILESTONE" \
--state open --json number,title --jq '.[] | "#\(.number) \(.title)"'
```
Anything else open is either finished first or moved to the next milestone by
a maintainer. Do not release around it silently.
## 2. Preparation PRs, on `v2/main`
All three are part of the milestone's work, so all three land on the develop
branch and reach `main` with everything else. **All are merged before the merge
PR is opened.** A milestone that touches only one language, with a clean audit,
has one.
Each branch this skill cuts by hand (the audit, the hand-run CalVer stamp, the
merge branch) goes into **its own sibling worktree**, as `/pr-flow` step 2
does, never by switching branches in the primary checkout. Run each recipe from
the primary checkout; the worktree is named for the checkout, the release
issue and the branch's role, since one issue owns several branches.
### 2a. The audit
Audit first, so the version bumps sit on a tree that was just checked.
```sh
git fetch origin v2/main
git worktree add --no-track "../$(basename "$PWD")-$N-audit" \
-b "v2/chore/$N-release-audit" origin/v2/main
cd "../$(basename "$PWD")-$N-audit" && npm install
# npm: one lockfile covers the root and all four TypeScript workspaces.
npm audit --audit-level=high
# Python: each server has its own lockfile. `uv` has no audit of its own in the
# version pinned here, so the locked requirements go through pip-audit.
# Exported to a file first: piped straight in, a failed `uv export` hands
# pip-audit empty input, which it reports as clean. And pip-audit exits
# non-zero both when it finds something and when it could not run, so a
# server counts as audited only when its output holds a verdict. The block
# runs in a subshell that exits non-zero if any server was not audited.
(
unaudited=
for s in fetch git time; do
echo "== $s"
REQ=$(mktemp) OUT=$(mktemp)
if (cd "src/$s" && uv export --frozen --no-emit-project --format requirements-txt) > "$REQ" \
&& [ -s "$REQ" ]; then
uvx pip-audit --require-hashes --disable-pip -r "$REQ" > "$OUT" 2>&1
cat "$OUT"
grep -qE 'No known vulnerabilities found|Found [0-9]+ known vulnerabilit' "$OUT" || {
echo "AUDIT DID NOT RUN for $s: no verdict in its output" >&2
unaudited="$unaudited $s"
}
else
echo "EXPORT FAILED for $s: nothing was audited" >&2
unaudited="$unaudited $s"
fi
rm -f "$REQ" "$OUT"
done
[ -z "$unaudited" ] || { echo "NOT AUDITED:$unaudited" >&2; exit 1; }
); echo "python audit complete: EXIT=$?"
```
**This step is a report.** Read it; do not let a tool rewrite the tree. The
`EXIT=` line says whether every Python server was audited, not whether the
audit was clean: `pip-audit`'s findings are in the output above it.
⚠️ **Never `npm audit fix`**, with or without `--force`. `AGENTS.md`
**Dependencies** rules it out: it resolves an advisory that has no upward
escape by silently downgrading, and the gate does not detect a version going
backwards.
Each finding gets one of three outcomes, decided by a person:
- **A fix inside the declared range**: a direct bump, or an `overrides` entry
(npm) or a raised lower bound plus `uv lock` (Python). Each fix is **its own
commit**, and a fix to a TypeScript server's runtime dependencies carries a
changeset.
- **A fix that needs a major**: its own issue and its own PR, not a
release-day edit.
- **No fix available, or not reachable from the server**: say so in the
report, with the reason.
Post the report as a comment on the release issue. If fixes were forced, open
the PR (`/pr-flow`), body starting `Closes #N`, with the report in it. **If
none were, there is no audit PR**: an empty PR is not evidence, the comment is.
### 2b. The TypeScript version bumps: "Version Packages"
`version-packages.yml` keeps this PR open whenever `.changeset/` holds pending
changesets. Find it:
```sh
gh pr list --repo modelcontextprotocol/servers --base v2/main \
--head changeset-release/v2/main --json number,title,url
```
No PR means no TypeScript server has a pending changeset. Check that is true
rather than assuming it, since a TypeScript change merged without its changeset
releases nothing:
```sh
# Refresh first, and read the REMOTE tree: the checkout may be a preparation
# branch cut before another PR merged.
git fetch origin main v2/main --tags
git ls-tree -r --name-only origin/v2/main .changeset/ | grep '\.md$' | grep -vx '.changeset/README.md' # the pending changesets
# Since the latest published Release, asked of GitHub: this repo's tags do not
# sort into release order (see 5a).
PREV=$(gh release view --repo modelcontextprotocol/servers --json tagName --jq .tagName)
git log --oneline "$PREV"..origin/v2/main -- \
src/everything src/filesystem src/memory src/sequentialthinking
```
If the second command lists changes to what a server publishes and the first
lists no changeset for that server, add the missing changeset through an
ordinary PR first.
Then, on the PR:
1. **Link it to the release issue, and leave a comment.** The action rewrites
the PR's body on every push to `v2/main`, so a `Closes` line there would
not survive; this is the exception `AGENTS.md` names. The link lives
outside the body, so make it now, with the `addCloseIssueReferences`
mutation from `/pr-flow` (step 6), and read it back. On `v2/main` it closes
nothing at merge. Then comment `Part of #N`, the textual record that every
rewrite leaves alone:
```sh
PR=<VERSION_PACKAGES_PR>
gh pr comment "$PR" --repo modelcontextprotocol/servers --body "Part of #$N"
```
2. **Wait for its CI to pass.** The workflow token's push starts no workflow,
so the Version Packages workflow dispatches the TypeScript, Python, DCO and
Everything diff workflows on `changeset-release/v2/main` after each update.
A PR does not list dispatched checks, so the result shows as four commit
statuses: `TypeScript (dispatched)`, `Python (dispatched)`,
`DCO (dispatched)` and `Everything diff (dispatched)`. The PR may also
list `DCO signoff` and the diff as pending or failed with no jobs: GitHub
holds the token's `pull_request` runs (#5173), so read the statuses
instead. Closing and reopening the PR does not start the CI workflows
(that fires `pull_request`; they run on `push`). If a status does not
appear, dispatch its workflow by hand:
`gh workflow run typescript.yml --ref changeset-release/v2/main` (or
`python.yml`, `dco.yml`, `everything-mcp-diff.yml`).
3. **Read it**: each bump matches the semver policy in `RELEASING.md`, each
`CHANGELOG.md` entry says what a user of the server needs to know, the
consumed changeset files are deleted, and `package-lock.json` records the
new versions.
4. **Put `Part of #N` as the body's first line, immediately before the
merge**, once nothing else will land on `v2/main` first: any push there
regenerates the body and drops the line.
```sh
# Read the body first, and edit only on a successful, non-empty read: a
# failed read must never replace the generated release notes with the
# prefix alone.
OLD=$(gh pr view "$PR" --repo modelcontextprotocol/servers --json body --jq .body) \
&& [ -n "$OLD" ] \
&& printf 'Part of #%s\n\n%s\n' "$N" "$OLD" \
| gh pr edit "$PR" --repo modelcontextprotocol/servers --body-file - \
|| echo "body read or edit FAILED — PR body left as it was" >&2
```
5. A maintainer merges it, **first checking the line is still the body's
first**. If a push regenerated the body in between, repeat step 4:
```sh
gh pr view "$PR" --repo modelcontextprotocol/servers \
--json body --jq '.body | split("\n")[0]' # must print: Part of #N
```
### 2c. The Python version stamps: "Prepare Python Release"
Do this **last**, close to the release: it stamps the date it runs on.
```sh
gh workflow run prepare-python-release.yml --repo modelcontextprotocol/servers \
--ref v2/main -f issue="$N"
```
It opens a PR that starts with `Closes #N`, on a branch named
`v2/chore/<N>-python-calver-<date>`, labeled `v2`, and dispatches the
TypeScript, Python and DCO workflows on that branch, as the Version Packages
workflow does (not the Everything diff, whose paths it never touches). Wait
for its three `(dispatched)` statuses to pass, check that each stamped package really
changed, and a maintainer merges it.
No PR means no Python server has a shipped change since its last stamp.
⚠️ GitHub only dispatches a workflow that exists on the default branch. Until
the first milestone merge carries this one to `main`, do it by hand, which is
the same script:
```sh
git fetch origin v2/main
git worktree add --no-track "../$(basename "$PWD")-$N-calver" \
-b "v2/chore/$N-python-calver-$(date -u +%Y.%-m.%-d)" origin/v2/main
cd "../$(basename "$PWD")-$N-calver" && npm install
node scripts/prepare-python-release.mjs # prints "name: old -> new" per stamped package
git commit -s -am "chore: stamp Python CalVer versions for $(date -u +%Y.%-m.%-d)"
```
then open the PR through `/pr-flow`, body starting `Closes #N`.
## 3. The merge PR: `v2/main` → `main`
Open it only when every preparation PR has merged.
The merge branch is `v2/main` under another name: cut from `origin/v2/main`,
with nothing added. The merge itself is made by GitHub when the PR merges, so
`main` gets exactly one merge commit and the PR has no commit of its own.
(Merging locally first would put a merge commit on the branch, and merging the
PR would then add a second.)
```sh
git fetch origin main v2/main
git worktree add --no-track "../$(basename "$PWD")-$N-merge" \
-b "v2/chore/$N-release-$MILESTONE" origin/v2/main
cd "../$(basename "$PWD")-$N-merge"
```
**The merge's result is the release candidate. Prove it will be `v2/main`'s
tree before opening the PR:**
```sh
git rev-parse 'origin/v2/main^{tree}'
git merge-tree --write-tree origin/main HEAD # must print the same hash, and nothing else
```
`git merge-tree` computes the tree the merge into `main` would produce,
without touching the checkout. A different hash, or conflict output, means
`main` holds a change `v2/main` does not. That is a change that exists only
downstream of the develop branch. Stop and find out what it is, rather than
pushing.
Open the PR against **`main`**, labeled `v2`. Its body's first line is
**`Part of #N`**, not `Closes #N`:
```sh
BODY=$(mktemp)
cat > "$BODY" <<EOF
Part of #$N
Milestone merge for $MILESTONE: \`v2/main\` → \`main\`. No commits of its own;
the merged tree is \`origin/v2/main\`'s (\`$(git rev-parse --short 'HEAD^{tree}')\`).
Release ledger: <link, added in step 4>
EOF
git push -u origin HEAD
gh pr create --repo modelcontextprotocol/servers --base main --label v2 \
--title "Release $MILESTONE: merge v2/main into main" --body-file "$BODY"
```
This is the one PR in the repository that targets `main`.
⚠️ **It is merged with "Create a merge commit", never squashed or rebased.** A
squash writes one new commit that `v2/main` does not contain, so the two
branches stop sharing history and the next milestone merge conflicts with
itself. `main`'s ruleset allows only merge commits, so the other two buttons
are not offered.
⚠️ **Do not click "Update branch" on the merge PR.** GitHub always offers it
here, because `main` always holds commits the merge branch lacks (its own
earlier merge commits), so it does not mean the PR is out of date. Clicking it
merges `main` into the merge branch: the PR gains a commit of its own (which
fails the required **Source is v2/main** check, `main-source.yml`), `main`
receives two merge commits, and the branch head is no longer the commit the
ledger names. If it was clicked, put the branch back on its head from before
the click: the update merge's **first parent**, which is the commit the ledger
GitHubで見る