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
64 changes: 11 additions & 53 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ jobs:
}

def is_dependency_input(path: PurePosixPath) -> bool:
# Workflow code changes are covered by CI tests; they do not
# change a dependency graph and should not force a comparison.
name = path.name
return (
name in exact_names
Expand All @@ -96,7 +98,6 @@ jobs:
or path.as_posix() in {
".github/dependabot.yaml",
".github/dependabot.yml",
".github/workflows/dependency-review.yml",
"vendor/modules.txt",
}
)
Expand Down Expand Up @@ -145,9 +146,6 @@ jobs:

if root_valid and all(covered(path) for path in changed if PurePosixPath(path).name == "package.json"):
snapshot_detector = "NpmLockfile3"
elif changed == [".github/workflows/dependency-review.yml"]:
# Exercise both focused detectors on the workflow change itself.
snapshot_detector = "UvLock,NpmLockfile3"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle:
handle.write(f"changed={'true' if changed else 'false'}\n")
handle.write(f"count={len(changed)}\n")
Expand All @@ -158,8 +156,8 @@ jobs:
handle.writelines(f"- `{path}`\n" for path in changed)
else:
handle.write(
"No dependency manifests, lockfiles, or dependency-review "
"configuration changed between the exact base and head trees.\n"
"No dependency inputs changed between the exact base "
"and head trees.\n"
)
PY

Expand Down Expand Up @@ -193,15 +191,7 @@ jobs:
locks = [Path(raw.decode("utf-8")) for raw in delta.split(b"\0") if raw
and Path(raw.decode("utf-8")).name == "package-lock.json"]
else:
# The workflow-only PR exercises both lockfile ecosystems.
locks = {Path("uv.lock")}
for sha in (os.environ["BASE_SHA"], os.environ["HEAD_SHA"]):
tree = subprocess.run(
["git", "ls-tree", "-r", "--name-only", "-z", sha],
check=True, capture_output=True,
).stdout
locks.update(Path(raw.decode("utf-8")) for raw in tree.split(b"\0") if raw
and Path(raw.decode("utf-8")).name == "package-lock.json")
raise ValueError(f"Unexpected focused snapshot detector: {detector}")

