Merge pull request #2243 from modelcontextprotocol/v2/chore/2233-dependabot-alert-sweep
chore(deps): sweep Dependabot alerts into board-tracked issues
This commit is contained in:
commit
1c1e70820b
12 changed files with 4403 additions and 98 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 — 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
|
||||
---
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"`.
|
||||
|
|
|
|||
93
.github/dependabot.yml
vendored
93
.github/dependabot.yml
vendored
|
|
@ -1,93 +0,0 @@
|
|||
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.)
|
||||
#
|
||||
# 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.
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "v2/main"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
labels:
|
||||
- "v2"
|
||||
groups:
|
||||
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:
|
||||
- "*"
|
||||
103
.github/workflows/dependabot-alerts.yml
vendored
Normal file
103
.github/workflows/dependabot-alerts.yml
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Dependabot alert sweep (#2233), the alert-consuming half of #2229.
|
||||
#
|
||||
# Dependabot's SECURITY-update PRs are turned off for this repo; its ALERTS are
|
||||
# left on. Those are two independent settings, and this workflow depends on the
|
||||
# split: it reads the alerts and turns them into ordinary board-tracked issues,
|
||||
# so the fix is written by hand against `v2/main` like any other work.
|
||||
#
|
||||
# alert -> this sweep -> issue (labeled, milestoned, boarded) -> maintainer PR -> v2/main
|
||||
#
|
||||
# It is a SCHEDULE, not an event handler, because there is no `dependabot_alert`
|
||||
# workflow trigger — that is a webhook event only. Daily is deliberate: with
|
||||
# security PRs off there is no mergeable-against-`main` artifact and no window to
|
||||
# race, so the merge guard #2060 needed has no analogue here. What replaces it is
|
||||
# a precondition inside the script: `automated-security-fixes` is a repo SETTING
|
||||
# and can be switched back on from the UI without a commit, so the sweep reads it
|
||||
# back and fails loudly on an explicit `enabled: true`. That read needs a
|
||||
# permission `GITHUB_TOKEN` cannot hold, so with the default token it reports
|
||||
# UNVERIFIED instead — see the token notes below; it is a conditional guard, not
|
||||
# an invariant.
|
||||
#
|
||||
# ⚠️ GitHub computes the dependency graph, and therefore every alert, from the
|
||||
# DEFAULT branch (`main`), while we ship from `v2/main`. Two consequences:
|
||||
#
|
||||
# * An alert is re-checked against `v2/main`'s own lockfile before an issue is
|
||||
# filed — hence the `ref: v2/main` checkout below. An alert whose vulnerable
|
||||
# range no longer matches is already fixed on the branch we ship from and is
|
||||
# waiting on a milestone merge to close, so it is skipped silently.
|
||||
# * A vulnerable dependency introduced on `v2/main` and not yet merged to
|
||||
# `main` produces NO alert at all. No approach that consumes GitHub's alerts
|
||||
# avoids that. The release-time `npm audit --audit-level=high` report from
|
||||
# #2231 is a second signal that partially covers it — at release time only;
|
||||
# running that same report over `v2/main`'s lockfiles on a schedule would
|
||||
# close it fully and is a separable follow-up.
|
||||
#
|
||||
# `vulnerability-alerts: read` is the one non-default permission, and
|
||||
# `GITHUB_TOKEN` supports it — no PAT is needed to read the alerts themselves.
|
||||
# Two side steps are outside its reach, and `PROJECT_TOKEN` is what covers them
|
||||
# when it exists:
|
||||
#
|
||||
# * Writing the board card, since board #28 is an ORG project
|
||||
# (`organization projects: write`). Absent, the issue is still filed labeled
|
||||
# and milestoned and the next `/issue-triage` sweep boards it.
|
||||
# * Reading back `automated-security-fixes`, which needs `administration: read`
|
||||
# — a permission `permissions:` has no key for, so `GITHUB_TOKEN` can never
|
||||
# have it. Absent, that assertion is reported as UNVERIFIED rather than
|
||||
# failing the run; an explicit `enabled: true` still fails it.
|
||||
#
|
||||
# Nothing the sweep exists to do is skipped for want of that secret.
|
||||
#
|
||||
# The version-update half of #2229 is the sibling `dependency-refresh.yml`.
|
||||
name: Dependabot Alert Sweep
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 6 * * *" # 06:17 UTC daily; alerts are not minute-sensitive
|
||||
workflow_dispatch:
|
||||
|
||||
# The marker check is a read-before-write, not an atomic one, and nothing stops
|
||||
# a `workflow_dispatch` from landing on top of the scheduled run. Two overlapping
|
||||
# runs would both see no open issue and both file one, which is the exact
|
||||
# duplicate this sweep's whole idempotency design exists to prevent (Copilot).
|
||||
# `cancel-in-progress: false` because the queued run must WAIT and then re-read
|
||||
# the state the first run wrote — cancelling it would drop a sweep instead.
|
||||
concurrency:
|
||||
group: dependabot-alert-sweep
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
vulnerability-alerts: read
|
||||
|
||||
jobs:
|
||||
alert-sweep:
|
||||
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"
|
||||
|
||||
# Root install only, and no lifecycle scripts. The sweep's one dependency
|
||||
# is `semver`; it reads every lockfile as JSON and never needs a client's
|
||||
# tree on disk, so the postinstall cascade into `clients/*` that
|
||||
# `dependency-refresh.yml` genuinely needs (it shells out to
|
||||
# `npm outdated` in each) would be minutes of nothing here.
|
||||
- name: Install root dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run the Dependabot alert sweep
|
||||
run: node scripts/dependabot-alerts.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
# Optional: an org-project PAT with `organization projects: write`.
|
||||
# Absent, the issue is filed unboarded and triage picks it up.
|
||||
PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
|
||||
60
.github/workflows/dependency-refresh.yml
vendored
Normal file
60
.github/workflows/dependency-refresh.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Monthly dependency sweep (#2229), replacing Dependabot's VERSION-UPDATE PRs.
|
||||
#
|
||||
# A Dependabot version-update PR carries no issue and no board card, so
|
||||
# `.github/dependabot.yml` was removed outright in #2235 — npm and
|
||||
# github-actions alike. This workflow is what replaced those PRs (the
|
||||
# security-update half is a separate mechanism, switched off separately; see
|
||||
# below): it runs `scripts/dependency-refresh.mjs` against `v2/main` once a
|
||||
# month and files or updates ONE tracking issue listing every outdated npm
|
||||
# package across the root install and each client, plus any workflow `uses:`
|
||||
# ref behind its action's highest released version. No PR is opened
|
||||
# automatically. A maintainer reviews the issue, picks what to bump, and opens
|
||||
# a normal PR against `v2/main`.
|
||||
#
|
||||
# Dependabot SECURITY updates are the other half, and #2233 turned their PRs
|
||||
# off too — they were enabled in repo settings rather than in a config file,
|
||||
# which is why they kept working while `dependabot.yml` was missing entirely
|
||||
# (see #1833, #1840) and why deleting that file did not touch them. Its
|
||||
# ALERTS stay on and are swept into issues daily by the sibling
|
||||
# `dependabot-alerts.yml`. Between them, Dependabot opens no PRs here at all.
|
||||
#
|
||||
# `GITHUB_TOKEN` is sufficient: it only needs to read milestones and the public
|
||||
# release feeds of the actions we use, and to 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:
|
||||
dependency-sweep:
|
||||
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 dependency sweep (npm packages + workflow actions)
|
||||
run: node scripts/dependency-refresh.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
28
AGENTS.md
28
AGENTS.md
|
|
@ -52,7 +52,8 @@ inspector/
|
|||
│ ├── react/ React hooks over the state stores (read during render — see React instructions)
|
||||
│ └── storage/ File I/O helpers for the OAuth persist backends
|
||||
├── test-servers/ Composable MCP test servers + JSON configs
|
||||
├── scripts/ Root build/verify tooling: install cascade, smokes, verify:* guards
|
||||
├── scripts/ Root build/verify tooling (install cascade, smokes, verify:* guards)
|
||||
│ plus repo automation run from CI (the dependency + alert sweeps)
|
||||
├── docs/ Task-oriented guides
|
||||
├── specification/ Design/build specifications
|
||||
└── .claude/skills/ The procedures (see the index above)
|
||||
|
|
@ -98,6 +99,31 @@ The reasoning behind each of these, and what breaks when it is ignored, is the
|
|||
- **One version per install-crossing dependency.** When bumping a dependency the shared sources pull in, bump it in every install that declares it. Consolidating to the root is what makes most of these unbumpable in two places at once, but it does not retire the rule — a client's `devDependencies`, and any package that arrives transitively into a client install, can still skew against the root. Never raise the tsc heap to work around one. `npm run verify:dep-lockstep` enforces this.
|
||||
- **Pin a transitive dependency with an `overrides` entry**, not with `npm audit fix` — which "resolves" an advisory with no upward escape by silently downgrading.
|
||||
|
||||
### Dependency updates are issue-driven, like everything else
|
||||
|
||||
**Dependabot opens no pull requests against this repo — neither version updates nor security updates.** A Dependabot PR carries no `Closes #N` and no board card, so it was the one standing exception to [Issue-driven Work Style](#issue-driven-work-style), enforced by nothing. Both halves are now replaced by scheduled workflows that file **issues**, and a maintainer writes the fix by hand against `v2/main`.
|
||||
|
||||
| Half | Switched off by | Replaced by | Cadence |
|
||||
| --- | --- | --- | --- |
|
||||
| Version updates | Deleting `.github/dependabot.yml` outright (#2235) — an empty `updates:` list is not valid config | `.github/workflows/dependency-refresh.yml` → `scripts/dependency-refresh.mjs`: `npm outdated` across every install, plus a `uses:` check against each action's latest release, folded into **one** tracking issue | Monthly |
|
||||
| Security updates | `DELETE /repos/{owner}/{repo}/automated-security-fixes` — a **repo setting**, not a file | `.github/workflows/dependabot-alerts.yml` → `scripts/dependabot-alerts.mjs`: reads the alerts and files one issue **per bump** | Daily |
|
||||
|
||||
Four things about this that are not obvious from the code:
|
||||
|
||||
- **Dependabot *alerts* stay on.** Alerts and security-update PRs are independent settings; only the PRs are off. Turning alerts off would blind the sweep that replaced them.
|
||||
- **The security half is a schedule, not an event handler**, because there is no `dependabot_alert` workflow trigger — it is a webhook event only.
|
||||
- **Alerts are computed from the default branch (`main`), and we ship from `v2/main`.** So the sweep re-checks each alert's vulnerable range against `v2/main`'s own lockfile before filing, and skips one that is already fixed there. The converse is a real blind spot with no fix on this path: a vulnerable dependency introduced on `v2/main` and not yet merged to `main` produces **no alert at all**. The release-time `npm audit --audit-level=high` report (#2231) is the partial second signal — and only at release time.
|
||||
- **`automated-security-fixes` can be re-enabled from the UI without a commit**, so nothing in the repo would record it. The sweep reads it back and **fails loudly on an explicit `enabled: true`**. ⚠️ It is a *conditional* guard, not an invariant: the endpoint needs `administration: read`, which `GITHUB_TOKEN` cannot be granted (`permissions:` has no such key), so under the default token the sweep logs **UNVERIFIED** and carries on rather than going red every day for an unrelated reason. Only a token carrying that scope makes it a real assertion.
|
||||
|
||||
An issue filed by either sweep is an ordinary board item — `v2` + `chore` + `dependabot`, the current milestone, and a card on #28. **How it gets its card differs, and the two sweeps are not interchangeable here:**
|
||||
|
||||
| | files the card itself? |
|
||||
| --- | --- |
|
||||
| Monthly version sweep | **No, never.** It does not attempt a board write at all and has no `PROJECT_TOKEN`; the issue arrives labeled and milestoned, and `/issue-triage` places it. |
|
||||
| Daily security sweep | **Only when it can.** With an org-project PAT it places the card directly at **Todo / High**; without one it degrades to the same triage hand-off. |
|
||||
|
||||
The board write needs `organization projects: write`, which `GITHUB_TOKEN` cannot have — hence "only when it can", and hence a filed-but-unboarded issue is a normal outcome rather than a failure. **Todo, not Incoming**, when the security sweep does place it: arriving through this pipeline *is* the approval. **`High` is a standing override** of the [priority rubric](.claude/skills/issue-triage/SKILL.md), which would otherwise score a routine bump Medium; the issue body records the override so it does not read as a mis-score. ⚠️ A milestone is a precondition for placing a card — `Incoming` ⇔ no milestone — so the security sweep leaves an issue **unboarded** rather than parked at Todo when no dated milestone is open. It picks the open milestone with the nearest **due date**, ignoring undated buckets; the monthly sweep's own selection does not yet filter those out (raised on #2239), so don't read this as a guarantee both scripts already implement.
|
||||
|
||||
## Contributing
|
||||
|
||||
External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ inspector/
|
|||
├── core/ Shared code consumed via the `@inspector/core` alias (no package.json)
|
||||
├── test-servers/ Composable MCP test servers + fixtures used by integration and smoke tests
|
||||
├── scripts/ Root build/verify tooling (install cascade, smokes, the verify:* guards)
|
||||
│ and repo automation run from CI (the dependency and Dependabot-alert sweeps)
|
||||
├── docs/ Task-oriented guides — see below
|
||||
├── specification/ Design/build specifications
|
||||
├── .claude/skills/ Agent skills: the repo's procedures, invokable by name
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -46,6 +46,7 @@
|
|||
"express": "^5.2.1",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "3.8.4",
|
||||
"semver": "^7.8.5",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vitest": "4.1.10"
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"express": "^5.2.1",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "3.8.4",
|
||||
"semver": "^7.8.5",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vitest": "4.1.10"
|
||||
|
|
|
|||
1424
scripts/dependabot-alerts.mjs
Normal file
1424
scripts/dependabot-alerts.mjs
Normal file
File diff suppressed because it is too large
Load diff
1784
scripts/dependabot-alerts.test.mjs
Normal file
1784
scripts/dependabot-alerts.test.mjs
Normal file
File diff suppressed because it is too large
Load diff
488
scripts/dependency-refresh.mjs
Normal file
488
scripts/dependency-refresh.mjs
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
#!/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();
|
||||
}
|
||||
475
scripts/dependency-refresh.test.mjs
Normal file
475
scripts/dependency-refresh.test.mjs
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
// Tests for dependency-refresh.mjs (#2229) — both the pure parsing/formatting
|
||||
// helpers and `main()`'s orchestration, the latter driven through the injected
|
||||
// spawn function so no `npm` or `gh` process is ever started.
|
||||
//
|
||||
// `main()` is covered rather than left to `workflow_dispatch` because a
|
||||
// production trigger is not a test (Copilot): the helper-only suite this
|
||||
// replaced passed while a non-zero `npm outdated` exit reported a clean sweep.
|
||||
// Run via `npm run test:scripts`.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildClearedBody,
|
||||
buildIssueBody,
|
||||
highestVersionTag,
|
||||
isActionStale,
|
||||
main,
|
||||
parseActionRefs,
|
||||
parseOutdated,
|
||||
parseVersionRef,
|
||||
staleActions,
|
||||
INSTALLS,
|
||||
ISSUE_MARKER,
|
||||
} from "./dependency-refresh.mjs";
|
||||
|
||||
/**
|
||||
* A fake `spawnSync` that answers by command shape and records every call.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {Record<string,unknown>} [opts.outdated] parsed `npm outdated` payload, per install dir
|
||||
* @param {number} [opts.outdatedStatus] exit status for every `npm outdated`
|
||||
* @param {Array<{number:number,body:string}>} [opts.existing] what `gh issue list` returns
|
||||
* @param {string|null} [opts.milestone] what the milestone lookup returns
|
||||
* @param {string[]} [opts.releaseTags] tags every action's release list returns
|
||||
* @param {number} [opts.releasesStatus] exit status for every release lookup
|
||||
* @param {string} [opts.releasesStderr] stderr for a failing release lookup
|
||||
*/
|
||||
function fakeSpawn({
|
||||
outdated = {},
|
||||
outdatedStatus,
|
||||
existing = [],
|
||||
milestone = "v2.6.0",
|
||||
releaseTags = [],
|
||||
releasesStatus,
|
||||
releasesStderr = "",
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
const fn = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, cwd: opts?.cwd });
|
||||
if (cmd === "npm") {
|
||||
const payload = outdated[opts.cwd] ?? {};
|
||||
const found = Object.keys(payload).length > 0;
|
||||
return {
|
||||
// Real `npm outdated` exits 1 precisely when it found something.
|
||||
status: outdatedStatus ?? (found ? 1 : 0),
|
||||
stdout: found ? JSON.stringify(payload) : "",
|
||||
stderr: outdatedStatus ? "ENOTFOUND registry.npmjs.org" : "",
|
||||
};
|
||||
}
|
||||
if (args[0] === "issue" && args[1] === "list")
|
||||
return { status: 0, stdout: JSON.stringify(existing), stderr: "" };
|
||||
// MUST be tested before the milestone branch below: both are `gh api`,
|
||||
// so matching on args[0] alone hands the release lookup the milestone
|
||||
// string and the assertion silently checks nothing.
|
||||
if (args[0] === "api" && args.some((a) => a.includes("/releases")))
|
||||
return {
|
||||
status: releasesStatus ?? 0,
|
||||
stdout: releasesStatus ? "" : releaseTags.join("\n"),
|
||||
stderr: releasesStderr,
|
||||
};
|
||||
if (args[0] === "api")
|
||||
return {
|
||||
status: 0,
|
||||
stdout: milestone ? `${milestone}\n` : "",
|
||||
stderr: "",
|
||||
};
|
||||
if (args[0] === "issue" && args[1] === "create")
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "https://github.com/o/r/issues/9\n",
|
||||
stderr: "",
|
||||
};
|
||||
if (args[0] === "issue" && args[1] === "edit")
|
||||
return { status: 0, stdout: "", stderr: "" };
|
||||
throw new Error(`unexpected spawn: ${cmd} ${args.join(" ")}`);
|
||||
};
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
const ghCall = (spawn, verb) =>
|
||||
spawn.calls.find((c) => c.cmd === "gh" && c.args[1] === verb);
|
||||
|
||||
/** Silence main()'s progress logging; returns the captured lines. */
|
||||
function captureLog(run) {
|
||||
const lines = [];
|
||||
const original = console.log;
|
||||
console.log = (...a) => lines.push(a.join(" "));
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
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("parseActionRefs pulls owner/repo@ref out of a workflow, in file order", () => {
|
||||
const yaml = [
|
||||
"jobs:",
|
||||
" build:",
|
||||
" steps:",
|
||||
" - uses: actions/checkout@v7",
|
||||
" - name: Setup",
|
||||
" uses: actions/setup-node@v7",
|
||||
' - uses: "docker/login-action@v4"',
|
||||
" - uses: docker/build-push-action@v7 # trailing comment",
|
||||
].join("\n");
|
||||
assert.deepEqual(parseActionRefs(yaml), [
|
||||
{ action: "actions/checkout", ref: "v7" },
|
||||
{ action: "actions/setup-node", ref: "v7" },
|
||||
{ action: "docker/login-action", ref: "v4" },
|
||||
{ action: "docker/build-push-action", ref: "v7" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseActionRefs skips local, container and unpinned steps", () => {
|
||||
const yaml = [
|
||||
" - uses: ./.github/actions/local",
|
||||
" - uses: docker://alpine:3.20",
|
||||
" - uses: actions/checkout",
|
||||
" - uses: github/codeql-action/init@v3",
|
||||
].join("\n");
|
||||
assert.deepEqual(parseActionRefs(yaml), [
|
||||
{ action: "github/codeql-action/init", ref: "v3" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseVersionRef reads a numeric ref and rejects anything else", () => {
|
||||
assert.deepEqual(parseVersionRef("v7"), [7]);
|
||||
assert.deepEqual(parseVersionRef("7.0.1"), [7, 0, 1]);
|
||||
assert.equal(parseVersionRef("main"), null);
|
||||
assert.equal(
|
||||
parseVersionRef("8f4b7f84864484a7bf31766abe9204da3cbe65b3"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("isActionStale compares only to the precision the ref specifies", () => {
|
||||
// `v7` is a moving major tag, so a v7.x release does not make it stale.
|
||||
assert.equal(isActionStale("v7", "v7.0.1"), false);
|
||||
assert.equal(isActionStale("v7", "v8.0.0"), true);
|
||||
// An exactly-pinned ref is behind its own patch release.
|
||||
assert.equal(isActionStale("v7.0.0", "v7.0.1"), true);
|
||||
assert.equal(isActionStale("v7.1", "v7.0.9"), false);
|
||||
});
|
||||
|
||||
test("isActionStale reports nothing for a ref it cannot rank", () => {
|
||||
// A SHA pin is deliberately immovable; a tag comparison says nothing about it.
|
||||
assert.equal(
|
||||
isActionStale("8f4b7f84864484a7bf31766abe9204da3cbe65b3", "v5"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isActionStale("main", "v5"), false);
|
||||
assert.equal(isActionStale("v5", "not-a-tag"), false);
|
||||
});
|
||||
|
||||
test("highestVersionTag picks the greatest version, not the newest entry", () => {
|
||||
// GitHub's `releases/latest` is the DESIGNATED latest, not the greatest
|
||||
// version: a maintenance release cut for an older major after a newer one
|
||||
// would make a workflow on v7 read as current (Copilot).
|
||||
assert.equal(highestVersionTag(["v6.9.1", "v8.0.0", "v7.2.0"]), "v8.0.0");
|
||||
assert.equal(highestVersionTag(["v7", "v7.0.1"]), "v7.0.1");
|
||||
assert.equal(highestVersionTag(["v10.0.0", "v9.9.9"]), "v10.0.0");
|
||||
});
|
||||
|
||||
test("highestVersionTag ignores tags it cannot parse", () => {
|
||||
assert.equal(highestVersionTag(["nightly", "v2.0.0", "latest"]), "v2.0.0");
|
||||
assert.equal(highestVersionTag(["nightly", "latest"]), null);
|
||||
assert.equal(highestVersionTag([]), null);
|
||||
});
|
||||
|
||||
test("staleActions dedupes, drops actions with no known release and sorts", () => {
|
||||
const refs = [
|
||||
{ action: "actions/checkout", ref: "v7" },
|
||||
{ action: "actions/cache", ref: "v6" },
|
||||
{ action: "actions/cache", ref: "v6" },
|
||||
{ action: "some/unreleased", ref: "v1" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
staleActions(refs, {
|
||||
"actions/cache": "v7.0.0",
|
||||
"actions/checkout": "v7.0.1",
|
||||
"some/unreleased": null,
|
||||
}),
|
||||
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
|
||||
);
|
||||
});
|
||||
|
||||
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 |"));
|
||||
});
|
||||
|
||||
test("buildClearedBody keeps the marker so the next sweep still finds the issue", () => {
|
||||
const body = buildClearedBody("2026-09-03");
|
||||
assert.ok(body.startsWith(ISSUE_MARKER));
|
||||
assert.ok(body.includes("2026-09-03"));
|
||||
assert.ok(body.includes("Safe to close"));
|
||||
});
|
||||
|
||||
test("main throws when npm outdated exits with an undocumented status", () => {
|
||||
// The regression that motivated covering main(): exit 2 used to fall through
|
||||
// to empty stdout and report a clean sweep across all five installs.
|
||||
const spawn = fakeSpawn({ outdatedStatus: 2 });
|
||||
assert.throws(
|
||||
() => captureLog(() => main("o/r", spawn)),
|
||||
/npm outdated failed in \.\s*\(exit 2\).*ENOTFOUND/s,
|
||||
);
|
||||
assert.equal(ghCall(spawn, "create"), undefined);
|
||||
});
|
||||
|
||||
test("main sweeps every install and files one milestoned issue", () => {
|
||||
const spawn = fakeSpawn({
|
||||
outdated: {
|
||||
"clients/web": {
|
||||
zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const log = captureLog(() => main("o/r", spawn));
|
||||
|
||||
assert.deepEqual(
|
||||
spawn.calls.filter((c) => c.cmd === "npm").map((c) => c.cwd),
|
||||
INSTALLS.map((i) => i.dir),
|
||||
);
|
||||
const create = ghCall(spawn, "create");
|
||||
assert.ok(create, "expected an issue to be created");
|
||||
assert.deepEqual(create.args.slice(-2), ["--milestone", "v2.6.0"]);
|
||||
assert.ok(create.args[create.args.indexOf("--body") + 1].includes("`zod`"));
|
||||
assert.ok(log.some((l) => l.includes("filed")));
|
||||
});
|
||||
|
||||
test("main edits the existing issue instead of filing a duplicate", () => {
|
||||
const spawn = fakeSpawn({
|
||||
outdated: {
|
||||
".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } },
|
||||
},
|
||||
existing: [{ number: 77, body: `${ISSUE_MARKER}\nstale` }],
|
||||
});
|
||||
const log = captureLog(() => main("o/r", spawn));
|
||||
|
||||
assert.equal(ghCall(spawn, "create"), undefined);
|
||||
const edit = ghCall(spawn, "edit");
|
||||
assert.equal(edit.args[2], "77");
|
||||
assert.ok(edit.args[edit.args.length - 1].includes("`ajv`"));
|
||||
assert.ok(log.some((l) => l.includes("updated existing #77")));
|
||||
});
|
||||
|
||||
test("main clears a still-open issue once everything is current again", () => {
|
||||
const spawn = fakeSpawn({
|
||||
existing: [{ number: 77, body: `${ISSUE_MARKER}\n| \`zod\` | 3.0.0 |` }],
|
||||
});
|
||||
const log = captureLog(() => main("o/r", spawn));
|
||||
|
||||
const edit = ghCall(spawn, "edit");
|
||||
assert.ok(edit, "an open issue must be cleared, not left with a stale table");
|
||||
const body = edit.args[edit.args.length - 1];
|
||||
assert.ok(body.startsWith(ISSUE_MARKER));
|
||||
assert.ok(!body.includes("`zod`"));
|
||||
assert.ok(log.some((l) => l.includes("cleared stale list on #77")));
|
||||
});
|
||||
|
||||
test("main is a true no-op when nothing is outdated and no issue is open", () => {
|
||||
const spawn = fakeSpawn();
|
||||
const log = captureLog(() => main("o/r", spawn));
|
||||
|
||||
assert.equal(ghCall(spawn, "edit"), undefined);
|
||||
assert.equal(ghCall(spawn, "create"), undefined);
|
||||
assert.ok(log.some((l) => l.includes("no-op")));
|
||||
});
|
||||
|
||||
test("main says Incoming, not Todo, when it files without a milestone", () => {
|
||||
const spawn = fakeSpawn({
|
||||
outdated: {
|
||||
".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } },
|
||||
},
|
||||
milestone: null,
|
||||
});
|
||||
const log = captureLog(() => main("o/r", spawn));
|
||||
|
||||
const create = ghCall(spawn, "create");
|
||||
assert.ok(!create.args.includes("--milestone"));
|
||||
// Unmilestoned is unapproved; triage parks it in Incoming.
|
||||
assert.ok(log.some((l) => l.includes("Incoming")));
|
||||
assert.ok(!log.some((l) => l.includes("Todo")));
|
||||
});
|
||||
|
||||
test("main refuses to run without a repo", () => {
|
||||
// `main`'s default reads process.env.GITHUB_REPOSITORY, which GitHub Actions
|
||||
// sets on every run — so this has to clear the variable rather than assume
|
||||
// the ambient environment lacks it. Relying on the ambient value passed
|
||||
// locally and failed in CI, which is the one place the default is always
|
||||
// populated.
|
||||
const saved = process.env.GITHUB_REPOSITORY;
|
||||
delete process.env.GITHUB_REPOSITORY;
|
||||
try {
|
||||
assert.throws(
|
||||
() => main(undefined, fakeSpawn()),
|
||||
/GITHUB_REPOSITORY unset/,
|
||||
);
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.GITHUB_REPOSITORY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("main falls back to GITHUB_REPOSITORY when no repo is passed", () => {
|
||||
// The other half of the default: with the variable set, `main(undefined, …)`
|
||||
// must use it rather than throw. Together the two tests pin the default's
|
||||
// behavior in both environments instead of inheriting whichever one happens
|
||||
// to be running.
|
||||
const saved = process.env.GITHUB_REPOSITORY;
|
||||
process.env.GITHUB_REPOSITORY = "env/repo";
|
||||
try {
|
||||
const spawn = fakeSpawn({
|
||||
outdated: {
|
||||
".": { ajv: { current: "8.0.0", wanted: "8.1.0", latest: "8.1.0" } },
|
||||
},
|
||||
});
|
||||
captureLog(() => main(undefined, spawn));
|
||||
const create = ghCall(spawn, "create");
|
||||
assert.equal(create.args[create.args.indexOf("--repo") + 1], "env/repo");
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.GITHUB_REPOSITORY;
|
||||
else process.env.GITHUB_REPOSITORY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildIssueBody renders a GitHub Actions section after the npm ones", () => {
|
||||
const body = buildIssueBody(
|
||||
[{ label: "root", packages: [] }],
|
||||
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
|
||||
);
|
||||
assert.ok(body.startsWith(ISSUE_MARKER));
|
||||
assert.ok(body.includes("### GitHub Actions"));
|
||||
assert.ok(body.includes("| `actions/cache` | v6 | v7.0.0 |"));
|
||||
assert.ok(!body.includes("### `root`"));
|
||||
});
|
||||
|
||||
test("buildIssueBody returns null only when npm and actions are both clean", () => {
|
||||
assert.equal(buildIssueBody([{ label: "root", packages: [] }], []), null);
|
||||
assert.notEqual(
|
||||
buildIssueBody(
|
||||
[],
|
||||
[{ action: "actions/cache", current: "v6", latest: "v7.0.0" }],
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildClearedBody speaks for both halves of the sweep", () => {
|
||||
const body = buildClearedBody("2026-09-04");
|
||||
assert.ok(body.startsWith(ISSUE_MARKER));
|
||||
assert.ok(body.includes("2026-09-04"));
|
||||
// npm-only wording here would assert an all-clear the sweep never checked.
|
||||
assert.match(body, /npm package/);
|
||||
assert.match(body, /uses:/);
|
||||
// Must not claim every ref was verified: SHA- and branch-pinned refs are
|
||||
// never ranked against a release (Copilot).
|
||||
assert.match(body, /SHA or a branch/);
|
||||
});
|
||||
|
||||
test("main fails the sweep when a release lookup errors, rather than reporting no stale actions", () => {
|
||||
// The silent-success shape one level down: a 403 read as "no release" is
|
||||
// indistinguishable from "not stale", and every action here is already on
|
||||
// its latest major, so the empty section would look like a healthy run.
|
||||
const spawn = fakeSpawn({
|
||||
releasesStatus: 1,
|
||||
releasesStderr: "gh: API rate limit exceeded (HTTP 403)",
|
||||
});
|
||||
assert.throws(
|
||||
() => captureLog(() => main("o/r", spawn)),
|
||||
/release lookup for .* failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main fails on a 404 release lookup — a missing action repo is not 'no releases'", () => {
|
||||
// The release LIST endpoint answers "no releases" with a successful empty
|
||||
// array, so a 404 means the repository is missing or inaccessible. Treating
|
||||
// it as benign would silently drop a broken or renamed action from the sweep
|
||||
// that replaced Dependabot (Copilot).
|
||||
const spawn = fakeSpawn({
|
||||
releasesStatus: 1,
|
||||
releasesStderr: "gh: Not Found (HTTP 404)",
|
||||
});
|
||||
assert.throws(
|
||||
() => captureLog(() => main("o/r", spawn)),
|
||||
/release lookup for .* failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main treats an empty release list as 'this action cuts no releases'", () => {
|
||||
// The benign case: status 0 with no tags. The action is simply not ranked.
|
||||
const spawn = fakeSpawn({ releaseTags: [] });
|
||||
const log = captureLog(() => main("o/r", spawn));
|
||||
assert.match(log.join("\n"), /no-op/);
|
||||
});
|
||||
|
||||
test("main asks for release lists, not the designated latest release", () => {
|
||||
const spawn = fakeSpawn();
|
||||
captureLog(() => main("o/r", spawn));
|
||||
const lookups = spawn.calls.filter(
|
||||
(c) =>
|
||||
c.cmd === "gh" &&
|
||||
c.args[0] === "api" &&
|
||||
c.args.some((a) => a.includes("/releases")),
|
||||
);
|
||||
assert.ok(lookups.length > 0, "expected at least one release lookup");
|
||||
for (const call of lookups) {
|
||||
// `releases/latest` is GitHub's designated latest, not the greatest
|
||||
// version — ranking must come from the list.
|
||||
assert.ok(!call.args.some((a) => /releases\/latest/.test(a)));
|
||||
assert.ok(call.args.some((a) => /\/releases\?/.test(a)));
|
||||
// And every page of it: the list is ordered by release DATE, so a genuine
|
||||
// maximum can sit past page 1 behind newer maintenance releases on lower
|
||||
// majors. Ranking one page would report a lower major as highest.
|
||||
assert.ok(
|
||||
call.args.includes("--paginate"),
|
||||
"release lookup must paginate, or the highest version can be missed",
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue