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:
parent
e915d4f038
commit
7dd9f47ca2
5 changed files with 361 additions and 78 deletions
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
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
|
||||
---
|
||||
|
||||
|
|
@ -27,10 +27,10 @@ job or the coverage gate red:
|
|||
There is **one version number** (only the root `package.json` has one — the
|
||||
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
|
||||
and flows into `main` with everything else.
|
||||
Both are part of the milestone's work, so both belong on the develop branch and
|
||||
flow into `main` together, in the same PR, audit fix first.
|
||||
|
||||
```sh
|
||||
# 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).
|
||||
git fetch 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
|
||||
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
|
||||
```
|
||||
|
||||
**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
|
||||
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"`.
|
||||
|
|
|
|||
91
.github/dependabot.yml
vendored
91
.github/dependabot.yml
vendored
|
|
@ -1,23 +1,26 @@
|
|||
version: 2
|
||||
|
||||
# Version updates target `v2/main`, the develop branch where all v2 work lands,
|
||||
# not the default branch — `main` is release-only, holding the latest released
|
||||
# v2 and receiving milestone merges from `v2/main`. (The deprecated v1 line
|
||||
# lives on `v1/main` and takes security fixes only.)
|
||||
# npm version updates are OFF (#2229): a Dependabot version-update PR carries no
|
||||
# issue and no board card, which is the same "every PR references an issue"
|
||||
# carve-out the security-update flow had. Routine npm bumps are handled instead
|
||||
# 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:
|
||||
# 1. Dependabot reads it from the DEFAULT branch (`main`). Changes here are
|
||||
# inert until the next milestone merge carries them there.
|
||||
# 2. `target-branch` scopes VERSION updates. Dependabot SECURITY updates are
|
||||
# enabled in repo settings, not here, and are raised against the default
|
||||
# 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.
|
||||
# 2. `target-branch` scopes VERSION updates only (npm updates are removed
|
||||
# here, so this now only applies to the github-actions entry below).
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
|
|
@ -31,63 +34,3 @@ updates:
|
|||
github-actions:
|
||||
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:
|
||||
- "*"
|
||||
|
|
|
|||
48
.github/workflows/dependency-refresh.yml
vendored
Normal file
48
.github/workflows/dependency-refresh.yml
vendored
Normal 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 }}
|
||||
208
scripts/dependency-refresh.mjs
Normal file
208
scripts/dependency-refresh.mjs
Normal 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();
|
||||
}
|
||||
61
scripts/dependency-refresh.test.mjs
Normal file
61
scripts/dependency-refresh.test.mjs
Normal 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 |"));
|
||||
});
|
||||
Loading…
Reference in a new issue