feat(ci): semantic package-lock.json diff as an upserted PR comment (#65206)

git diff on a lockfile is unreadable: npm reorders entries, rewrites
integrity hashes, and moves packages between nesting levels, so a
one-line package.json bump produces a thousand-line textual diff.

scripts/ci/lockfile_diff.py instead parses the `packages` map out of
both versions of every tracked package-lock.json (via `git show`),
reduces each to {install path: version}, and set-diffs the maps —
reorder/hash churn vanishes, leaving only actual version movement
(added / removed / updated, with nested dedup copies tracked
separately).

The lockfile-diff workflow posts the result as a Markdown table in a
PR comment gated behind a hidden marker: subsequent pushes PATCH the
existing comment instead of stacking new ones, and a push that reverts
all lockfile changes updates the comment to say so. Advisory only —
never fails on findings; fork PRs (read-only token) degrade to a
warning.

Wired through the ci.yml orchestrator with a new npm_lock lane in
classify_changes.py (fails open on .github/ changes per the existing
contract).
This commit is contained in:
ethernet 2026-07-15 23:18:15 -04:00 committed by GitHub
parent f8b6d381e2
commit f8ddf4fd86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 408 additions and 2 deletions

View file

@ -14,6 +14,7 @@ Lanes:
* ``site`` Docusaurus + generated skill docs.
* ``scan`` supply-chain scan (Python files, .pth, setup hooks).
* ``deps`` pyproject.toml dependency bounds check.
* ``npm_lock`` semantic package-lock.json diff PR comment.
* ``mcp_catalog`` bundled MCP catalog / installer review.
Docker is not a lane it builds on push-to-main and release only,
@ -98,6 +99,7 @@ def classify(files: list[str]) -> dict[str, bool]:
"site": any(f.startswith(_SITE) for f in files),
"scan": any(_is_scan(f) for f in files),
"deps": any(f == "pyproject.toml" for f in files),
"npm_lock": any(f.split("/")[-1] == "package-lock.json" for f in files),
"mcp_catalog": any(_is_mcp_catalog(f) for f in files),
"ci_review": any(_is_ci_review(f) for f in files),
}
@ -108,6 +110,7 @@ def classify(files: list[str]) -> dict[str, bool]:
ret["site"] = True
ret["scan"] = True
ret["deps"] = True
ret["npm_lock"] = True
ret["ci_review"] = True
# explicitly skip mcp catalog here. it's not needed unless those files are modified.

