From ee9b19569d81ef909f1eb908a4764c2615885109 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 12 May 2026 10:10:20 +0200 Subject: [PATCH 1/3] feat: add workflow js-dependency-audit.yaml --- .github/workflows/js-dependency-audit.yaml | 480 +++++++++++++++++++++ CHANGELOG.md | 6 + 2 files changed, 486 insertions(+) create mode 100644 .github/workflows/js-dependency-audit.yaml diff --git a/.github/workflows/js-dependency-audit.yaml b/.github/workflows/js-dependency-audit.yaml new file mode 100644 index 0000000..9bebb3b --- /dev/null +++ b/.github/workflows/js-dependency-audit.yaml @@ -0,0 +1,480 @@ +# Runs an audit against the JS dependencies of the calling repository on both +# the PR head and the PR base, then posts a sticky PR comment summarizing +# vulnerabilities and highlighting which ones the PR adds or removes. +# +# Auto-discovers every directory that contains a package.json with a recognized +# lockfile (pnpm-lock.yaml, yarn.lock, package-lock.json, npm-shrinkwrap.json) +# and picks the right audit command per project. The job never fails on +# vulnerabilities โ€” the signal is the comment. +# +# Recommended caller invocation: filter the trigger to dependency-relevant +# files so the audit doesn't run on every PR. Example: +# +# on: +# pull_request: +# paths: +# - '**/package.json' +# - '**/package-lock.json' +# - '**/npm-shrinkwrap.json' +# - '**/yarn.lock' +# - '**/.yarnrc' +# - '**/.yarnrc.yml' +# - '**/pnpm-lock.yaml' +# - '**/pnpm-workspace.yaml' +# jobs: +# audit: +# uses: giantswarm/github-workflows/.github/workflows/js-dependency-audit.yaml@main + +name: JS Dependency Audit + +on: + workflow_call: + inputs: + working_directory: + description: "Optional path to a single JS project to audit. When empty, the workflow auto-discovers every directory with a package.json and a recognized lockfile." + type: string + default: "" + node_version: + description: "Node.js version used to run the audit." + type: string + default: "24" + +permissions: {} + +jobs: + js-dependency-audit: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" + steps: + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ inputs.node_version }} + + - name: Enable Corepack + run: corepack enable + + - name: Write audit helper + # The helper is written once to /tmp and invoked twice (once per ref). + # It auto-discovers JS projects in the current working directory, + # detects each project's package manager from its lockfile, runs the + # corresponding audit, and appends one NDJSON record per project to + # the output file given as $1. + run: | + cat > /tmp/audit.sh <<'BASH_EOF' + #!/bin/bash + set -o pipefail + + OUTPUT_FILE="$1" + : > "${OUTPUT_FILE}" + + discover_projects() { + if [ -n "${WORKING_DIRECTORY:-}" ]; then + if [ -f "${WORKING_DIRECTORY}/package.json" ]; then + printf '%s\n' "${WORKING_DIRECTORY}" + else + echo "configured working_directory '${WORKING_DIRECTORY}' has no package.json" >&2 + fi + return + fi + find . -name package.json \ + -not -path '*/node_modules/*' \ + -not -path './_base_audit_ref/*' \ + -print0 \ + | xargs -0 -n1 dirname \ + | sort -u + } + + detect_pm() { + local dir="$1" + if [ -f "${dir}/pnpm-lock.yaml" ]; then + echo "pnpm" + return + fi + if [ -f "${dir}/yarn.lock" ]; then + local pm + pm="$(jq -r '.packageManager // ""' "${dir}/package.json" 2>/dev/null || true)" + if [ -f "${dir}/.yarnrc.yml" ] || [[ "${pm}" =~ ^yarn@[2-9] ]]; then + echo "yarn-berry" + else + echo "yarn-classic" + fi + return + fi + if [ -f "${dir}/package-lock.json" ] || [ -f "${dir}/npm-shrinkwrap.json" ]; then + echo "npm" + return + fi + echo "" + } + + run_audit() { + local dir="$1" + local pm="$2" + local raw="$3" + local err="$4" + case "${pm}" in + npm) + (cd "${dir}" && npm audit --json) > "${raw}" 2> "${err}" || true + ;; + pnpm) + (cd "${dir}" && pnpm install --frozen-lockfile --ignore-scripts && pnpm audit --json) > "${raw}" 2> "${err}" || true + ;; + yarn-berry) + (cd "${dir}" && yarn install --immutable && yarn npm audit --all --recursive --json) > "${raw}" 2> "${err}" || true + ;; + yarn-classic) + (cd "${dir}" && yarn audit --json --groups dependencies,devDependencies) > "${raw}" 2> "${err}" || true + ;; + esac + } + + mapfile -t PROJECTS < <(discover_projects) + echo "Discovered ${#PROJECTS[@]} project(s):" + if [ "${#PROJECTS[@]}" -eq 0 ]; then + echo " (none)" + else + printf ' %s\n' "${PROJECTS[@]}" + fi + + for proj in "${PROJECTS[@]:-}"; do + [ -z "${proj}" ] && continue + pm="$(detect_pm "${proj}")" + if [ -z "${pm}" ]; then + echo "Skipping ${proj}: no recognized lockfile" + jq -nc --arg proj "${proj}" \ + '{__project__: $proj, __pm__: "unknown", __skipped__: "no recognized lockfile"}' \ + >> "${OUTPUT_FILE}" + continue + fi + echo "Auditing ${proj} (manager: ${pm})" + raw="$(mktemp)" + err="$(mktemp)" + run_audit "${proj}" "${pm}" "${raw}" "${err}" + + if [ ! -s "${raw}" ]; then + jq -nc --arg proj "${proj}" --arg pm "${pm}" --rawfile err "${err}" \ + '{__project__: $proj, __pm__: $pm, __error__: ("audit produced no output: " + ($err | .[0:500]))}' \ + >> "${OUTPUT_FILE}" + rm -f "${raw}" "${err}" + continue + fi + + case "${pm}" in + yarn-berry|yarn-classic) + if ! jq -sc --arg proj "${proj}" --arg pm "${pm}" \ + '{__project__: $proj, __pm__: $pm, __data__: .}' "${raw}" \ + >> "${OUTPUT_FILE}" 2>/dev/null; then + jq -nc --arg proj "${proj}" --arg pm "${pm}" --rawfile raw "${raw}" \ + '{__project__: $proj, __pm__: $pm, __error__: ("could not parse NDJSON: " + ($raw | .[0:500]))}' \ + >> "${OUTPUT_FILE}" + fi + ;; + npm|pnpm) + if ! jq -c --arg proj "${proj}" --arg pm "${pm}" \ + '{__project__: $proj, __pm__: $pm, __data__: .}' "${raw}" \ + >> "${OUTPUT_FILE}" 2>/dev/null; then + jq -nc --arg proj "${proj}" --arg pm "${pm}" --rawfile raw "${raw}" \ + '{__project__: $proj, __pm__: $pm, __error__: ("could not parse JSON: " + ($raw | .[0:500]))}' \ + >> "${OUTPUT_FILE}" + fi + ;; + esac + rm -f "${raw}" "${err}" + done + + echo "Wrote $(wc -l < "${OUTPUT_FILE}") record(s) to ${OUTPUT_FILE}" + BASH_EOF + chmod +x /tmp/audit.sh + + - name: Checkout PR head + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Audit PR head + env: + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: /tmp/audit.sh /tmp/audit-head.ndjson + + - name: Checkout base branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: _base_audit_ref + persist-credentials: false + + - name: Audit base branch + env: + WORKING_DIRECTORY: ${{ inputs.working_directory }} + working-directory: ./_base_audit_ref + run: /tmp/audit.sh /tmp/audit-base.ndjson + + - name: Build comment body + id: build_comment + shell: python {0} + run: | + import json + import os + + SEVERITY_ORDER = {"critical": 0, "high": 1, "moderate": 2, "low": 3, "info": 4, "unknown": 5} + SEVERITY_ICON = { + "critical": "๐Ÿ”ด", + "high": "๐ŸŸ ", + "moderate": "๐ŸŸก", + "low": "๐Ÿ”ต", + "info": "โšช", + "unknown": "โ“", + } + MARKER = "" + + + def parse_npm(project, data): + # npm v7+: { vulnerabilities: { pkgName: { severity, via: [advisory...] } } } + if not isinstance(data, dict): + return + for pkg, info in (data.get("vulnerabilities") or {}).items(): + for via in info.get("via") or []: + if not isinstance(via, dict): + continue + source = via.get("source") + advisory_id = str(source) if source is not None else (via.get("url") or via.get("title") or "") + yield { + "project": project, + "package": via.get("name") or pkg, + "advisory_id": advisory_id, + "severity": (via.get("severity") or info.get("severity") or "unknown").lower(), + "title": via.get("title") or "", + "url": via.get("url") or "", + "range": via.get("range") or "", + } + + + def parse_pnpm(project, data): + # pnpm emits one of two shapes depending on version: + # old: { advisories: { id: {...} } } + # newer: { vulnerabilities: { ... } } (same as npm v7) + if not isinstance(data, dict): + return + if data.get("advisories"): + for adv_id, adv in data["advisories"].items(): + yield { + "project": project, + "package": adv.get("module_name") or "", + "advisory_id": str(adv.get("id") or adv_id), + "severity": (adv.get("severity") or "unknown").lower(), + "title": adv.get("title") or "", + "url": adv.get("url") or "", + "range": adv.get("vulnerable_versions") or "", + } + return + if "vulnerabilities" in data: + yield from parse_npm(project, data) + return + print(f" warning: pnpm output for {project} had no advisories or vulnerabilities key") + + + def parse_yarn_berry(project, lines): + # NDJSON: { value: pkgName, children: { ID, Issue, URL, Severity, "Vulnerable Versions" } } + for line in lines or []: + if not isinstance(line, dict): + continue + children = line.get("children") or {} + if "ID" not in children: + continue + yield { + "project": project, + "package": line.get("value") or "", + "advisory_id": str(children.get("ID") or ""), + "severity": (children.get("Severity") or "unknown").lower(), + "title": children.get("Issue") or "", + "url": children.get("URL") or "", + "range": children.get("Vulnerable Versions") or "", + } + + + def parse_yarn_classic(project, lines): + # NDJSON: { type: "auditAdvisory", data: { advisory: {...} } } + for line in lines or []: + if not isinstance(line, dict): + continue + if line.get("type") != "auditAdvisory": + continue + adv = (line.get("data") or {}).get("advisory") or {} + yield { + "project": project, + "package": adv.get("module_name") or "", + "advisory_id": str(adv.get("id") or ""), + "severity": (adv.get("severity") or "unknown").lower(), + "title": adv.get("title") or "", + "url": adv.get("url") or "", + "range": adv.get("vulnerable_versions") or "", + } + + + PARSERS = { + "npm": parse_npm, + "pnpm": parse_pnpm, + "yarn-berry": parse_yarn_berry, + "yarn-classic": parse_yarn_classic, + } + + + def load_records(path): + findings = {} + status = {} + if not os.path.exists(path): + return findings, status + with open(path) as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + project = rec.get("__project__") or "?" + pm = rec.get("__pm__") or "unknown" + status[project] = { + "pm": pm, + "error": rec.get("__error__"), + "skipped": rec.get("__skipped__"), + } + if rec.get("__error__") or rec.get("__skipped__"): + continue + parser = PARSERS.get(pm) + if not parser: + continue + for f in parser(project, rec.get("__data__")): + # Dedupe โ€” same finding may surface multiple times via workspaces / dep paths. + findings[(f["project"], f["package"], f["advisory_id"])] = f + return findings, status + + + head, head_status = load_records("/tmp/audit-head.ndjson") + base, base_status = load_records("/tmp/audit-base.ndjson") + + added_keys = sorted( + set(head) - set(base), + key=lambda k: (SEVERITY_ORDER.get(head[k]["severity"], 99), k), + ) + removed_keys = sorted( + set(base) - set(head), + key=lambda k: (SEVERITY_ORDER.get(base[k]["severity"], 99), k), + ) + + + def fmt(f): + icon = SEVERITY_ICON.get(f["severity"], "โ“") + parts = [f"{icon} **{f['severity']}**", f"`{f['package']}`"] + if f["range"]: + parts.append(f"({f['range']})") + if f["title"]: + parts.append("โ€” " + f["title"]) + if f["url"]: + parts.append(f"[advisory]({f['url']})") + prefix = f"`{f['project']}` " if f["project"] not in (".", "?") else "" + return "- " + prefix + " ".join(parts) + + + has_anything = bool(head_status or base_status) + if not has_anything: + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + fh.write("has_findings=false\n") + print("No JS projects found on either branch โ€” skipping PR comment.") + raise SystemExit(0) + + delta = len(head) - len(base) + delta_str = f"+{delta}" if delta > 0 else str(delta) + + lines = [MARKER, "", "## JS Dependency Audit", ""] + lines.append( + f"**{len(added_keys)} added ยท {len(removed_keys)} removed ยท " + f"{len(head)} total ({delta_str} vs base)**" + ) + lines.append("") + + all_projects = sorted(set(head_status) | set(base_status)) + if all_projects: + lines.append("
Projects audited") + lines.append("") + for p in all_projects: + hs = head_status.get(p, {}) + bs = base_status.get(p, {}) + pm = hs.get("pm") or bs.get("pm") or "unknown" + note = "" + if hs.get("error"): + note = f" โ€” โš ๏ธ error on PR head: {hs['error'][:200]}" + elif hs.get("skipped"): + note = f" โ€” skipped on PR head: {hs['skipped']}" + elif p not in head_status: + note = " โ€” removed in this PR" + elif p not in base_status: + note = " โ€” added in this PR" + lines.append(f"- `{p}` (manager: {pm}){note}") + lines.append("") + lines.append("
") + lines.append("") + + if added_keys: + lines.append(f"### Added by this PR ({len(added_keys)})") + lines.append("") + lines.extend(fmt(head[k]) for k in added_keys) + lines.append("") + + if removed_keys: + lines.append(f"### Removed by this PR ({len(removed_keys)})") + lines.append("") + lines.extend(fmt(base[k]) for k in removed_keys) + lines.append("") + + if not added_keys and not removed_keys: + lines.append("No change in vulnerabilities compared to the base branch.") + lines.append("") + + if head: + lines.append(f"
Full current vulnerability list ({len(head)})") + lines.append("") + for key in sorted(head, key=lambda k: (SEVERITY_ORDER.get(head[k]["severity"], 99), k)): + lines.append(fmt(head[key])) + lines.append("") + lines.append("
") + lines.append("") + + with open("/tmp/comment-body", "w") as fh: + fh.write("\n".join(lines) + "\n") + + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + fh.write("has_findings=true\n") + + - name: Find previous comment + if: steps.build_comment.outputs.has_findings == 'true' + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 + continue-on-error: true + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Delete previous comment + if: steps.build_comment.outputs.has_findings == 'true' && steps.fc.outputs.comment-id != 0 + uses: winterjung/comment@fda92dbcb5e7e79cccd55ecb107a8a3d7802a469 # v1.1.0 + continue-on-error: true + with: + type: delete + comment_id: ${{ steps.fc.outputs.comment-id }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create comment + if: steps.build_comment.outputs.has_findings == 'true' + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + body-path: /tmp/comment-body diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e3c3c..c7bdef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), however this project does not use Semantic Versioning and there are no releases. Instead this file uses a date-based structure. +## 2026-05-12 + +### Added + +- Add reusable workflow `js-dependency-audit.yaml`. It runs an audit (`npm`, `pnpm`, `yarn npm audit`, or `yarn audit`) on every JS project in a PR's head and base, then posts a sticky PR comment summarizing vulnerabilities and highlighting which ones the PR adds or removes. + ## 2026-05-11 ### Fixed From c72fdf993861b9c776fe52c8b5c13875587295c4 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 12 May 2026 11:25:39 +0200 Subject: [PATCH 2/3] Runtime optimization --- .github/workflows/js-dependency-audit.yaml | 27 ++++++++++++++++++---- CHANGELOG.md | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/js-dependency-audit.yaml b/.github/workflows/js-dependency-audit.yaml index 9bebb3b..8acf50c 100644 --- a/.github/workflows/js-dependency-audit.yaml +++ b/.github/workflows/js-dependency-audit.yaml @@ -197,11 +197,6 @@ jobs: with: persist-credentials: false - - name: Audit PR head - env: - WORKING_DIRECTORY: ${{ inputs.working_directory }} - run: /tmp/audit.sh /tmp/audit-head.ndjson - - name: Checkout base branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -209,6 +204,28 @@ jobs: path: _base_audit_ref persist-credentials: false + # Cache package-manager download caches across the two audits and across + # workflow runs. The main win is Yarn Berry, where `yarn install --immutable` + # fetches every package in the lockfile from the registry โ€” caching cuts + # the second install (base ref) and any subsequent run with an unchanged + # lockfile from minutes down to seconds. + - name: Restore dependency caches + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + .yarn/cache + _base_audit_ref/.yarn/cache + ~/.yarn/berry/cache + ~/.local/share/pnpm/store + key: ${{ runner.os }}-js-audit-${{ hashFiles('**/yarn.lock', '**/package-lock.json', '**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-js-audit- + + - name: Audit PR head + env: + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: /tmp/audit.sh /tmp/audit-head.ndjson + - name: Audit base branch env: WORKING_DIRECTORY: ${{ inputs.working_directory }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c7bdef9..fcd38fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Instead this file uses a date-based structure. ### Added -- Add reusable workflow `js-dependency-audit.yaml`. It runs an audit (`npm`, `pnpm`, `yarn npm audit`, or `yarn audit`) on every JS project in a PR's head and base, then posts a sticky PR comment summarizing vulnerabilities and highlighting which ones the PR adds or removes. +- Add reusable workflow `js-dependency-audit.yaml`. It runs an audit (`npm`, `pnpm`, `yarn npm audit`, or `yarn audit`) on every JS project in a PR's head and base, then posts a sticky PR comment summarizing vulnerabilities and highlighting which ones the PR adds or removes. Caches package-manager download caches across runs to keep Yarn Berry installs fast. ## 2026-05-11 From 4d53d18e8e9520f278207eed0a3d540aeb6c9951 Mon Sep 17 00:00:00 2001 From: Marian Steinbach Date: Tue, 12 May 2026 12:41:12 +0200 Subject: [PATCH 3/3] Simplify workflow --- .github/workflows/js-dependency-audit.yaml | 34 +++++++--------------- CHANGELOG.md | 2 +- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/.github/workflows/js-dependency-audit.yaml b/.github/workflows/js-dependency-audit.yaml index 8acf50c..7b9b382 100644 --- a/.github/workflows/js-dependency-audit.yaml +++ b/.github/workflows/js-dependency-audit.yaml @@ -118,15 +118,18 @@ jobs: local pm="$2" local raw="$3" local err="$4" + # None of these commands need node_modules โ€” they all read from the + # lockfile and query the registry directly. Skipping install keeps + # large monorepo audits down from minutes to seconds. case "${pm}" in npm) (cd "${dir}" && npm audit --json) > "${raw}" 2> "${err}" || true ;; pnpm) - (cd "${dir}" && pnpm install --frozen-lockfile --ignore-scripts && pnpm audit --json) > "${raw}" 2> "${err}" || true + (cd "${dir}" && pnpm audit --json) > "${raw}" 2> "${err}" || true ;; yarn-berry) - (cd "${dir}" && yarn install --immutable && yarn npm audit --all --recursive --json) > "${raw}" 2> "${err}" || true + (cd "${dir}" && yarn npm audit --all --recursive --json) > "${raw}" 2> "${err}" || true ;; yarn-classic) (cd "${dir}" && yarn audit --json --groups dependencies,devDependencies) > "${raw}" 2> "${err}" || true @@ -204,23 +207,6 @@ jobs: path: _base_audit_ref persist-credentials: false - # Cache package-manager download caches across the two audits and across - # workflow runs. The main win is Yarn Berry, where `yarn install --immutable` - # fetches every package in the lockfile from the registry โ€” caching cuts - # the second install (base ref) and any subsequent run with an unchanged - # lockfile from minutes down to seconds. - - name: Restore dependency caches - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: | - .yarn/cache - _base_audit_ref/.yarn/cache - ~/.yarn/berry/cache - ~/.local/share/pnpm/store - key: ${{ runner.os }}-js-audit-${{ hashFiles('**/yarn.lock', '**/package-lock.json', '**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-js-audit- - - name: Audit PR head env: WORKING_DIRECTORY: ${{ inputs.working_directory }} @@ -482,12 +468,12 @@ jobs: - name: Delete previous comment if: steps.build_comment.outputs.has_findings == 'true' && steps.fc.outputs.comment-id != 0 - uses: winterjung/comment@fda92dbcb5e7e79cccd55ecb107a8a3d7802a469 # v1.1.0 continue-on-error: true - with: - type: delete - comment_id: ${{ steps.fc.outputs.comment-id }} - token: ${{ secrets.GITHUB_TOKEN }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENT_ID: ${{ steps.fc.outputs.comment-id }} + REPO: ${{ github.repository }} + run: gh api -X DELETE "repos/${REPO}/issues/comments/${COMMENT_ID}" - name: Create comment if: steps.build_comment.outputs.has_findings == 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd38fa..c7bdef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Instead this file uses a date-based structure. ### Added -- Add reusable workflow `js-dependency-audit.yaml`. It runs an audit (`npm`, `pnpm`, `yarn npm audit`, or `yarn audit`) on every JS project in a PR's head and base, then posts a sticky PR comment summarizing vulnerabilities and highlighting which ones the PR adds or removes. Caches package-manager download caches across runs to keep Yarn Berry installs fast. +- Add reusable workflow `js-dependency-audit.yaml`. It runs an audit (`npm`, `pnpm`, `yarn npm audit`, or `yarn audit`) on every JS project in a PR's head and base, then posts a sticky PR comment summarizing vulnerabilities and highlighting which ones the PR adds or removes. ## 2026-05-11