Skip to content
Merged
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
42 changes: 40 additions & 2 deletions scripts/build_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions sources.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
25 changes: 25 additions & 0 deletions tests/test_pack_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_register_path_shape,
_verify_authenticode_signature,
collect_licenses,
normalize_node_launchers,
sha256_file,
)
from scripts.generate_release import (
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_validate_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions tests/test_workflow_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading