Repository navigation
478 lines (447 loc) · 22.8 KB
/
Copy pathrelease.yml
File metadata and controls
478 lines (447 loc) · 22.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# Publishes every package whose version the registry does not have yet, when a
# maintainer publishes a GitHub Release (#4472, #4873). The procedure is
# RELEASING.md, and the `release` skill.
#
# No job here computes, stamps or tags a version. The version in each
# package.json / pyproject.toml on the released ref is the source of truth: it
# got there through a reviewed PR (changesets' "Version Packages" for the
# TypeScript servers, "Prepare Python Release" for the Python ones), and the
# release tag is only a label on tested code. So a tag can never disagree with
# a version, and there is nothing to push back to a branch.
#
# BUILD AND PUBLISH ARE SEPARATE JOBS, per package:
#
# build-* checks out the tag, installs dependencies, runs the tests, and
# runs `pack:verify`: it builds the tarball or wheel, installs it
# into an empty directory and boots the installed server. Holds no
# `id-token` and no environment. Uploads the verified artifact.
# publish-* downloads that artifact and hands it to the registry. Holds
# `id-token: write` and the `release` environment. No checkout,
# no dependency install, no build.
#
# The split is about what runs next to the OIDC token. Installing dependencies
# runs their lifecycle scripts, and a build runs the toolchain; in a job that
# can mint a publish credential, any of that code can publish. Here the only
# things that run with the token are a pinned action or two and the registry
# client. And since what is published is the artifact the build job uploaded,
# every action in BOTH jobs is pinned to a commit SHA: `verify:action-pins`
# enforces that for any job holding a credential and any job whose artifact
# such a job downloads.
#
# Every package is a matrix leg, and each leg decides for itself: a version
# already on the registry is SKIPPED, not failed. That makes a release
# idempotent and self-healing. A package whose publish failed is picked up by
# the next release with no special handling, and one that did not change costs
# a skipped leg. For npm the build leg decides (`npm-publish-guard.mjs`) and
# uploads a `SKIP` marker instead of a tarball; for PyPI the registry decides
# at upload (`skip-existing`).
#
# The trigger is `release: published` and nothing else, so editing a
# published Release's notes never re-runs publishing. A release event runs the
# workflow as it is AT THE TAG, which is why the fix for a broken publish step
# has to reach `main` and be released again rather than re-run.
#
# Authentication is OIDC trusted publishing on both registries: there are no
# registry tokens. Each trusted publisher is bound to this workflow's filename
# (`release.yml`) and the `release` environment, so the publish jobs must stay
# in this file and keep that environment.
#
# Only a commit that is on `main` is published. A Release can be created for
# any tag on any commit, and the environment approval shows a reviewer a tag
# name, not what it points at, so `detect-packages` checks the released commit
# against `main` and every job after it needs it.
name: Release
on:
release:
types: [published]
# One release at a time, and none dropped. Each publish leg reads the
# registry's `latest`, chooses a dist-tag from it and publishes; two runs doing
# that at once could each see the old `latest` and then publish out of order,
# leaving the lower version tagged `latest`. Queuing a run behind the one in
# progress makes its read see what that one published.
#
# `queue: max` keeps every waiting run (up to 100). The default keeps only
# ONE pending run per group and cancels it when another arrives, which would
# silently drop a release whose packages then never publish at its versions.
# `cancel-in-progress` is left at its default, false: a run stopped between
# packages leaves a release half published, and GitHub rejects `queue: max`
# together with `cancel-in-progress: true`.
concurrency:
group: release
queue: max
# Nothing in this workflow needs more than read access by default; the two
# publish jobs add `id-token: write` for themselves.
permissions:
contents: read
jobs:
detect-packages:
timeout-minutes: 5
runs-on: ubuntu-latest
if: github.repository_owner == 'modelcontextprotocol'
outputs:
npm_packages: ${{ steps.find-packages.outputs.npm_packages }}
pypi_packages: ${{ steps.find-packages.outputs.pypi_packages }}
npm_expected: ${{ steps.expected.outputs.npm_expected }}
pypi_expected: ${{ steps.expected.outputs.pypi_expected }}
steps:
# On a release event the default ref is the release's tag: the exact
# commit the maintainer released. Full history, for the check below.
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
# `main` only receives reviewed milestone merges, so "on main" is what
# makes the released code reviewed code. A tag cut from `v2/main`, a
# feature branch or a fork's commit fails here, before any build or
# publish job is eligible to run.
- name: Refuse a release whose commit is not on main
run: |
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then
echo "::error::The released commit $GITHUB_SHA is not on main. Publish the Release from a tag on main (see RELEASING.md)."
exit 1
fi
- name: Find all packages
id: find-packages
working-directory: src
run: |
NPM=$(find . -name package.json -not -path "*/node_modules/*" -exec dirname {} \; | sed 's/^\.\///' | jq -R -s -c 'split("\n")[:-1]')
PYPI=$(find . -name pyproject.toml -exec dirname {} \; | sed 's/^\.\///' | jq -R -s -c 'split("\n")[:-1]')
echo "npm_packages=$NPM" >> "$GITHUB_OUTPUT"
echo "pypi_packages=$PYPI" >> "$GITHUB_OUTPUT"
# Each package's name and version, read from the released commit before
# any dependency code has run. The publish jobs compare the artifact
# they are handed with these; the reasoning is in the script's header.
# It imports nothing outside Node, so nothing is installed for it.
- name: Record what each package should be published as
id: expected
run: node scripts/release-manifest.mjs
build-npm:
timeout-minutes: 15
needs: [detect-packages]
if: ${{ needs.detect-packages.outputs.npm_packages != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.npm_packages) }}
name: Build ${{ matrix.package }} (npm)
runs-on: ubuntu-latest
env:
PACKAGE: ${{ matrix.package }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: npm
# The registry-diff guard: skip (not fail) a version that is already
# published, and treat a never-published package as "publish it".
- name: Check whether this version is already on npm
id: guard
run: node scripts/npm-publish-guard.mjs "src/$PACKAGE"
# This package plus the root, as in CI: `pack:verify` is a root script
# and its MCP client is a root devDependency.
- name: Install dependencies
if: steps.guard.outputs.skip != 'true'
run: npm ci --workspace "src/$PACKAGE" --include-workspace-root
# Defense in depth: CI already validated the branch, but this tests the
# exact ref being published, whatever path the tag took to get here.
- name: Run tests
if: steps.guard.outputs.skip != 'true'
working-directory: src/${{ matrix.package }}
run: npm test --if-present
# Packs the tarball (which builds it), installs it into an empty
# directory and boots the installed server. The tarball it leaves in
# `release-artifact/<package>/` is the one that gets published.
- name: Pack, install and boot the tarball
if: steps.guard.outputs.skip != 'true'
run: npm run pack:verify -- "$PACKAGE" --out release-artifact
# An artifact is uploaded either way, so the publish leg always has
# something to download; the marker is how it learns there is nothing
# to publish.
- name: Mark an already-published version as skipped
if: steps.guard.outputs.skip == 'true'
run: |
mkdir -p "release-artifact/$PACKAGE"
touch "release-artifact/$PACKAGE/SKIP"
- name: Upload the artifact for the publish job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: npm-${{ matrix.package }}
path: release-artifact/${{ matrix.package }}/
if-no-files-found: error
retention-days: 7
publish-npm:
timeout-minutes: 10
needs: [detect-packages, build-npm]
# `!cancelled()` rather than the default `success()`: `build-npm` is one
# job with a leg per package, so one package's failed build would
# otherwise skip every package's publish. Each publish leg stands on its
# own artifact instead, and the leg whose build failed has none to
# download, so it fails there, before anything is published.
if: ${{ !cancelled() && needs.detect-packages.result == 'success' && needs.detect-packages.outputs.npm_packages != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.npm_packages) }}
name: Publish ${{ matrix.package }} (npm)
# The trusted-publisher binding keys off this workflow file and this
# environment, so the environment stays on the job that publishes.
# `build-npm` deliberately does not take it.
environment: release
permissions:
# Required for npm trusted publishing (OIDC) and provenance. This job
# runs no dependency code: see the header.
id-token: write
runs-on: ubuntu-latest
steps:
# No `cache: npm`: there is no checkout, so no lockfile to key it on, and
# nothing here installs dependencies to cache.
# OIDC trusted publishing requires npm >= 11.5.1. Not every Node 24 bundles
# it, so this job pins a Node 24 release whose bundled npm meets the floor, and
# installs nothing: no package is fetched from the registry and run
# next to `id-token: write` (#5104). Pinned EXACTLY, not to a major: the
# runtime is the one thing this job trusts besides the pinned actions,
# and a range would let whatever 24.x is current at release time in.
# The build jobs take the current 24.x; this job only reads a manifest and runs
# `npm publish` on the verified tarball.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.21.0
registry-url: "https://registry.npmjs.org"
# Fails closed before anything is published if the bundled npm is ever
# below the OIDC floor (a different Node pin, or a runner override).
- name: Check the bundled npm supports OIDC trusted publishing
run: |
NPM_VERSION="$(npm --version)"
echo "node $(node --version), npm $NPM_VERSION"
node -e '
const [a, b, c] = process.argv[1].split(".").map(Number);
const ok = a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1)));
if (!ok) {
console.error("npm " + process.argv[1] + " is below 11.5.1, the floor for OIDC trusted publishing");
process.exit(1);
}
' "$NPM_VERSION"
- name: Download the artifact built by the build job
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-${{ matrix.package }}
path: release-artifact
# Publishes the tarball `build-npm` verified: a file argument, so npm
# runs no lifecycle scripts and builds nothing here. There is
# deliberately no `NODE_AUTH_TOKEN`: publishing is tokenless, and an
# empty token in `.npmrc` makes npm fail before it ever tries OIDC.
- name: Publish package
env:
NPM_CONFIG_PROVENANCE: "true"
EXPECTED_NAME: ${{ fromJson(needs.detect-packages.outputs.npm_expected)[matrix.package].name }}
EXPECTED_VERSION: ${{ fromJson(needs.detect-packages.outputs.npm_expected)[matrix.package].version }}
run: |
if [ -e release-artifact/SKIP ]; then
echo "This version is already on npm; nothing to publish."
exit 0
fi
# `./` is load-bearing: `npm publish` reads a bare `dir/file`
# argument as a GitHub `owner/repo` shorthand and tries to clone it
# instead of publishing the local tarball.
set -- ./release-artifact/*.tgz
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "Expected exactly one tarball in release-artifact/, found: $*"
exit 1
fi
# The tarball was built after dependency install scripts ran, so its
# manifest is untrusted input here. npm merges a manifest's
# `publishConfig` into its own config before the OIDC exchange, so an
# injected registry or proxy could redirect this job's credential
# flow. No package here declares one, so any present is refused, and
# the registry is named on the command line as well. For the same
# reason its name and version are compared with what the released
# commit declares (recorded by `detect-packages`, before any
# dependency code ran): every package here trusts this workflow, so
# a tampered build could otherwise publish as another of them, or at
# a version nobody reviewed.
# The check fails closed: a tarball that cannot be read, or a
# manifest that does not parse, stops the job. Only a manifest that
# was read and has no `publishConfig` goes on to `npm publish`.
MANIFEST="$(tar -xOzf "$1" package/package.json)" || {
echo "Refusing to publish: could not read package/package.json from $1"
exit 1
}
VERDICT="$(printf '%s' "$MANIFEST" | node -e "
const m = JSON.parse(require('fs').readFileSync(0, 'utf8'));
if ('publishConfig' in m) console.log('declares publishConfig');
else if (m.name !== process.env.EXPECTED_NAME) console.log('is named ' + m.name + ', expected ' + process.env.EXPECTED_NAME);
else if (m.version !== process.env.EXPECTED_VERSION) console.log('is version ' + m.version + ', expected ' + process.env.EXPECTED_VERSION);
else console.log('clean');
")" || {
echo "Refusing to publish: the tarball's package.json does not parse"
exit 1
}
if [ -z "$EXPECTED_NAME" ] || [ -z "$EXPECTED_VERSION" ]; then
echo "Refusing to publish: no expected name or version was recorded for this package"
exit 1
fi
if [ "$VERDICT" != "clean" ]; then
echo "Refusing to publish: the tarball's package.json $VERDICT"
exit 1
fi
# The dist-tag is chosen here, at publish time, and always passed
# explicitly.
#
# Explicitly, because npm 11 refuses to apply `latest` on its own
# when the registry already holds a HIGHER version, and it does: the
# date-stamped releases (2026.8.31) sort above every semver version
# these packages will carry. Without a tag the first 1.0.0 publish
# fails with "Cannot implicitly apply the latest tag".
#
# Chosen, because `latest` must never move BACKWARD. A failed leg of
# an older release can be re-run after a newer version has
# published; the registry-diff guard rightly lets the missing older
# version through, and tagging it `latest` would make every `npx`
# serve the older release. So:
# - the registry's `latest` is date-stamped (major >= 2000), or
# the package was never published: `latest`. This is the
# one-time move from 2026.x to 1.x.
# - this version is higher than the registry's `latest`: `latest`.
# - otherwise: `backfill`. The version is published and
# installable by number, and `latest` stays where it is.
# It is asked of the registry now, not when the run started: a
# re-run reuses the original run's job outputs. A registry answer
# that cannot be read, or a version that is not plain x.y.z (a
# prerelease needs its tag chosen by a person), stops the job.
ENCODED="$(printf '%s' "$EXPECTED_NAME" | sed 's|/|%2F|')"
CODE="$(curl -sS -o registry.json -w '%{http_code}' \
-H 'Accept: application/vnd.npm.install-v1+json' \
"https://registry.npmjs.org/$ENCODED")" || {
echo "Refusing to publish: could not reach the registry to read the current latest"
exit 1
}
case "$CODE" in
200) ;;
404) echo '{}' > registry.json ;;
*)
echo "Refusing to publish: the registry answered $CODE when asked for the current latest"
exit 1
;;
esac
TAG="$(node -e "
const num = (s, what) => {
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s);
if (!m) throw new Error(what + ' ' + JSON.stringify(s) + ' is not a plain x.y.z version');
return m.slice(1).map(Number);
};
const candidate = num(process.env.EXPECTED_VERSION, 'the version to publish');
const doc = JSON.parse(require('fs').readFileSync('registry.json', 'utf8'));
const latest = (doc['dist-tags'] || {}).latest;
if (typeof latest !== 'string') { console.log('latest'); process.exit(0); }
const current = num(latest, 'the registry latest');
let order = 0;
for (let i = 0; i < 3 && order === 0; i += 1) order = Math.sign(candidate[i] - current[i]);
console.log(current[0] >= 2000 || order > 0 ? 'latest' : 'backfill');
")" || {
echo "Refusing to publish: could not choose a dist-tag (see the error above)"
exit 1
}
echo "Publishing $EXPECTED_NAME@$EXPECTED_VERSION with dist-tag $TAG"
npm publish "$1" --access public --tag "$TAG" --registry https://registry.npmjs.org/
build-pypi:
timeout-minutes: 15
needs: [detect-packages]
if: ${{ needs.detect-packages.outputs.pypi_packages != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.pypi_packages) }}
name: Build ${{ matrix.package }} (PyPI)
runs-on: ubuntu-latest
env:
PACKAGE: ${{ matrix.package }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version-file: "src/${{ matrix.package }}/.python-version"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: npm
# Defense in depth: the same per-server chain CI and the gate run
# (locked sync, ruff, pyright, pytest, build), on the exact ref being
# published.
- name: Validate
run: node scripts/validate-py.mjs "$PACKAGE"
# `pack:verify` is a root script and its MCP client is a root
# devDependency. `--ignore-scripts` skips the TypeScript workspaces'
# `prepare` builds, which a Python package has no use for.
- name: Install the root tooling
run: npm ci --ignore-scripts
# Builds the wheel and sdist, installs the wheel into an empty
# environment and boots the installed console script. The distributions
# it leaves in `release-artifact/<package>/` are the ones published.
- name: Build, install and boot the wheel
run: npm run pack:verify -- "$PACKAGE" --out release-artifact
- name: Upload the artifact for the publish job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pypi-${{ matrix.package }}
path: release-artifact/${{ matrix.package }}/
if-no-files-found: error
retention-days: 7
publish-pypi:
timeout-minutes: 10
needs: [detect-packages, build-pypi]
# See `publish-npm`: one package's failed build must not block the rest.
if: ${{ !cancelled() && needs.detect-packages.result == 'success' && needs.detect-packages.outputs.pypi_packages != '[]' }}
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.pypi_packages) }}
name: Publish ${{ matrix.package }} (PyPI)
environment: release
permissions:
id-token: write # Required for trusted publishing
runs-on: ubuntu-latest
steps:
- name: Download the artifact built by the build job
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: pypi-${{ matrix.package }}
path: dist
# The artifact is untrusted input here, as in `publish-npm`: all three
# PyPI projects trust this workflow, so each file must be a wheel or an
# sdist of THIS project at the version the released commit declares.
# PyPI itself rejects a file whose name disagrees with its metadata, so
# the name is the thing to check.
- name: Check the distributions are this package's
env:
STEM: ${{ fromJson(needs.detect-packages.outputs.pypi_expected)[matrix.package].stem }}
run: |
if [ -z "$STEM" ]; then
echo "Refusing to publish: no expected name and version were recorded for this package"
exit 1
fi
found=0
for f in dist/* dist/.[!.]*; do
[ -e "$f" ] || continue
case "$(basename "$f")" in
"$STEM.tar.gz" | "$STEM"-*.whl) found=$((found + 1)) ;;
*)
echo "Refusing to publish: $(basename "$f") is not a distribution of $STEM"
exit 1
;;
esac
done
if [ "$found" -eq 0 ]; then
echo "Refusing to publish: no distribution of $STEM in the artifact"
exit 1
fi
# `skip-existing` is the PyPI side of the registry-diff guard: an
# already-published version is skipped, not failed.
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: dist
skip-existing: true