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,
|
publication_mutation_trust_issues,
|
||||||
publish_catalog_candidate,
|
publish_catalog_candidate,
|
||||||
remote_publication_identity,
|
remote_publication_identity,
|
||||||
|
run_checked,
|
||||||
verify_committed_publication,
|
verify_committed_publication,
|
||||||
verify_remote_branch_head,
|
verify_remote_branch_head,
|
||||||
verify_remote_publication,
|
verify_remote_publication,
|
||||||
|
_trusted_npm_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseCatalogPublicationTests(unittest.TestCase):
|
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:
|
def test_committed_publication_must_match_validated_blobs_exactly(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
web_root = Path(tmp) / "website"
|
web_root = Path(tmp) / "website"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -107,6 +108,12 @@ def publish_catalog_candidate(
|
|||||||
steps: list[CatalogPublishStep] = []
|
steps: list[CatalogPublishStep] = []
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
blockers: 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():
|
if not candidate_catalog.exists():
|
||||||
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
|
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
|
||||||
@@ -399,9 +406,18 @@ def publish_catalog_candidate(
|
|||||||
CatalogPublishStep(
|
CatalogPublishStep(
|
||||||
id="build-web",
|
id="build-web",
|
||||||
title="Build website",
|
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(
|
command=shlex.join(
|
||||||
[npm, "--prefix", str(resolved_web_root), "run", "build"]
|
[
|
||||||
|
resolved_npm,
|
||||||
|
"--prefix",
|
||||||
|
str(resolved_web_root),
|
||||||
|
"run",
|
||||||
|
"build",
|
||||||
|
]
|
||||||
),
|
),
|
||||||
mutating=True,
|
mutating=True,
|
||||||
status="planned" if not apply else "blocked" if blockers else "pending",
|
status="planned" if not apply else "blocked" if blockers else "pending",
|
||||||
@@ -559,7 +575,7 @@ def publish_catalog_candidate(
|
|||||||
]
|
]
|
||||||
if build_web:
|
if build_web:
|
||||||
run_checked(
|
run_checked(
|
||||||
[npm, "--prefix", str(resolved_web_root), "run", "build"],
|
[resolved_npm, "--prefix", str(resolved_web_root), "run", "build"],
|
||||||
cwd=resolved_web_root,
|
cwd=resolved_web_root,
|
||||||
)
|
)
|
||||||
completed_steps = [
|
completed_steps = [
|
||||||
@@ -2009,10 +2025,14 @@ def git_success(path: Path, *args: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def run_checked(args: list[str], *, cwd: Path) -> None:
|
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(
|
result = subprocess.run(
|
||||||
args,
|
args,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=_sanitized_git_environment(),
|
env=environment,
|
||||||
check=False,
|
check=False,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
@@ -2030,3 +2050,34 @@ def run_checked(args: list[str], *, cwd: Path) -> None:
|
|||||||
output=result.stdout,
|
output=result.stdout,
|
||||||
stderr=result.stderr,
|
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