From 7dd9f47ca2d7a7bee0923485c1ac2c85470241ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 3 Sep 2026 10:00:39 -0400 Subject: [PATCH 1/4] chore(deps): npm audit fix before release bump; monthly npm-outdated issue Adds npm audit fix (root + every client) as the first sub-step of the release skill's bump step, before npm version, so a release is never gated on remembering to check separately. Never --force; anything audit fix can't resolve is left to the dependabot-alert pipeline (#2229) or a follow-up issue. Disables Dependabot npm version updates in dependabot.yml (a version-update PR carries no issue and no board card) and replaces them with a monthly scheduled sweep that runs npm outdated across the root install and every client, filing or updating one idempotent tracking issue instead of an auto-generated PR. Closes #2231 Part of #2229 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017N9ha27Qg6LHpBQP7G7zPY Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 31 +++- .github/dependabot.yml | 91 ++-------- .github/workflows/dependency-refresh.yml | 48 ++++++ scripts/dependency-refresh.mjs | 208 +++++++++++++++++++++++ scripts/dependency-refresh.test.mjs | 61 +++++++ 5 files changed, 361 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/dependency-refresh.yml create mode 100644 scripts/dependency-refresh.mjs create mode 100644 scripts/dependency-refresh.test.mjs diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 3b0f475e78..22cd4e6455 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Cut an Inspector v2 release — bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. +description: Cut an Inspector v2 release — npm audit fix and bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. disable-model-invocation: true --- @@ -27,10 +27,10 @@ job or the coverage gate red: There is **one version number** (only the root `package.json` has one — the clients carry none), so the flow is three steps. -## 1. Bump on `v2/main`, before the milestone merge +## 1. `npm audit fix`, then bump, on `v2/main` — before the milestone merge -The bump is part of the milestone's work, so it belongs on the develop branch -and flows into `main` with everything else. +Both are part of the milestone's work, so both belong on the develop branch and +flow into `main` together, in the same PR, audit fix first. ```sh # Branch from the REMOTE ref, and read the version only once you are on it. @@ -39,11 +39,34 @@ and flows into `main` with everything else. # you are bumping from (Copilot). git fetch origin v2/main git checkout -b v2/chore/-bump- origin/v2/main + +# Audit + fix every install that has its own lockfile — root and each client. +npm audit fix +for c in web cli tui launcher; do (cd "clients/$c" && npm audit fix); done +npm run local:gate # confirm the fixes didn't break anything before bumping + node -p "require('./package.json').version" # what is on v2/main now npm version minor --no-git-tag-version # or major / patch; bump only, no tag node -p "require('./package.json').version" # confirm, then PR → v2/main ``` +**Never `npm audit fix --force`.** It will apply a fix outside a dependency's +declared semver range — a major bump a client's code was never adjusted for — +to close an advisory, silently trading a known vulnerability for an unvetted +breaking change. If `npm audit` still reports something `fix` can't resolve +(typically a transitive dependency needing an `overrides` pin per +[Dependency placement](../../../AGENTS.md#dependency-placement)), commit +whatever `fix` did resolve, leave the rest to the dependabot-alert pipeline +(#2229) or a follow-up issue, and say so in the release notes rather than +forcing it here. + +This step is a **backstop, not a substitute** for #2229's alert-driven issues — +those are what surface a transitive vulnerability that needs an `overrides` +entry `audit fix` cannot apply on its own, tracked and fixed as their own PRs +well before a release is cut. This step exists for whatever's left standing +right before a release ships, so a release is never gated on remembering to +check `npm audit` separately. + The branch name carries the version you are bumping **to**, so it is named after that second reading. If you want it before branching: `git show origin/v2/main:package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version"`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6888530a7e..2ca7bcb802 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,23 +1,26 @@ version: 2 -# Version updates target `v2/main`, the develop branch where all v2 work lands, -# not the default branch — `main` is release-only, holding the latest released -# v2 and receiving milestone merges from `v2/main`. (The deprecated v1 line -# lives on `v1/main` and takes security fixes only.) +# npm version updates are OFF (#2229): a Dependabot version-update PR carries no +# issue and no board card, which is the same "every PR references an issue" +# carve-out the security-update flow had. Routine npm bumps are handled instead +# by a monthly `npm outdated` sweep (`.github/workflows/dependency-refresh.yml`) +# that files ONE tracking issue per run, boarded and milestoned like any other +# work — so a maintainer picks the bump and opens a normal PR against `v2/main`. +# +# Security updates are unaffected by this file either way: they are enabled in +# repo settings, not here. #2229 additionally proposes consuming them as +# Dependabot ALERTS rather than PRs (tracked, not yet built) — until that lands, +# security-update PRs still open against `main` and still need retargeting by +# hand, same as before. +# +# github-actions version updates stay on Dependabot's own PR flow below — out of +# scope for #2229, which was about npm packages specifically. # # Two things to know about this file: # 1. Dependabot reads it from the DEFAULT branch (`main`). Changes here are # inert until the next milestone merge carries them there. -# 2. `target-branch` scopes VERSION updates. Dependabot SECURITY updates are -# enabled in repo settings, not here, and are raised against the default -# branch — they kept working while this file was missing entirely -# (see #1833, #1840). Per GitHub's Dependabot options reference, an entry -# whose `target-branch` names a non-default branch is NOT applied to -# security updates, so the schedule/labels/groups below shape version -# updates only. Re-confirm security PRs still appear after this lands. -# -# v2 is not an npm workspace: the root and each client under `clients/*` carry -# their own package.json + lockfile, so each needs its own entry. +# 2. `target-branch` scopes VERSION updates only (npm updates are removed +# here, so this now only applies to the github-actions entry below). updates: - package-ecosystem: "github-actions" @@ -31,63 +34,3 @@ updates: github-actions: patterns: - "*" - - - package-ecosystem: "npm" - directory: "/" - target-branch: "v2/main" - schedule: - interval: "monthly" - labels: - - "v2" - groups: - root-dependencies: - patterns: - - "*" - - - package-ecosystem: "npm" - directory: "/clients/web" - target-branch: "v2/main" - schedule: - interval: "monthly" - labels: - - "v2" - groups: - web-dependencies: - patterns: - - "*" - - - package-ecosystem: "npm" - directory: "/clients/cli" - target-branch: "v2/main" - schedule: - interval: "monthly" - labels: - - "v2" - groups: - cli-dependencies: - patterns: - - "*" - - - package-ecosystem: "npm" - directory: "/clients/tui" - target-branch: "v2/main" - schedule: - interval: "monthly" - labels: - - "v2" - groups: - tui-dependencies: - patterns: - - "*" - - - package-ecosystem: "npm" - directory: "/clients/launcher" - target-branch: "v2/main" - schedule: - interval: "monthly" - labels: - - "v2" - groups: - launcher-dependencies: - patterns: - - "*" diff --git a/.github/workflows/dependency-refresh.yml b/.github/workflows/dependency-refresh.yml new file mode 100644 index 0000000000..7e8b0fbeed --- /dev/null +++ b/.github/workflows/dependency-refresh.yml @@ -0,0 +1,48 @@ +# Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs. +# +# Dependabot version updates are disabled in `.github/dependabot.yml` because a +# version-update PR carries no issue and no board card. This workflow runs +# `scripts/dependency-refresh.mjs` against `v2/main` once a month and files or +# updates ONE tracking issue listing every outdated package across the root +# install and each client — no PR is opened automatically. A maintainer reviews +# the issue, picks what to bump, and opens a normal PR against `v2/main`. +# +# `GITHUB_TOKEN` is sufficient: it only needs to read milestones (public) and +# create/edit an issue (`issues: write`). Board placement is intentionally NOT +# attempted here — that needs an org-project PAT this token cannot have — so a +# filed-but-unboarded issue is picked up by the next `/issue-triage` sweep, +# same as any other maintainer-filed issue. +name: Dependency Refresh + +on: + schedule: + - cron: "23 6 1 * *" # 06:23 UTC on the 1st of every month + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + npm-outdated: + runs-on: ubuntu-latest + steps: + - name: Checkout v2/main + uses: actions/checkout@v7 + with: + ref: v2/main + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies (root + all clients) + run: npm install + + - name: Run the npm-outdated sweep + run: node scripts/dependency-refresh.mjs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} diff --git a/scripts/dependency-refresh.mjs b/scripts/dependency-refresh.mjs new file mode 100644 index 0000000000..726706ae7f --- /dev/null +++ b/scripts/dependency-refresh.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +// Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs. +// +// Dependabot's version updates were disabled in `.github/dependabot.yml` +// because a version-update PR carries no issue and no board card — the same +// carve-out from "every PR references an issue" that the security-update flow +// had (that half is handled separately by the alert-driven pipeline, also +// #2229). This script replaces the routine-bump half: once a month it runs +// `npm outdated` across the root install and every client under `clients/*` +// (each has its own package.json + lockfile — v2 is not a workspace), and +// files or updates ONE tracking issue listing everything behind. A maintainer +// picks what to bump and opens a normal PR against `v2/main`; there is no +// auto-generated PR here at all. +// +// Idempotent by design: the issue body starts with a fixed HTML marker +// (ISSUE_MARKER below), which is how a second run in the same month finds and +// updates the existing open issue instead of filing a duplicate. +// +// `parseOutdated` and `buildIssueBody` are pure and covered by +// `dependency-refresh.test.mjs`; `main()` is the CLI entry point, exercised +// against the real repo only via `workflow_dispatch` in CI, not by the test +// suite (it shells out to `npm outdated` and `gh`, per the workflow-script +// convention `verify-skills.mjs` and its siblings already use). + +import { spawnSync } from "node:child_process"; + +export const ISSUE_MARKER = ""; + +/** Installs to check, relative to the repo root, and their npm-outdated label. */ +export const INSTALLS = [ + { dir: ".", label: "root" }, + { dir: "clients/web", label: "clients/web" }, + { dir: "clients/cli", label: "clients/cli" }, + { dir: "clients/tui", label: "clients/tui" }, + { dir: "clients/launcher", label: "clients/launcher" }, +]; + +/** + * Normalize one install's `npm outdated --json` output. + * + * @param {string} json raw stdout from `npm outdated --json` (may be `""` or `"{}"`) + * @returns {Array<{name: string, current: string, wanted: string, latest: string}>} + */ +export function parseOutdated(json) { + const trimmed = json.trim(); + if (trimmed === "") return []; + const parsed = JSON.parse(trimmed); + return Object.entries(parsed) + .map(([name, info]) => ({ + name, + current: info.current ?? "(missing)", + wanted: info.wanted ?? info.current ?? "?", + latest: info.latest ?? "?", + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * @param {Array<{label: string, packages: ReturnType}>} installs + * @returns {string | null} the issue body, or `null` when nothing is outdated anywhere + */ +export function buildIssueBody(installs) { + const withPackages = installs.filter((i) => i.packages.length > 0); + if (withPackages.length === 0) return null; + + const sections = withPackages.map(({ label, packages }) => { + const rows = packages + .map( + (p) => `| \`${p.name}\` | ${p.current} | ${p.wanted} | ${p.latest} |`, + ) + .join("\n"); + return `### \`${label}\`\n\n| Package | Current | Wanted | Latest |\n| --- | --- | --- | --- |\n${rows}`; + }); + + return [ + ISSUE_MARKER, + "Routine dependency refresh — `npm outdated` run against `v2/main` on a monthly schedule, replacing Dependabot version-update PRs (#2229).", + "", + "This is a tracking issue, not a diff: pick what's worth bumping (`wanted` is the safe default; `latest` may cross a major and needs its own judgment call, especially for anything root-declared per [Dependency placement](https://github.com/modelcontextprotocol/inspector/blob/v2/main/AGENTS.md#dependency-placement)) and open a normal PR against `v2/main`.", + "", + ...sections, + "", + "A second run of this sweep before this issue closes updates this body in place rather than filing a duplicate.", + ].join("\n"); +} + +function runOutdated(dir) { + const result = spawnSync("npm", ["outdated", "--json"], { + cwd: dir, + encoding: "utf8", + }); + // `npm outdated` exits 1 when it finds anything outdated — that is not a + // failure of the command, only stderr / a thrown parse is. + if (result.error) throw result.error; + return result.stdout ?? ""; +} + +function findExistingIssue(repo) { + const result = spawnSync( + "gh", + [ + "issue", + "list", + "--repo", + repo, + "--state", + "open", + "--search", + ISSUE_MARKER, + "--json", + "number,body", + "--limit", + "10", + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + throw new Error(`gh issue list failed: ${result.stderr}`); + } + const issues = JSON.parse(result.stdout || "[]"); + return issues.find((i) => i.body?.startsWith(ISSUE_MARKER)) ?? null; +} + +function currentMilestone(repo) { + const result = spawnSync( + "gh", + [ + "api", + `repos/${repo}/milestones`, + "--jq", + 'map(select(.state=="open")) | sort_by(.due_on) | .[0].title // empty', + ], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + throw new Error(`milestone lookup failed: ${result.stderr}`); + } + return result.stdout.trim() || null; +} + +export function main(repo = process.env.GITHUB_REPOSITORY) { + if (!repo) throw new Error("repo not specified (GITHUB_REPOSITORY unset)"); + + const installs = INSTALLS.map(({ dir, label }) => ({ + label, + packages: parseOutdated(runOutdated(dir)), + })); + + const body = buildIssueBody(installs); + if (body === null) { + console.log("dependency-refresh: nothing outdated in any install — no-op"); + return; + } + + const existing = findExistingIssue(repo); + if (existing) { + const edit = spawnSync( + "gh", + [ + "issue", + "edit", + String(existing.number), + "--repo", + repo, + "--body", + body, + ], + { encoding: "utf8" }, + ); + if (edit.status !== 0) + throw new Error(`gh issue edit failed: ${edit.stderr}`); + console.log(`dependency-refresh: updated existing #${existing.number}`); + return; + } + + const milestone = currentMilestone(repo); + const args = [ + "issue", + "create", + "--repo", + repo, + "--title", + "chore(deps): monthly dependency refresh", + "--label", + "v2", + "--label", + "chore", + "--label", + "dependabot", + "--body", + body, + ]; + if (milestone) args.push("--milestone", milestone); + + const create = spawnSync("gh", args, { encoding: "utf8" }); + if (create.status !== 0) + throw new Error(`gh issue create failed: ${create.stderr}`); + if (!milestone) { + console.log( + "dependency-refresh: no open milestone — issue filed unmilestoned, will be swept into Todo at next triage", + ); + } + console.log(`dependency-refresh: filed ${create.stdout.trim()}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/scripts/dependency-refresh.test.mjs b/scripts/dependency-refresh.test.mjs new file mode 100644 index 0000000000..02ee339ae3 --- /dev/null +++ b/scripts/dependency-refresh.test.mjs @@ -0,0 +1,61 @@ +// Unit tests for the pure parsing/formatting halves of dependency-refresh.mjs +// (#2229). The impure half (`main()`, which shells out to `npm` and `gh`) is +// exercised only via `workflow_dispatch` in CI, per the same split +// `verify-skills.mjs` uses. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + buildIssueBody, + parseOutdated, + ISSUE_MARKER, +} from "./dependency-refresh.mjs"; + +test("parseOutdated returns [] for empty npm-outdated output", () => { + assert.deepEqual(parseOutdated(""), []); + assert.deepEqual(parseOutdated("{}"), []); +}); + +test("parseOutdated normalizes and sorts entries by name", () => { + const json = JSON.stringify({ + zod: { current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + ajv: { current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" }, + }); + assert.deepEqual(parseOutdated(json), [ + { name: "ajv", current: "8.0.0", wanted: "8.0.0", latest: "8.1.0" }, + { name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + ]); +}); + +test("parseOutdated falls back when a field is missing", () => { + const json = JSON.stringify({ pkg: { current: "1.0.0" } }); + assert.deepEqual(parseOutdated(json), [ + { name: "pkg", current: "1.0.0", wanted: "1.0.0", latest: "?" }, + ]); +}); + +test("buildIssueBody returns null when every install is up to date", () => { + assert.equal( + buildIssueBody([ + { label: "root", packages: [] }, + { label: "clients/web", packages: [] }, + ]), + null, + ); +}); + +test("buildIssueBody starts with the idempotency marker and skips empty installs", () => { + const body = buildIssueBody([ + { label: "root", packages: [] }, + { + label: "clients/web", + packages: [ + { name: "zod", current: "3.0.0", wanted: "3.1.0", latest: "4.0.0" }, + ], + }, + ]); + assert.ok(body.startsWith(ISSUE_MARKER)); + assert.ok(body.includes("### `clients/web`")); + assert.ok(!body.includes("### `root`")); + assert.ok(body.includes("| `zod` | 3.0.0 | 3.1.0 | 4.0.0 |")); +}); From ee14a9fc1ca6b3e259d027302a769e09e314b07d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 3 Sep 2026 20:15:59 -0400 Subject: [PATCH 2/4] chore(deps): move the dependabot.yml change out to #2235 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm-entry removal in `.github/dependabot.yml` is split out of this PR so that every change to that one file lands in a single reviewable piece — alongside the `github-actions` entry, which #2229 had declared out of scope but which opens the same issue-less, board-cardless PR the decision exists to remove. Reverts `.github/dependabot.yml` to its `v2/main` state, leaving this PR with the release-time `npm audit fix` and the monthly `npm outdated` sweep: the replacement flow, not the switch-off. Both new files' header comments claimed the switch-off had already happened, which is false until #2235 lands. They now say #2235 does it, and that until then Dependabot's npm PRs and this sweep overlap — duplicate signal rather than conflicting action. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei Signed-off-by: cliffhall --- .github/dependabot.yml | 91 +++++++++++++++++++----- .github/workflows/dependency-refresh.yml | 16 +++-- scripts/dependency-refresh.mjs | 7 +- 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2ca7bcb802..6888530a7e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,26 +1,23 @@ version: 2 -# npm version updates are OFF (#2229): a Dependabot version-update PR carries no -# issue and no board card, which is the same "every PR references an issue" -# carve-out the security-update flow had. Routine npm bumps are handled instead -# by a monthly `npm outdated` sweep (`.github/workflows/dependency-refresh.yml`) -# that files ONE tracking issue per run, boarded and milestoned like any other -# work — so a maintainer picks the bump and opens a normal PR against `v2/main`. -# -# Security updates are unaffected by this file either way: they are enabled in -# repo settings, not here. #2229 additionally proposes consuming them as -# Dependabot ALERTS rather than PRs (tracked, not yet built) — until that lands, -# security-update PRs still open against `main` and still need retargeting by -# hand, same as before. -# -# github-actions version updates stay on Dependabot's own PR flow below — out of -# scope for #2229, which was about npm packages specifically. +# 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 only (npm updates are removed -# here, so this now only applies to the github-actions entry below). +# 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" @@ -34,3 +31,63 @@ updates: github-actions: patterns: - "*" + + - package-ecosystem: "npm" + directory: "/" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + root-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/web" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + web-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/cli" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + cli-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/tui" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + tui-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/launcher" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + launcher-dependencies: + patterns: + - "*" diff --git a/.github/workflows/dependency-refresh.yml b/.github/workflows/dependency-refresh.yml index 7e8b0fbeed..4412fa7c3a 100644 --- a/.github/workflows/dependency-refresh.yml +++ b/.github/workflows/dependency-refresh.yml @@ -1,11 +1,15 @@ # Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs. # -# Dependabot version updates are disabled in `.github/dependabot.yml` because a -# version-update PR carries no issue and no board card. This workflow runs -# `scripts/dependency-refresh.mjs` against `v2/main` once a month and files or -# updates ONE tracking issue listing every outdated package across the root -# install and each client — no PR is opened automatically. A maintainer reviews -# the issue, picks what to bump, and opens a normal PR against `v2/main`. +# 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 diff --git a/scripts/dependency-refresh.mjs b/scripts/dependency-refresh.mjs index 726706ae7f..2f1dfa19f7 100644 --- a/scripts/dependency-refresh.mjs +++ b/scripts/dependency-refresh.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node // Monthly npm-outdated sweep (#2229), replacing Dependabot version-update PRs. // -// Dependabot's version updates were disabled in `.github/dependabot.yml` -// because a version-update PR carries no issue and no board card — the same +// 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). This script replaces the routine-bump half: once a month it runs +// #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 From 07bd98712931dc05c00ba8ed93570ef0c9380df6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 3 Sep 2026 21:37:29 -0400 Subject: [PATCH 3/4] chore(deps): address Copilot review round 1 on #2232 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, all valid. `runOutdated` accepted every exit status and returned `result.stdout ?? ""`. A failed `npm outdated` (exit 2 on a registry or config error) also prints nothing, so the empty string parsed to an empty package list — and across five installs a total outage produced output byte-identical to a clean sweep. It now throws on any status other than the documented 0/1. `main()` was untested, which is how that reached review: the helper-only suite passed while the silent-success path was live. It now takes its spawn function as a parameter and is driven by a fake, covering npm failure, create vs. edit, milestone handling, both no-op paths and the missing-repo guard. The no-op path returned before looking for the marker issue, so once every install caught up, a still-open tracking issue kept its obsolete package table indefinitely — contradicting the body's own promise to update in place. It now rewrites through `buildClearedBody()`. It deliberately does not close the issue: the sweep takes no board actions, and closing one whose card a maintainer may have moved would make the board assert work shipped that this script cannot verify shipped. The release step mandated `npm audit fix`, which AGENTS.md forbids. The reason is not `--force`: plain `audit fix` resolves an advisory with no upward escape in range by silently downgrading, as it did to esbuild across three installs in #2058, and `local:gate` has no version-regression check to catch it. The step is now `npm audit` report-only, with fixes applied deliberately via a direct bump or an `overrides` entry. An unmilestoned issue is swept into Incoming, not Todo — Todo asserts a maintainer signed off. Message corrected and pinned by a test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 52 ++++---- scripts/dependency-refresh.mjs | 121 +++++++++++++------ scripts/dependency-refresh.test.mjs | 179 +++++++++++++++++++++++++++- 3 files changed, 293 insertions(+), 59 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 22cd4e6455..d9f083dc3d 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Cut an Inspector v2 release — npm audit fix and bump the version on v2/main first, merge the milestone into main, tag origin/main with a bare x.y.z, and publish via the GitHub Release. Also covers the v1 line and what the publish jobs gate on. +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. `npm audit fix`, then bump, on `v2/main` — before the milestone merge +## 1. `npm audit`, then bump, on `v2/main` — before the milestone merge Both are part of the milestone's work, so both belong on the develop branch and -flow into `main` together, in the same PR, audit fix first. +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. @@ -40,32 +41,43 @@ flow into `main` together, in the same PR, audit fix first. git fetch origin v2/main git checkout -b v2/chore/-bump- origin/v2/main -# Audit + fix every install that has its own lockfile — root and each client. -npm audit fix -for c in web cli tui launcher; do (cd "clients/$c" && npm audit fix); done -npm run local:gate # confirm the fixes didn't break anything before bumping +# 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 ``` -**Never `npm audit fix --force`.** It will apply a fix outside a dependency's -declared semver range — a major bump a client's code was never adjusted for — -to close an advisory, silently trading a known vulnerability for an unvetted -breaking change. If `npm audit` still reports something `fix` can't resolve -(typically a transitive dependency needing an `overrides` pin per -[Dependency placement](../../../AGENTS.md#dependency-placement)), commit -whatever `fix` did resolve, leave the rest to the dependabot-alert pipeline -(#2229) or a follow-up issue, and say so in the release notes rather than +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 that needs an `overrides` -entry `audit fix` cannot apply on its own, tracked and fixed as their own PRs -well before a release is cut. This step exists for whatever's left standing -right before a release ships, so a release is never gated on remembering to -check `npm audit` separately. +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: diff --git a/scripts/dependency-refresh.mjs b/scripts/dependency-refresh.mjs index 2f1dfa19f7..6d6ad5a8b3 100644 --- a/scripts/dependency-refresh.mjs +++ b/scripts/dependency-refresh.mjs @@ -17,11 +17,13 @@ // (ISSUE_MARKER below), which is how a second run in the same month finds and // updates the existing open issue instead of filing a duplicate. // -// `parseOutdated` and `buildIssueBody` are pure and covered by -// `dependency-refresh.test.mjs`; `main()` is the CLI entry point, exercised -// against the real repo only via `workflow_dispatch` in CI, not by the test -// suite (it shells out to `npm outdated` and `gh`, per the workflow-script -// convention `verify-skills.mjs` and its siblings already use). +// `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"; @@ -85,19 +87,53 @@ export function buildIssueBody(installs) { ].join("\n"); } -function runOutdated(dir) { - const result = spawnSync("npm", ["outdated", "--json"], { +/** + * 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", }); - // `npm outdated` exits 1 when it finds anything outdated — that is not a - // failure of the command, only stderr / a thrown parse is. if (result.error) throw result.error; + // `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) { - const result = spawnSync( +function findExistingIssue(repo, spawn) { + const result = spawn( "gh", [ "issue", @@ -122,8 +158,8 @@ function findExistingIssue(repo) { return issues.find((i) => i.body?.startsWith(ISSUE_MARKER)) ?? null; } -function currentMilestone(repo) { - const result = spawnSync( +function currentMilestone(repo, spawn) { + const result = spawn( "gh", [ "api", @@ -139,42 +175,55 @@ function currentMilestone(repo) { return result.stdout.trim() || null; } -export function main(repo = process.env.GITHUB_REPOSITORY) { +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)), + 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) { - console.log("dependency-refresh: nothing outdated in any install — no-op"); + 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; } - const existing = findExistingIssue(repo); if (existing) { - const edit = spawnSync( - "gh", - [ - "issue", - "edit", - String(existing.number), - "--repo", - repo, - "--body", - body, - ], - { encoding: "utf8" }, - ); - if (edit.status !== 0) - throw new Error(`gh issue edit failed: ${edit.stderr}`); + editIssue(repo, existing.number, body, spawn); console.log(`dependency-refresh: updated existing #${existing.number}`); return; } - const milestone = currentMilestone(repo); + const milestone = currentMilestone(repo, spawn); const args = [ "issue", "create", @@ -193,12 +242,14 @@ export function main(repo = process.env.GITHUB_REPOSITORY) { ]; if (milestone) args.push("--milestone", milestone); - const create = spawnSync("gh", args, { encoding: "utf8" }); + 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 Todo at next triage", + "dependency-refresh: no open milestone — issue filed unmilestoned, will be swept into Incoming at next triage", ); } console.log(`dependency-refresh: filed ${create.stdout.trim()}`); diff --git a/scripts/dependency-refresh.test.mjs b/scripts/dependency-refresh.test.mjs index 02ee339ae3..d0b94ad855 100644 --- a/scripts/dependency-refresh.test.mjs +++ b/scripts/dependency-refresh.test.mjs @@ -1,16 +1,89 @@ -// Unit tests for the pure parsing/formatting halves of dependency-refresh.mjs -// (#2229). The impure half (`main()`, which shells out to `npm` and `gh`) is -// exercised only via `workflow_dispatch` in CI, per the same split -// `verify-skills.mjs` uses. Run via `npm run test:scripts`. +// 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, + main, parseOutdated, + INSTALLS, ISSUE_MARKER, } from "./dependency-refresh.mjs"; +/** + * A fake `spawnSync` that answers by command shape and records every call. + * + * @param {object} opts + * @param {Record} [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 + */ +function fakeSpawn({ + outdated = {}, + outdatedStatus, + existing = [], + milestone = "v2.6.0", +} = {}) { + 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: "" }; + 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("{}"), []); @@ -59,3 +132,101 @@ test("buildIssueBody starts with the idempotency marker and skips empty installs 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", () => { + assert.throws(() => main(undefined, fakeSpawn()), /GITHUB_REPOSITORY unset/); +}); From ddf2307c4c31d9c515241625b7feab758e755e9a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 3 Sep 2026 22:20:15 -0400 Subject: [PATCH 4/4] test(scripts): stop the repo-guard test depending on the ambient env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main refuses to run without a repo` called `main(undefined, …)` so the default parameter would fire, then asserted it throws. The default reads `process.env.GITHUB_REPOSITORY` — which GitHub Actions sets on every run — so the assertion held only where the variable happened to be unset. It passed locally and failed CI, the one environment where the default is always populated. The test now clears the variable and restores it, and a second test covers the other half of the default: with it set, `main(undefined, …)` uses it instead of throwing. Between them the default's behavior is pinned in both environments rather than inherited from whichever one is running. Verified both ways: 14 pass with the variable unset and with it set, and the full `test:scripts` suite is 411/411 under the CI environment shape. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RWMJENjs1mExXE5XPJTEei Signed-off-by: cliffhall --- scripts/dependency-refresh.test.mjs | 38 ++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/scripts/dependency-refresh.test.mjs b/scripts/dependency-refresh.test.mjs index d0b94ad855..f180b863c0 100644 --- a/scripts/dependency-refresh.test.mjs +++ b/scripts/dependency-refresh.test.mjs @@ -228,5 +228,41 @@ test("main says Incoming, not Todo, when it files without a milestone", () => { }); test("main refuses to run without a repo", () => { - assert.throws(() => main(undefined, fakeSpawn()), /GITHUB_REPOSITORY unset/); + // `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; + } });