Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 — run npm audit 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
---

Expand All @@ -27,10 +27,11 @@ 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`, 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 first, so the bump sits on top
of a tree you have just checked.

```sh
# Branch from the REMOTE ref, and read the version only once you are on it.
Expand All @@ -39,11 +40,45 @@ 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 every install that has its own lockfile — root and each client.
# REPORT ONLY. Read the output; do not let npm mutate the tree (see below).
npm audit --audit-level=high
for c in web cli tui launcher; do (cd "clients/$c" && npm audit --audit-level=high); done

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
```

Anything it reports is fixed **deliberately** — a direct bump, or an
`overrides` entry — and each fix is its own commit, gated by
`npm run local:gate` before the version bump goes on top.

⚠️ **Do not run `npm audit fix`, with or without `--force`.**
[Dependency placement](../../../AGENTS.md#dependency-placement) rules it out,
and the reason is not `--force`: plain `audit fix` resolves an advisory that has
no *upward* escape inside a declared range by silently **downgrading**. That is
not hypothetical here — `tsup@8.5.1` declares `esbuild: ^0.27.0` against an
advisory covering `0.27.3 - 0.28.0`, and `audit fix` walked three installs back
to `0.27.2` (~700 lines of lockfile churn for a low-severity dev-only advisory;
tried and reverted in #2058, written up in the `local-dev` skill). `local:gate`
does not detect a version regression, so nothing downstream would have caught
it. `--force` is worse again — it applies fixes *outside* the declared range,
trading a known vulnerability for an unvetted major.

So the release step is the **report**, and the judgment stays with a person.
Where `audit` names something with no in-range fix, pin it with `overrides`;
where it needs a major, that is its own issue and its own PR, not a release-day
edit. If something can't be resolved before the release ships, say so in the
release notes and leave it to the alert-driven pipeline (#2229) 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 well before a release is cut,
tracked and fixed as their own PRs. This exists 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"`.
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/dependency-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs.
#
# A Dependabot version-update PR carries no issue and no board card, so npm
# version updates are being switched off in `.github/dependabot.yml`. That file
# is changed in #2235, not here — until it lands, Dependabot's npm PRs and this
# sweep overlap, which is duplicate signal rather than conflicting action.
#
# 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 }}
260 changes: 260 additions & 0 deletions scripts/dependency-refresh.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#!/usr/bin/env node
// Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs.
//
// A Dependabot 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). Turning npm version updates off in `.github/dependabot.yml` is #2235;
// this script is the replacement it switches over to, and lands first, so the
// two flows overlap until #2235 does. 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`, `buildIssueBody` and `buildClearedBody` are pure. `main()`
// shells out to `npm outdated` and `gh`, so it takes its spawn function as a
// parameter (defaulting to the real one) and `dependency-refresh.test.mjs`
// drives it with a fake — covering npm failure, create vs. edit, the milestone
// lookup and both no-op paths. `workflow_dispatch` is a production trigger,
// not a substitute for that (Copilot): the helper-only tests it replaced let a
// non-zero `npm outdated` exit report a clean sweep.

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");
}

/**
* The body a still-open tracking issue is rewritten to once every install is
* current again. Without it the issue keeps its last package table forever and
* reads as live work that no longer exists (Copilot).
*
* The sweep rewrites rather than closes: it deliberately takes no board
* actions (see the workflow header), and closing an issue whose card a
* maintainer has already moved would make the board claim work shipped that
* this script cannot verify shipped. A maintainer closes it.
*
* @param {string} isoDate the sweep date, as `YYYY-MM-DD`
* @returns {string}
*/
export function buildClearedBody(isoDate) {
return [
ISSUE_MARKER,
`Every install is up to date as of ${isoDate} — nothing is outdated at the root or in any client.`,
"",
"This issue was filed by an earlier run of the monthly sweep (#2229) and its package table is gone because the packages it listed are no longer behind. Either they were bumped or their ranges caught up; nothing here is outstanding.",
"",
"Safe to close. A later sweep that finds something outdated will refile this body with a fresh table rather than open a duplicate.",
].join("\n");
}

function runOutdated(dir, spawn) {
const result = spawn("npm", ["outdated", "--json"], {
cwd: dir,
encoding: "utf8",
});
if (result.error) throw result.error;
// `npm outdated` exits 0 when everything is current and 1 when it finds
// something outdated — both are successful runs. Every other status is a
// real failure (a registry or config error exits 2), and it MUST throw
// rather than fall through: a failed run also prints nothing to stdout, so
// accepting it parses to an empty package list and reports a clean no-op.
// With five installs swept in a loop, that turns a total outage into a
// silent "nothing to do" (Copilot).
if (result.status !== 0 && result.status !== 1) {
throw new Error(
`npm outdated failed in ${dir} (exit ${result.status}): ${(result.stderr ?? "").trim()}`,
);
}
return result.stdout ?? "";
}

function findExistingIssue(repo, spawn) {
const result = spawn(
"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, spawn) {
const result = spawn(
"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;
}

function editIssue(repo, number, body, spawn) {
const edit = spawn(
"gh",
["issue", "edit", String(number), "--repo", repo, "--body", body],
{ encoding: "utf8" },
);
if (edit.status !== 0)
throw new Error(`gh issue edit failed: ${edit.stderr}`);
}

export function main(repo = process.env.GITHUB_REPOSITORY, spawn = spawnSync) {
if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)");

const installs = INSTALLS.map(({ dir, label }) => ({
label,
packages: parseOutdated(runOutdated(dir, spawn)),
}));

// Look the existing issue up BEFORE branching on `body`: the nothing-
// outdated case still has to reach an open issue to clear it.
const existing = findExistingIssue(repo, spawn);
const body = buildIssueBody(installs);

if (body === null) {
if (!existing) {
console.log(
"dependency-refresh: nothing outdated in any install — no-op",
);
return;
}
editIssue(
repo,
existing.number,
buildClearedBody(new Date().toISOString().slice(0, 10)),
spawn,
);
console.log(
`dependency-refresh: nothing outdated — cleared stale list on #${existing.number}`,
);
return;
Comment thread
cliffhall marked this conversation as resolved.
}

if (existing) {
editIssue(repo, existing.number, body, spawn);
console.log(`dependency-refresh: updated existing #${existing.number}`);
return;
}

const milestone = currentMilestone(repo, spawn);
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 = spawn("gh", args, { encoding: "utf8" });
if (create.status !== 0)
throw new Error(`gh issue create failed: ${create.stderr}`);
if (!milestone) {
// Unmilestoned means unapproved, so triage sweeps it into `Incoming` — NOT
// `Todo`, which asserts a maintainer signed off (Copilot).
console.log(
"dependency-refresh: no open milestone — issue filed unmilestoned, will be swept into Incoming at next triage",
);
}
console.log(`dependency-refresh: filed ${create.stdout.trim()}`);
}

if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
Loading