From c2488ba3c00cc27b75b86aae54c069349229d361 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 24 Apr 2026 19:44:20 +0200 Subject: [PATCH 1/2] verify-action-build: require a lock file for every dependency manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every action type — node, python, deno, dart, ruby, go, rust — now fails verification if a dependency manifest (package.json, pyproject.toml, go.mod, ...) is found without a matching lock file. Without a lock file, a rebuild of the action pulls whatever transitive versions are latest at build time, which makes the dist/ we verify non-reproducible. The new analyze_lock_files check runs for every action type (not just composite/docker, where analyze_dependency_pinning already runs), since JS actions are the primary case where a missing lock file breaks our rebuild comparison. Recognised manifest-to-lock mappings: package.json -> package-lock.json / yarn.lock / pnpm-lock.yaml / bun.lock pyproject.toml -> uv.lock / poetry.lock / pdm.lock / requirements.txt Pipfile -> Pipfile.lock deno.json(c) -> deno.lock pubspec.yaml -> pubspec.lock Gemfile -> Gemfile.lock go.mod -> go.sum Cargo.toml -> Cargo.lock Heuristics to avoid false positives: - pyproject.toml with only tool config (ruff/black/mypy) is skipped. - go.mod with no require directives is skipped. - Rust library crates ([lib] without [[bin]]) skip the Cargo.lock requirement per Cargo convention; binary crates and workspaces don't. - Sub-path manifests fall back to repo-root manifests when absent. --- .../verify_action_build/test_security.py | 217 ++++++++++++++++++ utils/verify_action_build/security.py | 140 +++++++++++ utils/verify_action_build/verification.py | 25 +- 3 files changed, 381 insertions(+), 1 deletion(-) diff --git a/utils/tests/verify_action_build/test_security.py b/utils/tests/verify_action_build/test_security.py index b02d9888..652ac1ab 100644 --- a/utils/tests/verify_action_build/test_security.py +++ b/utils/tests/verify_action_build/test_security.py @@ -22,6 +22,7 @@ analyze_binary_downloads, analyze_binary_downloads_recursive, analyze_dockerfile, + analyze_lock_files, analyze_scripts, analyze_action_metadata, analyze_repo_metadata, @@ -441,3 +442,219 @@ def fetch(org, repo, commit, path): with mock.patch("verify_action_build.security.fetch_file_from_github", side_effect=fetch): warnings = analyze_repo_metadata("actions", "checkout", "a" * 40) assert len(warnings) == 0 + + +class TestAnalyzeLockFiles: + def _run(self, files: dict, sub_path: str = "") -> list[str]: + def fetch(org, repo, commit, path): + return files.get(path) + + with mock.patch( + "verify_action_build.security.fetch_file_from_github", + side_effect=fetch, + ): + return analyze_lock_files("org", "repo", "a" * 40, sub_path=sub_path) + + # --- Node -------------------------------------------------------------- + + def test_node_package_json_with_package_lock_passes(self): + files = { + "package.json": '{"name":"x","dependencies":{"a":"1.0.0"}}', + "package-lock.json": "{}", + } + assert self._run(files) == [] + + def test_node_package_json_with_yarn_lock_passes(self): + files = {"package.json": "{}", "yarn.lock": ""} + assert self._run(files) == [] + + def test_node_package_json_with_pnpm_lock_passes(self): + files = {"package.json": "{}", "pnpm-lock.yaml": ""} + assert self._run(files) == [] + + def test_node_package_json_with_bun_lock_passes(self): + files = {"package.json": "{}", "bun.lock": ""} + assert self._run(files) == [] + + def test_node_package_json_without_lock_fails(self): + errors = self._run({"package.json": '{"name":"x"}'}) + assert len(errors) == 1 + assert "package.json" in errors[0] + assert "package-lock.json" in errors[0] + + # --- Python ------------------------------------------------------------ + + def test_python_pyproject_with_uv_lock_passes(self): + files = { + "pyproject.toml": '[project]\nname="x"\ndependencies = ["requests"]\n', + "uv.lock": "", + } + assert self._run(files) == [] + + def test_python_pyproject_with_poetry_lock_passes(self): + files = { + "pyproject.toml": "[tool.poetry.dependencies]\npython = '^3.11'\n", + "poetry.lock": "", + } + assert self._run(files) == [] + + def test_python_pyproject_with_requirements_txt_passes(self): + files = { + "pyproject.toml": '[project]\ndependencies = ["requests"]\n', + "requirements.txt": "requests==2.31.0\n", + } + assert self._run(files) == [] + + def test_python_pyproject_without_lock_fails(self): + files = { + "pyproject.toml": '[project]\nname="x"\ndependencies = ["requests"]\n', + } + errors = self._run(files) + assert len(errors) == 1 + assert "pyproject.toml" in errors[0] + + def test_python_pyproject_without_deps_skipped(self): + # Bare config (ruff, black, mypy settings) doesn't need a lock. + files = { + "pyproject.toml": "[tool.ruff]\nline-length = 100\n", + } + assert self._run(files) == [] + + def test_python_pipfile_with_lock_passes(self): + files = {"Pipfile": "[packages]\nrequests = '*'\n", "Pipfile.lock": "{}"} + assert self._run(files) == [] + + def test_python_pipfile_without_lock_fails(self): + errors = self._run({"Pipfile": "[packages]\nrequests = '*'\n"}) + assert len(errors) == 1 + assert "Pipfile" in errors[0] + + # --- Deno -------------------------------------------------------------- + + def test_deno_with_lock_passes(self): + files = {"deno.json": '{"imports":{}}', "deno.lock": "{}"} + assert self._run(files) == [] + + def test_deno_jsonc_without_lock_fails(self): + errors = self._run({"deno.jsonc": "{}"}) + assert len(errors) == 1 + assert "deno.jsonc" in errors[0] + + # --- Dart -------------------------------------------------------------- + + def test_dart_with_pubspec_lock_passes(self): + files = {"pubspec.yaml": "name: x\n", "pubspec.lock": ""} + assert self._run(files) == [] + + def test_dart_without_lock_fails(self): + errors = self._run({"pubspec.yaml": "name: x\n"}) + assert len(errors) == 1 + assert "pubspec.yaml" in errors[0] + + # --- Ruby -------------------------------------------------------------- + + def test_ruby_with_gemfile_lock_passes(self): + files = {"Gemfile": "gem 'rails'\n", "Gemfile.lock": ""} + assert self._run(files) == [] + + def test_ruby_without_lock_fails(self): + errors = self._run({"Gemfile": "gem 'rails'\n"}) + assert len(errors) == 1 + + # --- Go ---------------------------------------------------------------- + + def test_go_with_sum_passes(self): + files = { + "go.mod": "module x\n\nrequire github.com/a/b v1.2.3\n", + "go.sum": "github.com/a/b v1.2.3 h1:...\n", + } + assert self._run(files) == [] + + def test_go_without_sum_fails(self): + files = {"go.mod": "module x\n\nrequire github.com/a/b v1.2.3\n"} + errors = self._run(files) + assert len(errors) == 1 + assert "go.mod" in errors[0] + + def test_go_without_requires_skipped(self): + # go.mod with no external deps doesn't need go.sum. + assert self._run({"go.mod": "module x\n\ngo 1.21\n"}) == [] + + # --- Rust -------------------------------------------------------------- + + def test_rust_binary_without_lock_fails(self): + # Default binary crate — Cargo.lock expected. + files = {"Cargo.toml": '[package]\nname = "x"\n'} + errors = self._run(files) + assert len(errors) == 1 + + def test_rust_binary_with_lock_passes(self): + files = { + "Cargo.toml": '[package]\nname = "x"\n', + "Cargo.lock": "", + } + assert self._run(files) == [] + + def test_rust_library_without_lock_skipped(self): + # [lib] without [[bin]] — library crate, Cargo.lock not conventionally committed. + files = { + "Cargo.toml": '[package]\nname = "x"\n\n[lib]\nname = "x"\n', + } + assert self._run(files) == [] + + def test_rust_library_with_bin_still_requires_lock(self): + files = { + "Cargo.toml": ( + '[package]\nname = "x"\n' + '[lib]\nname = "x"\n' + '[[bin]]\nname = "x-cli"\n' + ), + } + errors = self._run(files) + assert len(errors) == 1 + + def test_rust_workspace_requires_lock(self): + files = {"Cargo.toml": '[workspace]\nmembers = ["a"]\n'} + errors = self._run(files) + assert len(errors) == 1 + + # --- Sub-path handling ------------------------------------------------ + + def test_sub_path_manifest_detected(self): + # Manifest in sub-path with lock in sub-path — passes. + files = { + "sub/package.json": "{}", + "sub/package-lock.json": "{}", + } + assert self._run(files, sub_path="sub") == [] + + def test_sub_path_falls_back_to_root(self): + # Sub-action may reuse repo-root manifests. + files = { + "package.json": "{}", + "package-lock.json": "{}", + } + assert self._run(files, sub_path="sub") == [] + + def test_sub_path_without_lock_fails(self): + files = {"sub/package.json": "{}"} + errors = self._run(files, sub_path="sub") + assert len(errors) == 1 + assert "sub/package.json" in errors[0] + + # --- No manifests ----------------------------------------------------- + + def test_no_manifests_found_passes(self): + # Pure composite action — no manifests anywhere. + assert self._run({}) == [] + + # --- Multiple ecosystems ----------------------------------------------- + + def test_multiple_ecosystems_all_missing_aggregates_errors(self): + files = { + "package.json": "{}", + "go.mod": "module x\n\nrequire a v1\n", + "pubspec.yaml": "name: x\n", + } + errors = self._run(files) + assert len(errors) == 3 diff --git a/utils/verify_action_build/security.py b/utils/verify_action_build/security.py index 7dbec30b..cf493bb4 100644 --- a/utils/verify_action_build/security.py +++ b/utils/verify_action_build/security.py @@ -557,6 +557,146 @@ def analyze_scripts( return warnings +def analyze_lock_files( + org: str, repo: str, commit_hash: str, sub_path: str = "", +) -> list[str]: + """Verify each detected dependency manifest has a matching lock file. + + Downstream build verification relies on a clean rebuild producing the same + output as the published artifacts. That reproducibility depends on every + transitive dependency being pinned — which is the lock file's job. A + manifest (package.json, pyproject.toml, go.mod, ...) without a matching + lock file means `npm install` / `pip install` / `go get` would resolve to + whatever version is latest at build time, making verification impossible. + + A missing lock file when the corresponding manifest is present is + returned as a hard error. Manifests that don't declare dependencies + (e.g. a bare pyproject.toml with only tool config, a Rust library crate + that conventionally doesn't commit Cargo.lock) are reported as skipped. + + Returns a list of error strings (empty = pass). + """ + errors: list[str] = [] + header_shown = False + + def _show_header() -> None: + nonlocal header_shown + if not header_shown: + console.print() + console.rule("[bold]Lock File Presence[/bold]") + header_shown = True + + def _candidate_paths(name: str) -> list[str]: + if sub_path: + return [f"{sub_path}/{name}", name] + return [name] + + def _find(name: str) -> tuple[str, str] | None: + for p in _candidate_paths(name): + c = fetch_file_from_github(org, repo, commit_hash, p) + if c is not None: + return p, c + return None + + # (ecosystem, manifest, [acceptable lock files in priority order]) + ecosystems: list[tuple[str, str, list[str]]] = [ + ("node", "package.json", ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]), + ("python", "pyproject.toml", ["uv.lock", "poetry.lock", "pdm.lock", "requirements.txt"]), + ("python", "Pipfile", ["Pipfile.lock"]), + ("deno", "deno.json", ["deno.lock"]), + ("deno", "deno.jsonc", ["deno.lock"]), + ("dart", "pubspec.yaml", ["pubspec.lock"]), + ("ruby", "Gemfile", ["Gemfile.lock"]), + ("go", "go.mod", ["go.sum"]), + ("rust", "Cargo.toml", ["Cargo.lock"]), + ] + + for ecosystem, manifest, lock_options in ecosystems: + found = _find(manifest) + if found is None: + continue + mpath, mcontent = found + + # Rust libraries conventionally don't commit Cargo.lock — only binary + # crates / workspaces do. Detect via [lib] without [[bin]] in the + # root manifest (workspaces are treated as needing a lock). + if ecosystem == "rust": + has_lib = bool(re.search(r"(?m)^\s*\[lib\]", mcontent)) + has_bin = bool(re.search(r"(?m)^\s*\[\[bin\]\]", mcontent)) + has_workspace = bool(re.search(r"(?m)^\s*\[workspace\]", mcontent)) + if has_lib and not has_bin and not has_workspace: + _show_header() + console.print( + f" [dim]⊘[/dim] {ecosystem}: {mpath} looks like a library crate — " + "Cargo.lock is not conventionally committed" + ) + continue + + # pyproject.toml is often a bare config file (ruff/black/mypy settings) + # with no dependencies. Skip if no deps section is declared. + if manifest == "pyproject.toml": + has_deps = bool(re.search( + r"(?m)^\s*(" + r"dependencies\s*=" + r"|\[project\.optional-dependencies\]" + r"|\[tool\.poetry\.dependencies\]" + r"|\[tool\.poetry\.dev-dependencies\]" + r"|\[tool\.poetry\.group\..+?\.dependencies\]" + r"|\[tool\.pdm\.dev-dependencies\]" + r")", + mcontent, + )) + if not has_deps: + _show_header() + console.print( + f" [dim]⊘[/dim] {ecosystem}: {mpath} declares no dependencies" + ) + continue + + # go.mod without any `require` directives has no third-party deps and + # thus no go.sum to generate. + if manifest == "go.mod": + has_require = bool(re.search(r"(?m)^\s*require\b", mcontent)) + if not has_require: + _show_header() + console.print( + f" [dim]⊘[/dim] {ecosystem}: {mpath} has no require directives" + ) + continue + + found_lock: str | None = None + for lock in lock_options: + lp = _find(lock) + if lp is not None: + found_lock = lp[0] + break + + _show_header() + manifest_link = ( + f"[link=https://github.com/{org}/{repo}/blob/{commit_hash}/{mpath}]" + f"{mpath}[/link]" + ) + if found_lock: + lock_link = ( + f"[link=https://github.com/{org}/{repo}/blob/{commit_hash}/{found_lock}]" + f"{found_lock}[/link]" + ) + console.print( + f" [green]✓[/green] {ecosystem}: {manifest_link} → {lock_link}" + ) + else: + console.print( + f" [red]✗[/red] {ecosystem}: {manifest_link} has no matching lock file " + f"(expected one of: {', '.join(lock_options)})" + ) + errors.append( + f"{mpath}: missing lock file; expected one of " + f"{', '.join(lock_options)} so transitive dependencies are pinned" + ) + + return errors + + def analyze_dependency_pinning( org: str, repo: str, commit_hash: str, sub_path: str = "", ) -> list[str]: diff --git a/utils/verify_action_build/verification.py b/utils/verify_action_build/verification.py index 563bd74a..7e1551fd 100644 --- a/utils/verify_action_build/verification.py +++ b/utils/verify_action_build/verification.py @@ -39,6 +39,7 @@ analyze_binary_downloads_recursive, analyze_dependency_pinning, analyze_dockerfile, + analyze_lock_files, analyze_nested_actions, analyze_repo_metadata, analyze_scripts, @@ -181,6 +182,7 @@ def verify_single_action( checked_actions: list[dict] = [] matched_with_approved_lockfile = False binary_download_failures: list[str] = [] + lock_file_errors: list[str] = [] # Detect source-detached release tags (orphan commits containing only # distributable artifacts) and resolve the default-branch source commit @@ -273,6 +275,21 @@ def verify_single_action( "disabled via --no-binary-download-check", )) + # Lock-file presence runs for every action type — reproducibility of + # the rebuilt dist/ (and of any pip/go/etc. install the action performs + # at runtime) depends on every transitive dependency being pinned. + lock_file_errors = analyze_lock_files(org, repo, commit_hash, sub_path) + if lock_file_errors: + checks_performed.append(( + "Lock file presence", "fail", + f"{len(lock_file_errors)} manifest(s) missing lock file", + )) + else: + checks_performed.append(( + "Lock file presence", "pass", + "all detected manifests have lock files", + )) + if not is_js_action: console.print() console.print( @@ -481,7 +498,7 @@ def verify_single_action( ci_mode=ci_mode, ) - overall_passed = all_match and not binary_download_failures + overall_passed = all_match and not binary_download_failures and not lock_file_errors console.print() checklist_hint = f"\n[dim]Security review checklist: {SECURITY_CHECKLIST_URL}[/dim]" @@ -523,6 +540,12 @@ def verify_single_action( f"{len(binary_download_failures)} unverified binary download(s) detected " f"(no checksum/signature check in file)[/red bold]" ) + elif lock_file_errors: + fail_msg = ( + f"[red bold]{action_type} action — " + f"{len(lock_file_errors)} manifest(s) without a matching lock file " + f"(transitive dependencies not pinned; rebuilds cannot be reproduced)[/red bold]" + ) else: fail_msg = f"[red bold]{action_type} action — verification failed[/red bold]" console.print( From 0327f65b0b70ffd37706ccadc7eb4aea46fe3d35 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 24 Apr 2026 20:08:50 +0200 Subject: [PATCH 2/2] verify-action-build: per-ecosystem exemption list for lock-file check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lock_file_exemptions.yml | 36 +++++++ .../verify_action_build/test_security.py | 95 +++++++++++++++++++ utils/verify_action_build/security.py | 63 ++++++++++++ utils/verify_action_build/verification.py | 2 +- 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 lock_file_exemptions.yml diff --git a/lock_file_exemptions.yml b/lock_file_exemptions.yml new file mode 100644 index 00000000..c49d83ab --- /dev/null +++ b/lock_file_exemptions.yml @@ -0,0 +1,36 @@ +# lock_file_exemptions.yml +# +# Per-(org/repo) per-ecosystem exemptions from the "every dependency manifest +# must have a matching lock file" check enforced by verify-action-build. +# +# When is an exemption appropriate? Only when the upstream repo is primarily +# a library or CLI tool that also ships a GitHub Action wrapper, and that +# project's ecosystem convention is to *not* commit a lock file. Typical +# examples: +# * Python libraries published to PyPI — lock files would pin transitive +# versions that downstream library consumers shouldn't be forced into. +# * Dart packages published to pub.dev — pubspec.lock is deliberately +# .gitignore'd for libraries. +# +# Exemptions are scoped per-ecosystem, so a project exempted for Python is +# still checked for its Node side (if it has one). +# +# Ecosystem keys (must match one of the names in analyze_lock_files): +# node python deno dart ruby go rust +# +# Format: +# org/repo: +# - ecosystem1 +# - ecosystem2 + +pypa/cibuildwheel: + # cibuildwheel is a Python library published to PyPI. Its pyproject.toml + # declares runtime deps for users who `pip install cibuildwheel`; committing + # a lock file would over-constrain library consumers. + - python + +dart-lang/setup-dart: + # The repo is a Dart package (pubspec.yaml) shipped as an action. Dart + # convention for library packages is to not commit pubspec.lock. The node + # side of this action is still checked (package.json → package-lock.json). + - dart diff --git a/utils/tests/verify_action_build/test_security.py b/utils/tests/verify_action_build/test_security.py index 652ac1ab..4c4f00b7 100644 --- a/utils/tests/verify_action_build/test_security.py +++ b/utils/tests/verify_action_build/test_security.py @@ -658,3 +658,98 @@ def test_multiple_ecosystems_all_missing_aggregates_errors(self): } errors = self._run(files) assert len(errors) == 3 + + # --- Exemptions ------------------------------------------------------- + + def _run_with_exemptions( + self, + files: dict, + exemptions: dict, + org: str = "org", + repo: str = "repo", + ) -> list[str]: + def fetch(o, r, commit, path): + return files.get(path) + + with mock.patch( + "verify_action_build.security.fetch_file_from_github", + side_effect=fetch, + ): + return analyze_lock_files(org, repo, "a" * 40, exemptions=exemptions) + + def test_exemption_skips_matching_ecosystem(self): + # pyproject.toml with deps but no lock — normally fails; exempted here. + files = { + "pyproject.toml": '[project]\ndependencies = ["requests"]\n', + } + errors = self._run_with_exemptions( + files, {("org", "repo"): {"python"}}, + ) + assert errors == [] + + def test_exemption_does_not_skip_other_ecosystems(self): + # Exempt only python; node still fails. + files = { + "pyproject.toml": '[project]\ndependencies = ["requests"]\n', + "package.json": "{}", + } + errors = self._run_with_exemptions( + files, {("org", "repo"): {"python"}}, + ) + assert len(errors) == 1 + assert "package.json" in errors[0] + + def test_exemption_case_insensitive(self): + # Look-up key lowercases org/repo, so an exemption entry written as + # "Pypa/cibuildwheel" matches a run on "pypa/cibuildwheel". + files = {"pyproject.toml": '[project]\ndependencies = ["a"]\n'} + errors = self._run_with_exemptions( + files, {("pypa", "cibuildwheel"): {"python"}}, + org="Pypa", repo="CIBuildWheel", + ) + assert errors == [] + + def test_exemption_for_different_repo_does_not_apply(self): + files = {"pyproject.toml": '[project]\ndependencies = ["a"]\n'} + errors = self._run_with_exemptions( + files, {("other", "project"): {"python"}}, + ) + assert len(errors) == 1 + + # --- Exemption file parser ------------------------------------------- + + def test_exemption_file_parses(self, tmp_path): + from verify_action_build.security import _load_lock_file_exemptions + + yml = tmp_path / "lock_file_exemptions.yml" + yml.write_text( + "# comment\n" + "pypa/cibuildwheel:\n" + " - python\n" + "\n" + "dart-lang/setup-dart:\n" + " - dart # trailing comment\n" + ) + result = _load_lock_file_exemptions(yml) + assert result == { + ("pypa", "cibuildwheel"): {"python"}, + ("dart-lang", "setup-dart"): {"dart"}, + } + + def test_exemption_file_missing_returns_empty(self, tmp_path): + from verify_action_build.security import _load_lock_file_exemptions + + result = _load_lock_file_exemptions(tmp_path / "does-not-exist.yml") + assert result == {} + + def test_exemption_file_multiple_ecosystems_per_repo(self, tmp_path): + from verify_action_build.security import _load_lock_file_exemptions + + yml = tmp_path / "lock_file_exemptions.yml" + yml.write_text( + "some/multiecosystem-repo:\n" + " - python\n" + " - dart\n" + ) + result = _load_lock_file_exemptions(yml) + assert result[("some", "multiecosystem-repo")] == {"python", "dart"} diff --git a/utils/verify_action_build/security.py b/utils/verify_action_build/security.py index cf493bb4..f3bb595f 100644 --- a/utils/verify_action_build/security.py +++ b/utils/verify_action_build/security.py @@ -22,6 +22,7 @@ import os import re from dataclasses import dataclass +from pathlib import Path from typing import Iterator import requests @@ -40,6 +41,53 @@ # Orgs we trust to the point of not descending into their nested action graph. TRUSTED_ORGS = {"actions", "github"} +# Exemptions file for the lock-file-presence check. Path matches the +# convention used by approved_actions.ACTIONS_YML. +LOCK_FILE_EXEMPTIONS_YML = ( + Path(__file__).resolve().parent.parent.parent / "lock_file_exemptions.yml" +) + + +def _load_lock_file_exemptions( + path: Path = LOCK_FILE_EXEMPTIONS_YML, +) -> dict[tuple[str, str], set[str]]: + """Parse lock_file_exemptions.yml into {(org, repo): {ecosystems}}. + + Uses a minimal line-based parser rather than PyYAML to keep the dependency + surface small (the rest of this project also avoids pulling in yaml). + Supported subset: + org/repo: + - ecosystem1 + - ecosystem2 + Comments (``#``) and blank lines are ignored. Keys are lowercased so + lookups are case-insensitive (``Pypa/cibuildwheel`` == ``pypa/cibuildwheel``). + """ + result: dict[tuple[str, str], set[str]] = {} + if not path.exists(): + return result + + current: tuple[str, str] | None = None + for raw in path.read_text().splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + if not line[0].isspace() and line.endswith(":"): + orgrepo = line[:-1].strip().strip("'\"") + if "/" in orgrepo: + org, repo = orgrepo.split("/", 1) + current = (org.lower(), repo.lower()) + result.setdefault(current, set()) + else: + current = None + continue + if current is not None: + stripped = line.lstrip() + if stripped.startswith("- "): + ecosystem = stripped[2:].strip().strip("'\"") + if ecosystem: + result[current].add(ecosystem) + return result + @dataclass class VisitedAction: @@ -559,6 +607,7 @@ def analyze_scripts( def analyze_lock_files( org: str, repo: str, commit_hash: str, sub_path: str = "", + exemptions: dict[tuple[str, str], set[str]] | None = None, ) -> list[str]: """Verify each detected dependency manifest has a matching lock file. @@ -574,8 +623,17 @@ def analyze_lock_files( (e.g. a bare pyproject.toml with only tool config, a Rust library crate that conventionally doesn't commit Cargo.lock) are reported as skipped. + ``exemptions`` maps ``(org, repo)`` to a set of ecosystem names where a + missing lock file is tolerated — for library-first projects (cibuildwheel, + setup-dart) that don't commit lock files per their ecosystem convention. + Defaults to the contents of ``lock_file_exemptions.yml`` at the repo root. + Returns a list of error strings (empty = pass). """ + if exemptions is None: + exemptions = _load_lock_file_exemptions() + exempted_ecosystems = exemptions.get((org.lower(), repo.lower()), set()) + errors: list[str] = [] header_shown = False @@ -684,6 +742,11 @@ def _find(name: str) -> tuple[str, str] | None: console.print( f" [green]✓[/green] {ecosystem}: {manifest_link} → {lock_link}" ) + elif ecosystem in exempted_ecosystems: + console.print( + f" [dim]⊘[/dim] {ecosystem}: {manifest_link} has no matching lock file " + f"— exempted in lock_file_exemptions.yml (library-first project)" + ) else: console.print( f" [red]✗[/red] {ecosystem}: {manifest_link} has no matching lock file " diff --git a/utils/verify_action_build/verification.py b/utils/verify_action_build/verification.py index 7e1551fd..8376fd2c 100644 --- a/utils/verify_action_build/verification.py +++ b/utils/verify_action_build/verification.py @@ -287,7 +287,7 @@ def verify_single_action( else: checks_performed.append(( "Lock file presence", "pass", - "all detected manifests have lock files", + "all detected manifests have lock files (or are exempted)", )) if not is_js_action: