inspector/.github/workflows/main.yml
cliffhall 03efc2df18 docs(smoke): finish the CI-topology correction in README and the workflow
Round 1 fixed the three smoke headers and AGENTS.md but left the same
overstatement in the two files Copilot flagged as previously-missed:

- README said the matrix runs 'the remaining engines', which reads as
  Firefox AND WebKit and contradicts the very next paragraph. It now
  names the current matrix (Firefox alone) and points at that paragraph.
- The workflow's fail-fast comment cited a WebKit-vs-Firefox scenario the
  one-entry matrix cannot produce. It now justifies keeping the flag for
  when a second engine returns, matching the AGENTS.md wording.

Swept the whole tree for the remaining variants of the claim; none left.

Addresses the suppressed comments on Copilot's second review of #2133.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-25 21:00:36 -04:00

354 lines
17 KiB
YAML

name: Run install, format, lint, build, and test on every push
on:
push:
# A published GitHub release triggers the `publish` job 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 `publish` and
# `publish-github-container-registry` jobs declare their own blocks below, which
# override this one entirely rather than adding to it — so each publish job must
# continue to list every scope it needs, including `contents: read`.
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
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 two coverage guards first (verify:format-coverage +
# verify:typecheck-coverage), 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 its own step below. Unit tests run in
# both steps (fast here, instrumented under coverage); the double unit
# run is an accepted trade-off for keeping this fast build step intact.
# 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: 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 step — a PR that regresses
# coverage below the threshold now 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 the former
# standalone `Run web integration tests` step was removed to avoid
# running the integration suite twice.
run: npm run coverage
- 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
# 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 self-skips here — the Ink TUI needs a real TTY (raw
# mode) that headless CI lacks, so its boot/render check is local-only.
run: npm run smoke
- name: Run Storybook play-function tests
working-directory: ./clients/web
run: npm run test:storybook
# The same headless web smokes the `build` job already runs in Chromium, run
# again in the other engines this repo supports (#2086).
#
# Why this is a job of its own rather than a matrix over `build`: `build` also
# runs validate, the coverage gate, two verify gates and Storybook, none of
# which are engine-dependent — matrixing it would triple all of that to gain
# three browser smokes. Chromium is deliberately NOT in the matrix here for
# the same reason: `npm run smoke` inside `build` is exactly the local
# `npm run ci` path, and it already covers it. Supported set = the Chromium run
# there plus whatever the matrix below names.
#
# This is the only place the MCP Apps sandbox is exercised on a non-Chromium
# engine, and it earned its keep immediately: pointing the smokes at WebKit is
# what found #2132, a Safari-only hang in the web client's SSE transport that
# every Chromium-only tier had been green through.
#
# The unit tests cannot substitute — `sandbox-csp.test.ts` asserts
# which policy STRING is built, which passes identically on an engine that
# ignores `<meta>` CSP entirely — and no Storybook story reaches the sandbox at
# all (all three App stories use a `data:` placeholder iframe and a mock
# bridge). Note Playwright's WebKit is a WebKit build, not Safari: close enough
# to catch engine-level CSP and iframe divergence, not close enough to certify
# Safari.
browser-engine-smokes:
runs-on: ubuntu-latest
strategy:
# Kept on despite the matrix currently holding one engine: the moment a
# second is added back (see below), failing fast would hide one engine's
# regression behind another's — which is the exact distinction this job
# exists to draw, so the flag should not have to be remembered then.
fail-fast: false
matrix:
# `webkit` belongs here and is deliberately absent: the smokes RUN in it
# (`SMOKE_BROWSER=webkit` works, and smoke:web:browser passes), but the
# two App smokes fail on a real, pre-existing Safari incompatibility in
# the web client's SSE transport — #2132, which carries the diagnosis and
# the verified fix. Adding it here is a one-word diff once that lands.
# An engine is either green or absent; a `continue-on-error` job would
# report coverage nobody is holding to a standard.
browser: [firefox]
name: Sandbox smokes (${{ matrix.browser }})
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: Build all clients
# The smokes need clients/web/dist and the cli/tui/launcher bundles.
# `build` rather than `validate` — the format/lint/typecheck half is
# engine-independent and already ran in the `build` job.
run: npm run build
- name: Cache Playwright browsers
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ matrix.browser }}-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }}
- name: Install Playwright ${{ matrix.browser }}
working-directory: ./clients/web
# `--with-deps` for the system libraries a bare runner lacks; WebKit is
# the large download here and dominates this step.
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run the headless web smokes in ${{ matrix.browser }}
# The set of smokes lives in the `smoke:web:engine` npm script, not
# enumerated here, so adding one covers every engine automatically.
env:
SMOKE_BROWSER: ${{ matrix.browser }}
run: npm run smoke:web:engine
# 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 `npm publish`, whose `prepack`
# (`npm run build`) builds every client bundle into the `files` allowlist.
# `pack:verify` runs first as the pre-publish gate: it builds, packs the real
# tarball, installs it into a clean throwaway consumer, and drives the
# installed `mcp-inspector` bin (web/cli/tui) end to end — so a broken package
# is caught before it reaches npm rather than after.
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: [build, browser-engine-smokes]
# Serialize publishes so two releases cut in quick succession can't run
# overlapping `npm publish`es. Never cancel an in-flight publish.
concurrency:
group: publish-npm
cancel-in-progress: false
permissions:
contents: read
# Required for npm provenance (`--provenance` mints a signed attestation
# via GitHub's OIDC token). The repo is public, so provenance is available.
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "22.x"
cache: "npm"
registry-url: "https://registry.npmjs.org"
- 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
# 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.
- name: Ensure npm CLI supports OIDC trusted publishing
run: npm install -g npm@^11.5.1
- 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. `smoke:tui` inside it self-skips on CI.
run: npm run pack:verify
- name: Publish to npm (single package, with provenance)
# `prepack` (`npm run build`) rebuilds the client bundles into the tarball.
# The build runs three times on this path (build job → pack:verify →
# prepack); the redundancy is intentional — each is a clean-tree rebuild
# and the `prepack` one is what actually populates the published tarball,
# so don't "optimize" it away.
#
# 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.
#
# 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: |
VERSION="$(node -p "require('./package.json').version")"
case "$VERSION" in
*-*) NPM_TAG=next ;;
*) NPM_TAG=latest ;;
esac
echo "Publishing $VERSION under dist-tag '$NPM_TAG'"
npm publish --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, browser-engine-smokes]
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
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@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true