Skip to content
Closed
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
211 changes: 15 additions & 196 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ jobs:
run: |
python - <<'PY'
import os
import json
from pathlib import PurePosixPath
import subprocess

Expand Down Expand Up @@ -84,6 +83,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,186 +97,36 @@ jobs:
or path.as_posix() in {
".github/dependabot.yaml",
".github/dependabot.yml",
".github/workflows/dependency-review.yml",
"vendor/modules.txt",
}
)

changed = sorted(path.as_posix() for path in paths if is_dependency_input(path))
# A lockfile-only PR needs a snapshot of its changed ecosystem,
# not a repo-wide PipReport resolution of unrelated requirements.
# Keep the full scan for mixed/unsupported inputs and main pushes.
snapshot_detector = ""
if changed and all(path in {"pyproject.toml", "uv.lock"} for path in changed):
if "uv.lock" in changed:
snapshot_detector = "UvLock"
elif changed and all(PurePosixPath(path).name in {"package.json", "package-lock.json"} for path in changed):
locks = {path for path in changed if PurePosixPath(path).name == "package-lock.json"}
if locks:
workspace_patterns = []
root_lock_packages = {}
root_valid = True
if "package-lock.json" in locks:
def head_json(path):
content = subprocess.run(
["git", "show", f"{head}:{path}"],
check=True, capture_output=True,
).stdout
return json.loads(content)

try:
root = head_json("package.json")
workspaces = root.get("workspaces", [])
workspace_patterns = workspaces.get("packages", []) if isinstance(workspaces, dict) else workspaces
root_lock_packages = head_json("package-lock.json").get("packages", {})
root_valid = isinstance(workspace_patterns, list) and isinstance(root_lock_packages, dict)
except (subprocess.CalledProcessError, json.JSONDecodeError, AttributeError):
root_valid = False

def covered(manifest):
path = PurePosixPath(manifest)
if path.with_name("package-lock.json").as_posix() in locks:
return True
directory = path.parent.as_posix()
return (
"package-lock.json" in locks
and directory in root_lock_packages
and any(PurePosixPath(directory).match(pattern) for pattern in workspace_patterns)
)

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")
handle.write(f"snapshot_detector={snapshot_detector}\n")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
if changed:
handle.write("Dependency inputs changed:\n\n")
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

- name: Stage focused dependency inputs
if: >-
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.snapshot_detector != '' }}
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
SNAPSHOT_DETECTOR: ${{ steps.dependency-changes.outputs.snapshot_detector }}
SNAPSHOT_BASE_SOURCE: ${{ runner.temp }}/dependency-review-base
SNAPSHOT_HEAD_SOURCE: ${{ runner.temp }}/dependency-review-head
run: |
python - <<'PY'
import os
import json
from pathlib import Path
import subprocess

detector = os.environ["SNAPSHOT_DETECTOR"]
if detector == "UvLock":
locks = [Path("uv.lock")]
elif detector == "NpmLockfile3":
delta = subprocess.run(
["git", "diff", "--name-only", "-z", os.environ["BASE_SHA"], os.environ["HEAD_SHA"]],
check=True, capture_output=True,
).stdout
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")

for sha, directory in (
(os.environ["BASE_SHA"], os.environ["SNAPSHOT_BASE_SOURCE"]),
(os.environ["HEAD_SHA"], os.environ["SNAPSHOT_HEAD_SOURCE"]),
):
target = Path(directory)
target.mkdir(parents=True, exist_ok=True)
for lock in locks:
manifest = lock.with_name("pyproject.toml" if lock.name == "uv.lock" else "package.json")
sources = {lock, manifest}
if lock == Path("package-lock.json"):
root_result = subprocess.run(
["git", "show", f"{sha}:package.json"],
capture_output=True,
)
lock_result = subprocess.run(
["git", "show", f"{sha}:package-lock.json"],
capture_output=True,
)
if root_result.returncode == 0 and lock_result.returncode == 0:
root = json.loads(root_result.stdout)
workspaces = root.get("workspaces", [])
patterns = workspaces.get("packages", []) if isinstance(workspaces, dict) else workspaces
lock_data = json.loads(lock_result.stdout)
for name in lock_data.get("packages", {}):
if name and any(Path(name).match(pattern) for pattern in patterns):
sources.add(Path(name) / "package.json")
for source in sources:
result = subprocess.run(
["git", "show", f"{sha}:{source.as_posix()}"], capture_output=True,
)
if result.returncode == 0:
destination = target / source
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(result.stdout)
PY

- name: Submit focused base dependency snapshot
if: >-
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.snapshot_detector != '' }}
uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6
with:
snapshot-sha: ${{ github.event.pull_request.base.sha }}
snapshot-ref: ${{ format('refs/heads/{0}', github.event.pull_request.base.ref) }}
filePath: ${{ runner.temp }}/dependency-review-base
correlator: focused-lockfile-review
detectorArgs: UvLock=EnableIfDefaultOff,NpmLockfile3=EnableIfDefaultOff

- name: Submit focused dependency snapshot
if: >-
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.snapshot_detector != '' }}
uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6
with:
snapshot-sha: ${{ github.event.pull_request.head.sha }}
snapshot-ref: ${{ format('refs/heads/{0}', github.event.pull_request.head.ref) }}
filePath: ${{ runner.temp }}/dependency-review-head
correlator: focused-lockfile-review
detectorArgs: UvLock=EnableIfDefaultOff,NpmLockfile3=EnableIfDefaultOff

- name: Submit dependency snapshots
if: >-
${{ github.event_name == 'push' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
steps.dependency-changes.outputs.changed == 'true' &&
steps.dependency-changes.outputs.snapshot_detector == '') }}
# Keep scanning the full tree, but bound each pip report. On a report
# timeout component-detection falls back to declared source dependencies.
steps.dependency-changes.outputs.changed == 'true') }}
# Scan the same full tree on main and on dependency PRs so GitHub compares
# matching snapshot sets. Bound each pip report; on a timeout, component-
# detection falls back to declared source dependencies.
env:
PipReportFileLevelTimeoutSeconds: "120"
uses: advanced-security/component-detection-dependency-submission-action@b282c67b1008fde9b60da4bdfcd8849613e293b2 # v0.1.6
uses: advanced-security/component-detection-dependency-submission-action@a02aeda7a175ad4dd38a13c4d3ff2df9da8f2cf0 # v0.2.0
with:
snapshot-sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
snapshot-ref: ${{ github.event_name == 'pull_request' && format('refs/heads/{0}', github.event.pull_request.head.ref) || github.ref }}
Expand Down Expand Up @@ -352,8 +203,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 +214,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 +224,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
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

from __future__ import annotations

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


def record_dependency_review(
environment: dict[str, str],
destination: Path,
opener=urlopen,
) -> 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()
snapshot_warning = response.headers.get(
"x-github-dependency-graph-snapshot-warnings"
)
if snapshot_warning:
raise ValueError(
"Dependency graph reported missing snapshots; 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")

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