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
55 changes: 53 additions & 2 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,14 @@ 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.
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, `main`
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
Expand All @@ -281,6 +283,55 @@ reaches `main` through `v2/main`, so there is nothing on `main` to bring back
except the merge commits themselves. `v2/main` ahead of `main` means a release
is in flight; that is the normal state.

### A release that cannot be a pure merge

Sometimes `v2/main` holds work that must not ship yet, and the release has to
come from a branch of its own: a release branch with cherry-picks (1.0.0
shipped that way, from `v2/release/1.0.0` in #5090), or a hotfix. The required
**Source is v2/main** check fails that PR, by design, and nobody can bypass
it, admins included. The only way through is a deliberate act by a repo
admin: disable the `main: milestone merges only` ruleset for that one merge,
and turn it straight back on.

The disable, the merge and the re-enable run as one command, in a subshell
whose exit trap re-enables the ruleset however it ends: the merge failing, an
error, or Ctrl-C. Approve the PR first; the other rulesets on `main` still
require that.

```sh
PR=<the release PR's number>
RS=$(gh api repos/modelcontextprotocol/servers/rulesets \
--jq '.[] | select(.name == "main: milestone merges only") | .id')
[ -n "$RS" ] || echo "ruleset not found by name; list them and look" >&2
[ -n "$RS" ] && (
set -e
restore() {
gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" \
-f enforcement=active --jq '"ruleset: \(.enforcement)"'
}
trap restore EXIT
trap 'exit 130' INT TERM HUP # exit, so the EXIT trap restores
gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" \
-f enforcement=disabled >/dev/null
# --merge: with the ruleset off, squash and rebase are allowed again.
gh pr merge "$PR" --repo modelcontextprotocol/servers --merge
)
gh api "repos/modelcontextprotocol/servers/rulesets/$RS" --jq .enforcement # must print "active"
```

A trap cannot run if the machine itself dies mid-command, so the last line
is not optional: if it prints anything but `active`, set it back by hand
(Settings → Rules → Rulesets) before doing anything else.

- **Only a human admin does this**, and only for a PR whose diff has been
reviewed as the release. Never disable the ruleset outside the command
above.
- **Record it in the release ledger**: the PR, why it could not be a pure
merge, and the times the ruleset was disabled and re-enabled.
- **Then merge the release branch back into `v2/main`** through a PR of its
own (#5118 did this for 1.0.0), so its commits are on `v2/main` and the
next milestone merge is a pure one again.

## 4. Verify the release candidate, and write the ledger

Work from a **dedicated worktree** with its own install, so nothing stale from
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/main-source.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# A pull request into `main` brings nothing that is not already on `v2/main`
# (#5119). `main` only ever receives a milestone's merge PR, a pure merge of
# `v2/main`; GitHub rulesets cannot restrict a PR's source branch, so this
# check, required by the `main` ruleset, is what does. The rule and why it is
# on the head commit rather than the branch name are in
# `scripts/verify-main-source.mjs`.
#
# On `pull_request_target`, unlike dco.yml: it runs `main`'s copy of this
# workflow and of the script, so a PR cannot edit the guard into passing. That
# is safe here because nothing from the PR runs: the checkout is `main`, and
# the PR's head is only fetched as git objects for `git merge-base` to read.
# Never add a step that checks out, installs or runs the PR's code.
Comment thread
cliffhall marked this conversation as resolved.
# `edited` is there so retargeting a PR onto `main` runs the check.
on:
pull_request_target:
types: [opened, synchronize, reopened, edited]
Comment thread
Copilot marked this conversation as resolved.
branches: [main]

# Read-only, and handed no secret, so this job holds no credential and stays
# outside the SHA-pin rule (`verify:action-pins`).
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
main-source:
timeout-minutes: 5
name: Source is v2/main
runs-on: ubuntu-latest
steps:
# The base branch (`main`), which `pull_request_target` checks out by
# default; never the PR's ref.
- uses: actions/checkout@v6
with:
persist-credentials: false

# The PR's head and `v2/main`, as objects only. A fork PR's head is
# reachable here through `refs/pull/<n>/head`. Full history, so
# `git merge-base` can walk both.
- name: Fetch the PR head and v2/main
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
git fetch --no-tags --unshallow origin \
"+refs/heads/v2/main:refs/remotes/origin/v2/main" \
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head"
- uses: actions/setup-node@v7
with:
node-version: 22

# Node built-ins and git only, so no `npm ci`.
- name: Check the head is already on v2/main
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/verify-main-source.mjs --head "$HEAD_SHA"
15 changes: 13 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ servers/
├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release),
│ version-packages.yml (the changesets PR), prepare-python-release.yml (the CalVer PR),
│ claude.yml (@claude mentions), everything-mcp-diff.yml (everything's interface diff on PRs),
│ main-source.yml (a PR into main must bring nothing that is not on v2/main),
│ and the scheduled sweeps that file issues: dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml
├── .github/ISSUE_TEMPLATE/ Bug and feature issue forms; config.yml routes security and new servers away
├── .github/pull_request_template.md The "issues, not PRs" banner that turns outside PRs away
Expand Down Expand Up @@ -153,6 +154,10 @@ diagnosing a red stage is the `pre-push-gate` skill.
publish artifact into an empty directory and boots it, which needs the
network. `release.yml` runs it before anything publishes, and the `release`
skill runs it by hand for the ledger.
- **`verify-main-source` is not a gate stage either.** It applies only to a
pull request into `main` (see the merge PR exception under
[Contributing](#contributing)), and every local push targets a v2 branch.
`main-source.yml` runs it.
- **`local:*` scripts never run in a workflow, and every CI job declares
`timeout-minutes`.** The script tests enforce both
(`scripts/lib/workflow-gate.mjs`). A check added to CI is added to
Expand Down Expand Up @@ -353,8 +358,14 @@ holds for a maintainer's own one-line fix as much as for a feature.
default branch a closing keyword would close that issue at merge, before
anything is published. The release issue is closed by hand once the Release
is published. In place of the checklist it links the **release ledger**. It
is merged with a merge commit, never squashed. The flow is the `release`
skill.
is merged with a merge commit, never squashed. Both are enforced on `main`:
its ruleset allows only merge commits, and requires the **Source is
v2/main** check (`main-source.yml`), which fails a PR whose head carries
any commit not already on `v2/main`. That ruleset has no bypass actors; a
release that cannot be a pure merge (a release branch, a hotfix) goes
through only by a repo admin disabling it for that one merge and
re-enabling it at once, which is never automated. The flow is the
`release` skill.
- **Exception: the two version PRs that automation opens.** Each changes
version metadata only, so neither answers the PR checklist below, and each
is tied to its milestone's **release issue** rather than to an issue of its
Expand Down
9 changes: 7 additions & 2 deletions docs/quality-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ out; everything else points here.
| --- | --- | --- | --- |
| Inner loop | `npm run validate -w src/<server>`, `npm run validate:py -- <server>` | Your machine | While iterating on one server |
| Pre-push gate | `npm run local:gate` | Your machine, under a lease | Before every push |
| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only | Before merge |
| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml`; `main-source.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only; `main-source.yml` on pull requests into `main` only, and not mirrored by a gate stage, since no local push targets `main` | Before merge |
Comment thread
Copilot marked this conversation as resolved.

**`npm run local:gate` runs every check CI runs.** CI splits the same checks
into parallel jobs on a fresh install; the gate runs them in sequence on your
Expand All @@ -22,13 +22,18 @@ your machine. It is the strongest predictor of a green CI there is here, though
not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 22 and each
Python server's pinned interpreter.

**The one exception is the Windows legs** (#1149): `test-windows` in both
**There are two exceptions.** The first is the Windows legs (#1149): `test-windows` in both
workflows runs each server's test suite (`npm test`, `pytest`) on a
`windows-latest` runner. The gate runs the same suites, but only on your
machine's OS, so a Windows-only failure (a drive letter, a UNC path, a missing
`O_NOFOLLOW`, a locked file) shows up first in CI. When a PR touches
platform-specific code, wait for those legs, not just the gate.

The second is **Source is v2/main** (`main-source.yml`), which runs only on a
pull request into `main` and checks that its head is already on `v2/main`. No
local push targets `main`, so the gate has nothing to check there and no stage
mirrors it.

The inner-loop commands are subsets. They are for speed while working, and are
never a substitute for the gate.

Expand Down
142 changes: 142 additions & 0 deletions scripts/verify-main-source.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env node
// Guard: a pull request into `main` brings nothing that is not already on
// `v2/main` (#5119).
//
// `main` is the release line. The one PR that may target it is a milestone's
// merge PR, a pure merge of `v2/main` (AGENTS.md, "Contributing"). GitHub
// rulesets can require a PR and its approvals, but cannot say where a PR comes
// from, so before this guard a PR from any branch could merge into `main`.
//
// The rule is on the COMMIT, not the branch name: the PR's head must be an
// ancestor of the source (`origin/v2/main`), or the source itself. The
// `release` skill opens the merge PR from `v2/chore/<N>-release-<milestone>`,
// a branch pushed from `origin/v2/main`, so a check on the head's name would
// refuse the one PR the rule exists to allow. An ancestor rather than only an
// equal commit, because `v2/main` keeps moving while the merge PR is open, and
// the PR is still a pure merge of what it was opened with.
//
// CI runs it from `main-source.yml` on `pull_request_target`, which runs
// `main`'s copy of the workflow and of this script, so a PR cannot edit the
// guard into passing. The PR's head is only fetched as git objects; none of
// its code runs. It is not a `local:gate` stage: every local push targets a
// v2 branch, where the rule does not apply.

import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

/** The branch every commit on `main` must already be on. */
export const DEFAULT_SOURCE = "origin/v2/main";

/** How many of the offending commits the report lists. */
export const MAX_LISTED = 20;

/**
* Pure: `--head <rev>` (required) and `--source <rev>`.
*
* @param {string[]} argv
*/
export function parseArgs(argv) {
const opts = { source: DEFAULT_SOURCE, head: "" };
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag !== "--source" && flag !== "--head")
throw new Error(`unknown argument: ${flag}`);
const value = argv[++i];
if (!value) throw new Error(`${flag} needs a revision`);
opts[flag.slice(2)] = value;
}
if (!opts.head) throw new Error("--head is required");
return opts;
}

/**
* Pure: the report for a head that carries commits the source does not.
*
* @param {{ source: string, head: string, commits: string[] }} args
* `commits` is `git log --oneline <source>..<head>`, one per entry
*/
export function formatReport({ source, head, commits }) {
const n = commits.length;
const listed = commits.slice(0, MAX_LISTED).map((c) => ` ${c}`);
if (n > MAX_LISTED) listed.push(` … and ${n - MAX_LISTED} more`);
return [
`verify:main-source: ${head} carries ${n} commit${n === 1 ? "" : "s"} that ${source} does not:`,
...listed,
"",
"A pull request into `main` must be a pure merge of `v2/main`: its head",
"is a commit already on `v2/main`. Land the change on `v2/main` through",
"its own PR, and it reaches `main` with the next milestone merge (the",
"`release` skill).",
].join("\n");
}

function git(args, cwd) {
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
if (r.error) throw r.error;
return r;
}

/**
* Check that `head` is `source` or an ancestor of it, in the repository at
* `cwd`.
*
* @returns {{ code: number, output: string }}
*/
export function verifyMainSource({ source, head, cwd }) {
for (const rev of [source, head]) {
if (
git(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], cwd).status
)
return {
code: 1,
output:
`verify:main-source: cannot resolve ${rev}.` +
(rev === DEFAULT_SOURCE
? " Fetch it (`git fetch origin v2/main`), or name another source with --source."
: ""),
};
}
const anc = git(["merge-base", "--is-ancestor", head, source], cwd);
if (anc.status === 0)
return {
code: 0,
output: `verify:main-source: ${head} is on ${source}; nothing else comes with it.`,
};
if (anc.status !== 1)
return {
code: 1,
output: `verify:main-source: git merge-base failed:\n${anc.stderr}`,
};
const log = git(
["log", "--oneline", "--no-decorate", `${source}..${head}`],
cwd,
);
if (log.status)
return {
code: 1,
output: `verify:main-source: git log failed:\n${log.stderr}`,
};
const commits = log.stdout.split("\n").filter((l) => l.trim() !== "");
return { code: 1, output: formatReport({ source, head, commits }) };
}

if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
let opts;
try {
opts = parseArgs(process.argv.slice(2));
} catch (e) {
console.error(`verify:main-source: ${e.message}`);
process.exit(2);
}
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const { code, output } = verifyMainSource({ ...opts, cwd: repoRoot });
(code === 0 ? console.log : console.error)(output);
process.exit(code);
}
Loading
Loading