diff --git a/scripts/build_pack.py b/scripts/build_pack.py index 1c01846..a13b46d 100644 --- a/scripts/build_pack.py +++ b/scripts/build_pack.py @@ -569,18 +569,47 @@ def _probe_command(component_id: str, name: str, executable: Path) -> list[str]: raise PackBuildError(f"no probe for {component_id}.{name}") +def normalize_node_launchers(executables: Mapping[str, str], payload: Path) -> None: + """Repair npm launchers whose upstream symlinks were safely materialized.""" + + for name in ("npm", "npx"): + relative = PurePosixPath(executables[name]) + if relative.suffix.casefold() == ".cmd": + continue + launcher = payload.joinpath(*relative.parts) + cli_relative = PurePosixPath("lib", "node_modules", "npm", "bin", f"{name}-cli.js") + cli = payload.joinpath(*cli_relative.parts) + if not launcher.is_file() or not cli.is_file(): + raise PackBuildError(f"Node.js {name} launcher target is missing") + if launcher.read_bytes() != cli.read_bytes(): + raise PackBuildError(f"Node.js {name} launcher is not the reviewed upstream link") + launcher.write_bytes( + ( + "#!/usr/bin/env node\n" + f"require('../{cli_relative.as_posix()}')\n" + ).encode() + ) + launcher.chmod(0o755) + + def probe_payload( component_id: str, version: str, + bin_dirs: list[str], executables: Mapping[str, str], payload: Path, ) -> None: outputs: dict[str, str] = {} probe_names = { "python": {"python"}, - "node": {"node"}, + "node": {"node", "npm", "npx"}, "gitBash": {"git", "bash"}, }[component_id] + environment = os.environ.copy() + path_key = next((key for key in environment if key.casefold() == "path"), "PATH") + environment[path_key] = os.pathsep.join( + str(payload.joinpath(*PurePosixPath(relative).parts)) for relative in bin_dirs + ) for name, relative in sorted(executables.items()): executable = payload.joinpath(*PurePosixPath(relative).parts) if not executable.is_file(): @@ -597,6 +626,7 @@ def probe_payload( stderr=subprocess.STDOUT, text=True, timeout=60, + env=environment, ) output = completed.stdout.strip() if completed.returncode != 0 or not output: @@ -778,7 +808,15 @@ def build_pack( payload, case_sensitive_paths=target.startswith("linux-"), ) - probe_payload(component_id, component["version"], component["executables"], payload) + if component_id == "node": + normalize_node_launchers(component["executables"], payload) + probe_payload( + component_id, + component["version"], + component["binDirs"], + component["executables"], + payload, + ) collect_licenses(payload, pack_root / "licenses") manifest = { "schemaVersion": 1, diff --git a/sources.json b/sources.json index 998c300..5078547 100644 --- a/sources.json +++ b/sources.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "catalogVersion": "2026-08-21.1", - "releaseTag": "v2026.08.21.1", + "catalogVersion": "2026-08-21.2", + "releaseTag": "v2026.08.21.2", "sourceDateEpoch": 1787270400, "targets": { "darwin-arm64": { diff --git a/tests/test_pack_safety.py b/tests/test_pack_safety.py index 91872f1..7b91344 100644 --- a/tests/test_pack_safety.py +++ b/tests/test_pack_safety.py @@ -17,6 +17,7 @@ _register_path_shape, _verify_authenticode_signature, collect_licenses, + normalize_node_launchers, sha256_file, ) from scripts.generate_release import ( @@ -72,6 +73,30 @@ def test_tar_extraction_materializes_safe_internal_symlink(tmp_path: Path) -> No assert not (payload / "bin" / "tool").is_symlink() +def test_node_launcher_normalization_repairs_materialized_npm_symlinks( + tmp_path: Path, +) -> None: + payload = tmp_path / "payload" + cli_root = payload / "lib" / "node_modules" / "npm" / "bin" + cli_root.mkdir(parents=True) + (payload / "bin").mkdir() + executables = {"node": "bin/node", "npm": "bin/npm", "npx": "bin/npx"} + for name in ("npm", "npx"): + original = f"#!/usr/bin/env node\n// {name}\n".encode() + (cli_root / f"{name}-cli.js").write_bytes(original) + (payload / "bin" / name).write_bytes(original) + + normalize_node_launchers(executables, payload) + + for name in ("npm", "npx"): + launcher = payload / "bin" / name + assert launcher.read_text(encoding="utf-8") == ( + "#!/usr/bin/env node\n" + f"require('../lib/node_modules/npm/bin/{name}-cli.js')\n" + ) + assert launcher.stat().st_mode & 0o111 + + @pytest.mark.parametrize("name", ["root/NUL.txt", "root/file:stream", "root/trailing. "]) def test_tar_extraction_rejects_nonportable_names(tmp_path: Path, name: str) -> None: archive = tmp_path / "input.tar.gz" diff --git a/tests/test_validate_sources.py b/tests/test_validate_sources.py index 4ab6ccf..5b6b556 100644 --- a/tests/test_validate_sources.py +++ b/tests/test_validate_sources.py @@ -17,7 +17,7 @@ def _valid() -> dict: def test_checked_in_sources_are_complete_and_native() -> None: value = load_sources(ROOT / "sources.json") - assert value["catalogVersion"] == "2026-08-21.1" + assert value["catalogVersion"] == "2026-08-21.2" assert set(value["targets"]) == { "darwin-arm64", "darwin-x64", diff --git a/tests/test_workflow_contract.py b/tests/test_workflow_contract.py index 29a6589..72097f7 100644 --- a/tests/test_workflow_contract.py +++ b/tests/test_workflow_contract.py @@ -91,8 +91,8 @@ def test_every_workflow_is_valid_yaml() -> None: def test_catalog_identity_matches_release_tag_contract() -> None: sources = json.loads((ROOT / "sources.json").read_text(encoding="utf-8")) - assert sources["catalogVersion"] == "2026-08-21.1" - assert sources["releaseTag"] == "v2026.08.21.1" + assert sources["catalogVersion"] == "2026-08-21.2" + assert sources["releaseTag"] == "v2026.08.21.2" def test_repository_publishes_the_complete_apache_2_license() -> None: