Repository navigation
300 lines (278 loc) · 15.7 KB
/
Copy pathpublish.yml
File metadata and controls
300 lines (278 loc) · 15.7 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
name: Publish
on:
push:
tags: ['v*']
# Re-publish (or first-publish) the OFFICIAL REGISTRY LISTING for a version
# already released to npm, without cutting a new npm release — e.g. this
# fix's own backlog #75: v0.39.2 published to npm before `mcpm publish`
# could reach the live registry at all, so the listing lagged at 0.34.0.
# Deliberately does NOT run `dogfood`/`publish` (see each job's `if:`) —
# this only ever touches the registry job below, and it verifies the given
# version is already live on npm before doing anything.
workflow_dispatch:
inputs:
version:
description: 'Already-published npm version to (re-)list on the official MCP registry, e.g. 0.39.2'
required: true
type: string
# Least-privilege default for the workflow. The publish job below explicitly
# opts into exactly what it needs (contents: write for the GitHub Release,
# id-token: write for npm OIDC Trusted Publishing, provenance and the Sigstore
# SBOM signature), which fully overrides this default for
# that job — so publishing is unaffected; any other/future step defaults to read.
permissions:
contents: read
jobs:
# PRE-PUBLISH GATE: pack the tarball, install it into a clean project, and smoke-run
# the real `mcpm` binary — on every Node major ci.yml builds against, not just the one
# the publish job happens to build on. Source tests pass on a broken pack; this exercises
# the bytes users receive (bin, files:["dist"], prod dep resolution, `--engine-strict`,
# the bundled Sigstore trusted root, lazy imports).
#
# "every major ci.yml builds against" and not "every major engines.node admits": the
# range ends in an open-ended `>=26.0.0`, so the admitted set is unbounded and no fixed
# matrix can cover it. `engines-invariant.test.ts` asserts this matrix EQUALS ci.yml's,
# and separately that ci.yml's is admitted by engines.node — so the two lists cannot
# drift apart, but a NEW major (27, when it ships) still has to be added by hand.
#
# A separate JOB because neither inline shape works, not because it is faster. A step
# cannot carry `strategy.matrix`, so three majors inside `publish` would run SERIALLY;
# and matrixing the `publish` job itself would run `pnpm publish` three times, which is
# fatal rather than slow. Be honest about the cost: measured over the last four releases
# the old inline gate was 11-13s of a 62-80s publish job, so three serial legs would have
# been ~1.3x, not the "~3x" TODOS #47 quoted — that figure is scoped to the GATE, not the
# release. This shape instead adds a job bootstrap ahead of publish (~26-28s per leg, over
# half of it checkout/setup/install), taking the release critical path from ~75s to ~95s.
# ~20s buys two more majors of artifact coverage, and the legs stay parallel, so the next
# major is free.
#
# `needs:` makes it fail-closed — a leg that fails stops `pnpm publish` from ever
# running. What this does NOT prove is that the publish job's own `pnpm pack` produces
# byte-identical output to this job's: it never did, because `pnpm publish` re-packs
# from source anyway. Both pack the same commit at the same version from the same frozen
# lockfile, and every failure this gate catches is a property of the pack CONFIG, not of
# one runner.
#
# Reaches each major's LATEST minor, not the declared floor: `setup-node` with `22`
# resolves the newest 22.x, so an API added after 22.22.2 is still untested here. The
# dependency-floor half of that class is covered independently by engines-invariant.
dogfood:
name: pre-publish dogfood · node ${{ matrix.node }}
# Tag-push releases only. workflow_dispatch (registry-listing-only re-runs,
# see the trigger comment above) must never build/pack/smoke a new npm
# release — it isn't cutting one.
if: github.event_name == 'push'
runs-on: ubuntu-latest
strategy:
# "26 is broken, 22 and 24 are fine" is the useful answer, and it is the answer you
# most want while deciding whether to cut a release. fail-fast would hide it behind
# whichever leg died first. Same reasoning as dogfood.yml.
fail-fast: false
matrix:
node: [22, 24, 26]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node }}
cache: pnpm
# Repeated from the publish job on purpose, not extracted. package.json is committed
# at a placeholder version (the tag is the source of truth), so without this the gate
# would pack and smoke an artifact whose version is not the one being released — and
# the `--version` line in the transcript, the one thing proving WHICH build passed,
# would name the placeholder.
- name: Set version from git tag
run: npm pkg set version="${GITHUB_REF_NAME#v}"
- run: pnpm install --frozen-lockfile
- run: pnpm run dogfood:release
publish:
# Fail-closed on the gate above: no leg green, no publish. The explicit
# `success()` replicates the DEFAULT needs-gating (skip if `dogfood`
# failed) that an explicit `if:` would otherwise silently drop — this job
# must never run on a `dogfood` failure just because it's a tag push.
if: github.event_name == 'push' && success()
needs: dogfood
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org
# $VERSION is quoted on every use below (and in the registry job's own
# workflow_dispatch step further down, which references this comment)
# so a value containing shell metacharacters can't be interpreted by
# `run:` — GITHUB_REF_NAME is push-trigger-controlled (a tag name, not
# attacker-free-text), but the same quoting discipline costs nothing
# and avoids depending on that distinction holding forever.
- name: Set version from git tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "Publishing version: $VERSION"
npm pkg set version="$VERSION"
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
- run: pnpm run build
- run: pnpm run test
# npm auth is OIDC Trusted Publishing, NOT a token. The package's npm
# settings require a trusted publisher (this repo + this workflow file) and
# disallow tokens, so `NODE_AUTH_TOKEN`/`NPM_TOKEN` are gone on purpose:
# `id-token: write` above is the whole credential. `pnpm publish` spawns the
# npm CLI for the upload and that CLI (>= 11.5.1; Node 24 ships 11.x) does
# the OIDC exchange — pnpm 10.30.x is the version pnpm's own tracker records
# as working with trusted publishing (pnpm/pnpm#11566; the 11.0 regression
# was fixed via pnpm/action-setup, and `version: 10` never resolves 11).
# `--provenance` is redundant under trusted publishing (attestations are
# automatic) and kept as a belt-and-braces no-op. If this step ever fails
# with a masked `E404 PUT https://registry.npmjs.org/@getmcpm%2fcli`, that
# is npm's OIDC-rejection shape: check the trusted-publisher entry on
# npmjs.com (repo `getmcpm/cli`, workflow `publish.yml`, no environment),
# not the token — there is no token to check.
- run: pnpm publish --no-git-checks --provenance
# SBOM generation runs AFTER publish and is best-effort, so a SBOM-tool
# hiccup can never block the npm release. If it fails, the release is still
# created below (just without the asset) and the workflow stays green.
- name: Generate CycloneDX SBOM
continue-on-error: true
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
format: cyclonedx-json
output-file: mcpm.cdx.json
upload-artifact: false
upload-release-assets: false
# Keyless Sigstore signature over the SBOM, attached beside it as a
# standard Sigstore bundle (`.sigstore.json`). Same `id-token: write` the
# npm provenance already uses; the signing identity is this workflow's
# OIDC identity, which is what a verifier pins:
# cosign verify-blob --bundle mcpm.cdx.json.sigstore.json mcpm.cdx.json \
# --certificate-identity-regexp '^https://github.com/getmcpm/cli/\.github/workflows/publish\.yml@refs/tags/v' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
# Best-effort like the SBOM step it depends on: a Fulcio/Rekor blip must
# not block the npm release that already happened. Signs ONLY the SBOM —
# the npm tarball is not a release asset (`pnpm publish` re-packs from
# source, so a locally packed copy would not be the published bytes), and
# its own signature is the npm provenance attestation. It starts the
# OpenSSF Scorecard Signed-Releases row off 0; the check averages the last
# five releases with assets, so it takes five signed releases to top out.
# The installer is best-effort too: without it a cosign download failure
# would abort the job after npm publish and cost the GitHub Release and
# the registry listing.
- uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
continue-on-error: true
- name: Sign SBOM (keyless, Sigstore)
if: hashFiles('mcpm.cdx.json') != ''
continue-on-error: true
run: cosign sign-blob --yes --bundle mcpm.cdx.json.sigstore.json mcpm.cdx.json
- name: Create GitHub Release + attach SBOM and its signature
env:
GH_TOKEN: ${{ github.token }}
# Attach assets AT CREATION TIME. GitHub immutable releases seal a
# release's assets on creation and reject any later `gh release upload`
# with HTTP 422, so every asset must be passed to `gh release create` as
# a positional argument — not uploaded in a follow-up step. Idempotent: a
# release may already exist (manual create, or a re-run); we don't fail
# the publish, but we also can't amend an immutable one. Each asset is
# attached only if its best-effort step above actually produced it.
run: |
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME already exists — skipping (immutable releases can't be amended)."
exit 0
fi
ASSETS=()
for f in mcpm.cdx.json mcpm.cdx.json.sigstore.json; do
[ -f "$f" ] && ASSETS+=("$f") || echo "No $f produced — releasing without it."
done
gh release create "$GITHUB_REF_NAME" "${ASSETS[@]}" --generate-notes --title "$GITHUB_REF_NAME"
# OFFICIAL REGISTRY LISTING: `mcpm publish --github-oidc` authenticates via a
# GitHub Actions OIDC token exchanged for a registry JWT (POST
# /v0.1/auth/github-oidc → /v0.1/publish) — no PAT/secret needed, just
# `id-token: write`. A SEPARATE job from `publish` on purpose: a registry
# failure must never roll back or block the npm publish that already
# happened in that job — `if: ${{ !failure() && !cancelled() }}` runs this
# even though `publish` is SKIPPED (not failed) on workflow_dispatch, but
# still skips it if `publish` (or `dogfood`) actually failed on a tag push.
# A failure HERE fails this job/run visibly; it does not touch npm.
#
# Known gap (the v0.17.0 SBOM-asset history, #216 review LOW 11): a
# `publish`-job failure AFTER `pnpm publish` already succeeded — e.g. the
# SBOM generation or GitHub Release step — still counts as a job failure,
# so `needs: publish` + this `if:` SKIP this job even though the npm
# release is real and would benefit from a registry listing. No behaviour
# change here; the recovery is the `workflow_dispatch` trigger at the top
# of this file with that version, which re-lists an already-published npm
# version independently of whether the `publish` job itself succeeded.
registry:
name: registry listing
needs: publish
if: ${{ !failure() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: pnpm
# Tag push: list the version that was just published above — but WAIT for
# npm to actually serve it first. The MCP registry validates the coordinate
# before listing it and REFUSES a version it cannot see on npm; npm's
# read path lags its own publish by tens of seconds, so this job used to
# race it. v0.40.0's run is the measurement: `pnpm publish` succeeded and
# ~20s later this step's publish came back
# `400 — NPM package '@getmcpm/cli' exists, but version '0.40.0' was not
# found (status: 404)`. Bounded at 5 minutes and fails CLOSED, naming the
# workflow_dispatch recovery — never attempt a listing the registry will
# reject. $VERSION is quoted throughout, per the note in the publish job.
- name: Wait for npm to serve this version, then set it
if: github.event_name == 'push'
run: |
VERSION="${GITHUB_REF_NAME#v}"
for i in $(seq 1 30); do
if [ "$(npm view "@getmcpm/cli@$VERSION" version 2>/dev/null)" = "$VERSION" ]; then
echo "npm serves @getmcpm/cli@$VERSION (attempt $i)."
npm pkg set version="$VERSION"
exit 0
fi
echo "npm does not serve @getmcpm/cli@$VERSION yet (attempt $i/30); waiting 10s..."
sleep 10
done
echo "npm still does not serve @getmcpm/cli@$VERSION after 5 minutes — refusing to attempt a listing the registry would reject. Re-run this workflow via workflow_dispatch with version=$VERSION once npm has it." >&2
exit 1
# workflow_dispatch: re-list an ALREADY-published npm version (backlog
# #75) using whatever ref this workflow was dispatched on — i.e. this
# fix's own code, not the old broken publish path the tag originally
# shipped with. Refuses to proceed if npm doesn't actually have that
# version, so a typo'd input can't silently list nothing or the wrong
# thing. $VERSION is quoted throughout — see the security note above on
# not interpolating untrusted input directly into `run:` (this input is
# dispatch-trigger-permission-gated, not attacker-controlled, but the
# same quoting discipline applies either way).
- name: Verify npm has this version, then set it (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
env:
VERSION: ${{ inputs.version }}
run: |
PUBLISHED="$(npm view "@getmcpm/cli@$VERSION" version)"
if [ "$PUBLISHED" != "$VERSION" ]; then
echo "npm view @getmcpm/cli@$VERSION returned '$PUBLISHED', not '$VERSION' — refusing to list an unpublished version." >&2
exit 1
fi
npm pkg set version="$VERSION"
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: node dist/index.js publish --github-oidc