for sha, directory in (
(os.environ["BASE_SHA"], os.environ["SNAPSHOT_BASE_SOURCE"]),
Expand Down Expand Up @@ -352,8 +342,9 @@ jobs:
pkg:npm/@img/sharp-win32-x64
comment-summary-in-pr: always

# Preserve the graph comparison even when a composite license is rejected.
# An empty/missing action output stays empty; it is not a clean result.
# Fetch the same dependency diff from GitHub's API instead of expanding
# dependency-changes in an Actions expression. Large grouped updates can
# exceed the runner's template-object limit before the record step starts.
- name: Record dependency review evidence
if: >-
${{ always() && github.event_name == 'pull_request' &&
Expand All @@ -362,41 +353,8 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REVIEW_OUTCOME: ${{ steps.review.outcome }}
DEPENDENCY_CHANGES: ${{ steps.review.outputs.dependency-changes }}
INVALID_LICENSE_CHANGES: ${{ steps.review.outputs.invalid-license-changes }}
VULNERABLE_CHANGES: ${{ steps.review.outputs.vulnerable-changes }}
DENIED_CHANGES: ${{ steps.review.outputs.denied-changes }}
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path

evidence = {
"schema_version": 1,
"base_sha": os.environ["BASE_SHA"],
"head_sha": os.environ["HEAD_SHA"],
"run_id": os.environ["GITHUB_RUN_ID"],
"run_attempt": os.environ["GITHUB_RUN_ATTEMPT"],
"review_outcome": os.environ["REVIEW_OUTCOME"],
# Retain the exact raw JSON strings, including duplicate entries,
# full SPDX expressions, and empty outputs from incomplete reviews.
"raw_outputs": {
name: os.environ[variable]
for name, variable in {
"dependency-changes": "DEPENDENCY_CHANGES",
"invalid-license-changes": "INVALID_LICENSE_CHANGES",
"vulnerable-changes": "VULNERABLE_CHANGES",
"denied-changes": "DENIED_CHANGES",
}.items()
},
}
destination = Path("dependency-review-evidence")
destination.mkdir(exist_ok=True)
(destination / "review.json").write_text(
json.dumps(evidence, indent=2) + "\n", encoding="utf-8"
)
PY
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/ci/record_dependency_review.py

- name: Upload dependency review evidence
if: >-
Expand All @@ -405,7 +363,7 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dependency-review-${{ github.event.pull_request.head.sha }}-${{ github.run_attempt }}
path: dependency-review-evidence/review.json
path: dependency-review-evidence/
if-no-files-found: error
retention-days: 30

Expand Down
153 changes: 153 additions & 0 deletions scripts/ci/record_dependency_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Save the complete GitHub dependency diff without Actions output expansion."""

from __future__ import annotations

import hashlib
import json
import os
import re
import subprocess
from pathlib import Path
from urllib.request import Request, urlopen


def _changed_files(base: str, head: str) -> set[str]:
output = subprocess.run(
["git", "diff", "--name-only", "--no-renames", "-z", base, head],
check=True,
capture_output=True,
).stdout
return {os.fsdecode(path) for path in output.split(b"\0") if path}


def _npm_lock_components(sha: str, manifest: str) -> set[tuple[str, str, str]]:
result = subprocess.run(["git", "show", f"{sha}:{manifest}"], capture_output=True)
if result.returncode:
# A newly added or deleted lockfile is absent on one side of the diff.
return set()
lock = json.loads(result.stdout)
packages = lock.get("packages")
if not isinstance(packages, dict):
raise ValueError(f"Invalid npm lockfile in {sha}:{manifest}")
components = set()
for location, package in packages.items():
if not isinstance(location, str) or not isinstance(package, dict):
raise ValueError(f"Invalid npm lockfile entry in {sha}:{manifest}")
name = package.get("name")
if not name and "node_modules/" in location:
name = location.rsplit("node_modules/", 1)[1]
version = package.get("version")
if isinstance(name, str) and isinstance(version, str):
scope = "development" if package.get("dev") is True else "runtime"
components.add((name, version, scope))
return components


def _validate_changes(
changes: list[dict], base: str, head: str, changed_files: set[str]
) -> None:
npm_locks: dict[tuple[str, str], set[tuple[str, str, str]]] = {}
for change in changes:
manifest = change.get("manifest")
if not isinstance(manifest, str) or manifest not in changed_files:
raise ValueError(
"Dependency graph includes a change in an unchanged manifest: "
f"{manifest!r}"
)
if change.get("ecosystem") != "npm" or not manifest.endswith(
"package-lock.json"
):
continue
name, version, scope = (
change.get("name"),
change.get("version"),
change.get("scope"),
)
if not all(isinstance(value, str) for value in (name, version, scope)):
raise ValueError("npm dependency change lacks name, version, or scope")
for sha in (base, head):
if (sha, manifest) not in npm_locks:
npm_locks[(sha, manifest)] = _npm_lock_components(sha, manifest)
component = (name, version, scope)
on_base = component in npm_locks[(base, manifest)]
on_head = component in npm_locks[(head, manifest)]
kind = change.get("change_type")
if kind == "added" and on_head and not on_base:
continue
if kind == "removed" and on_base and not on_head:
continue
raise ValueError(
"npm dependency change does not match lockfile version/scope: "
f"{manifest} {name}@{version} {scope} {kind}"
)


def record_dependency_review(
environment: dict[str, str],
destination: Path,
opener=urlopen,
changed_files: set[str] | None = None,
) -> dict:
base = environment["BASE_SHA"]
head = environment["HEAD_SHA"]
if not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)):
raise ValueError("Dependency review requires exact commit SHAs")

repository = environment["GITHUB_REPOSITORY"]
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository):
raise ValueError("Invalid GitHub repository")

api_root = environment.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
url = f"{api_root}/repos/{repository}/dependency-graph/compare/{base}...{head}"
request = Request(
url,
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {environment['GITHUB_TOKEN']}",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with opener(request, timeout=90) as response:
raw_diff = response.read()
Comment thread
groupthinking marked this conversation as resolved.
snapshot_warning = response.headers.get(
"x-github-dependency-graph-snapshot-warnings"
)
if snapshot_warning:
raise ValueError(
"Dependency graph reported snapshot warnings; refusing to save "
"an incomplete comparison as review evidence"
)
changes = json.loads(raw_diff)
if not isinstance(changes, list) or not all(
isinstance(item, dict) for item in changes
):
raise ValueError("Dependency diff API did not return a complete change list")
if changes:
_validate_changes(
changes,
base,
head,
changed_files if changed_files is not None else _changed_files(base, head),
)

destination.mkdir(parents=True, exist_ok=True)
(destination / "dependency-diff.json").write_bytes(raw_diff)
evidence = {
"schema_version": 2,
"base_sha": base,
"head_sha": head,
"run_id": environment["GITHUB_RUN_ID"],
"run_attempt": environment["GITHUB_RUN_ATTEMPT"],
"review_outcome": environment["REVIEW_OUTCOME"],
"dependency_diff_source": url,
"dependency_diff_count": len(changes),
"dependency_diff_sha256": hashlib.sha256(raw_diff).hexdigest(),
"dependency_diff_file": "dependency-diff.json",
}
(destination / "review.json").write_text(json.dumps(evidence, indent=2) + "\n")
return evidence


if __name__ == "__main__":
result = record_dependency_review(os.environ, Path("dependency-review-evidence"))
print(f"Saved {result['dependency_diff_count']} dependency changes")
Loading
Loading