-
Notifications
You must be signed in to change notification settings - Fork 1.5k
chore(deps): npm audit fix before release bump; monthly npm-outdated issue #2232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+619
−4
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7dd9f47
chore(deps): npm audit fix before release bump; monthly npm-outdated …
cliffhall ee14a9f
chore(deps): move the dependabot.yml change out to #2235
cliffhall 07bd987
chore(deps): address Copilot review round 1 on #2232
cliffhall ddf2307
test(scripts): stop the repo-guard test depending on the ambient env
cliffhall File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| 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(); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.