180
scripts/ci/lockfile_diff.py Normal file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Semantic diff of npm ``package-lock.json`` files for PR comments.
``git diff`` on a lockfile is unreadable: npm reorders entries, rewrites
integrity hashes, and moves packages between nesting levels, so a one-line
``package.json`` bump can produce a thousand-line textual diff. This script
ignores the text entirely it parses the ``packages`` map out of both
versions of each lockfile (lockfileVersion 2/3), reduces each to
``{install path: version}``, and set-diffs the two dicts. Reordering and
hash churn vanish; what's left is the actual dependency change.
Usage (from a checkout that still has the base ref available):
python scripts/ci/lockfile_diff.py --base <ref> --head <ref> \
--output diff.md [--repo-root .]
Reads every ``package-lock.json`` tracked at either ref (top-level and
nested the repo has several), diffs each, and writes a Markdown report
to ``--output``. Exits 0 always; an empty report file means "no version
changes" (the caller uses that to decide whether to post/update the PR
comment). The report embeds ``COMMENT_MARKER`` so the workflow can find
and update its own previous comment instead of stacking new ones.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
# Hidden marker used to locate the bot's previous comment for in-place update.
COMMENT_MARKER = "<!-- hermes-lockfile-diff -->"
def parse_lockfile(text: str) -> dict[str, str]:
"""Reduce lockfile JSON to ``{install path: version}``.
Keys are the ``packages`` map's keys (e.g. ``node_modules/react`` or
``node_modules/foo/node_modules/react``), so the same package deduped
at two versions shows up as two distinct entries. The root entry
(``""``, the workspace itself) is skipped, as are versionless link
entries.
"""
data = json.loads(text)
out: dict[str, str] = {}
for path, meta in data.get("packages", {}).items():
if not path:
continue # root project entry, not a dependency
version = meta.get("version")
if version:
out[path] = version
return out
def diff_locks(base: dict[str, str], head: dict[str, str]) -> dict[str, list]:
"""Set-diff two ``{path: version}`` maps.
Returns ``added`` / ``removed`` as ``[(path, version)]`` and
``updated`` as ``[(path, base_version, head_version)]``, each sorted
by path.
"""
added = sorted((p, v) for p, v in head.items() if p not in base)
removed = sorted((p, v) for p, v in base.items() if p not in head)
updated = sorted(
(p, base[p], head[p]) for p in base.keys() & head.keys() if base[p] != head[p]
)
return {"added": added, "removed": removed, "updated": updated}
def _display_name(path: str) -> str:
"""``node_modules/foo/node_modules/@scope/bar`` → ``@scope/bar (nested under foo)``."""
parts = path.split("node_modules/")
name = parts[-1].rstrip("/")
if len(parts) > 2:
parents = "".join(p.rstrip("/") for p in parts[1:-1])
return f"{name} *(nested under {parents})*"
return name
def render_markdown(diffs: dict[str, dict[str, list]]) -> str:
"""Render per-lockfile diffs as a Markdown PR comment body.
``diffs`` maps lockfile repo-path the output of :func:`diff_locks`.
Lockfiles with no version changes are omitted. Returns ``""`` when
nothing changed anywhere (caller skips commenting entirely).
"""
sections = []
total = 0
for lockfile, d in sorted(diffs.items()):
added, removed, updated = d["added"], d["removed"], d["updated"]
n = len(added) + len(removed) + len(updated)
if n == 0:
continue
total += n
lines = [f"### `{lockfile}`", ""]
lines.append("| Package | Before | After |")
lines.append("| --- | --- | --- |")
for path, old, new in updated:
lines.append(f"| {_display_name(path)} | `{old}` | `{new}` |")
for path, version in added:
lines.append(f"| {_display_name(path)} | — | `{version}` |")
for path, version in removed:
lines.append(f"| {_display_name(path)} | `{version}` | — |")
sections.append("\n".join(lines))
if not sections:
return ""
header = (
f"{COMMENT_MARKER}\n"
f"## ⚠️ `package-lock.json` changes ({total} package"
f"{'s' if total != 1 else ''})\n\n"
"This PR changes locked npm dependency versions."
)
return header + "\n" + "\n\n".join(sections) + "\n"
def _git_show(ref: str, path: str, repo_root: str) -> str | None:
"""Contents of ``path`` at ``ref``, or None if it doesn't exist there."""
proc = subprocess.run(
["git", "show", f"{ref}:{path}"],
capture_output=True,
text=True,
cwd=repo_root,
)
return proc.stdout if proc.returncode == 0 else None
def _tracked_lockfiles(ref: str, repo_root: str) -> set[str]:
proc = subprocess.run(
["git", "ls-tree", "-r", "--name-only", ref],
capture_output=True,
text=True,
cwd=repo_root,
check=True,
)
return {
line
for line in proc.stdout.splitlines()
if line.split("/")[-1] == "package-lock.json"
}
def diff_refs(base: str, head: str, repo_root: str = ".") -> dict[str, dict[str, list]]:
"""Diff every package-lock.json tracked at either ref."""
lockfiles = _tracked_lockfiles(base, repo_root) | _tracked_lockfiles(head, repo_root)
diffs = {}
for path in sorted(lockfiles):
base_text = _git_show(base, path, repo_root)
head_text = _git_show(head, path, repo_root)
base_map = parse_lockfile(base_text) if base_text else {}
head_map = parse_lockfile(head_text) if head_text else {}
diffs[path] = diff_locks(base_map, head_map)
return diffs
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--base", required=True, help="base git ref (merge base)")
ap.add_argument("--head", required=True, help="head git ref")
ap.add_argument("--output", required=True, help="markdown output path")
ap.add_argument("--repo-root", default=".", help="repository root")
args = ap.parse_args()
diffs = diff_refs(args.base, args.head, args.repo_root)
markdown = render_markdown(diffs)
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(markdown)
if markdown:
changed = sum(len(v) for d in diffs.values() for v in d.values())
print(f"{changed} package version change(s) — report written to {args.output}")
else:
print("No package version changes.")
return 0
if __name__ == "__main__":
sys.exit(main())