ci: run the coverage gate in a parallel job (#2159)

`build` ran `npm run validate` and `npm run coverage` back to back, and on a
recent green run those two steps were 84% of a 17m9s wall clock (7m41 + 6m50).
They are serial for no reason: `coverage` consumes nothing `validate` produces.
Every client's `test:coverage` is self-sufficient — web and cli build the test
servers themselves (cli its own bin too), tui and launcher run from source — and
the only consumers of `clients/*/build` are the smokes, which stay in `build`
alongside the `validate` that produces them.

So split `coverage` into a sibling job. Both publish jobs become
`needs: [build, coverage]`: that is the safety property, not a side effect —
left as `needs: build` a release could publish with the coverage gate red.

Deliberately NOT backgrounding the two inside one job: that puts two vitest
fleets on one 4-core runner, which already times tests out at the 5s default.

Wall clock ~17m -> ~10m, for one extra runner and a duplicated ~24s install.
Splitting `coverage` per client is the next lever and stays out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 22:39:47 -04:00
commit 9cd06598a4

View file

@ -43,27 +43,15 @@ jobs:
# 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
# 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.
# 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
@ -115,6 +103,49 @@ jobs:
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
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`
@ -127,7 +158,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: build
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:
@ -220,7 +251,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: build
needs: [build, coverage]
permissions:
contents: read
packages: write