From ff7cf12b53b267502d7e71487124699ca7ed8e22 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Mon, 2 Nov 2026 11:51:55 +0100 Subject: [PATCH] ci(dependency-review): fail only on advisories the base doesn't have The dependency-review job failed on a version a pull request adds or moves to that has an advisory of moderate severity or worse, even when the version it replaces had the same advisory. Such a pull request leaves main as it was. Renovate's lock file maintenance ran into it: npm 11.21.0 to 11.21.1 in the NSS 6 test server moves the ip-address that npm bundles from 10.5.0 to 10.7.2, both under GHSA-rxp7-6hxx-x8q5, and no npm 11.x bundles a fixed one yet, so every other update in the pull request was held back. The only way through was allow-ghsas, a waiver for the whole repository that has to be removed by hand. npm run dependencies:review (dependencies:summary before) now judges the advisories from the comparison the job already fetches. An advisory of an added version fails the job unless a removed version of the same package in the same manifest has the same GHSA id: then it is listed as carried over from the base, and the job passes. It matches the advisory, not the package, so a version that fixes one advisory and has another still fails. The run's summary lists both kinds above the package tables. dependency-review-action keeps checking the licenses, with its vulnerability check turned off. Dependabot alerts on main report the advisories main already has. Closes: #506 --- .github/workflows/ci.yml | 26 ++-- docs/testing.md | 32 +++-- package.json | 2 +- scripts/dependencyChanges.test.ts | 198 ++++++++++++++++++++++++++++-- scripts/dependencyChanges.ts | 136 +++++++++++++++++--- 5 files changed, 353 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 276b5b8a..90cf570c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1221,9 +1221,10 @@ jobs: # base and of its head compared, over every manifest the graph reads, # the lockfiles of the root and of the end-to-end servers and the # pinned Python tools among them. It fails on a package the pull - # request adds, or moves to another version, with a known - # vulnerability of moderate severity or worse, or under a license not - # on the list below, and lists what moved in the run's summary, which + # request adds, or moves to another version, under a license not on + # the list below, or with a known vulnerability of moderate severity or + # worse that the base doesn't have, and lists what moved in the run's + # summary, which # a person reads before merging Renovate's lock file maintenance # (GOVERNANCE.md#dependency-updates). On a pull request only, as ci-ok # expects: what the merge queue's tree and a push to main bring in came @@ -1243,7 +1244,10 @@ jobs: steps: - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: - fail-on-severity: moderate + # The licenses only: the action fails on every advisory of a + # version that comes, even one the version that goes had too, + # which leaves main as it was. The step below judges those. + vulnerability-check: false # Every scope, not only the packages the app runs: the # development tools run in CI and on contributors' machines, # and build the site that is deployed. @@ -1279,9 +1283,13 @@ jobs: # Scorecard's scores come from another service, and the summary # below names every package that moved already. show-openssf-scorecard: false - # The action's summary names the problems it found. This lists - # every package added, changed or removed, per manifest, from the - # same comparison, whether the review passed or not. gh fetches it + # The action's summary names the licenses it refused. This fails + # on an advisory of moderate severity or worse that a version + # which comes has and no version which goes, of the same package + # in the same manifest, had: one the base has already is listed, + # and Dependabot's alerts on main report it. It also lists every + # package added, changed or removed, per manifest, from the same + # comparison, whether the licenses passed or not. gh fetches it # again rather than a step reading the action's output, which a # lock file maintenance can make too large to pass to a step. - if: success() || failure() @@ -1294,7 +1302,7 @@ jobs: node-version-file: .nvmrc # The script imports no package, so nothing is installed. - if: success() || failure() - name: npm run dependencies:summary -- + name: npm run dependencies:review -- env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -1303,7 +1311,7 @@ jobs: run: | changes="$RUNNER_TEMP/changes.json" gh api --paginate --slurp "repos/$REPO/dependency-graph/compare/$BASE...$HEAD?per_page=100" > "$changes" - npm run dependencies:summary -- "$changes" + npm run dependencies:review -- "$changes" # When a job that tests the tree fails, the run's result is settled, so # the jobs still going are canceled rather than left to finish: the diff --git a/docs/testing.md b/docs/testing.md index 911dfeec..84a1aa53 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -24,7 +24,7 @@ npm run browsers # once: Chromium's headless shell, which npm run check and npm npm run lint # Biome: the formatting and lint rules of the TypeScript, JSON and CSS (docs/style.md) npm run format # Biome: rewrite those files as it formats them npm run coverage:summary # the coverage the last tests measured, per package, as a table -npm run dependencies:summary -- changes.json # the packages GitHub's comparison of two commits' dependency graphs adds, changes and removes, as tables, as CI's dependency-review job writes them +npm run dependencies:review -- changes.json # the advisories and the packages GitHub's comparison of two commits' dependency graphs adds, changes and removes, as tables, as CI's dependency-review job writes them; fails on an advisory the base doesn't have npm run lint:markdown # markdownlint over the project's Markdown, as npm run check runs it npm run lint:spelling # cspell, American English, over the Markdown and en.json, as npm run check runs it npm run lint:prose # the docs' prose rules, per page kind (docs/style.md#the-prose-check), as npm run check runs it; -- docs/srs.md for one page @@ -639,7 +639,7 @@ jobs run side by side, each | `docs` | deterministic | the docs' links, every file's license, and `publiccode.yml` ([Documentation](#documentation)) | | `workflows` | deterministic | the workflows and local actions, by actionlint and zizmor (`npm run lint:workflows`, [The workflows](#the-workflows)) | | `commits` | deterministic | on a pull request only: each commit it adds follows Conventional Commits, by commitlint ([CONTRIBUTING.md](../CONTRIBUTING.md#commit-messages)), and its body or a commit closes its issue, or the body says it's one step of it (`npm run check:closes`; not for Renovate's) | -| `dependency-review` | deterministic | on a pull request only, skipped on every other run: no package it adds or changes has a known vulnerability of moderate severity or worse, or a license off the list, by GitHub's dependency graph; every package it adds, changes or removes goes to the run's summary ([Dependency review](#dependency-review)) | +| `dependency-review` | deterministic | on a pull request only, skipped on every other run: no package it adds or changes has a known vulnerability of moderate severity or worse that the base doesn't have, or a license off the list, by GitHub's dependency graph; every package it adds, changes or removes goes to the run's summary ([Dependency review](#dependency-review)) | | `caches` | deterministic | nothing: on `main`, after `check` or `reuse`, it deletes all but the newest of `main`'s Turbo caches, of the CodeQL databases per language, and of each end-to-end server's images, and all but the two newest of the browsers' system libraries per browser. Its token can write (`actions: write`), as only the `fail-fast` jobs' can besides. It runs none of the repository's code, and its failure fails neither the run nor `ci-ok` | | `fail-fast ()` | deterministic | nothing: one per job that tests the tree, `commits` and `dependency-review` aside, needing that job alone. When it fails, outside `main` and not for a fork's pull request, it cancels the run ([Canceled runs](#canceled-runs)); when it passes, it is skipped. Its token can write (`actions: write`), and it runs none of the repository's code | | `warm`, `warm-images`, `warm-browsers`, `warm-pip` | deterministic | nothing: on `main`, when the run reuses another, they save only the caches the skipped jobs would have saved and `main` lacks (`releasing.md`), failing neither the run nor `ci-ok` | @@ -858,7 +858,7 @@ maintainer account still publishes a validly signed version. CI's `dependency-review` job runs GitHub's [dependency-review-action](https://github.com/actions/dependency-review-action) -on a pull request. The action compares GitHub's dependency graph of +on a pull request. The job compares GitHub's dependency graph of the pull request's base and of its head. The graph covers every manifest GitHub reads here: the root's `package-lock.json`, those of the end-to-end servers in `e2e/pod/servers/`, the @@ -867,22 +867,34 @@ on a package the pull request adds, or moves to another version, that has either of these: - a known vulnerability of moderate severity or worse, in GitHub's - advisory database, in any scope, the development tools included; + advisory database, in any scope, the development tools included, that + the base doesn't have; - a license that isn't on the job's `allow-licenses` list, the licenses the project can ship a package under ([style.md](style.md#licenses)). +The base has a vulnerability already when a version of the same package +that the pull request removes, in the same manifest, has the same +advisory. Moving a package from one vulnerable version to another, as +lock file maintenance does when no fixed version can be locked yet, +leaves `main` as it was, so the job lists that advisory as carried over +and passes. A new version that fixes one advisory and has another still +fails. GitHub's Dependabot alerts on `main`, not this job, report the +advisories that `main` already has, and Renovate's vulnerability alerts +propose the fixed versions +([renovate.json5](../renovate.json5)). + A package whose license GitHub doesn't know is reported, and doesn't fail the job. A package already accepted under a license that can't be on the list is in the job's `allow-dependencies-licenses`, with its reason in a comment. None of them ships in the site: an end-to-end test server, build and release tools, and the license linter. -The action names what it found in the run's summary. -`npm run dependencies:summary` lists every package added, changed or -removed, per manifest, with its scope and license, from the same -comparison. That list is what a maintainer reads before merging -Renovate's lock file maintenance +The action checks the licenses and names the ones it refused in the +run's summary. `npm run dependencies:review` judges the advisories, and +lists them and every package added, changed or removed, per manifest, +with its scope and license, from the same comparison. That list is what +a maintainer reads before merging Renovate's lock file maintenance ([GOVERNANCE.md](../GOVERNANCE.md#dependency-updates)). The job runs on a pull request only. The packages the merge queue's @@ -893,6 +905,8 @@ the repository's dependency graph, which an admin turns on in Settings › Advanced Security. Without it the job fails with "Dependency review is not supported on this repository", and `ci-ok` fails with it on every pull request. +Dependabot alerts, turned on in the same place, report what the job +lets through as carried over. ### Code scanning diff --git a/package.json b/package.json index dfb71707..9fe51927 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "check:keys": "node -e \"import('./scripts/checkQueryKeys.ts').then((m) => m.run(process))\"", "ci:gate": "node -e \"import('./scripts/ciGate.ts').then((m) => m.run(process))\"", "check:providers": "node -e \"import('./scripts/checkProviders.ts').then((m) => m.run(process))\"", - "dependencies:summary": "node -e \"import('./scripts/dependencyChanges.ts').then((m) => m.run(process))\" --", + "dependencies:review": "node -e \"import('./scripts/dependencyChanges.ts').then((m) => m.run(process))\" --", "coverage:summary": "node -e \"import('./scripts/coverageSummary.ts').then((m) => m.run(process))\"", "release:commit": "node -e \"import('./scripts/releaseCommit.ts').then((m) => m.run(process))\" --", "release:version": "node -e \"import('./scripts/releaseVersion.ts').then((m) => m.run(process))\" --", diff --git a/scripts/dependencyChanges.test.ts b/scripts/dependencyChanges.test.ts index 7f42895a..a18f3aa0 100644 --- a/scripts/dependencyChanges.test.ts +++ b/scripts/dependencyChanges.test.ts @@ -6,7 +6,14 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { type Change, changes, render, run } from "./dependencyChanges.ts"; +import { + advisories, + type Change, + changes, + render, + renderAdvisories, + run, +} from "./dependencyChanges.ts"; function change( change_type: Change["change_type"], @@ -64,6 +71,22 @@ const TABLES = [ "", ].join("\n"); +/** An advisory on a version, at a severity. */ +function advisory( + id: string, + severity: "critical" | "high" | "moderate" | "low" = "moderate", +): NonNullable[number] { + return { + severity, + advisory_ghsa_id: id, + advisory_summary: `${id}: a | in the summary`, + advisory_url: `https://github.com/advisories/${id}`, + }; +} + +const NO_ADVISORIES = + "## Advisories\n\nNo added version has an advisory of moderate severity or worse.\n"; + describe("changes", () => { it("reads gh's pages, each a list of changes, as one list", () => { const [a, b, c] = CHANGES; @@ -95,30 +118,189 @@ describe("render", () => { }); }); +describe("advisories", () => { + it("carries over an advisory that a removed version of the package in the manifest had", () => { + const moved = change("added", "ip-address", "10.7.2", { + vulnerabilities: [advisory("GHSA-a")], + }); + expect( + advisories([ + change("removed", "ip-address", "10.5.0", { vulnerabilities: [advisory("GHSA-a")] }), + moved, + ]), + ).toEqual({ + introduced: [], + carried: [ + { + manifest: "package-lock.json", + name: "ip-address", + version: "10.7.2", + vulnerability: advisory("GHSA-a"), + }, + ], + }); + }); + + it("introduces an advisory the removed version didn't have, though it had another", () => { + const { introduced, carried } = advisories([ + change("removed", "a", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + change("added", "a", "2.0.0", { vulnerabilities: [advisory("GHSA-a"), advisory("GHSA-b")] }), + ]); + expect(introduced.map(({ vulnerability }) => vulnerability.advisory_ghsa_id)).toEqual([ + "GHSA-b", + ]); + expect(carried.map(({ vulnerability }) => vulnerability.advisory_ghsa_id)).toEqual(["GHSA-a"]); + }); + + it("introduces an advisory that only another manifest, or another package, had", () => { + const { introduced, carried } = advisories([ + change("removed", "a", "1.0.0", { + manifest: "e2e/pod/servers/jss/package-lock.json", + vulnerabilities: [advisory("GHSA-a")], + }), + change("removed", "b", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + change("added", "a", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + ]); + expect(introduced).toHaveLength(1); + expect(carried).toEqual([]); + }); + + it("introduces the advisory of a package the pull request adds", () => { + expect( + advisories([change("added", "new", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] })]) + .introduced, + ).toHaveLength(1); + }); + + it("leaves out the advisories below moderate, and the versions without any", () => { + expect( + advisories([ + change("added", "a", "1.0.0", { vulnerabilities: [advisory("GHSA-a", "low")] }), + change("added", "b", "1.0.0"), + ]), + ).toEqual({ introduced: [], carried: [] }); + }); + + it("counts every severity from moderate up", () => { + expect( + advisories([ + change("added", "a", "1.0.0", { + vulnerabilities: [advisory("GHSA-a", "critical"), advisory("GHSA-b", "high")], + }), + ]).introduced, + ).toHaveLength(2); + }); +}); + +describe("renderAdvisories", () => { + it("lists the introduced advisories and those carried over, in a table each", () => { + expect( + renderAdvisories([ + change("removed", "a", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + change("added", "a", "2.0.0", { + vulnerabilities: [advisory("GHSA-a"), advisory("GHSA-b", "high")], + }), + ]), + ).toBe( + [ + "## Advisories", + "", + "1 introduced, 1 carried over from the base, of moderate severity or worse. The review fails on the introduced ones.", + "", + "### Introduced", + "", + "| Manifest | Package | Advisory | Severity | Summary |", + "|---|---|---|---|---|", + "| `package-lock.json` | `a` 2.0.0 | [GHSA-b](https://github.com/advisories/GHSA-b) | high | GHSA-b: a \\| in the summary |", + "", + "### Carried over from the base", + "", + "| Manifest | Package | Advisory | Severity | Summary |", + "|---|---|---|---|---|", + "| `package-lock.json` | `a` 2.0.0 | [GHSA-a](https://github.com/advisories/GHSA-a) | moderate | GHSA-a: a \\| in the summary |", + "", + ].join("\n"), + ); + }); + + it("leaves out a table with nothing in it", () => { + const text = renderAdvisories([ + change("added", "a", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + ]); + expect(text).toContain("### Introduced"); + expect(text).not.toContain("### Carried over from the base"); + }); + + it("says so when there are none", () => { + expect(renderAdvisories(CHANGES)).toBe(NO_ADVISORIES); + }); +}); + describe("run", () => { - function file(): string { + function file(list: readonly Change[] = CHANGES): string { const path = join(mkdtempSync(join(tmpdir(), "solid-memo-dependencies-")), "changes.json"); - writeFileSync(path, JSON.stringify([CHANGES])); + writeFileSync(path, JSON.stringify([list])); return path; } - it("adds the tables to the run's summary", () => { + it("adds the advisories and the tables to the run's summary", () => { const summary = join(mkdtempSync(join(tmpdir(), "solid-memo-summary-")), "summary.md"); writeFileSync(summary, "before\n"); - run({ argv: ["node", file()], env: { GITHUB_STEP_SUMMARY: summary } }); - expect(readFileSync(summary, "utf8")).toBe(`before\n${TABLES}`); + const process: Parameters[0] = { + argv: ["node", file()], + env: { GITHUB_STEP_SUMMARY: summary }, + }; + run(process); + expect(readFileSync(summary, "utf8")).toBe(`before\n${NO_ADVISORIES}\n${TABLES}`); + expect(process.exitCode).toBeUndefined(); }); it("prints them without a summary", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); run({ argv: ["node", file()], env: {} }); - expect(log).toHaveBeenCalledWith(TABLES); + expect(log).toHaveBeenCalledWith(`${NO_ADVISORIES}\n${TABLES}`); + log.mockRestore(); + }); + + it("passes on an advisory carried over from the base", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const process: Parameters[0] = { + argv: [ + "node", + file([ + change("removed", "a", "1.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + change("added", "a", "2.0.0", { vulnerabilities: [advisory("GHSA-a")] }), + ]), + ], + env: {}, + }; + run(process); + expect(process.exitCode).toBeUndefined(); + log.mockRestore(); + }); + + it("fails on an advisory the pull request introduces, and names it", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const process: Parameters[0] = { + argv: [ + "node", + file([change("added", "a", "2.0.0", { vulnerabilities: [advisory("GHSA-b")] })]), + ], + env: {}, + }; + run(process); + expect(process.exitCode).toBe(1); + expect(error).toHaveBeenCalledWith( + "package-lock.json » a@2.0.0: GHSA-b (moderate), which the base doesn't have: https://github.com/advisories/GHSA-b", + ); log.mockRestore(); + error.mockRestore(); }); it("needs the file", () => { expect(() => run({ argv: ["node"], env: {} })).toThrow( - "Usage: npm run dependencies:summary -- ", + "Usage: npm run dependencies:review -- ", ); }); }); diff --git a/scripts/dependencyChanges.ts b/scripts/dependencyChanges.ts index 9c2a1898..5125b3e3 100644 --- a/scripts/dependencyChanges.ts +++ b/scripts/dependencyChanges.ts @@ -3,21 +3,33 @@ // SPDX-License-Identifier: MIT /** - * `npm run dependencies:summary -- `: the packages a pull - * request adds, changes and removes, as a Markdown table per manifest - * (docs/testing.md#dependency-review). CI's dependency-review job adds it - * to its run's summary ($GITHUB_STEP_SUMMARY); without that variable it - * is printed. The file is GitHub's comparison of the dependency graph of - * the pull request's base and head + * `npm run dependencies:review -- `: the packages a pull + * request adds, changes and removes, as a Markdown table per manifest, + * and the advisories it brings in (docs/testing.md#dependency-review). + * CI's dependency-review job adds them to its run's summary + * ($GITHUB_STEP_SUMMARY); without that variable they are printed. The + * file is GitHub's comparison of the dependency graph of the pull + * request's base and head * (`gh api --paginate --slurp repos//dependency-graph/compare/...`), - * the same one dependency-review-action judges: its pages, each a list - * of changes, or the list itself. A version that goes and another of the - * same package that comes, in one manifest, are one change. It only - * reports: the action fails the job. + * the same one dependency-review-action judges the licenses of: its + * pages, each a list of changes, or the list itself. A version that goes + * and another of the same package that comes, in one manifest, are one + * change. It fails on an advisory of moderate severity or worse that an + * added version has and no removed version of the same package in the + * same manifest had: one the pull request carries over from its base + * leaves `main` as it was, and is listed without failing. */ import { appendFileSync, readFileSync } from "node:fs"; -/** One change of GitHub's comparison, as much of it as the table shows. */ +/** A known vulnerability of one version, as GitHub's comparison gives it. */ +type Vulnerability = { + severity: "critical" | "high" | "moderate" | "low"; + advisory_ghsa_id: string; + advisory_summary: string; + advisory_url: string; +}; + +/** One change of GitHub's comparison, as much of it as the review reads. */ export type Change = { change_type: "added" | "removed"; manifest: string; @@ -26,8 +38,20 @@ export type Change = { version: string; license: string | null; scope?: string; + vulnerabilities?: Vulnerability[]; }; +/** An advisory of an added version, at the severity the review fails from. */ +type Advisory = { + manifest: string; + name: string; + version: string; + vulnerability: Vulnerability; +}; + +/** The severities the review fails on, as the job's action did before it. */ +const FAILING = new Set(["critical", "high", "moderate"]); + /** One row: a package of one manifest, its versions before and after. */ type Row = { ecosystem: string; @@ -81,6 +105,77 @@ function what({ removed, added }: Row): string { return `${removed.join(", ")} → ${added.join(", ")}`; } +/** + * The advisories of the added versions, split into those the pull + * request introduces and those its base already had: the same advisory + * on a removed version of the same package in the same manifest. + */ +export function advisories(changes: readonly Change[]): { + introduced: Advisory[]; + carried: Advisory[]; +} { + const key = (change: Change, id: string) => + [change.manifest, change.ecosystem, change.name, id].join("\u0000"); + const before = new Set( + changes + .filter((change) => change.change_type === "removed") + .flatMap((change) => + (change.vulnerabilities ?? []).map((vulnerability) => + key(change, vulnerability.advisory_ghsa_id), + ), + ), + ); + const introduced: Advisory[] = []; + const carried: Advisory[] = []; + for (const change of changes) { + if (change.change_type === "removed") continue; + for (const vulnerability of change.vulnerabilities ?? []) { + if (!FAILING.has(vulnerability.severity)) continue; + const advisory = { + manifest: change.manifest, + name: change.name, + version: change.version, + vulnerability, + }; + (before.has(key(change, vulnerability.advisory_ghsa_id)) ? carried : introduced).push( + advisory, + ); + } + } + return { introduced, carried }; +} + +function advisoryLines(heading: string, list: readonly Advisory[]): string[] { + if (list.length === 0) return []; + return [ + "", + `### ${heading}`, + "", + "| Manifest | Package | Advisory | Severity | Summary |", + "|---|---|---|---|---|", + ...list.map( + ({ manifest, name, version, vulnerability }) => + `| \`${manifest}\` | \`${name}\` ${version} | [${vulnerability.advisory_ghsa_id}](${vulnerability.advisory_url}) | ${vulnerability.severity} | ${vulnerability.advisory_summary.replaceAll("|", "\\|")} |`, + ), + ]; +} + +/** The advisories the pull request introduces and carries over, or a line saying there are none. */ +export function renderAdvisories(changes: readonly Change[]): string { + const { introduced, carried } = advisories(changes); + if (introduced.length === 0 && carried.length === 0) { + return "## Advisories\n\nNo added version has an advisory of moderate severity or worse.\n"; + } + const lines = [ + "## Advisories", + "", + `${introduced.length} introduced, ${carried.length} carried over from the base, of moderate severity or worse. The review fails on the introduced ones.`, + ...advisoryLines("Introduced", introduced), + ...advisoryLines("Carried over from the base", carried), + ]; + return `${lines.join("\n")}\n`; +} + /** The tables, or a line saying nothing changed. */ export function render(changes: readonly Change[]): string { const byManifest = rows(changes); @@ -110,14 +205,27 @@ export function render(changes: readonly Change[]): string { return `${lines.join("\n")}\n`; } -/** The script entry: `node -e "import('./scripts/dependencyChanges.ts').then((m) => m.run(process))" -- `. */ +/** + * The script entry: `node -e "import('./scripts/dependencyChanges.ts').then((m) => m.run(process))" -- `. + * It sets the exit code to 1, and names each one, when the pull request + * introduces an advisory. + */ export function run(process: { argv: readonly string[]; env: Record; + exitCode?: number | string | null | undefined; }): void { const path = process.argv[1]; - if (path === undefined) throw new Error("Usage: npm run dependencies:summary -- "); - const text = render(changes(readFileSync(path, "utf8"))); + if (path === undefined) throw new Error("Usage: npm run dependencies:review -- "); + const all = changes(readFileSync(path, "utf8")); + const text = `${renderAdvisories(all)}\n${render(all)}`; if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, text); else console.log(text); + const { introduced } = advisories(all); + for (const { manifest, name, version, vulnerability } of introduced) { + console.error( + `${manifest} » ${name}@${version}: ${vulnerability.advisory_ghsa_id} (${vulnerability.severity}), which the base doesn't have: ${vulnerability.advisory_url}`, + ); + } + if (introduced.length > 0) process.exitCode = 1; }