Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/dependency-report.yml
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:
Comment thread
BigSimmo marked this conversation as resolved.
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}`);
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
153 changes: 153 additions & 0 deletions scripts/dependency-report.mjs
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;
}
}
Comment thread
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();
46 changes: 46 additions & 0 deletions tests/dependency-report.test.ts
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");
});
});