Repository navigation
docs(publishing): list pack:verify's npm default-bin check (#2658) #6443
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| # A published GitHub release triggers the `package` → `publish` jobs below. The release's | |
| # target commit determines which workflow runs — so this only publishes when a | |
| # release is cut from a commit that carries this (v2) workflow. | |
| release: | |
| types: [published] | |
| # Default least-privilege scope for GITHUB_TOKEN. Without this, jobs inherit the | |
| # repository's default token permissions, which are broader than any job here | |
| # needs (CodeQL `actions/missing-workflow-permissions`). The `package`, `publish` | |
| # and `publish-github-container-registry` jobs declare their own blocks below, | |
| # which override this one entirely rather than adding to it — so each must list | |
| # exactly the scopes it needs and no more. `publish` deliberately omits | |
| # `contents: read`: it has no checkout, and its only scope is `id-token: write` | |
| # (#2483). Do not re-add scopes to it. | |
| permissions: | |
| contents: read | |
| # Serialize whole RELEASE runs, never cancelling an in-progress one (#2483). | |
| # Packaging and publishing are separate jobs, so a job-level group on `publish` | |
| # alone would let two releases race through `package` and publish in the order | |
| # their packaging happened to finish — and because the dist-tag is passed | |
| # explicitly, an older release finishing last would move `latest` back to it. | |
| # Holding the group for the whole run restores what the single pre-split job | |
| # gave: one release run at a time, packaging and publishing together. | |
| # Push runs get a unique group (their run id), so they are unaffected. | |
| # ⚠️ This is mutual exclusion, NOT a version-order guarantee. GitHub orders a | |
| # group's queue by when each run starts waiting, not by release order, and | |
| # keeps only ONE pending run per group, so a third release cut while one runs | |
| # and one waits cancels the waiting one. Cut releases one at a time, and | |
| # re-run a cancelled one by hand. | |
| concurrency: | |
| group: ${{ github.event_name == 'release' && 'release-npm' || format('run-{0}', github.run_id) }} | |
| cancel-in-progress: false | |
| # The `package`, `publish` and `publish-github-container-registry` jobs pin | |
| # every action to a commit SHA with a `# vX.Y.Z` comment; `build` and | |
| # `coverage` stay on moving major tags (#2235). A job holding a credential — or | |
| # building what one publishes — must not run code a moved tag can replace | |
| # (#2484); `npm run verify:action-pins` enforces it, and the monthly | |
| # dependency-refresh sweep reads the comment to report a newer release. | |
| # Every job below declares `timeout-minutes` (#2333). It is a HUNG-JOB GUARD, | |
| # not a flake remedy: GitHub runners are not the contended machine the local | |
| # gate runs on, and nothing measured implicates them — so no value here is | |
| # sized for load. The rule, for every job in this file, is roughly TWICE the | |
| # slowest run observed, rounded up to the next five minutes: real headroom for | |
| # a slow runner, and a hang cut off in minutes instead of at GitHub's | |
| # 360-minute default. Each job states the range it was read from; when one | |
| # legitimately outgrows its number, raise it here with the new range. | |
| jobs: | |
| build: | |
| runs-on: ubuntu-latest | |
| # Observed 7.6–11.8 min across the 40 push runs before this landed | |
| # (2026-09-11 → 09-13), all since the coverage split (#2159). A release run | |
| # before that split reached 15.6 min, with the coverage gate still inside | |
| # this job — that shape no longer exists. | |
| timeout-minutes: 25 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22.x' | |
| cache: 'npm' | |
| - name: Install dependencies (root + all clients) | |
| # The root postinstall (scripts/install-clients.mjs) cascades | |
| # `npm install` into clients/web, clients/cli, clients/tui, and | |
| # clients/launcher, so this single step sets up every client. | |
| run: npm install | |
| - name: Validate (coverage guards, format, lint, typecheck, build, fast tests) | |
| # Runs the five durable guards first (verify:format-coverage, | |
| # verify:skills, verify:typecheck-coverage, verify:dep-lockstep, | |
| # verify:test-timeouts), then | |
| # test:scripts, then validate:core, then each client's | |
| # self-validation: format:check + lint + typecheck + build + test (no | |
| # coverage instrumentation — fast). This also builds every client bundle | |
| # (web dist, cli/tui/launcher) that the smokes below need. The heavier | |
| # per-file coverage gate runs in the parallel `coverage` job below | |
| # (#2159), which consumes nothing this job produces — every client's | |
| # `test:coverage` builds whatever it needs itself. Unit tests run in | |
| # both jobs (fast here, instrumented there); now that the two run in | |
| # parallel that duplication costs no wall clock at all. (The local gate, | |
| # which runs serially, calls `local:validate` instead and runs them once | |
| # — #2341. Keep `validate` here: this job IS the fast inner loop.) | |
| # A future optimization could split `coverage` into per-client parallel | |
| # jobs, but that's a larger restructure and deliberately out of scope. | |
| run: npm run validate | |
| - name: Validate the skills with the authoritative CLI (#2163) | |
| # `npm run validate` above already ran `verify:skills`, whose own parser | |
| # reads each SKILL.md the way Claude Code does. This is the second | |
| # opinion: `claude plugin validate` is the authoritative schema, and the | |
| # guard skips it whenever the CLI is absent — which, without this step, | |
| # would mean always, in CI. Installing it here is what makes the | |
| # acceptance criterion true rather than aspirational. | |
| # | |
| # The script resolves the CLI itself — an installed one ONLY when it | |
| # matches the pin exactly, otherwise the pinned package via `npx -y` — | |
| # so this is the same command `npm run local:gate` runs, and the two | |
| # cannot drift. Exact rather than a floor: a newer local CLI is a | |
| # DIFFERENT schema, which is how a "reproducible" gate starts disagreeing | |
| # across machines. Pinned | |
| # rather than @latest: an unpinned validator can start failing a PR that | |
| # changed nothing. It needs no authentication — verified with a clean HOME. | |
| run: npm run verify:skills:cli | |
| - name: Verify the browser-externalized-builtin build gate (#1769) | |
| # Runs a real `vite build` with a Node built-in forced into the browser | |
| # graph and asserts the build FAILS via the #1769 gate. The unit tests | |
| # cover the detection logic against a captured message; only this real | |
| # build catches the risk the issue calls out — that the Vite warning | |
| # phrasing drifts across releases, silently disabling the message-keyed | |
| # gate. It restores the mutated entry afterward (see the script). | |
| run: npm run verify:build-gate | |
| - name: Verify no externalized dependency was inlined (#2067) | |
| # The mirror of the "must be bundled" rule. `undici` was declared only in | |
| # the root and `clients/cli` manifests, so tsup — which auto-externalizes | |
| # what the NEAREST manifest declares — inlined 1.05MB of it into the web | |
| # and TUI bundles. Inlined CommonJS in an ESM bundle throws | |
| # `Dynamic require of "assert" is not supported` on first use, and the | |
| # rewritten relative specifier meant no user-side install could fix it. | |
| # This reads the built output rather than the config, because those two | |
| # disagreed for four releases. | |
| run: npm run verify:bundle-externals | |
| # Playwright chromium is installed BEFORE the smokes because | |
| # `smoke:web:browser` (the headless-browser boot smoke, #1615) drives the | |
| # prod web bundle in chromium — restoring/installing it here lets that | |
| # smoke reuse the cache instead of downloading its own copy. The Storybook | |
| # step below reuses the same install. | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@v6 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} | |
| - name: Install Playwright browsers | |
| working-directory: ./clients/web | |
| run: npx playwright install --with-deps chromium | |
| - name: Run cross-client smokes | |
| # NOTE: this workflow runs GitHub CI's tier only. The LOCAL pre-push | |
| # gate is `npm run local:gate`, and it runs every check here plus the | |
| # local-only ones — do not add it, a `local:*` script, | |
| # `smoke:web:firefox`/`smoke:web:webkit`, `smoke:web:engine`, or a | |
| # non-Chromium `SMOKE_BROWSER` to any workflow. | |
| # `npm run test:scripts` fails if you do (scripts/lib/workflow-gate.mjs, | |
| # #2146). `npm run smoke` and `smoke:web:chromium` belong here and are | |
| # not affected. The canonical CI-vs-local split is docs/quality-gate.md. | |
| # | |
| # End-to-end smokes through the built launcher (--help dispatch + prod | |
| # CLI/web). Not part of any client's `validate`: it needs the | |
| # cli/tui/launcher bundles, which `validate` above already built | |
| # (smoke:web builds clients/web/dist on demand — #1486). smoke:web:browser | |
| # boots the prod web bundle in headless chromium (#1615); smoke:web:app | |
| # goes further and drives connect → open app → widget ready against a | |
| # composable MCP App server (#1859). Both reuse the chromium installed | |
| # above. smoke:tui runs for real here too (#2408): it gives the Ink TUI | |
| # a pseudoterminal through util-linux script(1), which ubuntu-latest | |
| # ships, and pins CI=false for the child so Ink renders interactively. | |
| run: npm run smoke | |
| - name: Run Storybook play-function tests | |
| working-directory: ./clients/web | |
| run: npm run test:storybook | |
| # The per-file coverage gate, in its own job so it runs in PARALLEL with | |
| # `build` rather than serially after it (#2159). The two together were 84% of | |
| # a ~17m wall clock; split, the workflow finishes in roughly the length of | |
| # `build` alone. | |
| # | |
| # This is safe because `coverage` consumes nothing `build` produces: every | |
| # client's `test:coverage` is self-sufficient (web and cli build the test | |
| # servers — and cli its own bin — themselves; tui and launcher run from | |
| # source). The only consumers of `clients/*/build` are the smokes, which stay | |
| # in `build` alongside the `validate` that produces them. | |
| # | |
| # Do NOT "optimize" this back into one job by backgrounding the two commands: | |
| # that puts two vitest fleets on one 4-core runner, which is already known to | |
| # time tests out at the 5s default. Separate jobs get separate runners. | |
| coverage: | |
| runs-on: ubuntu-latest | |
| # Observed 5.9–8.5 min across the same 40 push runs as `build`. | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22.x' | |
| cache: 'npm' | |
| - name: Install dependencies (root + all clients) | |
| run: npm install | |
| - name: Enforce per-file coverage gate (≥90% on all four dimensions) | |
| # CI-ENFORCED coverage gate (#1550): runs `npm run coverage`, which | |
| # chains every client's `test:coverage` (v8-instrumented) and fails the | |
| # job if ANY file drops below 90% on lines, statements, functions, or | |
| # branches. This is the whole point of the job — a PR that regresses | |
| # coverage below the threshold blocks merge instead of relying on a | |
| # contributor remembering to run the gate locally. | |
| # | |
| # This also covers the web integration project: web's `test:coverage` | |
| # runs `--project=unit --project=integration --coverage`, so there is no | |
| # separate integration step anywhere in this workflow. | |
| run: npm run coverage | |
| # Publish the single `@modelcontextprotocol/inspector` package to npm on a | |
| # published GitHub release. v2 is not an npm workspace, so there is no | |
| # `publish-all` / `--workspaces` (v1) — just one package. | |
| # | |
| # Two jobs, split on the credential (#2483). `package` does everything that | |
| # executes third-party code — the dependency install (whose lifecycle scripts | |
| # and the root `postinstall` client cascade all run), `pack:verify`, and the | |
| # `npm pack` that builds the tarball — and holds NO `id-token`. `publish` | |
| # holds `id-token: write` and does nothing but download that tarball and hand | |
| # it to `npm publish`: no checkout, no dependency install, no build. Before | |
| # the split both halves shared one job, so any install script from any | |
| # transitive dependency ran in the process environment carrying the OIDC | |
| # request variables and could mint a token and publish under the | |
| # trusted-publisher identity. Permissions are scoped per JOB, not per step, | |
| # so a separate job is the only way to keep that token away from the | |
| # install. Do not fold them back together, and do not add an install or a | |
| # checkout to `publish`. | |
| package: | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'release' | |
| needs: [build, coverage] | |
| # Carries over the old single `publish` job's budget: it was observed at | |
| # 2.3–2.8 min across the last three releases (2.4.0–2.6.0), of which | |
| # `pack:verify` is about half, and all of that work now lives here. | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: '22.x' | |
| cache: 'npm' | |
| - name: Assert release tag matches package version | |
| # `npm publish` ships whatever `version` is in the root package.json, | |
| # regardless of the release's tag. Fail fast (before the heavy install / | |
| # pack:verify) if they disagree — e.g. a release drafted without running | |
| # `npm version`, or cut from the wrong commit — since publishing is | |
| # irreversible. Tolerates the conventional leading `v` (npm version tags | |
| # as `vX.Y.Z`). The tag arrives via `env:` (not spliced into the script) | |
| # to avoid the script-injection surface of interpolating `${{ }}` into a | |
| # `run:` block. | |
| env: | |
| TAG: ${{ github.event.release.tag_name }} | |
| run: | | |
| PKG="$(node -p "require('./package.json').version")" | |
| if [ "${TAG#v}" != "$PKG" ]; then | |
| echo "Release tag '$TAG' does not match package.json version '$PKG'" | |
| exit 1 | |
| fi | |
| - name: Install dependencies (root + all clients) | |
| run: npm install | |
| - name: Verify the publishable tarball end to end | |
| # Builds, `npm pack`s, installs the tarball into a clean consumer, and | |
| # drives the installed bin. Needs registry access to pull the tarball's | |
| # runtime deps — available here. Its TUI check is `--tui --help` only; | |
| # the real TUI boot is `smoke:tui`, which the `build` job runs. | |
| run: npm run pack:verify | |
| - name: Pack the tarball to publish | |
| # `pack:verify` deletes its own tarball on success, so this packs the | |
| # one that ships. `prepack` (`npm run build`) runs here, so the build | |
| # happens three times on this path (build job → pack:verify → this | |
| # step); the redundancy is intentional — each is a clean-tree rebuild | |
| # and this one is what actually populates the published tarball, so | |
| # don't "optimize" it away. `publish` never builds: publishing a `.tgz` | |
| # runs no lifecycle scripts, so what this step packs is what ships. | |
| run: | | |
| mkdir -p release-tarball | |
| npm pack --pack-destination release-tarball | |
| ls -l release-tarball | |
| - name: Upload the tarball for the publish job | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: npm-tarball | |
| path: release-tarball/*.tgz | |
| if-no-files-found: error | |
| retention-days: 7 | |
| publish: | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'release' | |
| # npm's trusted-publisher config keys off this workflow file and the | |
| # `release` environment, so the environment stays on the job that | |
| # publishes. `package` deliberately does not take it. | |
| environment: release | |
| needs: [package] | |
| # Serialize publishes so two releases cut in quick succession can't run | |
| # overlapping `npm publish`es. Never cancel an in-flight publish. The | |
| # workflow-level `concurrency` above already orders whole release runs; | |
| # this group is the narrower guarantee kept on the job that publishes. | |
| concurrency: | |
| group: publish-npm | |
| cancel-in-progress: false | |
| # Unmeasured since the split (#2483): it now only downloads an artifact, | |
| # installs one pinned npm and publishes, so the old job's 10-minute budget | |
| # is a generous ceiling. Re-size it from observed runs after a release or | |
| # two. The publish itself is atomic on npm's side, so a cut-off here | |
| # leaves nothing half-published. | |
| timeout-minutes: 10 | |
| permissions: | |
| # Required for npm provenance (`--provenance` mints a signed attestation | |
| # via GitHub's OIDC token) and for trusted publishing. This is the only | |
| # job in the workflow that runs npm with this token, and it deliberately | |
| # executes no dependency code — see the comment on `package`. | |
| id-token: write | |
| env: | |
| # The release tag, re-checked against the tarball below. It arrives via | |
| # `env:` rather than spliced into the script, as in `package`. | |
| TAG: ${{ github.event.release.tag_name }} | |
| steps: | |
| - name: Setup Node.js | |
| # No `cache: 'npm'`: there is no checkout, so no lockfile to key it on, | |
| # and nothing here installs dependencies to cache. | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: '22.x' | |
| registry-url: 'https://registry.npmjs.org' | |
| # OIDC trusted publishing requires npm >= 11.5.1; Node 22's bundled npm is | |
| # 10.x, which fails with ENEEDAUTH before OIDC is ever attempted. Pinned | |
| # EXACTLY, not to a range (#2483): this install runs in the job that | |
| # holds `id-token: write`, so it is the one package this job trusts, and | |
| # a range would let whatever 11.x is current at release time into it. | |
| # `--ignore-scripts` keeps even that package's lifecycle scripts out. | |
| # Bump deliberately; stay on 11.x until 12's Node floor is checked. | |
| - name: Install the pinned npm CLI (OIDC trusted publishing) | |
| run: npm install -g --ignore-scripts npm@11.20.0 | |
| - name: Download the tarball built by the package job | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: npm-tarball | |
| path: release-tarball | |
| - name: Publish to npm (single package, with provenance) | |
| # Publishes the tarball `package` built — a file argument, so npm runs | |
| # no lifecycle scripts and builds nothing here. Exactly one tarball is | |
| # expected; anything else is a packaging fault, not something to pick | |
| # from. | |
| # | |
| # The dist-tag is derived from the version, and passing it explicitly is | |
| # NOT optional: `npm publish` defaults to `--tag latest` regardless of | |
| # semver prerelease status, so publishing `2.0.0-rc.1` without this would | |
| # point every `npx @modelcontextprotocol/inspector` at a release | |
| # candidate. A prerelease is a hyphen after the patch component | |
| # (`2.0.0-rc.1`); build metadata uses `+` and is not a prerelease. Done | |
| # in shell rather than with `semver` because that package is only a | |
| # transitive dependency here and must not be relied on in CI. The | |
| # version is read from the tarball's own `package.json`, since there is | |
| # no checkout — and that file is what npm publishes anyway. | |
| # | |
| # There is deliberately NO `NODE_AUTH_TOKEN` here. Publishing uses npm | |
| # OIDC trusted publishing (`id-token: write` + `environment: release`), | |
| # which needs no token — and the repo has no `NPM_TOKEN` secret. Setting | |
| # it from a non-existent secret writes an EMPTY `_authToken` into the | |
| # `.npmrc` that `setup-node` generates, and npm then fails `ENEEDAUTH` | |
| # before OIDC is ever attempted. Do not "restore" it. | |
| run: | | |
| # `./` is load-bearing: `npm publish` reads a bare `dir/file` | |
| # argument as GitHub `owner/repo` shorthand and tries to clone it | |
| # over SSH instead of publishing the local tarball (#2551). | |
| set -- ./release-tarball/*.tgz | |
| if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then | |
| echo "Expected exactly one tarball in release-tarball/, found: $*" | |
| exit 1 | |
| fi | |
| TARBALL="$1" | |
| MANIFEST="$(tar -xOzf "$TARBALL" package/package.json)" | |
| # 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, proxy or TLS setting could redirect this job's | |
| # credential flow. This package declares no `publishConfig`, so any | |
| # present is refused outright, and the registry is pinned on the | |
| # command line below as well. | |
| if printf '%s' "$MANIFEST" | node -e "process.exit('publishConfig' in JSON.parse(require('fs').readFileSync(0, 'utf8')) ? 0 : 1)"; then | |
| echo "Refusing to publish: the tarball's package.json declares publishConfig" | |
| exit 1 | |
| fi | |
| # `package` asserts tag == version BEFORE its install runs, so that | |
| # check cannot vouch for what was packed afterwards: an install | |
| # script could rewrite the root manifest. Re-assert the tarball's own | |
| # name and version against the expected package and the release tag. | |
| NAME="$(printf '%s' "$MANIFEST" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name")" | |
| VERSION="$(printf '%s' "$MANIFEST" | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")" | |
| if [ "$NAME" != "@modelcontextprotocol/inspector" ]; then | |
| echo "Refusing to publish: tarball package name is '$NAME'" | |
| exit 1 | |
| fi | |
| if [ "${TAG#v}" != "$VERSION" ]; then | |
| echo "Refusing to publish: tarball version '$VERSION' does not match release tag '$TAG'" | |
| exit 1 | |
| fi | |
| case "$VERSION" in | |
| *-*) NPM_TAG=next ;; | |
| *) NPM_TAG=latest ;; | |
| esac | |
| echo "Publishing $TARBALL ($VERSION) under dist-tag '$NPM_TAG'" | |
| npm publish "$TARBALL" --registry https://registry.npmjs.org/ --access public --provenance --tag "$NPM_TAG" | |
| # Build and push the multi-arch container image to GHCR on a published | |
| # release. The image installs the packed tarball (`Dockerfile`) so it ships | |
| # the same artifact as npm. Independent of the npm `publish` job (both gated on | |
| # the release event) — a container failure doesn't block the npm publish. | |
| publish-github-container-registry: | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'release' | |
| environment: release | |
| needs: [build, coverage] | |
| # Observed 14.5–15.4 min across the last three releases (2.4.0–2.6.0); | |
| # the linux/arm64 half of the build runs under QEMU and is most of it. | |
| timeout-minutes: 35 | |
| permissions: | |
| contents: read | |
| packages: write | |
| attestations: write | |
| # Lets `attest-build-provenance` write the artifact metadata storage | |
| # record alongside the provenance itself (#2228). See the long comment on | |
| # the "Generate artifact attestation" step for why this is separate from | |
| # `attestations: write` and what to check after the next release. | |
| artifact-metadata: write | |
| id-token: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Log in to the Container registry | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Extract metadata (tags, labels) for Docker | |
| id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: ghcr.io/${{ github.repository }} | |
| # Be explicit rather than relying on `flavor.latest=auto`: on a release | |
| # cut both the version tags and `latest` land, so the README's bare | |
| # `ghcr.io/…/inspector` (implicit `:latest`) never 404s. | |
| tags: | | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| flavor: | | |
| latest=true | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - name: Build and push Docker image | |
| id: push | |
| uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 | |
| with: | |
| context: . | |
| push: true | |
| platforms: linux/amd64,linux/arm64 | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| - name: Generate artifact attestation | |
| # Two scopes, two different things, and only the first is the | |
| # attestation (#2228). `attestations: write` persists the signed SLSA | |
| # provenance — that half has always worked, and | |
| # `gh attestation verify oci://ghcr.io/modelcontextprotocol/inspector:<version> | |
| # --repo modelcontextprotocol/inspector` passed on 2.5.0 without the | |
| # second scope. `artifact-metadata: write` persists the separate | |
| # *storage record*: GitHub's org-level index of where a published | |
| # artifact lives (registry, active/eol status), surfaced at | |
| # https://github.com/orgs/modelcontextprotocol/artifacts. The action | |
| # emits one automatically when `push-to-registry` is true AND the | |
| # workflow carries this scope; with only the first condition met, every | |
| # release logged two warning annotations on an otherwise-green job: | |
| # | |
| # Failed to create storage record: Error: Failed to persist storage | |
| # record: no artifacts found | |
| # Please check that the "artifact-metadata:write" permission has been | |
| # included | |
| # | |
| # `no artifacts found` reads like it is about this job uploading no | |
| # *workflow* artifact, and is not: it is the generic 404 body of the org | |
| # artifact-metadata API that @actions/attest POSTs to. The same message | |
| # comes back from the sibling read endpoint for 2.5.0's real digest, and | |
| # from an all-zeros digest that cannot exist — so it carries no | |
| # information beyond "nothing resolved", and the documented precondition | |
| # we were failing is this permission. | |
| # | |
| # ⚠️ Confirm at the next release rather than assuming: the job should be | |
| # annotation-free, and | |
| # `gh api /orgs/modelcontextprotocol/artifacts/<digest>/metadata/storage-records` | |
| # should return a record instead of 404. If the warning persists, the | |
| # honest fix is `create-storage-record: false` plus a note here saying | |
| # the record is unavailable to us — not carrying an unexplained warning. | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-name: ghcr.io/${{ github.repository }} | |
| subject-digest: ${{ steps.push.outputs.digest }} | |
| push-to-registry: true |