From b6e9a9bd812973a0b0732d2e1ef1b2ca905d93ee Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 22:20:09 +0200 Subject: [PATCH] Pin the website publication toolchain --- tests/test_release_catalog_publication.py | 51 ++++++++++++++++++ tools/release/govoplan_release/publisher.py | 59 +++++++++++++++++++-- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/tests/test_release_catalog_publication.py b/tests/test_release_catalog_publication.py index da1e027..f20bc5e 100644 --- a/tests/test_release_catalog_publication.py +++ b/tests/test_release_catalog_publication.py @@ -22,13 +22,64 @@ from govoplan_release.publisher import ( # noqa: E402 publication_mutation_trust_issues, publish_catalog_candidate, remote_publication_identity, + run_checked, verify_committed_publication, verify_remote_branch_head, verify_remote_publication, + _trusted_npm_command, ) class ReleaseCatalogPublicationTests(unittest.TestCase): + def test_build_command_uses_its_pinned_node_directory(self) -> None: + completed = subprocess.CompletedProcess( + ["/trusted/node/bin/npm"], + 0, + stdout=b"", + stderr=b"", + ) + with patch( + "govoplan_release.publisher.subprocess.run", + return_value=completed, + ) as runner: + run_checked( + ["/trusted/node/bin/npm", "run", "build"], + cwd=Path("/trusted/website"), + ) + + environment = runner.call_args.kwargs["env"] + self.assertEqual( + "/trusted/node/bin:/usr/bin:/bin", + environment["PATH"], + ) + + def test_npm_command_requires_trusted_npm_and_sibling_node(self) -> None: + with ( + patch( + "govoplan_release.publisher.shutil.which", + return_value="/trusted/node/bin/npm", + ), + patch( + "govoplan_release.publisher._trusted_runtime_executable_issue", + side_effect=(None, None), + ) as trust_check, + ): + self.assertEqual( + "/trusted/node/bin/npm", + _trusted_npm_command("npm"), + ) + + self.assertEqual(Path("/trusted/node/bin/npm"), trust_check.call_args_list[0].args[0]) + self.assertEqual(Path("/trusted/node/bin/node"), trust_check.call_args_list[1].args[0]) + + def test_npm_command_rejects_caller_relative_path(self) -> None: + with ( + patch("govoplan_release.publisher.shutil.which") as which, + self.assertRaisesRegex(RuntimeError, "absolute path or the bare name"), + ): + _trusted_npm_command("./npm") + which.assert_not_called() + def test_committed_publication_must_match_validated_blobs_exactly(self) -> None: with tempfile.TemporaryDirectory() as tmp: web_root = Path(tmp) / "website" diff --git a/tools/release/govoplan_release/publisher.py b/tools/release/govoplan_release/publisher.py index f4d205c..63d3161 100644 --- a/tools/release/govoplan_release/publisher.py +++ b/tools/release/govoplan_release/publisher.py @@ -11,6 +11,7 @@ from pathlib import Path import re import secrets import shlex +import shutil import stat import subprocess import sys @@ -107,6 +108,12 @@ def publish_catalog_candidate( steps: list[CatalogPublishStep] = [] notes: list[str] = [] blockers: list[str] = [] + resolved_npm = npm + if build_web: + try: + resolved_npm = _trusted_npm_command(npm) + except (OSError, RuntimeError) as exc: + blockers.append(f"website build command is not trusted: {exc}") if not candidate_catalog.exists(): blockers.append(f"candidate catalog is missing: {candidate_catalog}") @@ -399,9 +406,18 @@ def publish_catalog_candidate( CatalogPublishStep( id="build-web", title="Build website", - detail="Run the website build after copying catalog files.", + detail=( + "Run the website build after copying catalog files with " + f"{resolved_npm}." + ), command=shlex.join( - [npm, "--prefix", str(resolved_web_root), "run", "build"] + [ + resolved_npm, + "--prefix", + str(resolved_web_root), + "run", + "build", + ] ), mutating=True, status="planned" if not apply else "blocked" if blockers else "pending", @@ -559,7 +575,7 @@ def publish_catalog_candidate( ] if build_web: run_checked( - [npm, "--prefix", str(resolved_web_root), "run", "build"], + [resolved_npm, "--prefix", str(resolved_web_root), "run", "build"], cwd=resolved_web_root, ) completed_steps = [ @@ -2009,10 +2025,14 @@ def git_success(path: Path, *args: str) -> bool: def run_checked(args: list[str], *, cwd: Path) -> None: + if not args or not Path(args[0]).is_absolute(): + raise RuntimeError("publication build command must be an absolute path") + environment = _sanitized_git_environment() + environment["PATH"] = f"{Path(args[0]).parent}:/usr/bin:/bin" result = subprocess.run( args, cwd=cwd, - env=_sanitized_git_environment(), + env=environment, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -2030,3 +2050,34 @@ def run_checked(args: list[str], *, cwd: Path) -> None: output=result.stdout, stderr=result.stderr, ) + + +def _trusted_npm_command(value: str) -> str: + candidate = Path(value) + if not candidate.is_absolute(): + if candidate.name != value: + raise RuntimeError( + "npm executable must be an absolute path or the bare name npm" + ) + located = shutil.which(value, path="/usr/bin:/bin") + if not located: + raise RuntimeError("npm executable is unavailable on the fixed system path") + candidate = Path(located) + candidate = Path(os.path.abspath(candidate)) + permitted_owners = {0, os.geteuid()} + issue = _trusted_runtime_executable_issue( + candidate, + permitted_owners=permitted_owners, + label="npm executable", + ) + if issue: + raise RuntimeError(issue) + node = candidate.parent / "node" + issue = _trusted_runtime_executable_issue( + node, + permitted_owners=permitted_owners, + label="Node.js executable", + ) + if issue: + raise RuntimeError(issue) + return str(candidate)