Skip to content

Commit 0327f65

Browse files
committed
verify-action-build: per-ecosystem exemption list for lock-file check
Some upstream projects are libraries or CLI tools that also ship a GitHub Action wrapper — e.g. pypa/cibuildwheel (Python library on PyPI), dart-lang/setup-dart (Dart package on pub.dev). These repos legitimately don't commit a lock file because doing so would over-constrain their library consumers. Hard-failing on those would block otherwise-valid Dependabot bumps. Add lock_file_exemptions.yml at the repo root listing per-(org/repo) per-ecosystem exemptions. analyze_lock_files consults this file and reports an exempted manifest as a dim skipped entry (⊘) instead of a red failure. Exemptions are scoped per-ecosystem, so an action exempted for one ecosystem is still checked for others it declares (e.g. dart-lang/setup-dart's node side must still have package-lock.json). Preseeded entries: pypa/cibuildwheel → python (library on PyPI) dart-lang/setup-dart → dart (Dart library convention) Lookups lowercase org/repo so case mismatches don't silently miss.
1 parent c2488ba commit 0327f65

4 files changed

Lines changed: 195 additions & 1 deletion

File tree

lock_file_exemptions.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# lock_file_exemptions.yml
2+
#
3+
# Per-(org/repo) per-ecosystem exemptions from the "every dependency manifest
4+
# must have a matching lock file" check enforced by verify-action-build.
5+
#
6+
# When is an exemption appropriate? Only when the upstream repo is primarily
7+
# a library or CLI tool that also ships a GitHub Action wrapper, and that
8+
# project's ecosystem convention is to *not* commit a lock file. Typical
9+
# examples:
10+
# * Python libraries published to PyPI — lock files would pin transitive
11+
# versions that downstream library consumers shouldn't be forced into.
12+
# * Dart packages published to pub.dev — pubspec.lock is deliberately
13+
# .gitignore'd for libraries.
14+
#
15+
# Exemptions are scoped per-ecosystem, so a project exempted for Python is
16+
# still checked for its Node side (if it has one).
17+
#
18+
# Ecosystem keys (must match one of the names in analyze_lock_files):
19+
# node python deno dart ruby go rust
20+
#
21+
# Format:
22+
# org/repo:
23+
# - ecosystem1
24+
# - ecosystem2
25+
26+
pypa/cibuildwheel:
27+
# cibuildwheel is a Python library published to PyPI. Its pyproject.toml
28+
# declares runtime deps for users who `pip install cibuildwheel`; committing
29+
# a lock file would over-constrain library consumers.
30+
- python
31+
32+
dart-lang/setup-dart:
33+
# The repo is a Dart package (pubspec.yaml) shipped as an action. Dart
34+
# convention for library packages is to not commit pubspec.lock. The node
35+
# side of this action is still checked (package.json → package-lock.json).
36+
- dart

utils/tests/verify_action_build/test_security.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,3 +658,98 @@ def test_multiple_ecosystems_all_missing_aggregates_errors(self):
658658
}
659659
errors = self._run(files)
660660
assert len(errors) == 3
661+
662+
# --- Exemptions -------------------------------------------------------
663+
664+
def _run_with_exemptions(
665+
self,
666+
files: dict,
667+
exemptions: dict,
668+
org: str = "org",
669+
repo: str = "repo",
670+
) -> list[str]:
671+
def fetch(o, r, commit, path):
672+
return files.get(path)
673+
674+
with mock.patch(
675+
"verify_action_build.security.fetch_file_from_github",
676+
side_effect=fetch,
677+
):
678+
return analyze_lock_files(org, repo, "a" * 40, exemptions=exemptions)
679+
680+
def test_exemption_skips_matching_ecosystem(self):
681+
# pyproject.toml with deps but no lock — normally fails; exempted here.
682+
files = {
683+
"pyproject.toml": '[project]\ndependencies = ["requests"]\n',
684+
}
685+
errors = self._run_with_exemptions(
686+
files, {("org", "repo"): {"python"}},
687+
)
688+
assert errors == []
689+
690+
def test_exemption_does_not_skip_other_ecosystems(self):
691+
# Exempt only python; node still fails.
692+
files = {
693+
"pyproject.toml": '[project]\ndependencies = ["requests"]\n',
694+
"package.json": "{}",
695+
}
696+
errors = self._run_with_exemptions(
697+
files, {("org", "repo"): {"python"}},
698+
)
699+
assert len(errors) == 1
700+
assert "package.json" in errors[0]
701+
702+
def test_exemption_case_insensitive(self):
703+
# Look-up key lowercases org/repo, so an exemption entry written as
704+
# "Pypa/cibuildwheel" matches a run on "pypa/cibuildwheel".
705+
files = {"pyproject.toml": '[project]\ndependencies = ["a"]\n'}
706+
errors = self._run_with_exemptions(
707+
files, {("pypa", "cibuildwheel"): {"python"}},
708+
org="Pypa", repo="CIBuildWheel",
709+
)
710+
assert errors == []
711+
712+
def test_exemption_for_different_repo_does_not_apply(self):
713+
files = {"pyproject.toml": '[project]\ndependencies = ["a"]\n'}
714+
errors = self._run_with_exemptions(
715+
files, {("other", "project"): {"python"}},
716+
)
717+
assert len(errors) == 1
718+
719+
# --- Exemption file parser -------------------------------------------
720+
721+
def test_exemption_file_parses(self, tmp_path):
722+
from verify_action_build.security import _load_lock_file_exemptions
723+
724+
yml = tmp_path / "lock_file_exemptions.yml"
725+
yml.write_text(
726+
"# comment\n"
727+
"pypa/cibuildwheel:\n"
728+
" - python\n"
729+
"\n"
730+
"dart-lang/setup-dart:\n"
731+
" - dart # trailing comment\n"
732+
)
733+
result = _load_lock_file_exemptions(yml)
734+
assert result == {
735+
("pypa", "cibuildwheel"): {"python"},
736+
("dart-lang", "setup-dart"): {"dart"},
737+
}
738+
739+
def test_exemption_file_missing_returns_empty(self, tmp_path):
740+
from verify_action_build.security import _load_lock_file_exemptions
741+
742+
result = _load_lock_file_exemptions(tmp_path / "does-not-exist.yml")
743+
assert result == {}
744+
745+
def test_exemption_file_multiple_ecosystems_per_repo(self, tmp_path):
746+
from verify_action_build.security import _load_lock_file_exemptions
747+
748+
yml = tmp_path / "lock_file_exemptions.yml"
749+
yml.write_text(
750+
"some/multiecosystem-repo:\n"
751+
" - python\n"
752+
" - dart\n"
753+
)
754+
result = _load_lock_file_exemptions(yml)
755+
assert result[("some", "multiecosystem-repo")] == {"python", "dart"}

