inspector/.github/workflows/main.yml
Workflow config file is invalid. Please check your config file: Line: 302 Column 5: Failed to match job-factory: Line: 310 Column 7: Failed to match permissions-mapping: Line: 317 Column 7: Unknown Property artifact-metadata Line: 310 Column 7: Failed to match permission-level-shorthand-read-all: Line: 310 Column 7: Expected a scalar got mapping Line: 310 Column 7: Failed to match permission-level-shorthand-write-all: Line: 310 Column 7: Expected a scalar got mapping Line: 302 Column 5: Failed to match workflow-job: Line: 304 Column 5: Unknown Property environment Line: 308 Column 5: Unknown Property timeout-minutes Line: 310 Column 7: Failed to match permissions-mapping: Line: 317 Column 7: Unknown Property artifact-metadata Line: 310 Column 7: Failed to match permission-level-shorthand-read-all: Line: 310 Column 7: Expected a scalar got mapping Line: 310 Column 7: Failed to match permission-level-shorthand-write-all: Line: 310 Column 7: Expected a scalar got mapping Line: 319 Column 5: Unknown Property steps Forgejo Actions YAML Schema validation error
cliffhall 5705a9e97c docs: correct two stale gate comments after #2354
`MAX_WAIT_MS` in scripts/gate-lease.mjs said the 45-minute wait only ever
expires against a live holder still running after 45 minutes. The budget is
measured from the waiter's first `acquireLease` attempt and is not reset when
the lease changes hands, so a queue of healthy gates deeper than about ten
also exhausts it. The comment now says so, matching docs/quality-gate.md.

The "Run cross-client smokes" step in .github/workflows/main.yml pointed at
a CI-vs-local table in the root README that the README no longer carries; it
now points at docs/quality-gate.md, which declares itself canonical.

Comment-only; no behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 18:12:25 -04:00

397 lines
20 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: CI
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
# 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 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 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 `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, coverage]
# 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
# Observed 2.3–2.8 min across the last three releases (2.4.0–2.6.0), of
# which `pack:verify` is about half and the only step that reaches the
# registry. The publish itself is atomic on npm's side, so a cut-off here
# leaves nothing half-published.
timeout-minutes: 10
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, 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@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
# 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@v4
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true