chore(scripts): scan the shell command template, and flag a multi-line env override (#2146)

Copilot, ninth and tenth reviews:

- A custom `shell:` is a COMMAND TEMPLATE that Actions runs around every
  script (`{0}` is the script path), so `shell: npm run local:gate && bash {0}`
  invoked the gate while the `run:` value beside it looked harmless. Step
  `shell`, and `defaults.run.shell` at both workflow and job level, are
  command regions now. An ordinary `bash`/`pwsh`/`cmd` matches no rule, so
  naming them costs nothing.

- GitHub's multiline environment-file syntax (`SMOKE_BROWSER<<EOF`, value,
  delimiter) bypassed the assignment patterns. It is reported as an override
  whose value the guard cannot READ rather than parsed: the value can arrive
  through any number of shapes — a brace group redirected to $GITHUB_ENV, a
  heredoc nested in another — and a parser handling some of them would be a
  coverage claim that holds only for the shapes someone thought of. Nobody
  spells `chromium` that way, and if they did, `SMOKE_BROWSER=chromium` is the
  fix.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 02:32:23 -04:00
commit 2ead041572

View file

@ -502,7 +502,7 @@ So the direction that matters holds: **passing `npm run local:gate` means CI's g
**The gate is deliberately not named `ci`** ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)). That name collided with `npm ci` — a built-in that clean-installs from the lockfile, does **not** run this script, and is one keystroke away — and it invited the cross-engine passes into GitHub CI by looking like the thing a workflow ought to call. There is **no back-compat alias**: `npm run ci` now fails with npm's missing-script error, which prints the available scripts.
That is the readable half, and prose rots. The enforced half is `scripts/lib/workflow-gate.mjs`, run by `npm run test:scripts` over every file under `.github/workflows/`. It fails the suite if a workflow invokes a `local:*` script, a non-Chromium engine pass (`smoke:web:firefox`, `smoke:web:webkit`), or `smoke:web:engine` (whose engine comes from the environment and so cannot be read off the workflow at all), or sets `SMOKE_BROWSER` to anything but a literal `chromium` (including setting it to *nothing*, which is present-and-empty rather than unset, and is rejected rather than defaulted). That last rule respects **where** it is written — an `env:` entry whose key is `SMOKE_BROWSER` (case-insensitively, since Windows environment variables are), or an assignment in a `run:` script (POSIX, cmd, a `>> $GITHUB_ENV` append, or PowerShell — the default shell on a Windows runner) — so a script that merely *prints* the variable's name is not read as setting it. It treats any name it cannot **read** the same way — a workflow expression (`smoke:web:${{ matrix.browser }}`) or a shell variable the script expands itself (`smoke:web:$ENGINE`, `%ENGINE%`) — since a value the workflow can vary may well be `firefox`. It deliberately does **not** forbid `npm run smoke`, `smoke:web:chromium`, or `smoke:tui` — those belong in CI and are there today. It **parses** the workflow and descends the schema's executable paths — workflow `env`, job `env`/`container.env`/`with`, step `run`/`env`/`with`, with aliases resolved — rather than matching text or key names, so it reads only what actually executes — so a step *name* explaining the rule is not mistaken for an invocation of it, and the workflow can still document itself.
That is the readable half, and prose rots. The enforced half is `scripts/lib/workflow-gate.mjs`, run by `npm run test:scripts` over every file under `.github/workflows/`. It fails the suite if a workflow invokes a `local:*` script, a non-Chromium engine pass (`smoke:web:firefox`, `smoke:web:webkit`), or `smoke:web:engine` (whose engine comes from the environment and so cannot be read off the workflow at all), or sets `SMOKE_BROWSER` to anything but a literal `chromium` (including setting it to *nothing*, which is present-and-empty rather than unset, and is rejected rather than defaulted). That last rule respects **where** it is written — an `env:` entry whose key is `SMOKE_BROWSER` (case-insensitively, since Windows environment variables are), or an assignment in a `run:` script (POSIX, cmd, a `>> $GITHUB_ENV` append, or PowerShell — the default shell on a Windows runner) — so a script that merely *prints* the variable's name is not read as setting it. It treats any name it cannot **read** the same way — a workflow expression (`smoke:web:${{ matrix.browser }}`) or a shell variable the script expands itself (`smoke:web:$ENGINE`, `%ENGINE%`) — since a value the workflow can vary may well be `firefox`. It deliberately does **not** forbid `npm run smoke`, `smoke:web:chromium`, or `smoke:tui` — those belong in CI and are there today. It **parses** the workflow and descends the schema's executable paths — workflow `env`/`defaults.run.shell`, job `env`/`container.env`/`with`/`defaults.run.shell`, step `run`/`shell`/`env`/`with` — a custom `shell:` counts, since Actions runs it *around* the script — with aliases resolved — rather than matching text or key names, so it reads only what actually executes — so a step *name* explaining the rule is not mistaken for an invocation of it, and the workflow can still document itself.
| Script | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |