diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3b0f475e..22cd4e64 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -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/-bump- 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"`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6888530a..2ca7bcb8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: - - "*" diff --git a/.github/workflows/dependency-refresh.yml b/.github/workflows/dependency-refresh.yml new file mode 100644 index 00000000..7e8b0fbe --- /dev/null +++ b/.github/workflows/dependency-refresh.yml @@ -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 }} diff --git a/scripts/dependency-refresh.mjs b/scripts/dependency-refresh.mjs new file mode 100644 index 00000000..726706ae --- /dev/null +++ b/scripts/dependency-refresh.mjs @@ -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 = ""; + +/** 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}>} 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(); +} diff --git a/scripts/dependency-refresh.test.mjs b/scripts/dependency-refresh.test.mjs new file mode 100644 index 00000000..02ee339a --- /dev/null +++ b/scripts/dependency-refresh.test.mjs @@ -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 |")); +});