From 4fa633d54f45957966f5d7ab87cb31e7d396b4d2 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Wed, 6 May 2026 22:13:06 +0200 Subject: [PATCH] verify-action-build: skip lock-file check for empty package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check already skips bare-config pyproject.toml and require-less go.mod. Mirror that for package.json with no dependencies — the case that release-please-style bundled action tags ship (a self-contained index.js next to a minimal {"type": "module"}). browser-actions/ setup-firefox v1.7.2 (PR #813) hits this; v1.7.0 / v1.7.1 were approved before the lock-file check landed. --- .../verify_action_build/test_security.py | 63 ++++++++++++++++--- utils/verify_action_build/security.py | 28 +++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/utils/tests/verify_action_build/test_security.py b/utils/tests/verify_action_build/test_security.py index 586c443c1..83f101157 100644 --- a/utils/tests/verify_action_build/test_security.py +++ b/utils/tests/verify_action_build/test_security.py @@ -558,23 +558,70 @@ def test_node_package_json_with_package_lock_passes(self): assert self._run(files) == [] def test_node_package_json_with_yarn_lock_passes(self): - files = {"package.json": "{}", "yarn.lock": ""} + files = { + "package.json": '{"name":"x","dependencies":{"a":"1.0.0"}}', + "yarn.lock": "", + } assert self._run(files) == [] def test_node_package_json_with_pnpm_lock_passes(self): - files = {"package.json": "{}", "pnpm-lock.yaml": ""} + files = { + "package.json": '{"name":"x","dependencies":{"a":"1.0.0"}}', + "pnpm-lock.yaml": "", + } assert self._run(files) == [] def test_node_package_json_with_bun_lock_passes(self): - files = {"package.json": "{}", "bun.lock": ""} + files = { + "package.json": '{"name":"x","dependencies":{"a":"1.0.0"}}', + "bun.lock": "", + } assert self._run(files) == [] def test_node_package_json_without_lock_fails(self): - errors = self._run({"package.json": '{"name":"x"}'}) + # ``dependencies`` are declared, so the lock-file requirement applies. + errors = self._run({ + "package.json": '{"name":"x","dependencies":{"a":"1.0.0"}}', + }) assert len(errors) == 1 assert "package.json" in errors[0] assert "package-lock.json" in errors[0] + def test_node_package_json_dev_deps_only_without_lock_fails(self): + # ``devDependencies`` alone count: a rebuild still installs them and + # pins through them. + errors = self._run({ + "package.json": '{"devDependencies":{"typescript":"5.0.0"}}', + }) + assert len(errors) == 1 + assert "package.json" in errors[0] + + def test_node_package_json_no_deps_skipped(self): + # browser-actions/setup-firefox v1.7.2 shape: bundled-action release + # tag ships ``index.js`` next to a minimal ``{"type":"module"}`` + # ``package.json`` declaring zero dependencies. A lock file would + # describe an empty graph; require it and the check falsely fails + # this whole class of release-please-style bundled tags. + assert self._run({"package.json": '{"type":"module"}'}) == [] + + def test_node_package_json_completely_empty_skipped(self): + # ``{}`` — no fields at all. No deps to pin. + assert self._run({"package.json": "{}"}) == [] + + def test_node_package_json_invalid_json_treated_as_no_deps(self): + # A package.json that doesn't parse (truncated, malformed) shouldn't + # crash the check. We can't see deps, so we fall through to the + # skip path — same as a syntactically-empty manifest. + assert self._run({"package.json": "{ not json"}) == [] + + def test_node_package_json_peer_deps_without_lock_fails(self): + # ``peerDependencies`` and ``optionalDependencies`` also imply a + # transitive resolution that benefits from pinning. + errors = self._run({ + "package.json": '{"peerDependencies":{"react":"^18"}}', + }) + assert len(errors) == 1 + # --- Python ------------------------------------------------------------ def test_python_pyproject_with_uv_lock_passes(self): @@ -730,7 +777,9 @@ def test_sub_path_falls_back_to_root(self): assert self._run(files, sub_path="sub") == [] def test_sub_path_without_lock_fails(self): - files = {"sub/package.json": "{}"} + files = { + "sub/package.json": '{"dependencies":{"a":"1.0.0"}}', + } errors = self._run(files, sub_path="sub") assert len(errors) == 1 assert "sub/package.json" in errors[0] @@ -745,7 +794,7 @@ def test_no_manifests_found_passes(self): def test_multiple_ecosystems_all_missing_aggregates_errors(self): files = { - "package.json": "{}", + "package.json": '{"dependencies":{"a":"1.0.0"}}', "go.mod": "module x\n\nrequire a v1\n", "pubspec.yaml": "name: x\n", } @@ -784,7 +833,7 @@ def test_exemption_does_not_skip_other_ecosystems(self): # Exempt only python; node still fails. files = { "pyproject.toml": '[project]\ndependencies = ["requests"]\n', - "package.json": "{}", + "package.json": '{"dependencies":{"a":"1.0.0"}}', } errors = self._run_with_exemptions( files, {("org", "repo"): {"python"}}, diff --git a/utils/verify_action_build/security.py b/utils/verify_action_build/security.py index 7c288e2d5..dca32f361 100644 --- a/utils/verify_action_build/security.py +++ b/utils/verify_action_build/security.py @@ -740,6 +740,34 @@ def _find(name: str) -> tuple[str, str] | None: ) continue + # package.json that declares no dependencies has no transitive deps + # to pin — a lock file would describe an empty graph. Bundled-action + # release tags (release-please style) commonly ship a minimal + # ``package.json`` like ``{"type": "module"}`` next to a self- + # contained ``index.js``; the runtime behaviour comes from the + # bundle, not from npm install. + if manifest == "package.json": + try: + pkg = json.loads(mcontent) + except json.JSONDecodeError: + pkg = {} + has_deps = isinstance(pkg, dict) and any( + pkg.get(k) for k in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + "bundledDependencies", + "bundleDependencies", + ) + ) + if not has_deps: + _show_header() + console.print( + f" [dim]⊘[/dim] {ecosystem}: {mpath} declares no dependencies" + ) + continue + found_lock: str | None = None for lock in lock_options: lp = _find(lock)