chore(deps): sweep Dependabot alerts into board-tracked issues
Closes #2233 — the alert-consuming half of #2229. #2235 removed `.github/dependabot.yml`, ending Dependabot's version-update PRs; this ends its security-update PRs and replaces them with a daily sweep that turns the alerts into ordinary board items. - `scripts/dependabot-alerts.mjs` groups alerts by `(package, manifest_path, first_patched_version)` — one issue per BUMP, not per advisory — re-checks each vulnerable range against `v2/main`'s own lockfile before filing, and keys idempotency on a marker comment naming every GHSA the issue covers. - `.github/workflows/dependabot-alerts.yml` runs it daily and on `workflow_dispatch`, with `vulnerability-alerts: read`. - `semver` is declared at the repo root, per Dependency placement: `scripts/` is root-owned code with no manifest of its own. - The sibling `dependency-refresh` comments no longer claim security updates are unaffected, and AGENTS.md gains the flow both halves now follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6Cew3KB3jVmQ5x4Dq1sq Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0cb12a19ca
commit
66a3332f1d
8 changed files with 1085 additions and 17 deletions
89
.github/workflows/dependabot-alerts.yml
vendored
Normal file
89
.github/workflows/dependabot-alerts.yml
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# 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 asserts
|
||||
# it is still disabled and fails loudly if it is not.
|
||||
#
|
||||
# ⚠️ 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 fix` from #2231 is a second signal
|
||||
# that partially covers it; a scheduled `npm audit --audit-level=high` over
|
||||
# `v2/main`'s lockfiles 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:
|
||||
|
||||
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 }}
|
||||
26
.github/workflows/dependency-refresh.yml
vendored
26
.github/workflows/dependency-refresh.yml
vendored
|
|
@ -2,19 +2,21 @@
|
|||
#
|
||||
# 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 (security
|
||||
# updates are a separate mechanism and stay on; 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`.
|
||||
# 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 unaffected: they are enabled in repo
|
||||
# settings, not in a config file, and kept working while `dependabot.yml` was
|
||||
# missing entirely (see #1833, #1840). They are raised against the default
|
||||
# branch and still need retargeting by hand.
|
||||
# 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
|
||||
|
|
|
|||
18
AGENTS.md
18
AGENTS.md
|
|
@ -98,6 +98,24 @@ 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 fix` (#2231) is the partial second signal.
|
||||
- **`automated-security-fixes` can be re-enabled from the UI without a commit**, so nothing in the repo would record it. The sweep asserts it is still disabled and **fails loudly** if it is not; a red run of that workflow means the setting was flipped, not that the script broke.
|
||||
|
||||
An issue filed by either sweep is an ordinary board item — `v2` + `chore` + `dependabot`, the current milestone, and a card on #28. A security issue lands at **Todo / High**: arriving through this pipeline *is* the approval, and `High` is a standing override of the [priority rubric](.claude/skills/issue-triage/SKILL.md), which would otherwise score a routine bump Medium. Board placement needs an org-project PAT that `GITHUB_TOKEN` cannot have, so it is **best-effort** — without the secret the issue is still created labeled and milestoned, and the next triage sweep boards it.
|
||||
|
||||
## Contributing
|
||||
|
||||
External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow.
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
691
scripts/dependabot-alerts.mjs
Normal file
691
scripts/dependabot-alerts.mjs
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
#!/usr/bin/env node
|
||||
// Dependabot alert sweep (#2233), the alert-consuming half of #2229.
|
||||
//
|
||||
// Dependabot's SECURITY-update PRs are turned off in repo settings; its alerts
|
||||
// stay on. This script is what consumes them:
|
||||
//
|
||||
// alert -> scheduled sweep -> issue (labeled, milestoned, boarded) -> maintainer PR -> v2/main
|
||||
//
|
||||
// A Dependabot-opened PR carries no `Closes #N` and no board card, which is the
|
||||
// carve-out from "every PR references an issue" that #2229 exists to remove.
|
||||
// The version-update half was removed outright in #2235 and replaced by
|
||||
// `dependency-refresh.mjs`; this is the security half, and it files issues
|
||||
// rather than PRs for the same reason.
|
||||
//
|
||||
// Three things shape the design, each verified against this repo before it was
|
||||
// written:
|
||||
//
|
||||
// 1. There is no `dependabot_alert` WORKFLOW trigger — it is a webhook event
|
||||
// only — so this is a scheduled sweep, not event-driven. Daily is enough;
|
||||
// alerts are not minute-sensitive.
|
||||
// 2. `GITHUB_TOKEN` can read alerts with `vulnerability-alerts: read`, so no
|
||||
// PAT is needed for the sweep itself. Two side steps DO need one, and both
|
||||
// are best-effort rather than preconditions: writing the board card (an org
|
||||
// project is outside `GITHUB_TOKEN`'s reach — an unboarded-but-milestoned
|
||||
// issue is swept into Todo by the next `/issue-triage` pass), and reading
|
||||
// back the `automated-security-fixes` setting (`administration: read`,
|
||||
// which `permissions:` cannot grant at all).
|
||||
// 3. Alerts are per-ADVISORY but a fix is per-BUMP. Today's seven open alerts
|
||||
// are three `overrides` entries, so grouping by
|
||||
// `(package, manifest_path, first_patched_version)` is what keeps this from
|
||||
// filing seven issues for three pieces of work.
|
||||
//
|
||||
// ⚠️ GitHub computes the dependency graph — and therefore every alert — from
|
||||
// the DEFAULT branch (`main`), while we ship from `v2/main`. So an alert is not
|
||||
// trusted on its face: the vulnerable range is re-checked against `v2/main`'s
|
||||
// own lockfile before anything is filed. The blind spot that leaves is stated
|
||||
// plainly in the workflow header: a vulnerable dependency introduced on
|
||||
// `v2/main` and not yet merged to `main` produces no alert at all, and no
|
||||
// approach that consumes GitHub's alerts can see it.
|
||||
//
|
||||
// Idempotency key is the marker comment at the top of each issue body, which
|
||||
// names the package, the manifest and every GHSA the issue covers. A second run
|
||||
// the same day is a complete no-op; a NEW advisory for a package that already
|
||||
// has an open issue lands as a comment on it and rewrites the marker, rather
|
||||
// than filing a second issue.
|
||||
//
|
||||
// The pure halves — `toSemverRange`, `lockfileVersions`, `isDirectDependency`,
|
||||
// `groupAlerts`, `buildMarker`, `parseMarker`, `mergeGhsas`, `buildIssueTitle`,
|
||||
// `buildIssueBody` and `buildNewAdvisoryComment` — are covered by
|
||||
// `dependabot-alerts.test.mjs`. `main()` is the CLI entry point, exercised
|
||||
// against the real repo only via `workflow_dispatch` in CI, per the same split
|
||||
// `dependency-refresh.mjs` and `verify-skills.mjs` already use.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import semver from "semver";
|
||||
|
||||
/** Board #28 (v2). The project and field node ids are stable; option ids are not. */
|
||||
export const PROJECT_ID = "PVT_kwDOCt2Azc4BJVxt";
|
||||
export const STATUS_FIELD_ID = "PVTSSF_lADOCt2Azc4BJVxtzg5iI8c";
|
||||
export const PRIORITY_FIELD_ID = "PVTSSF_lADOCt2Azc4BJVxtzg5iJE4";
|
||||
/**
|
||||
* Option ids are regenerated whenever a single-select field's option list is
|
||||
* edited, so they are resolved by NAME at run time rather than hardcoded here —
|
||||
* a hardcoded id turns an unrelated board edit into a silently mis-set field.
|
||||
*/
|
||||
export const BOARD_STATUS = "Todo";
|
||||
export const BOARD_PRIORITY = "High";
|
||||
|
||||
/**
|
||||
* The branch this repo actually ships from, and whose lockfiles are probed.
|
||||
*
|
||||
* The workflow checks this branch out, so manifests are read from the working
|
||||
* tree rather than through `git show` — the same shape `dependency-refresh.mjs`
|
||||
* uses to run `npm outdated` against it. Named here only so the issue body can
|
||||
* say which branch the versions it quotes came from.
|
||||
*/
|
||||
export const TARGET_BRANCH = "v2/main";
|
||||
|
||||
const MARKER_RE =
|
||||
/^<!-- dependabot-alerts: pkg=(.+?); manifest=(.+?); ghsas=(.+?) -->/;
|
||||
|
||||
/**
|
||||
* The issue body's first line: the idempotency key.
|
||||
*
|
||||
* @param {{package: string, manifestPath: string, ghsas: string[]}} group
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMarker({ package: pkg, manifestPath, ghsas }) {
|
||||
return `<!-- dependabot-alerts: pkg=${pkg}; manifest=${manifestPath}; ghsas=${[...ghsas].sort().join(",")} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a marker back off an issue body.
|
||||
*
|
||||
* @param {string | undefined} body
|
||||
* @returns {{package: string, manifestPath: string, ghsas: string[]} | null}
|
||||
*/
|
||||
export function parseMarker(body) {
|
||||
const match = MARKER_RE.exec(body ?? "");
|
||||
if (!match) return null;
|
||||
return {
|
||||
package: match[1],
|
||||
manifestPath: match[2],
|
||||
ghsas: match[3].split(",").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `group`'s advisories the existing issue does not already name.
|
||||
*
|
||||
* @param {string[]} existing the marker's GHSA list
|
||||
* @param {string[]} incoming the GHSAs the sweep just saw
|
||||
* @returns {{merged: string[], added: string[]}} both sorted
|
||||
*/
|
||||
export function mergeGhsas(existing, incoming) {
|
||||
const known = new Set(existing);
|
||||
const added = [...new Set(incoming.filter((g) => !known.has(g)))].sort();
|
||||
const merged = [...new Set([...existing, ...incoming])].sort();
|
||||
return { merged, added };
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a GitHub `vulnerable_version_range` into a range npm `semver`
|
||||
* understands.
|
||||
*
|
||||
* ⚠️ GitHub separates conjuncts with a COMMA (`>= 3.1.3, < 3.1.6`); node-semver
|
||||
* reads a comma as nothing at all and quietly returns `false` for a version
|
||||
* that is in fact in range. Space is semver's AND, so the fix is a split/join —
|
||||
* but the failure it prevents is silent, which is why this is its own tested
|
||||
* function rather than an inline `.replace`.
|
||||
*
|
||||
* @param {string} range
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toSemverRange(range) {
|
||||
return range
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version of `pkg` installed anywhere in an npm lockfile.
|
||||
*
|
||||
* A transitive package can legitimately appear more than once (a nested
|
||||
* `node_modules/x/node_modules/y`), and the alert applies if ANY copy is in
|
||||
* range, so this returns them all rather than picking one.
|
||||
*
|
||||
* @param {object} lock parsed `package-lock.json` (lockfileVersion 2 or 3)
|
||||
* @param {string} pkg
|
||||
* @returns {string[]} sorted, deduped
|
||||
*/
|
||||
export function lockfileVersions(lock, pkg) {
|
||||
const suffix = `node_modules/${pkg}`;
|
||||
const versions = new Set();
|
||||
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
||||
if (path !== suffix && !path.endsWith(`/${suffix}`)) continue;
|
||||
if (entry?.version) versions.add(entry.version);
|
||||
}
|
||||
return [...versions].sort(semver.compare);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `pkg` declared by the manifest itself, rather than pulled in transitively?
|
||||
*
|
||||
* Decides which fix the issue asks for: a direct dependency is a plain version
|
||||
* bump, a transitive one is an `overrides` entry per AGENTS.md's Dependency
|
||||
* placement — never `npm audit fix`, which "resolves" an advisory with no
|
||||
* upward escape by silently downgrading.
|
||||
*
|
||||
* @param {object} lock parsed `package-lock.json`
|
||||
* @param {string} pkg
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDirectDependency(lock, pkg) {
|
||||
const root = lock.packages?.[""] ?? {};
|
||||
return Boolean(
|
||||
root.dependencies?.[pkg] ??
|
||||
root.devDependencies?.[pkg] ??
|
||||
root.optionalDependencies?.[pkg] ??
|
||||
root.peerDependencies?.[pkg],
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, moderate: 2, low: 1 };
|
||||
|
||||
/**
|
||||
* Collapse per-advisory alerts into one entry per BUMP.
|
||||
*
|
||||
* Grouped by `(package, manifest_path, first_patched_version)`: that triple is
|
||||
* one edit to one manifest, which is the unit a maintainer actually acts on.
|
||||
* Two advisories on the same package with different patched versions are
|
||||
* different bumps and stay apart.
|
||||
*
|
||||
* @param {object[]} alerts raw `GET /repos/{o}/{r}/dependabot/alerts` entries
|
||||
* @returns {Array<{key: string, package: string, manifestPath: string, fixedIn: string, scope: string, severity: string, ghsas: string[], advisories: Array<{ghsa: string, cve: string | null, severity: string, summary: string, range: string, url: string}>}>}
|
||||
*/
|
||||
export function groupAlerts(alerts) {
|
||||
const groups = new Map();
|
||||
for (const alert of alerts) {
|
||||
if (alert.state !== "open") continue;
|
||||
const pkg = alert.dependency?.package?.name;
|
||||
const manifestPath = alert.dependency?.manifest_path;
|
||||
const fixedIn =
|
||||
alert.security_vulnerability?.first_patched_version?.identifier;
|
||||
// No patched version means there is nothing to bump TO — an issue asking
|
||||
// for an unavailable upgrade is noise, so it waits for one to be published.
|
||||
if (!pkg || !manifestPath || !fixedIn) continue;
|
||||
|
||||
const key = `${pkg} | ||||