diff --git a/.github/workflows/dependency-report.yml b/.github/workflows/dependency-report.yml new file mode 100644 index 000000000..16908468b --- /dev/null +++ b/.github/workflows/dependency-report.yml @@ -0,0 +1,96 @@ +# Fortnightly dependency report. Surfaces outdated direct dependencies and audit +# vulnerabilities into a rolling issue. REPORT ONLY — it never updates deps; acting +# on the report means running the AGENTS.md `dependency` protocol by hand. +# +# SHIPPED DISABLED: workflow_dispatch only; the schedule is commented out. To enable +# the cadence, uncomment `schedule:` and run one dispatch to confirm the issue. +name: Dependency Report + +on: + workflow_dispatch: {} + # schedule: + # # 07:00 UTC on the 1st and 15th. + # - cron: "0 7 1,15 * *" + +concurrency: + group: dependency-report + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + dependency-report: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: ".nvmrc" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Render dependency report + id: report + run: node scripts/dependency-report.mjs --out dependency-report.md + + - name: Publish to rolling issue + uses: actions/github-script@v9 + env: + # Pass step outputs via env (not inline template expansion) to avoid the + # GitHub Actions template-injection pattern, even though both values are + # currently a fixed enum / plain integer produced by our own script. + OUTDATED_COUNT: ${{ steps.report.outputs.outdated }} + SEVERITY: ${{ steps.report.outputs.severity }} + with: + script: | + const fs = require("fs"); + const label = "dependency-report"; + const body = fs.readFileSync("dependency-report.md", "utf8"); + const outdated = Number(process.env.OUTDATED_COUNT || "0"); + const severity = process.env.SEVERITY || "none"; + const { data: existing } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + labels: label, + }); + let issueNumber; + if (existing.length > 0) { + issueNumber = existing[0].number; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + } else { + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: "Dependency report", + labels: [label], + body, + }); + issueNumber = created.data.number; + } + // Notify only when there are high/critical vulns. + if (severity === "high" || severity === "critical") { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `⚠ ${severity} vulnerability present (${outdated} outdated direct deps) — ${new Date().toISOString()}`, + }); + core.warning(`Dependency audit highest severity: ${severity}`); + } diff --git a/package.json b/package.json index 2d0406cc0..98934ff11 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "sweep:branch-ledger": "node scripts/sweep-branch-ledger.mjs", "docs:check-links": "node scripts/check-docs-links.mjs", "docs:check-scripts": "node scripts/check-docs-script-refs.mjs", + "deps:report": "node scripts/dependency-report.mjs", "docs:check-index": "node scripts/check-codebase-index-coverage.mjs", "sitemap:update": "node scripts/run-tsx.mjs scripts/generate-site-map.ts", "sitemap:check": "node scripts/run-tsx.mjs scripts/generate-site-map.ts --check", diff --git a/scripts/dependency-report.mjs b/scripts/dependency-report.mjs new file mode 100644 index 000000000..1959f80ad --- /dev/null +++ b/scripts/dependency-report.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * dependency-report — render a digest of outdated direct dependencies and audit + * vulnerabilities. REPORTS ONLY; it never updates anything. + * + * This repo's `dependency` maintenance is a careful, judgment-heavy protocol + * (compatibility audit, grouped upgrades — see AGENTS.md), never an unattended + * bulk-bump. So the cron just surfaces drift + vulnerabilities proactively; a + * human/agent then runs the real protocol. Reads only npm registry metadata. + * + * Flags: + * --out also write the Markdown digest to a file (for the workflow) + * --input read a pre-captured { outdated, audit } JSON instead of running + * npm (used by tests / offline runs) + * + * Always exits 0 (`npm outdated`/`npm audit` exit non-zero merely because findings + * exist — that is normal, not a script failure). + */ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +function argValue(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +// `npm outdated --json` and `npm audit --json` exit non-zero when they find +// something; capture stdout regardless of exit code. But a REAL failure +// (network/registry/auth) also lands in catch — surface its stderr so the run +// logs show it instead of silently rendering a clean-looking report. +function runNpmJson(args) { + try { + return execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch (error) { + if (error.stderr) process.stderr.write(String(error.stderr)); + return error.stdout?.toString() ?? ""; + } +} + +function parseJsonSafe(text, fallback) { + try { + return text.trim() ? JSON.parse(text) : fallback; + } catch { + process.stderr.write(`[dependency-report] warning: failed to parse npm JSON output: ${text.slice(0, 200)}\n`); + return fallback; + } +} + +function majorOf(version) { + const m = /^\D*(\d+)/.exec(String(version ?? "")); + return m ? Number.parseInt(m[1], 10) : null; +} + +/** + * Pure renderer. `outdated` is the `npm outdated --json` object (pkg → {current, + * wanted, latest}), or `null` when the outdated check itself failed (rendered as + * "data unavailable" rather than a falsely-clean "none"). `audit` is the + * `npm audit --json` object. Returns Markdown. + */ +export function renderDependencyReport(outdated, audit) { + const stamp = new Date().toISOString(); + const lines = [`### Dependency report — ${stamp}`, ""]; + + if (outdated == null) { + lines.push("**Outdated direct dependencies:** data unavailable (npm outdated failed — see run logs)."); + } else if (Object.keys(outdated).length === 0) { + lines.push("**Outdated direct dependencies:** none 🎉"); + } else { + const entries = Object.entries(outdated); + const majors = entries.filter(([, v]) => { + const cur = majorOf(v.current); + const latest = majorOf(v.latest); + return cur != null && latest != null && latest > cur; + }); + lines.push(`**Outdated direct dependencies:** ${entries.length} (${majors.length} major)`, ""); + lines.push("| package | current | wanted | latest | major? |", "| --- | --- | --- | --- | --- |"); + for (const [name, v] of entries.sort((a, b) => a[0].localeCompare(b[0]))) { + const isMajor = majorOf(v.latest) != null && majorOf(v.current) != null && majorOf(v.latest) > majorOf(v.current); + lines.push( + `| ${name} | ${v.current ?? "?"} | ${v.wanted ?? "?"} | ${v.latest ?? "?"} | ${isMajor ? "⚠ yes" : "—"} |`, + ); + } + } + + const vulns = audit?.metadata?.vulnerabilities; + lines.push(""); + if (vulns) { + const { info = 0, low = 0, moderate = 0, high = 0, critical = 0, total = 0 } = vulns; + lines.push( + `**Vulnerabilities:** ${total} total — critical ${critical}, high ${high}, moderate ${moderate}, low ${low}, info ${info}`, + ); + } else { + lines.push("**Vulnerabilities:** audit data unavailable."); + } + + lines.push( + "", + "_Report only. Run the AGENTS.md `dependency` protocol (compatibility audit + grouped upgrades) to act — never a bulk auto-bump._", + ); + return lines.join("\n") + "\n"; +} + +/** Highest severity present, for the workflow to decide whether to notify. */ +export function highestSeverity(audit) { + const v = audit?.metadata?.vulnerabilities ?? {}; + for (const level of ["critical", "high", "moderate", "low"]) { + if ((v[level] ?? 0) > 0) return level; + } + return "none"; +} + +function main() { + const inputPath = argValue("--input"); + let outdated; + let audit; + if (inputPath) { + const payload = parseJsonSafe(readFileSync(inputPath, "utf8"), {}); + outdated = payload.outdated ?? {}; + audit = payload.audit ?? {}; + } else { + outdated = parseJsonSafe(runNpmJson(["outdated", "--json"]), {}); + // Include dev dependencies: the repo's dependency-maintenance protocol covers the + // dev toolchain (Vitest/Playwright/ESLint), and prod-only high/critical is already + // gated by the CI safety job's `npm audit --omit=dev --audit-level=high`. + audit = parseJsonSafe(runNpmJson(["audit", "--json"]), {}); + } + + // A failed `npm outdated --json` can still emit a JSON error payload ({ error: … }) on + // stdout; treat that as "data unavailable" rather than counting `error` as a package. + if (outdated && typeof outdated === "object" && "error" in outdated) { + process.stderr.write( + `[dependency-report] warning: npm outdated failed: ${JSON.stringify(outdated.error).slice(0, 200)}\n`, + ); + outdated = null; + } + + const digest = renderDependencyReport(outdated, audit); + process.stdout.write(digest); + const out = argValue("--out"); + if (out) writeFileSync(out, digest); + + if (process.env.GITHUB_OUTPUT) { + const outdatedCount = Object.keys(outdated ?? {}).length; + writeFileSync(process.env.GITHUB_OUTPUT, `outdated=${outdatedCount}\nseverity=${highestSeverity(audit)}\n`, { + flag: "a", + }); + } + process.exit(0); +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) main(); diff --git a/tests/dependency-report.test.ts b/tests/dependency-report.test.ts new file mode 100644 index 000000000..67df1aa50 --- /dev/null +++ b/tests/dependency-report.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { highestSeverity, renderDependencyReport } from "../scripts/dependency-report.mjs"; + +describe("renderDependencyReport", () => { + it("reports 'none' when nothing is outdated", () => { + const md = renderDependencyReport({}, { metadata: { vulnerabilities: { total: 0 } } }); + expect(md).toContain("none 🎉"); + expect(md).toContain("Vulnerabilities:** 0 total"); + }); + + it("tables outdated deps and flags major bumps", () => { + const md = renderDependencyReport( + { + next: { current: "16.2.10", wanted: "16.2.10", latest: "16.3.0" }, + zod: { current: "3.24.0", wanted: "3.24.1", latest: "4.4.3" }, + }, + { metadata: { vulnerabilities: { moderate: 2, total: 2 } } }, + ); + expect(md).toContain("2 (1 major)"); + // zod 3 → 4 is a major bump + expect(md).toMatch(/\| zod \|.*⚠ yes \|/); + // next 16 → 16 is not + expect(md).toMatch(/\| next \|.*— \|/); + expect(md).toContain("moderate 2"); + }); + + it("handles missing audit data gracefully", () => { + const md = renderDependencyReport({}, {}); + expect(md).toContain("audit data unavailable"); + }); + + it("renders 'data unavailable' (not 'none') when the outdated check failed", () => { + const md = renderDependencyReport(null, { metadata: { vulnerabilities: { total: 0 } } }); + expect(md).toContain("data unavailable"); + expect(md).not.toContain("none 🎉"); + }); +}); + +describe("highestSeverity", () => { + it("returns the most severe non-zero level", () => { + expect(highestSeverity({ metadata: { vulnerabilities: { high: 1, low: 3 } } })).toBe("high"); + expect(highestSeverity({ metadata: { vulnerabilities: { low: 3 } } })).toBe("low"); + expect(highestSeverity({ metadata: { vulnerabilities: {} } })).toBe("none"); + expect(highestSeverity({})).toBe("none"); + }); +});