chore(deps): npm audit fix before release bump; monthly npm-outdated issue

Adds npm audit fix (root + every client) as the first sub-step of the
release skill's bump step, before npm version, so a release is never
gated on remembering to check separately. Never --force; anything
audit fix can't resolve is left to the dependabot-alert pipeline
(#2229) or a follow-up issue.

Disables Dependabot npm version updates in dependabot.yml (a
version-update PR carries no issue and no board card) and replaces
them with a monthly scheduled sweep that runs npm outdated across the
root install and every client, filing or updating one idempotent
tracking issue instead of an auto-generated PR.

Closes #2231
Part of #2229

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017N9ha27Qg6LHpBQP7G7zPY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-03 10:00:39 -04:00 • committed by GitHub
commit 7dd9f47ca2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 361 additions and 78 deletions

View file

@ -1,6 +1,6 @@
--- ---
name: release name: release
description: Cut an Inspector v2 release — bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. description: Cut an Inspector v2 release — npm audit fix and bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on.
disable-model-invocation: true disable-model-invocation: true
--- ---
@ -27,10 +27,10 @@ job or the coverage gate red:
There is **one version number** (only the root `package.json` has one — the There is **one version number** (only the root `package.json` has one — the
clients carry none), so the flow is three steps. clients carry none), so the flow is three steps.
## 1. Bump on `v2/main`, before the milestone merge ## 1. `npm audit fix`, then bump, on `v2/main` — before the milestone merge
The bump is part of the milestone's work, so it belongs on the develop branch Both are part of the milestone's work, so both belong on the develop branch and
and flows into `main` with everything else. flow into `main` together, in the same PR, audit fix first.
```sh ```sh
# Branch from the REMOTE ref, and read the version only once you are on it. # Branch from the REMOTE ref, and read the version only once you are on it.
@ -39,11 +39,34 @@ and flows into `main` with everything else.
# you are bumping from (Copilot). # you are bumping from (Copilot).
git fetch origin v2/main git fetch origin v2/main
git checkout -b v2/chore/<ISSUE>-bump-<X-Y-Z> origin/v2/main git checkout -b v2/chore/<ISSUE>-bump-<X-Y-Z> origin/v2/main
# Audit + fix every install that has its own lockfile — root and each client.
npm audit fix
for c in web cli tui launcher; do (cd "clients/$c" && npm audit fix); done
npm run local:gate # confirm the fixes didn't break anything before bumping
node -p "require('./package.json').version" # what is on v2/main now node -p "require('./package.json').version" # what is on v2/main now
npm version minor --no-git-tag-version # or major / patch; bump only, no tag npm version minor --no-git-tag-version # or major / patch; bump only, no tag
node -p "require('./package.json').version" # confirm, then PR → v2/main node -p "require('./package.json').version" # confirm, then PR → v2/main
``` ```
**Never `npm audit fix --force`.** It will apply a fix outside a dependency's
declared semver range — a major bump a client's code was never adjusted for —
to close an advisory, silently trading a known vulnerability for an unvetted
breaking change. If `npm audit` still reports something `fix` can't resolve
(typically a transitive dependency needing an `overrides` pin per
[Dependency placement](../../../AGENTS.md#dependency-placement)), commit
whatever `fix` did resolve, leave the rest to the dependabot-alert pipeline
(#2229) or a follow-up issue, and say so in the release notes rather than
forcing it here.
This step is a **backstop, not a substitute** for #2229's alert-driven issues —
those are what surface a transitive vulnerability that needs an `overrides`
entry `audit fix` cannot apply on its own, tracked and fixed as their own PRs
well before a release is cut. This step exists for whatever's left standing
right before a release ships, so a release is never gated on remembering to
check `npm audit` separately.
The branch name carries the version you are bumping **to**, so it is named after The branch name carries the version you are bumping **to**, so it is named after
that second reading. If you want it before branching: that second reading. If you want it before branching:
`git show origin/v2/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version"`. `git show origin/v2/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version"`.

View file

@ -1,23 +1,26 @@
version: 2 version: 2
# Version updates target `v2/main`, the develop branch where all v2 work lands, # npm version updates are OFF (#2229): a Dependabot version-update PR carries no
# not the default branch — `main` is release-only, holding the latest released # issue and no board card, which is the same "every PR references an issue"
# v2 and receiving milestone merges from `v2/main`. (The deprecated v1 line # carve-out the security-update flow had. Routine npm bumps are handled instead
# lives on `v1/main` and takes security fixes only.) # by a monthly `npm outdated` sweep (`.github/workflows/dependency-refresh.yml`)
# that files ONE tracking issue per run, boarded and milestoned like any other
# work — so a maintainer picks the bump and opens a normal PR against `v2/main`.
#
# Security updates are unaffected by this file either way: they are enabled in
# repo settings, not here. #2229 additionally proposes consuming them as
# Dependabot ALERTS rather than PRs (tracked, not yet built) — until that lands,
# security-update PRs still open against `main` and still need retargeting by
# hand, same as before.
#
# github-actions version updates stay on Dependabot's own PR flow below — out of
# scope for #2229, which was about npm packages specifically.
# #
# Two things to know about this file: # Two things to know about this file:
# 1. Dependabot reads it from the DEFAULT branch (`main`). Changes here are # 1. Dependabot reads it from the DEFAULT branch (`main`). Changes here are
# inert until the next milestone merge carries them there. # inert until the next milestone merge carries them there.
# 2. `target-branch` scopes VERSION updates. Dependabot SECURITY updates are # 2. `target-branch` scopes VERSION updates only (npm updates are removed
# enabled in repo settings, not here, and are raised against the default # here, so this now only applies to the github-actions entry below).
# branch — they kept working while this file was missing entirely
# (see #1833, #1840). Per GitHub's Dependabot options reference, an entry
# whose `target-branch` names a non-default branch is NOT applied to
# security updates, so the schedule/labels/groups below shape version
# updates only. Re-confirm security PRs still appear after this lands.
#
# v2 is not an npm workspace: the root and each client under `clients/*` carry
# their own package.json + lockfile, so each needs its own entry.
updates: updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
@ -31,63 +34,3 @@ updates:
github-actions: github-actions:
patterns: patterns:
- "*" - "*"
- package-ecosystem: "npm"
directory: "/"
target-branch: "v2/main"
schedule:
interval: "monthly"
labels:
- "v2"
groups:
root-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/clients/web"
target-branch: "v2/main"
schedule:
interval: "monthly"
labels:
- "v2"
groups:
web-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/clients/cli"
target-branch: "v2/main"
schedule:
interval: "monthly"
labels:
- "v2"
groups:
cli-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/clients/tui"
target-branch: "v2/main"
schedule:
interval: "monthly"
labels:
- "v2"
groups:
tui-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/clients/launcher"
target-branch: "v2/main"
schedule:
interval: "monthly"
labels:
- "v2"
groups:
launcher-dependencies:
patterns:
- "*"

View file

@ -0,0 +1,48 @@
# Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs.
#
# Dependabot version updates are disabled in `.github/dependabot.yml` because a
# version-update PR carries no issue and no board card. This workflow runs
# `scripts/dependency-refresh.mjs` against `v2/main` once a month and files or
# updates ONE tracking issue listing every outdated package across the root
# install and each client — no PR is opened automatically. A maintainer reviews
# the issue, picks what to bump, and opens a normal PR against `v2/main`.
#
# `GITHUB_TOKEN` is sufficient: it only needs to read milestones (public) and
# create/edit an issue (`issues: write`). Board placement is intentionally NOT
# attempted here — that needs an org-project PAT this token cannot have — so a
# filed-but-unboarded issue is picked up by the next `/issue-triage` sweep,
# same as any other maintainer-filed issue.
name: Dependency Refresh
on:
schedule:
- cron: "23 6 1 * *" # 06:23 UTC on the 1st of every month
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
npm-outdated:
runs-on: ubuntu-latest
steps:
- name: Checkout v2/main
uses: actions/checkout@v7
with:
ref: v2/main
- 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: Run the npm-outdated sweep
run: node scripts/dependency-refresh.mjs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}

View file

@ -0,0 +1,208 @@
#!/usr/bin/env node
// Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs.
//
// Dependabot's version updates were disabled in `.github/dependabot.yml`
// because a version-update PR carries no issue and no board card — the same
// carve-out from "every PR references an issue" that the security-update flow
// had (that half is handled separately by the alert-driven pipeline, also
// #2229). This script replaces the routine-bump half: once a month it runs
// `npm outdated` across the root install and every client under `clients/*`
// (each has its own package.json + lockfile — v2 is not a workspace), and
// files or updates ONE tracking issue listing everything behind. A maintainer
// picks what to bump and opens a normal PR against `v2/main`; there is no
// auto-generated PR here at all.
//
// Idempotent by design: the issue body starts with a fixed HTML marker
// (ISSUE_MARKER below), which is how a second run in the same month finds and
// updates the existing open issue instead of filing a duplicate.
//
// `parseOutdated` and `buildIssueBody` are pure and covered by
// `dependency-refresh.test.mjs`; `main()` is the CLI entry point, exercised
// against the real repo only via `workflow_dispatch` in CI, not by the test
// suite (it shells out to `npm outdated` and `gh`, per the workflow-script
// convention `verify-skills.mjs` and its siblings already use).
import { spawnSync } from "node:child_process";
export const ISSUE_MARKER = "<!-- dependency-refresh:monthly-sweep -->";
/** Installs to check, relative to the repo root, and their npm-outdated label. */
export const INSTALLS = [
{ dir: ".", label: "root" },
{ dir: "clients/web", label: "clients/web" },
{ dir: "clients/cli", label: "clients/cli" },
{ dir: "clients/tui", label: "clients/tui" },
{ dir: "clients/launcher", label: "clients/launcher" },
];
/**
* Normalize one install's `npm outdated --json` output.
*
* @param {string} json raw stdout from `npm outdated --json` (may be `""` or `"{}"`)
* @returns {Array<{name: string, current: string, wanted: string, latest: string}>}
*/
export function parseOutdated(json) {
const trimmed = json.trim();
if (trimmed === "") return [];
const parsed = JSON.parse(trimmed);
return Object.entries(parsed)
.map(([name, info]) => ({
name,
current: info.current ?? "(missing)",
wanted: info.wanted ?? info.current ?? "?",
latest: info.latest ?? "?",
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* @param {Array<{label: string, packages: ReturnType<typeof parseOutdated>}>} installs
* @returns {string | null} the issue body, or `null` when nothing is outdated anywhere
*/
export function buildIssueBody(installs) {
const withPackages = installs.filter((i) => i.packages.length > 0);
if (withPackages.length === 0) return null;
const sections = withPackages.map(({ label, packages }) => {
const rows = packages
.map(
(p) => `| \`${p.name}\` | ${p.current} | ${p.wanted} | ${p.latest} |`,
)
.join("\n");
return `### \`${label}\`\n\n| Package | Current | Wanted | Latest |\n| --- | --- | --- | --- |\n${rows}`;
});
return [
ISSUE_MARKER,
"Routine dependency refresh — `npm outdated` run against `v2/main` on a monthly schedule, replacing Dependabot version-update PRs (#2229).",
"",
"This is a tracking issue, not a diff: pick what's worth bumping (`wanted` is the safe default; `latest` may cross a major and needs its own judgment call, especially for anything root-declared per [Dependency placement](https://github.com/modelcontextprotocol/inspector/blob/v2/main/AGENTS.md#dependency-placement)) and open a normal PR against `v2/main`.",
"",
...sections,
"",
"A second run of this sweep before this issue closes updates this body in place rather than filing a duplicate.",
].join("\n");
}
function runOutdated(dir) {
const result = spawnSync("npm", ["outdated", "--json"], {
cwd: dir,
encoding: "utf8",
});
// `npm outdated` exits 1 when it finds anything outdated — that is not a
// failure of the command, only stderr / a thrown parse is.
if (result.error) throw result.error;
return result.stdout ?? "";
}
function findExistingIssue(repo) {
const result = spawnSync(
"gh",
[
"issue",
"list",
"--repo",
repo,
"--state",
"open",
"--search",
ISSUE_MARKER,
"--json",
"number,body",
"--limit",
"10",
],
{ encoding: "utf8" },
);
if (result.status !== 0) {
throw new Error(`gh issue list failed: ${result.stderr}`);
}
const issues = JSON.parse(result.stdout || "[]");
return issues.find((i) => i.body?.startsWith(ISSUE_MARKER)) ?? null;
}
function currentMilestone(repo) {
const result = spawnSync(
"gh",
[
"api",
`repos/${repo}/milestones`,
"--jq",
'map(select(.state=="open")) | sort_by(.due_on) | .[0].title // empty',
],
{ encoding: "utf8" },
);
if (result.status !== 0) {
throw new Error(`milestone lookup failed: ${result.stderr}`);
}
return result.stdout.trim() || null;
}
export function main(repo = process.env.GITHUB_REPOSITORY) {
if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)");
const installs = INSTALLS.map(({ dir, label }) => ({
label,
packages: parseOutdated(runOutdated(dir)),
}));
const body = buildIssueBody(installs);
if (body === null) {
console.log("dependency-refresh: nothing outdated in any install — no-op");
return;
}
const existing = findExistingIssue(repo);
if (existing) {
const edit = spawnSync(
"gh",
[
"issue",
"edit",
String(existing.number),
"--repo",
repo,
"--body",
body,
],
{ encoding: "utf8" },
);
if (edit.status !== 0)
throw new Error(`gh issue edit failed: ${edit.stderr}`);
console.log(`dependency-refresh: updated existing #${existing.number}`);
return;
}
const milestone = currentMilestone(repo);
const args = [
"issue",
"create",
"--repo",
repo,
"--title",
"chore(deps): monthly dependency refresh",
"--label",
"v2",
"--label",
"chore",
"--label",
"dependabot",
"--body",
body,
];
if (milestone) args.push("--milestone", milestone);
const create = spawnSync("gh", args, { encoding: "utf8" });
if (create.status !== 0)
throw new Error(`gh issue create failed: ${create.stderr}`);
if (!milestone) {
console.log(
"dependency-refresh: no open milestone — issue filed unmilestoned, will be swept into Todo at next triage",
);
}
console.log(`dependency-refresh: filed ${create.stdout.trim()}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View file

@ -0,0 +1,61 @@
// Unit tests for the pure parsing/formatting halves of dependency-refresh.mjs
// (#2229). The impure half (`main()`, which shells out to `npm` and `gh`) is
// exercised only via `workflow_dispatch` in CI, per the same split
// `verify-skills.mjs` uses. Run via `npm run test:scripts`.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildIssueBody,
parseOutdated,
ISSUE_MARKER,
} from "./dependency-refresh.mjs";
test("parseOutdated returns [] for empty npm-outdated output", () => {
assert.deepEqual(parseOutdated(""), []);
assert.deepEqual(parseOutdated("{}"), []);
});
test("parseOutdated normalizes and sorts entries by name", () => {
const json = JSON.stringify({
zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
ajv: { current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" },
});
assert.deepEqual(parseOutdated(json), [
{ name: "ajv", current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" },
{ name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
]);
});
test("parseOutdated falls back when a field is missing", () => {
const json = JSON.stringify({ pkg: { current: "1.0.0" } });
assert.deepEqual(parseOutdated(json), [
{ name: "pkg", current: "1.0.0", wanted: "1.0.0", latest: "?" },
]);
});
test("buildIssueBody returns null when every install is up to date", () => {
assert.equal(
buildIssueBody([
{ label: "root", packages: [] },
{ label: "clients/web", packages: [] },
]),
null,
);
});
test("buildIssueBody starts with the idempotency marker and skips empty installs", () => {
const body = buildIssueBody([
{ label: "root", packages: [] },
{
label: "clients/web",
packages: [
{ name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
],
},
]);
assert.ok(body.startsWith(ISSUE_MARKER));
assert.ok(body.includes("### `clients/web`"));
assert.ok(!body.includes("### `root`"));
assert.ok(body.includes("| `zod` | 3.0.0 | 3.1.0 | 4.0.0 |"));
});