Pin the website publication toolchain
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user