From 105d62243245325a2857dc1164a63a8b1e802672 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:03:45 +0800 Subject: [PATCH 1/2] feat(ops): fortnightly dependency report cron (disabled, report-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier D. Surfaces dependency drift + vulnerabilities proactively without unsafe auto-updates (this repo's `dependency` maintenance is a careful, judgment-heavy protocol per AGENTS.md, never an unattended bulk-bump). - scripts/dependency-report.mjs: renders `npm outdated --json` + `npm audit --json` into a Markdown digest — direct-dep table (current→wanted→latest, major-bump flags) + vuln counts by severity. Always exits 0 (findings are normal, not a failure). `--input` reads captured JSON for offline/test runs. Pure renderer + highestSeverity exported. - .github/workflows/dependency-report.yml: SHIPPED DISABLED (dispatch-only, schedule commented). Posts to a rolling "dependency-report" issue; comments only on high/critical vulns. Pinned actions, least-privilege. - deps:report npm script; tests/dependency-report.test.ts (4 tests). Verified: 4/4 vitest, mock-input smoke, check:github-actions, eslint, tsc (own files clean), prettier. No auto-update; reads only npm registry metadata. Co-Authored-By: Claude Fable 5 --- .github/workflows/dependency-report.yml | 90 ++++++++++++++++ package.json | 1 + scripts/dependency-report.mjs | 133 ++++++++++++++++++++++++ tests/dependency-report.test.ts | 40 +++++++ 4 files changed, 264 insertions(+) create mode 100644 .github/workflows/dependency-report.yml create mode 100644 scripts/dependency-report.mjs create mode 100644 tests/dependency-report.test.ts diff --git a/.github/workflows/dependency-report.yml b/.github/workflows/dependency-report.yml new file mode 100644 index 000000000..b3051ff83 --- /dev/null +++ b/.github/workflows/dependency-report.yml @@ -0,0 +1,90 @@ +# 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 + with: + script: | + const fs = require("fs"); + const label = "dependency-report"; + const body = fs.readFileSync("dependency-report.md", "utf8"); + const outdated = Number("${{ steps.report.outputs.outdated }}" || "0"); + const severity = "${{ steps.report.outputs.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 b88b4d280..d6de4ec78 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", "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", "brand:update": "node scripts/run-tsx.mjs scripts/generate-brand-assets.ts", diff --git a/scripts/dependency-report.mjs b/scripts/dependency-report.mjs new file mode 100644 index 000000000..be5bdcae9 --- /dev/null +++ b/scripts/dependency-report.mjs @@ -0,0 +1,133 @@ +#!/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. +function runNpmJson(args) { + try { + return execFileSync("npm", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch (error) { + return error.stdout?.toString() ?? ""; + } +} + +function parseJsonSafe(text, fallback) { + try { + return text.trim() ? JSON.parse(text) : fallback; + } catch { + 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}); `audit` is the `npm audit --json` object. Returns Markdown. + */ +export function renderDependencyReport(outdated, audit) { + const stamp = new Date().toISOString(); + const lines = [`### Dependency report — ${stamp}`, ""]; + + const entries = Object.entries(outdated ?? {}); + if (entries.length === 0) { + lines.push("**Outdated direct dependencies:** none 🎉"); + } else { + 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"]), {}); + audit = parseJsonSafe(runNpmJson(["audit", "--json", "--omit=dev"]), {}); + } + + 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..9b78ae13a --- /dev/null +++ b/tests/dependency-report.test.ts @@ -0,0 +1,40 @@ +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"); + }); +}); + +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"); + }); +}); From 53f45088a7020724c26da5c508ac74b8211cdb00 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:50:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(deps-report):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20error=20payloads,=20dev-dep=20audit,=20injection-sa?= =?UTF-8?q?fe=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #618 review fixes: - Detect npm error payloads: a failed `npm outdated --json` can emit { error: … } on stdout; now treated as "data unavailable" (renders null, not a bogus package count). (Codex) - Surface silent failures: runNpmJson captures stderr and parseJsonSafe warns on non-JSON, so a network/registry failure shows in logs instead of a falsely-clean "none 🎉". (CodeRabbit) - Include dev dependencies in audit (drop --omit=dev): the dependency-maintenance protocol covers the dev toolchain; prod high/critical is already gated by the CI safety job. (Codex) - Pass step outputs to github-script via env: instead of inline template expansion (avoids the Actions template-injection pattern). (CodeRabbit) Declined: SHA-pinning first-party actions/* — the repo's check:github-actions deliberately permits vetted major-version tags for first-party actions, and the sibling ops-digest/ci-triage/ingestion-autopilot workflows use the same form; pinning only this one would be inconsistent. The gate still passes. Verified: 5/5 vitest (incl. new data-unavailable case), error-payload smoke, check:github-actions, eslint, tsc, prettier. Co-Authored-By: Claude Fable 5 --- .github/workflows/dependency-report.yml | 10 ++++++-- scripts/dependency-report.mjs | 32 ++++++++++++++++++++----- tests/dependency-report.test.ts | 6 +++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.github/workflows/dependency-report.yml b/.github/workflows/dependency-report.yml index b3051ff83..16908468b 100644 --- a/.github/workflows/dependency-report.yml +++ b/.github/workflows/dependency-report.yml @@ -46,13 +46,19 @@ jobs: - 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("${{ steps.report.outputs.outdated }}" || "0"); - const severity = "${{ steps.report.outputs.severity }}" || "none"; + 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, diff --git a/scripts/dependency-report.mjs b/scripts/dependency-report.mjs index be5bdcae9..1959f80ad 100644 --- a/scripts/dependency-report.mjs +++ b/scripts/dependency-report.mjs @@ -26,11 +26,14 @@ function argValue(name) { } // `npm outdated --json` and `npm audit --json` exit non-zero when they find -// something; capture stdout regardless of exit code. +// 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", "ignore"] }); + 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() ?? ""; } } @@ -39,6 +42,7 @@ 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; } } @@ -50,16 +54,20 @@ function majorOf(version) { /** * Pure renderer. `outdated` is the `npm outdated --json` object (pkg → {current, - * wanted, latest}); `audit` is the `npm audit --json` object. Returns Markdown. + * 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}`, ""]; - const entries = Object.entries(outdated ?? {}); - if (entries.length === 0) { + 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); @@ -112,7 +120,19 @@ function main() { audit = payload.audit ?? {}; } else { outdated = parseJsonSafe(runNpmJson(["outdated", "--json"]), {}); - audit = parseJsonSafe(runNpmJson(["audit", "--json", "--omit=dev"]), {}); + // 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); diff --git a/tests/dependency-report.test.ts b/tests/dependency-report.test.ts index 9b78ae13a..67df1aa50 100644 --- a/tests/dependency-report.test.ts +++ b/tests/dependency-report.test.ts @@ -28,6 +28,12 @@ describe("renderDependencyReport", () => { 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", () => {