-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ops): fortnightly dependency report cron (disabled, report-only) #618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
105d622
feat(ops): fortnightly dependency report cron (disabled, report-only)
BigSimmo 53f4508
fix(deps-report): address review — error payloads, dev-dep audit, inj…
BigSimmo dbf1a8d
Merge remote-tracking branch 'origin/main' into claude/dependency-report
BigSimmo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}`); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <path> also write the Markdown digest to a file (for the workflow) | ||
| * --input <file> 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; | ||
| } | ||
| } | ||
|
BigSimmo marked this conversation as resolved.
|
||
|
|
||
| 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(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.