utils/verify_action_build/security.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import os
2323
import re
2424
from dataclasses import dataclass
25+
from pathlib import Path
2526
from typing import Iterator
2627

2728
import requests
@@ -40,6 +41,53 @@
4041
# Orgs we trust to the point of not descending into their nested action graph.
4142
TRUSTED_ORGS = {"actions", "github"}
4243

44+
# Exemptions file for the lock-file-presence check. Path matches the
45+
# convention used by approved_actions.ACTIONS_YML.
46+
LOCK_FILE_EXEMPTIONS_YML = (
47+
Path(__file__).resolve().parent.parent.parent / "lock_file_exemptions.yml"
48+
)
49+
50+
51+
def _load_lock_file_exemptions(
52+
path: Path = LOCK_FILE_EXEMPTIONS_YML,
53+
) -> dict[tuple[str, str], set[str]]:
54+
"""Parse lock_file_exemptions.yml into {(org, repo): {ecosystems}}.
55+
56+
Uses a minimal line-based parser rather than PyYAML to keep the dependency
57+
surface small (the rest of this project also avoids pulling in yaml).
58+
Supported subset:
59+
org/repo:
60+
- ecosystem1
61+
- ecosystem2
62+
Comments (``#``) and blank lines are ignored. Keys are lowercased so
63+
lookups are case-insensitive (``Pypa/cibuildwheel`` == ``pypa/cibuildwheel``).
64+
"""
65+
result: dict[tuple[str, str], set[str]] = {}
66+
if not path.exists():
67+
return result
68+
69+
current: tuple[str, str] | None = None
70+
for raw in path.read_text().splitlines():
71+
line = raw.split("#", 1)[0].rstrip()
72+
if not line.strip():
73+
continue
74+
if not line[0].isspace() and line.endswith(":"):
75+
orgrepo = line[:-1].strip().strip("'\"")
76+
if "/" in orgrepo:
77+
org, repo = orgrepo.split("/", 1)
78+
current = (org.lower(), repo.lower())
79+
result.setdefault(current, set())
80+
else:
81+
current = None
82+
continue
83+
if current is not None:
84+
stripped = line.lstrip()
85+
if stripped.startswith("- "):
86+
ecosystem = stripped[2:].strip().strip("'\"")
87+
if ecosystem:
88+
result[current].add(ecosystem)
89+
return result
90+
4391

4492
@dataclass
4593
class VisitedAction:
@@ -559,6 +607,7 @@ def analyze_scripts(
559607

560608
def analyze_lock_files(
561609
org: str, repo: str, commit_hash: str, sub_path: str = "",
610+
exemptions: dict[tuple[str, str], set[str]] | None = None,
562611
) -> list[str]:
563612
"""Verify each detected dependency manifest has a matching lock file.
564613
@@ -574,8 +623,17 @@ def analyze_lock_files(
574623
(e.g. a bare pyproject.toml with only tool config, a Rust library crate
575624
that conventionally doesn't commit Cargo.lock) are reported as skipped.
576625
626+
``exemptions`` maps ``(org, repo)`` to a set of ecosystem names where a
627+
missing lock file is tolerated — for library-first projects (cibuildwheel,
628+
setup-dart) that don't commit lock files per their ecosystem convention.
629+
Defaults to the contents of ``lock_file_exemptions.yml`` at the repo root.
630+
577631
Returns a list of error strings (empty = pass).
578632
"""
633+
if exemptions is None:
634+
exemptions = _load_lock_file_exemptions()
635+
exempted_ecosystems = exemptions.get((org.lower(), repo.lower()), set())
636+
579637
errors: list[str] = []
580638
header_shown = False
581639

@@ -684,6 +742,11 @@ def _find(name: str) -> tuple[str, str] | None:
684742
console.print(
685743
f" [green]✓[/green] {ecosystem}: {manifest_link}{lock_link}"
686744
)
745+
elif ecosystem in exempted_ecosystems:
746+
console.print(
747+
f" [dim]⊘[/dim] {ecosystem}: {manifest_link} has no matching lock file "
748+
f"— exempted in lock_file_exemptions.yml (library-first project)"
749+
)
687750
else:
688751
console.print(
689752
f" [red]✗[/red] {ecosystem}: {manifest_link} has no matching lock file "

utils/verify_action_build/verification.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ def verify_single_action(
287287
else:
288288
checks_performed.append((
289289
"Lock file presence", "pass",
290-
"all detected manifests have lock files",
290+
"all detected manifests have lock files (or are exempted)",
291291
))
292292

293293
if not is_js_action:

0 commit comments

Comments
 (0)