inspector/scripts/dependency-refresh.mjs
cliffhall b53266bc72
chore(deps): address Copilot review round 1 on #2243
- `--slurp` the paginated alert feed; a bare `--paginate` emits one JSON
  array per page and `JSON.parse` rejects it past 100 open alerts.
- Put `fixedIn` in the marker and in the existing-issue lookup, so a
  second bump of one package cannot merge into the first one's issue.
- Only an authorization-shaped failure of the `automated-security-fixes`
  read becomes UNVERIFIED; a rate limit or 5xx now throws.
- Distinguish "card never added" (benign, triage picks it up) from
  "card added, field not set" — the latter finishes every group, then
  fails the run.
- Don't board an unmilestoned issue at Todo; `Incoming` <=> no milestone.
- Comment before rewriting the marker, and give the comment its own
  marker, so a failed comment cannot be skipped forever.
- Ask a direct dependency's range to be raised, not widened to `>=`.
- Test `main()` through an injected spawn, as the sibling sweep does.
- Correct the docs that named the removed release-time `npm audit fix`,
  the ones promising an unconditional guard, and the sibling sweep's
  issue body claiming security updates remain enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-04 09:06:32 -04:00

488 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Monthly dependency sweep (#2229), replacing Dependabot's 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 `dependabot-alerts.mjs`, #2233, which turned those PRs off
// as well and files an issue per bump from the alerts they leave behind — so
// between them Dependabot opens no PRs against this repo at all, though its
// security ALERTS stay on, since that sweep is what consumes them).
// #2235 removed `.github/dependabot.yml` outright, so Dependabot opens
// no version-update PRs against this repo at all and this script is what
// replaced them.
//
// 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), checks every `uses:` ref under `.github/workflows` against that
// action's highest released version, 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.
//
// The actions half is here rather than left on Dependabot because the
// `github-actions` entry had exactly the property #2229 exists to remove: it
// opened a grouped monthly PR carrying no `Closes #N` and no board card.
// Deleting that entry without replacing it would have left action versions
// unwatched, and `npm outdated` says nothing about actions — hence the
// separate release lookup below.
//
// 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`, `parseActionRefs`, `parseVersionRef`, `isActionStale`,
// `staleActions`, `highestVersionTag`, `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";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
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" },
];
/** Where the `uses:` refs this sweep checks live, relative to the repo root. */
export const WORKFLOW_DIR = ".github/workflows";
/**
* 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));
}
/**
* Pull every action reference out of one workflow file.
*
* Deliberately a line regex rather than a YAML parse: `uses:` is always a
* scalar on its own line in this repo's workflows, and a real parser would be
* this script's only dependency. Local (`./…`) and container (`docker://…`)
* steps are skipped — neither has a releases feed to compare against — as is
* an unpinned `uses:` with no `@ref` at all.
*
* @param {string} yaml raw contents of a workflow file
* @returns {Array<{action: string, ref: string}>} in file order, duplicates kept
*/
export function parseActionRefs(yaml) {
const refs = [];
for (const line of yaml.split("\n")) {
const match = /^\s*(?:-\s+)?uses:\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/.exec(
line,
);
if (!match) continue;
const uses = match[1] ?? match[2] ?? match[3];
if (uses.startsWith("./") || uses.startsWith("docker://")) continue;
const at = uses.lastIndexOf("@");
if (at === -1) continue;
refs.push({ action: uses.slice(0, at), ref: uses.slice(at + 1) });
}
return refs;
}
/**
* Split a `v`-prefixed numeric ref into its components, or `null` when it is
* not one — a SHA pin or a branch name, which a tag comparison cannot rank.
*
* @param {string} ref e.g. `v7`, `v7.0`, `7.0.1`
* @returns {number[] | null}
*/
export function parseVersionRef(ref) {
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(ref);
if (!match) return null;
return match
.slice(1)
.filter((part) => part !== undefined)
.map(Number);
}
/**
* Is `current` behind `latest`?
*
* Compared only to the precision `current` actually specifies, because that is
* what pinning to it means: `v7` is a moving major tag that GitHub repoints at
* every `v7.x` release, so `v7` against a latest of `v7.0.1` is up to date and
* only `v8` makes it stale. An exactly-pinned `v7.0.0` *is* behind `v7.0.1`.
*
* @param {string} current the `uses:` ref
* @param {string} latest the action's latest release tag
* @returns {boolean} `false` when either side is not a numeric ref
*/
export function isActionStale(current, latest) {
const from = parseVersionRef(current);
const to = parseVersionRef(latest);
if (from === null || to === null) return false;
for (let i = 0; i < from.length; i++) {
const other = to[i] ?? 0;
if (other !== from[i]) return other > from[i];
}
return false;
}
/**
* @param {Array<{action: string, ref: string}>} refs every ref found across the workflows
* @param {Record<string, string | null>} latestByAction latest release tag per action, `null` when unknown
* @returns {Array<{action: string, current: string, latest: string}>} the stale ones, deduped and sorted
*/
export function staleActions(refs, latestByAction) {
const stale = new Map();
for (const { action, ref } of refs) {
const latest = latestByAction[action];
if (!latest || !isActionStale(ref, latest)) continue;
stale.set(`${action}@${ref}`, { action, current: ref, latest });
}
return [...stale.values()].sort(
(a, b) =>
a.action.localeCompare(b.action) || a.current.localeCompare(b.current),
);
}
/**
* @param {Array<{label: string, packages: ReturnType<typeof parseOutdated>}>} installs
* @param {ReturnType<typeof staleActions>} actions
* @returns {string | null} the issue body, or `null` when nothing is behind anywhere
*/
export function buildIssueBody(installs, actions = []) {
const withPackages = installs.filter((i) => i.packages.length > 0);
if (withPackages.length === 0 && actions.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}`;
});
if (actions.length > 0) {
const rows = actions
.map((a) => `| \`${a.action}\` | ${a.current} | ${a.latest} |`)
.join("\n");
sections.push(
`### GitHub Actions\n\n| Action | Current | Latest |\n| --- | --- | --- |\n${rows}`,
);
}
return [
ISSUE_MARKER,
"Routine dependency refresh — `npm outdated` plus a workflow `uses:` check, run against `v2/main` on a monthly schedule. This sweep replaces Dependabot's version-update PRs (#2229, #2235); its security-update PRs are off too (#2233), and the alerts they used to act on are swept into their own issues daily.",
"",
"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 AND
* every workflow action is current again. Without it the issue keeps its last
* table forever and reads as live work that no longer exists (Copilot).
*
* It has to speak for both halves of the sweep: once actions are in scope
* (#2235), npm-only wording here would assert a clean bill of health the sweep
* never checked, which is the same silent-all-clear shape the rest of this
* file guards against.
*
* 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,
`Everything this sweep can check is current as of ${isoDate} — no npm package is outdated at the root or in any client, and no version-pinned workflow \`uses:\` ref is behind its action's highest release.`,
"",
"Refs pinned to a commit SHA or a branch are deliberately **not** covered by that statement: neither can be ranked against a release tag, so this sweep says nothing about them either way.",
"",
"This issue was filed by an earlier run of the monthly sweep (#2229, #2235) and its tables are gone because nothing they listed is behind any more. Either it was bumped or the ranges caught up; nothing here is outstanding.",
"",
"Safe to close. A later sweep that finds something behind will refile this body with fresh tables 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 collectActionRefs() {
return readdirSync(WORKFLOW_DIR)
.filter((file) => /\.ya?ml$/.test(file))
.flatMap((file) =>
parseActionRefs(readFileSync(join(WORKFLOW_DIR, file), "utf8")),
);
}
/**
* The highest parseable version among these tags, or `null` if none parse.
*
* Deliberately NOT `releases/latest`, which is GitHub's *designated* most
* recent release rather than the greatest version: an action that ships a
* maintenance release for an older major (a `v6.9.1` cut after `v8.0.0`) makes
* `releases/latest` report `v6.9.1`, and a workflow pinned to `v7` would then
* compare against v6 and read as current — silently missing a whole major
* upgrade, which is the one thing this check exists to catch (Copilot).
*
* @param {string[]} tags release tag names, in any order
* @returns {string | null}
*/
export function highestVersionTag(tags) {
let best = null;
let bestParts = null;
for (const tag of tags) {
const parts = parseVersionRef(tag);
if (parts === null) continue;
if (bestParts === null || comparePadded(parts, bestParts) > 0) {
best = tag;
bestParts = parts;
}
}
return best;
}
/** Compare two version component arrays, padding the shorter with zeroes. */
function comparePadded(a, b) {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
/**
* The action's highest released version tag.
*
* Reads the release *list* rather than `releases/latest`, for the reason on
* `highestVersionTag`. Drafts and prereleases are excluded — neither is
* something a workflow should be told to move to.
*
* EVERY page is fetched, not just the newest 100. The list comes back
* newest-first, which is ordering by release date and not by version: an
* action that keeps cutting maintenance releases on lower majors pushes the
* genuine maximum off page 1, and ranking what remains would report a lower
* major as highest — recreating the exact false-current result that dropping
* `releases/latest` was meant to prevent (Copilot). Paginating is the only
* way the "highest released version" this function promises is actually that.
*
* @returns {string | null} `null` ONLY for a successful response carrying no
* usable release — an action that has never cut one, or whose tags are all
* unparseable. A failed lookup is never `null`; see below.
* @throws on any non-zero status, 404 included
*/
function latestReleaseTag(action, spawn) {
// `owner/repo/subpath@ref` is a valid `uses:`; releases live on `owner/repo`.
const repo = action.split("/").slice(0, 2).join("/");
const result = spawn(
"gh",
[
"api",
"--paginate",
`repos/${repo}/releases?per_page=100`,
"--jq",
'[.[] | select(.draft == false and .prerelease == false) | .tag_name] | join("\\n")',
],
{ encoding: "utf8" },
);
if (result.error) throw result.error;
// EVERY non-zero status is fatal, 404 included. This is the release *list*
// endpoint, which answers "no releases" with a successful empty array — so a
// 404 here does not mean "this action cuts no releases", it means the
// repository is missing or inaccessible, i.e. a `uses:` ref this sweep
// cannot check at all. Converting that to `null` would silently drop a
// broken or renamed action from the sweep that replaced Dependabot
// (Copilot). The empty-array case is already handled by the parse below.
if (result.status !== 0) {
throw new Error(
`release lookup for ${repo} failed (exit ${result.status}): ${(result.stderr ?? "").trim()}`,
);
}
const tags = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return highestVersionTag(tags);
}
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)),
}));
const refs = collectActionRefs();
const latestByAction = Object.fromEntries(
[...new Set(refs.map((r) => r.action))].map((action) => [
action,
latestReleaseTag(action, spawn),
]),
);
const actions = staleActions(refs, latestByAction);
// Look the existing issue up BEFORE branching on `body`: the nothing-
// behind case still has to reach an open issue to clear it.
const existing = findExistingIssue(repo, spawn);
const body = buildIssueBody(installs, actions);
if (body === null) {
if (!existing) {
console.log(
"dependency-refresh: nothing outdated, no stale actions — no-op",
);
return;
}
editIssue(
repo,
existing.number,
buildClearedBody(new Date().toISOString().slice(0, 10)),
spawn,
);
console.log(
`dependency-refresh: nothing behind — cleared stale list on #${existing.number}`,
);
return;
}
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();
}