Pin the website publication toolchain
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 13s

This commit is contained in:
2026-07-22 22:20:09 +02:00
parent dc46bda224
commit b6e9a9bd81
2 changed files with 106 additions and 4 deletions

View File

@@ -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"

View File

@@ -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)