diff --git a/.github/actions/rust-build-release/README.md b/.github/actions/rust-build-release/README.md index ebb8aca7..b0802208 100644 --- a/.github/actions/rust-build-release/README.md +++ b/.github/actions/rust-build-release/README.md @@ -1,5 +1,7 @@ # rust-build-release + + Build Rust application release artefacts using the repository's `setup-rust` action, `uv`, and `cross`. FreeBSD targets (for example `x86_64-unknown-freebsd`) require `cross` with a @@ -27,6 +29,20 @@ Toolchains are resolved from the target repository in this order: explicit `toolchain` input, repository `rust-toolchain.toml` or `rust-toolchain`, manifest `rust-version`, then the action's bundled fallback version. +## Toolchain specification for cross builds + +When `cross` is used for compilation, toolchain selection must be made via one +of the following methods — **not** via a `+` CLI override: + +- **`rust-toolchain.toml`** in the project repository (recommended). +- The `toolchain` action input, which is propagated as the `RUSTUP_TOOLCHAIN` + environment variable for the `cross` invocation. +- The `RUSTUP_TOOLCHAIN` environment variable set upstream in the workflow. + +Passing a `+` override on the `cross` command line is rejected by the +action and will cause the build to fail with a `::error::` annotation. This +restriction does not apply to `cargo`-only builds. + ## Inputs | Name | Type | Default | Description | Required | diff --git a/.github/actions/rust-build-release/src/cross_manager.py b/.github/actions/rust-build-release/src/cross_manager.py index 6b4a901b..05041b80 100644 --- a/.github/actions/rust-build-release/src/cross_manager.py +++ b/.github/actions/rust-build-release/src/cross_manager.py @@ -4,6 +4,7 @@ import hashlib import shutil +import subprocess import sys import tempfile import urllib.error @@ -214,7 +215,7 @@ def version_compare(installed: str, required: str) -> bool: required_cross_version, ] ) - except ProcessExecutionError: + except (ProcessExecutionError, subprocess.CalledProcessError): try: run_cmd( local["cargo"][ @@ -227,7 +228,7 @@ def version_compare(installed: str, required: str) -> bool: f"v{required_cross_version}", ] ) - except ProcessExecutionError: + except (ProcessExecutionError, subprocess.CalledProcessError): if sys.platform == "win32": typer.echo( "::warning:: cross install failed; continuing without " diff --git a/.github/actions/rust-build-release/src/gha.py b/.github/actions/rust-build-release/src/gha.py new file mode 100644 index 00000000..b4a3f0aa --- /dev/null +++ b/.github/actions/rust-build-release/src/gha.py @@ -0,0 +1,30 @@ +"""GitHub Actions workflow command helpers.""" + +from __future__ import annotations + +import typing as typ + +import typer + + +class _Echo(typ.Protocol): + """Callable shape used by typer.echo-compatible adapters.""" + + def __call__(self, message: str, *, err: bool = False) -> None: + """Emit *message*, optionally to stderr.""" + ... + + +def debug(message: str, *, echo: _Echo = typer.echo) -> None: + """Emit a ::debug:: workflow command.""" + echo(f"::debug:: {message}") + + +def warning(message: str, *, echo: _Echo = typer.echo) -> None: + """Emit a ::warning:: workflow command.""" + echo(f"::warning:: {message}", err=True) + + +def error(message: str, *, echo: _Echo = typer.echo) -> None: + """Emit a ::error:: workflow command.""" + echo(f"::error:: {message}", err=True) diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index 079af5cd..4b10e954 100755 --- a/.github/actions/rust-build-release/src/main.py +++ b/.github/actions/rust-build-release/src/main.py @@ -22,6 +22,9 @@ import typer from cross_manager import ensure_cross +from gha import debug as gha_debug +from gha import error as gha_error +from gha import warning as gha_warning from plumbum import local from plumbum.commands.processes import ( ProcessExecutionError, @@ -98,69 +101,70 @@ class _CrossDecision(typ.NamedTuple): class _CommandWrapper: """Expose a stable display name for a plumbum command.""" - def __init__(self, command: SupportsFormulate, display_name: str) -> None: - formulate_callable = getattr(command, "formulate", None) - if not callable(formulate_callable): - message = ( - f"{command!r} does not expose a callable formulate(); cannot wrap " - "for display override" - ) - raise TypeError(message) + def __init__( + self, + command: SupportsFormulate, + display_name: str, + ) -> None: + """Wrap *command* with *display_name*.""" + _validate_formulation(command, display_name) self._command: typ.Any = command self._display_name = display_name def formulate(self) -> cabc.Sequence[str]: + """Return the command argv with the configured display name applied.""" formulate_callable = getattr(self._command, "formulate", None) if not callable(formulate_callable): - typer.echo( - f"::warning:: command {self._command!r} does not support formulate(); " - "returning display name only", - err=True, - ) return [self._display_name] try: parts = list(formulate_callable()) - except Exception as exc: # noqa: BLE001 # pragma: no cover - unexpected failure - message = ( - "::warning:: failed to generate command line for " - f"{self._command!r}: {exc}" - ) - typer.echo(message, err=True) + except Exception: # noqa: BLE001 # pragma: no cover - unexpected failure return [self._display_name] if parts: parts[0] = self._display_name return parts def __str__(self) -> str: + """Return a shell-escaped display string for the wrapped command.""" parts = [str(part) for part in self.formulate()] return shlex.join(parts) def __call__(self, *args: object, **kwargs: object) -> SupportsFormulate: + """Delegate command invocation to the wrapped command.""" return self._command(*args, **kwargs) def run( self, *args: object, **kwargs: object ) -> tuple[int, str | bytes | None, str | bytes | None]: + """Run the wrapped command and return plumbum's result tuple.""" return self._command.run(*args, **kwargs) def popen(self, *args: object, **kwargs: object) -> subprocess.Popen[typ.Any]: + """Start the wrapped command as a subprocess.""" return self._command.popen(*args, **kwargs) def with_env(self, *args: object, **kwargs: object) -> _CommandWrapper: + """Return a wrapper around the command with temporary environment values.""" wrapped = self._command.with_env(*args, **kwargs) - wrapped_formulate = getattr(wrapped, "formulate", None) - if not callable(wrapped_formulate): - message = ( - f"{wrapped!r} returned from with_env() does not expose formulate(); " - "cannot maintain display override" - ) - raise TypeError(message) + _validate_formulation(wrapped, self._display_name) return _CommandWrapper(wrapped, self._display_name) def __getattr__(self, name: str) -> object: + """Delegate unknown attributes to the wrapped command.""" return getattr(self._command, name) +def _validate_formulation(command: SupportsFormulate, display_name: str) -> None: + """Raise TypeError when *command* does not expose a callable formulate().""" + formulate_callable = getattr(command, "formulate", None) + if not callable(formulate_callable): + message = ( + f"{command!r} does not expose a callable formulate(); " + f"cannot wrap '{display_name}' for display override" + ) + raise TypeError(message) + + def _target_is_windows(target: str) -> bool: """Return True if *target* resolves to a Windows triple.""" normalized = target.strip().lower() @@ -214,11 +218,9 @@ def _probe_runtime(name: str) -> bool: except ProcessTimedOut as exc: timeout = getattr(exc, "timeout", None) duration = f" after {timeout}s" if timeout else "" - message = ( - f"::warning::{name} runtime probe timed out{duration}; " - "treating runtime as unavailable" + gha_warning( + f"{name} runtime probe timed out{duration}; treating runtime as unavailable" ) - typer.echo(message, err=True) return False @@ -228,12 +230,12 @@ def _emit_missing_target_error() -> typ.NoReturn: env_input_target = os.environ.get("INPUT_TARGET", "") env_github_ref = os.environ.get("GITHUB_REF", "") message = ( - "::error:: no build target specified; set input 'target' or env RBR_TARGET\n" + "no build target specified; set input 'target' or env RBR_TARGET\n" f"RBR_TARGET={env_rbr_target} " f"INPUT_TARGET={env_input_target} " f"GITHUB_REF={env_github_ref}" ) - typer.echo(message, err=True) + gha_error(message) raise typer.Exit(1) @@ -251,12 +253,12 @@ def _ensure_rustup_exec() -> str: """Locate a trusted rustup executable or exit with an error.""" rustup_path = shutil.which("rustup") if rustup_path is None: - typer.echo("::error:: rustup not found", err=True) + gha_error("rustup not found") raise typer.Exit(1) try: return ensure_allowed_executable(rustup_path, ("rustup", "rustup.exe")) except UnexpectedExecutableError: - typer.echo("::error:: unexpected rustup executable", err=True) + gha_error("unexpected rustup executable") raise typer.Exit(1) from None @@ -286,14 +288,8 @@ def _install_toolchain_channel(rustup_exec: str, toolchain: str) -> None: ] ) except ProcessExecutionError: - typer.echo( - f"::error:: failed to install toolchain '{toolchain}'", - err=True, - ) - typer.echo( - f"::error:: requested toolchain '{toolchain}' not installed", - err=True, - ) + gha_error(f"failed to install toolchain '{toolchain}'") + gha_error(f"requested toolchain '{toolchain}' not installed") raise typer.Exit(1) from None @@ -314,10 +310,7 @@ def _resolve_toolchain(rustup_exec: str, toolchain: str, target: str) -> str: if toolchain_name: return toolchain_name - typer.echo( - f"::error:: requested toolchain '{toolchain}' not installed", - err=True, - ) + gha_error(f"requested toolchain '{toolchain}' not installed") raise typer.Exit(1) @@ -336,10 +329,9 @@ def _ensure_target_installed( ] ) except ProcessExecutionError: - typer.echo( - f"::warning:: toolchain '{toolchain_name}' does not support " - f"target '{target}'; continuing", - err=True, + gha_warning( + f"toolchain '{toolchain_name}' does not support target '{target}'; " + "continuing" ) return False return True @@ -400,12 +392,11 @@ def _validate_cross_requirements( return if decision.use_cross_local_backend: - typer.echo( - "::error:: target " + gha_error( + "target " f"'{target_to_build}' requires cross with a container runtime " f"on host '{host_target}'; CROSS_NO_DOCKER=1 is unsupported when " - "a container runtime is required", - err=True, + "a container runtime is required" ) raise typer.Exit(1) @@ -416,12 +407,11 @@ def _validate_cross_requirements( if not decision.has_container: details.append("no container runtime detected") detail_suffix = f", {', '.join(details)}" if details else "" - typer.echo( - "::error:: target " + gha_error( + "target " f"'{target_to_build}' requires cross with a container runtime " f"on host '{host_target}'" - f"{detail_suffix}", - err=True, + f"{detail_suffix}" ) raise typer.Exit(1) @@ -477,6 +467,7 @@ def _configure_cross_container_engine( def _restore_container_engine( previous_engine: str | None, *, applied_engine: str | None ) -> None: + """Restore CROSS_CONTAINER_ENGINE after a temporary cross configuration.""" current_engine = os.environ.get("CROSS_CONTAINER_ENGINE") if previous_engine is None: @@ -501,12 +492,29 @@ def _normalize_features(features: str) -> str: return ",".join(normalized) +def _assert_cross_command_has_no_toolchain_override(cmd: cabc.Sequence[object]) -> None: + """Raise ValueError if a cross argv contains a +toolchain override.""" + # Cross must not be given a +; rely on rust-toolchain.toml / + # rustup override. + offenders = [ + str(arg) for arg in cmd[1:] if isinstance(arg, str) and arg.startswith("+") + ] + if offenders: + message = ( + "cross command must not include a + override; " + f"found: {offenders!r}" + ) + raise ValueError(message) + + def _build_cross_command( decision: _CrossDecision, target_to_build: str, manifest_path: Path, features: str ) -> SupportsFormulate: + """Build a cross command argv and validate it contains no +toolchain.""" cross_executable = decision.cross_path or "cross" executor = local[cross_executable] - build_cmd = executor[ + cmd: list[object] = [ + cross_executable, "build", "--manifest-path", str(manifest_path), @@ -516,7 +524,9 @@ def _build_cross_command( ] normalized_features = _normalize_features(features) if normalized_features: - build_cmd = build_cmd["--features", normalized_features] + cmd.extend(["--features", normalized_features]) + _assert_cross_command_has_no_toolchain_override(cmd) + build_cmd = executor[cmd[1:]] if decision.cross_path: build_cmd = _CommandWrapper(build_cmd, Path(decision.cross_path).name) return build_cmd @@ -525,9 +535,9 @@ def _build_cross_command( def _build_cargo_command( cargo_toolchain_spec: str, target_to_build: str, manifest_path: Path, features: str ) -> SupportsFormulate: + """Build a cargo command argv, preserving any configured +toolchain.""" executor = local["cargo"] - build_cmd = executor[ - cargo_toolchain_spec, + cmd = [ "build", "--manifest-path", str(manifest_path), @@ -537,8 +547,12 @@ def _build_cargo_command( ] normalized_features = _normalize_features(features) if normalized_features: - build_cmd = build_cmd["--features", normalized_features] - return _CommandWrapper(build_cmd, "cargo") + cmd.extend(["--features", normalized_features]) + if cargo_toolchain_spec: + cmd.insert(0, cargo_toolchain_spec) + build_cmd = executor[cmd] + wrapped_cmd = _CommandWrapper(build_cmd, "cargo") + return wrapped_cmd # noqa: RET504 - keep the named command for call-site logging. def _handle_cross_container_error( @@ -548,28 +562,30 @@ def _handle_cross_container_error( manifest_path: Path, features: str, ) -> None: + """Handle cross container startup failures or re-raise other errors.""" if decision.use_cross and exc.retcode in CROSS_CONTAINER_ERROR_CODES: if decision.requires_cross_container and not decision.use_cross_local_backend: engine = decision.container_engine or "unknown" - typer.echo( - "::error:: cross failed to start a container runtime for " - f"target '{target_to_build}' (engine={engine})", - err=True, + gha_error( + "cross failed to start a container runtime for " + f"target '{target_to_build}' (engine={engine})" ) raise typer.Exit(exc.retcode) from exc - typer.echo( - "::warning:: cross failed to start a container; retrying with cargo", - err=True, - ) + gha_warning("cross failed to start a container; retrying with cargo") fallback_cmd = _build_cargo_command( decision.cargo_toolchain_spec, target_to_build, manifest_path, features, ) + gha_debug(f"fallback cargo argv: {fallback_cmd}") run_cmd(fallback_cmd) + gha_debug(f"fallback cargo build completed for target '{target_to_build}'") return + gha_error( + f"cross build failed for target '{target_to_build}' (retcode={exc.retcode})" + ) raise exc @@ -588,10 +604,7 @@ def _resolve_manifest_path() -> Path: manifest_location = manifest_location.resolve() if not manifest_location.is_file(): - typer.echo( - f"::error:: Cargo manifest not found at {manifest_location}", - err=True, - ) + gha_error(f"Cargo manifest not found at {manifest_location}") raise typer.Exit(1) return manifest_location @@ -605,6 +618,51 @@ def _manifest_argument(manifest_path: Path) -> Path: return manifest_path +def _check_target_support( + decision: _CrossDecision, + toolchain_name: str, + target_to_build: str, + *, + target_installed: bool, +) -> None: + """Exit with an error if the toolchain cannot build the requested target.""" + if not target_installed and ( + not decision.use_cross or decision.use_cross_local_backend + ): + gha_error( + f"toolchain '{toolchain_name}' does not support target '{target_to_build}'" + ) + raise typer.Exit(1) + + +def _assemble_build_command( + decision: _CrossDecision, + target_to_build: str, + manifest_argument: Path, + features: str, + explicit_toolchain: str, +) -> tuple[SupportsFormulate | None, str | None]: + """Assemble the build command; return (cmd, None) or (None, error_message).""" + if not decision.use_cross: + cmd = _build_cargo_command( + decision.cargo_toolchain_spec, target_to_build, manifest_argument, features + ) + return cmd, None + try: + build_cmd = _build_cross_command( + decision, target_to_build, manifest_argument, features + ) + except ValueError as exc: + return None, ( + f"cross command validation failed for target '{target_to_build}': {exc}" + ) + if explicit_toolchain: + build_cmd = typ.cast("_SupportsEnvFormulate", build_cmd).with_env( + RUSTUP_TOOLCHAIN=explicit_toolchain + ) + return build_cmd, None + + @app.command() def main( target: str = typer.Argument("", help="Target triple to build"), @@ -636,43 +694,33 @@ def main( target_installed = _ensure_target_installed( rustup_exec, toolchain_name, target_to_build ) - configure_windows_linkers(toolchain_name, target_to_build, rustup_exec) - host_target = DEFAULT_HOST_TARGET decision = _decide_cross_usage(toolchain_name, target_to_build, host_target) - _validate_cross_requirements(decision, target_to_build, host_target) - - if not target_installed and ( - not decision.use_cross or decision.use_cross_local_backend - ): - typer.echo( - f"::error:: toolchain '{toolchain_name}' does not support " - f"target '{target_to_build}'", - err=True, - ) - raise typer.Exit(1) - + _check_target_support( + decision, toolchain_name, target_to_build, target_installed=target_installed + ) _announce_build_mode(decision) - previous_engine, applied_engine = _configure_cross_container_engine(decision) - - manifest_argument = _manifest_argument(manifest_path) - if decision.use_cross: - build_cmd = _build_cross_command( - decision, target_to_build, manifest_argument, features - ) - if explicit_toolchain: - build_cmd = typ.cast("_SupportsEnvFormulate", build_cmd).with_env( - RUSTUP_TOOLCHAIN=toolchain_name - ) - else: - build_cmd = _build_cargo_command( - decision.cargo_toolchain_spec, target_to_build, manifest_argument, features - ) try: + manifest_argument = _manifest_argument(manifest_path) + build_cmd, assemble_error = _assemble_build_command( + decision, + target_to_build, + manifest_argument, + features, + explicit_toolchain, + ) + if assemble_error is not None: + gha_error(assemble_error) + raise typer.Exit(1) + if decision.use_cross: + gha_debug(f"cross argv: {build_cmd}") + else: + gha_debug(f"cargo argv: {build_cmd}") run_cmd(build_cmd) + gha_debug(f"build completed for target '{target_to_build}'") except ProcessExecutionError as exc: _handle_cross_container_error( exc, decision, target_to_build, manifest_argument, features diff --git a/.github/actions/rust-build-release/tests/__snapshots__/test_commands.ambr b/.github/actions/rust-build-release/tests/__snapshots__/test_commands.ambr new file mode 100644 index 00000000..fc7fa8df --- /dev/null +++ b/.github/actions/rust-build-release/tests/__snapshots__/test_commands.ambr @@ -0,0 +1,4 @@ +# serializer version: 1 +# name: test_cross_debug_output_snapshot + '::debug:: cross argv: cross build --manifest-path /snapshot/Cargo.toml --release --target aarch64-unknown-linux-gnu' +# --- diff --git a/.github/actions/rust-build-release/tests/conftest.py b/.github/actions/rust-build-release/tests/conftest.py index 31b736aa..bd7d095e 100644 --- a/.github/actions/rust-build-release/tests/conftest.py +++ b/.github/actions/rust-build-release/tests/conftest.py @@ -122,6 +122,7 @@ def isolated_rust_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def _ensure_dependency(name: str, attribute: str | None = None) -> None: + """Skip the test if *name* is not importable or lacks *attribute*.""" try: module = importlib.import_module(name) except ModuleNotFoundError: # pragma: no cover - environment guard @@ -136,6 +137,7 @@ def _load_module( *, deps: cabc.Sequence[tuple[str, str | None]] = (), ) -> ModuleType: + """Load *filename* as *module_name*, skipping if any *deps* are absent.""" prepend_to_syspath(SRC_DIR) for dep_name, attr in deps: _ensure_dependency(dep_name, attr) @@ -153,6 +155,7 @@ class ModuleHarness: """Utility wrapper around a loaded module for patching helpers.""" def __init__(self, module: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None: + """Bind *module* and *monkeypatch* and initialise the call log.""" self.module = module self.monkeypatch = monkeypatch self.calls: list[list[str]] = [] @@ -197,16 +200,19 @@ def patch_subprocess_run( class _DummyCommand: def __init__(self, name: str = "dummy") -> None: + """Initialise a dummy command with *name* and an empty env.""" self._name = name self.env: dict[str, str] = {} def formulate(self) -> list[str]: + """Return a single-element argv containing the command name.""" return [self._name] def __call__(self, *_args: object, **_kwargs: object) -> None: - return None + """Accept and discard any invocation arguments.""" def with_env(self, *args: object, **kwargs: str) -> _DummyCommand: + """Return a copy of this command with the supplied env bindings merged.""" env_from_args: dict[str, str] = {} for arg in args: if not isinstance(arg, cabc.Mapping): @@ -237,9 +243,36 @@ class CrossDecision(typ.Protocol): DummyCommandFactory = cabc.Callable[..., _DummyCommand] +class _EchoRecorder(list[str]): + """Capture echo output in two modes for rust-build-release tests. + + _EchoRecorder itself subclasses list[str] so the echo_recorder fixture can + collect global typer.echo messages patched through monkeypatch. Calling + _EchoRecorder.__call__(module) patches module.typer.echo with fake_echo and + returns a separate list[tuple[str, bool]] of (message, err) pairs for + module-scoped captures that need stderr tracking. + """ + + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Initialise the recorder with the provided *monkeypatch* instance.""" + super().__init__() + self._monkeypatch = monkeypatch + + def __call__(self, module: ModuleType) -> list[tuple[str, bool]]: + """Patch *module*.typer.echo to record (message, err) tuples.""" + messages: list[tuple[str, bool]] = [] + + def fake_echo(message: str, *, err: bool = False) -> None: + messages.append((message, err)) + + self._monkeypatch.setattr(module.typer, "echo", fake_echo) + return messages + + def _cross_decision( main_module: ModuleType, *, use_cross: bool, requires_container: bool = False ) -> CrossDecision: + """Return a _CrossDecision with common test defaults.""" return main_module._CrossDecision( # type: ignore[attr-defined] cross_path="/usr/bin/cross" if use_cross else None, cross_version="0.2.5", @@ -279,21 +312,17 @@ def factory( @pytest.fixture -def echo_recorder( - monkeypatch: pytest.MonkeyPatch, -) -> cabc.Callable[[ModuleType], list[tuple[str, bool]]]: - """Return a helper that patches ``typer.echo`` and records messages.""" - - def install(module: ModuleType) -> list[tuple[str, bool]]: - messages: list[tuple[str, bool]] = [] +def echo_recorder(monkeypatch: pytest.MonkeyPatch) -> _EchoRecorder: + """Capture all typer.echo calls and return the recorded lines.""" + import typer - def fake_echo(message: str, *, err: bool = False) -> None: - messages.append((message, err)) + lines = _EchoRecorder(monkeypatch) - monkeypatch.setattr(module.typer, "echo", fake_echo) - return messages + def record_echo(msg: object = "", **_kwargs: object) -> None: + lines.append(str(msg)) - return install + monkeypatch.setattr(typer, "echo", record_echo) + return lines @pytest.fixture diff --git a/.github/actions/rust-build-release/tests/test_commands.py b/.github/actions/rust-build-release/tests/test_commands.py new file mode 100644 index 00000000..812c1e04 --- /dev/null +++ b/.github/actions/rust-build-release/tests/test_commands.py @@ -0,0 +1,707 @@ +"""Regression tests for rust-build-release command construction.""" + +from __future__ import annotations + +import contextlib +import dataclasses +import importlib.util +import stat +import sys +import typing as typ +from pathlib import Path, PurePosixPath + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from plumbum.commands.processes import ProcessExecutionError +from rust_build_release_test_helpers import assert_no_toolchain_override + +SRC_DIR = Path(__file__).resolve().parents[1] / "src" +REPO_ROOT = Path(__file__).resolve().parents[4] + +if typ.TYPE_CHECKING: + from types import ModuleType + +ALNUM_TEXT = st.text( + alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd")), + min_size=1, +) + + +@dataclasses.dataclass(frozen=True) +class CrossDecisionConfig: + """Overridable fields for constructing a _CrossDecision in tests.""" + + cargo_toolchain_spec: str + requires_cross_container: bool = False + use_cross_local_backend: bool = False + + +@contextlib.contextmanager +def _preserve_packaging_version() -> typ.Iterator[None]: + """Remove packaging.version after loading when the import side effect remains.""" + import packaging + import packaging.version as packaging_version + + try: + yield + finally: + if getattr(packaging, "version", None) is packaging_version: + delattr(packaging, "version") + + +def _make_cross_executable(tmp_path: Path) -> Path: + """Create a minimal executable cross stub at *tmp_path*.""" + cross_path = tmp_path / "cross" + cross_path.write_text("#!/bin/sh\n", encoding="utf-8") + cross_path.chmod( + cross_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + return cross_path + + +def _load_main_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType: + """Load main.py as a fresh module after patching the action path.""" + monkeypatch.setenv("GITHUB_ACTION_PATH", str(REPO_ROOT)) + monkeypatch.syspath_prepend(str(SRC_DIR)) + sys.modules.pop("gha", None) + + spec = importlib.util.spec_from_file_location( + "rbr_main_commands", SRC_DIR / "main.py" + ) + if spec is None or spec.loader is None: + msg = f"failed to load main.py from {SRC_DIR}" + raise RuntimeError(msg) + + module = importlib.util.module_from_spec(spec) + with _preserve_packaging_version(): + spec.loader.exec_module(module) + return module + + +def _make_cross_decision( + main_module: ModuleType, + cross_path: Path | str | None, + config: CrossDecisionConfig, +) -> object: + """Construct a _CrossDecision from the given module, path, and config.""" + return main_module._CrossDecision( + cross_path=str(cross_path) if cross_path is not None else None, + cross_version="0.2.5", + use_cross=True, + cargo_toolchain_spec=config.cargo_toolchain_spec, + use_cross_local_backend=config.use_cross_local_backend, + docker_present=True, + podman_present=False, + has_container=True, + container_engine="docker", + requires_cross_container=config.requires_cross_container, + ) + + +def test_build_cross_command_never_injects_toolchain_override( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Cross commands start with `cross build` and omit +toolchain arguments.""" + main_module = _load_main_module(monkeypatch) + cross_path = _make_cross_executable(tmp_path) + decision = main_module._CrossDecision( + cross_path=str(cross_path), + cross_version="0.2.5", + use_cross=True, + cargo_toolchain_spec="+bogus-nightly", + use_cross_local_backend=False, + docker_present=True, + podman_present=False, + has_container=True, + container_engine="docker", + requires_cross_container=False, + ) + + cmd = main_module._build_cross_command( + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + ) + + parts = list(cmd.formulate()) + assert parts[:3] == ["cross", "build", "--manifest-path"] + assert_no_toolchain_override(parts) + + +def test_build_cross_command_calls_toolchain_override_guard( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """_build_cross_command invokes the override guard exactly once.""" + import unittest.mock as mock + + main_module = _load_main_module(monkeypatch) + cross_path = _make_cross_executable(tmp_path) + decision = main_module._CrossDecision( + cross_path=str(cross_path), + cross_version="0.2.5", + use_cross=True, + cargo_toolchain_spec="+bogus-nightly", + use_cross_local_backend=False, + docker_present=True, + podman_present=False, + has_container=True, + container_engine="docker", + requires_cross_container=False, + ) + + real_guard = main_module._assert_cross_command_has_no_toolchain_override + with mock.patch.object( + main_module, + "_assert_cross_command_has_no_toolchain_override", + wraps=real_guard, + ) as spy: + main_module._build_cross_command( + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + ) + + spy.assert_called_once() + + +def test_build_cross_command_rejects_injected_toolchain_override( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """_build_cross_command raises ValueError when argv would contain +toolchain.""" + main_module = _load_main_module(monkeypatch) + cross_path = _make_cross_executable(tmp_path) + + # Patch the guard to inject a +toolchain token after the fact + original_guard = main_module._assert_cross_command_has_no_toolchain_override + + def injecting_guard(_cmd: object) -> None: + # Simulate the bug: a +toolchain has been inserted into the argv + original_guard(["cross", "+injected-nightly", "build"]) + + monkeypatch.setattr( + main_module, "_assert_cross_command_has_no_toolchain_override", injecting_guard + ) + decision = _make_cross_decision( + main_module, + cross_path, + CrossDecisionConfig(cargo_toolchain_spec="+bogus-nightly"), + ) + + with pytest.raises(ValueError, match=r"\+"): + main_module._build_cross_command( + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + ) + + +@pytest.mark.parametrize( + ("cargo_toolchain_spec", "features", "expected_prefix"), + [ + pytest.param( + "+nightly", + "", + ["+nightly", "build"], + id="toolchain_spec_prepended", + ), + pytest.param( + "", + "", + ["build"], + id="no_toolchain_spec", + ), + pytest.param( + "+stable", + "async,tls", + ["+stable", "build"], + id="with_features", + ), + ], +) +def test_build_cargo_command_argv_shape( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + cargo_toolchain_spec: str, + features: str, + expected_prefix: list[str], +) -> None: + """_build_cargo_command produces the expected argv shape.""" + main_module = _load_main_module(monkeypatch) + manifest = tmp_path / "Cargo.toml" + + cmd = main_module._build_cargo_command( + cargo_toolchain_spec, "aarch64-unknown-linux-gnu", manifest, features + ) + parts = list(cmd.formulate()) + + assert parts[0] == "cargo" + assert parts[1 : 1 + len(expected_prefix)] == expected_prefix + assert "--manifest-path" in parts + assert "--release" in parts + assert "--target" in parts + assert "aarch64-unknown-linux-gnu" in parts + if features: + assert "--features" in parts + feat_idx = parts.index("--features") + assert parts[feat_idx + 1] == features + + +@pytest.fixture +def cross_module_context( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> tuple[ModuleType, Path]: + """Load the main module and create a stub cross executable.""" + main_module = _load_main_module(monkeypatch) + cross_path = _make_cross_executable(tmp_path) + return main_module, cross_path + + +@settings( + max_examples=40, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(target=ALNUM_TEXT, features=ALNUM_TEXT, manifest_stem=ALNUM_TEXT) +def test_build_cross_command_property_omits_toolchain_override( + cross_module_context: tuple[ModuleType, Path], + target: str, + features: str, + manifest_stem: str, +) -> None: + """Generated cross commands never include +toolchain tokens after argv[0].""" + main_module, cross_path = cross_module_context + decision = _make_cross_decision( + main_module, + cross_path, + CrossDecisionConfig(cargo_toolchain_spec="+bogus-nightly"), + ) + + cmd = main_module._build_cross_command( + decision, + target, + Path(f"{manifest_stem}.toml"), + features, + ) + + assert_no_toolchain_override(list(cmd.formulate())) + + +@settings( + max_examples=40, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(prefix=st.lists(ALNUM_TEXT), override=ALNUM_TEXT, suffix=st.lists(ALNUM_TEXT)) +def test_cross_command_guard_rejects_any_generated_toolchain_override( + monkeypatch: pytest.MonkeyPatch, + prefix: list[str], + override: str, + suffix: list[str], +) -> None: + """The cross guard rejects every +token after the executable.""" + main_module = _load_main_module(monkeypatch) + argv = ["cross", *prefix, f"+{override}", *suffix] + + with pytest.raises( + ValueError, + match=r"cross command must not include a \+ override", + ): + main_module._assert_cross_command_has_no_toolchain_override(argv) + + +@settings( + max_examples=40, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(args=st.lists(ALNUM_TEXT)) +def test_cross_command_guard_accepts_generated_argv_without_toolchain_override( + monkeypatch: pytest.MonkeyPatch, + args: list[str], +) -> None: + """The cross guard accepts generated argv values with no +token.""" + main_module = _load_main_module(monkeypatch) + + main_module._assert_cross_command_has_no_toolchain_override(["cross", *args]) + + +def test_cross_debug_output_snapshot( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + echo_recorder: list[str], + snapshot: object, +) -> None: + """The ::debug:: cross argv line matches the recorded snapshot.""" + main_module = _load_main_module(monkeypatch) + manifest_path = tmp_path / "Cargo.toml" + manifest_path.write_text( + "[package]\nname='demo'\nversion='0.1.0'\n", encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + cross_path = _make_cross_executable(tmp_path) + # Use a stable manifest path suffix for the snapshot + stable_manifest = PurePosixPath("/snapshot/Cargo.toml") + decision = _make_cross_decision( + main_module, + cross_path, + CrossDecisionConfig(cargo_toolchain_spec="+bogus-nightly"), + ) + monkeypatch.setattr( + main_module, + "resolve_requested_toolchain", + lambda *_args, **_kw: "bogus-nightly", + ) + monkeypatch.setattr(main_module, "_ensure_rustup_exec", lambda: "rustup") + monkeypatch.setattr( + main_module, "_resolve_toolchain", lambda *_args: "bogus-nightly" + ) + monkeypatch.setattr(main_module, "_ensure_target_installed", lambda *_args: True) + monkeypatch.setattr(main_module, "configure_windows_linkers", lambda *_args: None) + monkeypatch.setattr(main_module, "_decide_cross_usage", lambda *_args: decision) + monkeypatch.setattr( + main_module, "_validate_cross_requirements", lambda *_args: None + ) + monkeypatch.setattr(main_module, "_announce_build_mode", lambda *_args: None) + monkeypatch.setattr(main_module, "run_cmd", lambda *_args: None) + monkeypatch.setattr( + main_module, "_manifest_argument", lambda _path: stable_manifest + ) + + main_module.main("aarch64-unknown-linux-gnu", "") + + debug_lines = [ln for ln in echo_recorder if "cross argv" in ln] + assert len(debug_lines) == 1 + assert debug_lines[0] == snapshot + + +def test_assemble_build_command_sets_rustup_toolchain_env_when_explicit_toolchain_given( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """When an explicit toolchain is supplied, RUSTUP_TOOLCHAIN is injected.""" + main_module = _load_main_module(monkeypatch) + cross_path = _make_cross_executable(tmp_path) + decision = _make_cross_decision( + main_module, + cross_path, + CrossDecisionConfig(cargo_toolchain_spec=""), + ) + + cmd, error_msg = main_module._assemble_build_command( + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + "nightly", # explicit_toolchain - non-empty triggers with_env + ) + assert error_msg is None + assert cmd is not None + + # The returned wrapper must carry the RUSTUP_TOOLCHAIN binding. + env = getattr(cmd, "env", None) or getattr(cmd._command, "env", {}) or {} + assert env.get("RUSTUP_TOOLCHAIN") == "nightly", ( + f"Expected RUSTUP_TOOLCHAIN=nightly in env, got: {env}" + ) + + +@pytest.mark.parametrize( + ("target_installed", "expect_exit"), + [ + pytest.param(False, True, id="exits_when_not_installed_and_no_cross"), + pytest.param(True, False, id="passes_when_target_installed"), + ], +) +def test_check_target_support( + monkeypatch: pytest.MonkeyPatch, + target_installed: bool, # noqa: FBT001 + expect_exit: bool, # noqa: FBT001 +) -> None: + """_check_target_support exits only when target is missing and cross is disabled.""" + main_module = _load_main_module(monkeypatch) + decision = main_module._CrossDecision( + cross_path=None, + cross_version=None, + use_cross=False, + cargo_toolchain_spec="+bogus-nightly", + use_cross_local_backend=False, + docker_present=False, + podman_present=False, + has_container=False, + container_engine=None, + requires_cross_container=False, + ) + + ctx = ( + pytest.raises(main_module.typer.Exit) + if expect_exit + else contextlib.nullcontext() + ) + with ctx: + main_module._check_target_support( + decision, + "bogus-nightly", + "aarch64-unknown-linux-gnu", + target_installed=target_installed, + ) + + +def test_assemble_build_command_returns_cargo_when_use_cross_false( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Build command assembly returns cargo when cross is disabled.""" + main_module = _load_main_module(monkeypatch) + decision = main_module._CrossDecision( + cross_path=None, + cross_version=None, + use_cross=False, + cargo_toolchain_spec="+bogus-nightly", + use_cross_local_backend=False, + docker_present=False, + podman_present=False, + has_container=False, + container_engine=None, + requires_cross_container=False, + ) + + cmd, error_msg = main_module._assemble_build_command( + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + "", + ) + + assert error_msg is None + assert cmd is not None + parts = list(cmd.formulate()) + assert parts[0] == "cargo" + + +def test_assemble_build_command_returns_error_message_on_toolchain_override( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Build command assembly returns an error message when cross validation fails.""" + main_module = _load_main_module(monkeypatch) + decision = _make_cross_decision( + main_module, + "/usr/bin/cross", + CrossDecisionConfig(cargo_toolchain_spec="+bogus-nightly"), + ) + + def fail_build_cross_command(*_args: object) -> object: + msg = "cross command must not include a + override" + raise ValueError(msg) + + monkeypatch.setattr(main_module, "_build_cross_command", fail_build_cross_command) + + cmd, error_msg = main_module._assemble_build_command( + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + "", + ) + + assert cmd is None + assert error_msg is not None + assert "cross command validation failed" in error_msg + + +def test_main_emits_error_annotation_when_cross_guard_raises( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + echo_recorder: list[str], +) -> None: + """main() emits ::error:: and exits 1 when the cross guard raises ValueError.""" + main_module = _load_main_module(monkeypatch) + manifest_path = tmp_path / "Cargo.toml" + manifest_path.write_text( + "[package]\nname='demo'\nversion='0.1.0'\n", encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + cross_path = _make_cross_executable(tmp_path) + decision = _make_cross_decision( + main_module, + cross_path, + CrossDecisionConfig(cargo_toolchain_spec="+bogus-nightly"), + ) + + def bad_build_cross(*_args: object) -> object: + msg = ( + "cross command must not include a + override; " + "found: ['+bogus-nightly']" + ) + raise ValueError(msg) + + monkeypatch.setattr( + main_module, + "resolve_requested_toolchain", + lambda *_args, **_kw: "bogus-nightly", + ) + monkeypatch.setattr(main_module, "_ensure_rustup_exec", lambda: "rustup") + monkeypatch.setattr( + main_module, "_resolve_toolchain", lambda *_args: "bogus-nightly" + ) + monkeypatch.setattr(main_module, "_ensure_target_installed", lambda *_args: True) + monkeypatch.setattr(main_module, "configure_windows_linkers", lambda *_args: None) + monkeypatch.setattr(main_module, "_decide_cross_usage", lambda *_args: decision) + monkeypatch.setattr( + main_module, "_validate_cross_requirements", lambda *_args: None + ) + monkeypatch.setattr(main_module, "_announce_build_mode", lambda *_args: None) + monkeypatch.setattr(main_module, "run_cmd", lambda *_args: None) + monkeypatch.setattr(main_module, "_build_cross_command", bad_build_cross) + + with pytest.raises(main_module.typer.Exit) as exc_info: + main_module.main("aarch64-unknown-linux-gnu", "") + + assert exc_info.value.exit_code == 1 + error_lines = [ + line for line in echo_recorder if "cross command validation failed" in line + ] + assert len(error_lines) == 1 + assert "aarch64-unknown-linux-gnu" in error_lines[0] + + +@pytest.mark.parametrize( + ("cargo_toolchain_spec", "extra_args"), + [ + pytest.param( + "+bogus-nightly", + ["+bogus-nightly"], + id="keeps_toolchain_override", + ), + pytest.param("", [], id="omits_toolchain_override"), + ], +) +def test_cross_container_fallback_cargo_toolchain( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + cargo_toolchain_spec: str, + extra_args: list[str], +) -> None: + """Container-error fallback emits the correct cargo argv for the toolchain spec.""" + main_module = _load_main_module(monkeypatch) + calls: list[list[str]] = [] + + def fake_run_cmd(cmd: object) -> None: + formulated = list(cmd.formulate()) + if formulated: + formulated[0] = Path(formulated[0]).name + calls.append(formulated) + + monkeypatch.setattr(main_module, "run_cmd", fake_run_cmd) + decision = main_module._CrossDecision( + cross_path="/usr/bin/cross", + cross_version="0.2.5", + use_cross=True, + cargo_toolchain_spec=cargo_toolchain_spec, + use_cross_local_backend=False, + docker_present=True, + podman_present=False, + has_container=True, + container_engine="docker", + requires_cross_container=False, + ) + exc = ProcessExecutionError(["cross", "build"], 125, "", "") + + main_module._handle_cross_container_error( + exc, + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + ) + + manifest_path = str(tmp_path / "Cargo.toml") + expected = [ + "cargo", + *extra_args, + "build", + "--manifest-path", + manifest_path, + "--release", + "--target", + "aarch64-unknown-linux-gnu", + ] + assert calls == [expected] + + +def test_cross_container_error_with_other_retcode_reraises_original_exception( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + echo_recorder: list[str], +) -> None: + """Non-container cross failures are re-raised unchanged.""" + main_module = _load_main_module(monkeypatch) + decision = _make_cross_decision( + main_module, + "/usr/bin/cross", + CrossDecisionConfig(cargo_toolchain_spec="+bogus-nightly"), + ) + exc = ProcessExecutionError(["cross", "build"], 2, "", "") + + with pytest.raises(ProcessExecutionError) as raised: + main_module._handle_cross_container_error( + exc, + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + ) + + assert raised.value is exc + assert any("cross build failed" in ln for ln in echo_recorder) + + +def test_required_cross_container_error_exits_without_fallback( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + echo_recorder: list[str], +) -> None: + """Required container failures are emitted as errors and exit.""" + main_module = _load_main_module(monkeypatch) + decision = _make_cross_decision( + main_module, + "/usr/bin/cross", + CrossDecisionConfig( + cargo_toolchain_spec="+bogus-nightly", + requires_cross_container=True, + ), + ) + exc = ProcessExecutionError(["cross", "build"], 125, "", "") + + with pytest.raises(main_module.typer.Exit): + main_module._handle_cross_container_error( + exc, + decision, + "aarch64-unknown-linux-gnu", + tmp_path / "Cargo.toml", + "", + ) + + assert echo_recorder == [ + "::error:: cross failed to start a container runtime for target " + "'aarch64-unknown-linux-gnu' (engine=docker)" + ] + + +def test_cross_command_guard_rejects_toolchain_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The cross argv guard catches accidental +toolchain injection.""" + main_module = _load_main_module(monkeypatch) + with pytest.raises( + ValueError, + match=r"cross command must not include a \+ override", + ): + main_module._assert_cross_command_has_no_toolchain_override( + ["cross", "build", "--manifest-path", "Cargo.toml", "+bogus-nightly"] + ) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7f56f470 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Refactored `rust-build-release` command construction to assemble argv lists + before handing commands to plumbum. +- Added Makefile tool discovery through candidate path lists for `RUFF` and + `ACTION_VALIDATOR`. + +### Added + +- Added a cross command validation guard that rejects `+` overrides. +- Added the `rust-build-release` `test_commands.py` regression test module. diff --git a/Makefile b/Makefile index 7949bb03..d1414eb4 100644 --- a/Makefile +++ b/Makefile @@ -14,8 +14,14 @@ clean: ## Remove transient artefacts rm -rf .venv .pytest_cache .ruff_cache workspace/.ruff_cache BUILD_JOBS ?= -MDLINT ?= markdownlint +MARKDOWNLINT_BASE ?= origin/main +MDLINT_CANDIDATES := $(wildcard $(HOME)/.bun/bin/markdownlint $(HOME)/.local/bin/markdownlint /usr/local/bin/markdownlint /usr/bin/markdownlint) +MDLINT ?= $(if $(MDLINT_CANDIDATES),$(firstword $(MDLINT_CANDIDATES)),markdownlint) NIXIE ?= nixie +ACTION_VALIDATOR_CANDIDATES := $(wildcard $(HOME)/.cargo/bin/action-validator $(HOME)/.bun/bin/action-validator /usr/local/bin/action-validator /usr/bin/action-validator) +ACTION_VALIDATOR ?= $(if $(ACTION_VALIDATOR_CANDIDATES),$(firstword $(ACTION_VALIDATOR_CANDIDATES)),action-validator) +RUFF_CANDIDATES := $(wildcard $(CURDIR)/.venv/bin/ruff $(HOME)/.local/bin/ruff /usr/local/bin/ruff /usr/bin/ruff) +RUFF ?= $(if $(RUFF_CANDIDATES),$(firstword $(RUFF_CANDIDATES)),uv tool run ruff) RUFF_FIX_RULES ?= D202,I001 test: .venv ## Run tests @@ -30,9 +36,9 @@ endif uv sync --group dev lint: ## Check test scripts and actions - uvx ruff check + $(RUFF) check find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | xargs -r -0 -n1 action-validator + | xargs -r -0 -n1 $(ACTION_VALIDATOR) typecheck: .venv ## Run static type checking with Ty ./.venv/bin/ty check \ @@ -58,15 +64,17 @@ typecheck: .venv ## Run static type checking with Ty --extra-search-path .github/actions/macos-package/scripts \ .github/actions/macos-package/scripts fmt: ## Format Python files and auto-fix selected lint rules - uvx ruff format - uvx ruff check --select $(RUFF_FIX_RULES) --fix + $(RUFF) format + $(RUFF) check --select $(RUFF_FIX_RULES) --fix check-fmt: ## Check Python formatting without modifying files - uvx ruff format --check - uvx ruff check --select $(RUFF_FIX_RULES) + $(RUFF) format --check + $(RUFF) check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files - find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT) + $(eval MARKDOWNLINT_DIFF_BASE := $(shell git rev-parse --verify --quiet $(MARKDOWNLINT_BASE) >/dev/null && printf '%s' '$(MARKDOWNLINT_BASE)' || printf '%s' 'HEAD')) + git diff --name-only --diff-filter=ACMRT $(MARKDOWNLINT_DIFF_BASE) -- '*.md' \ + | xargs -r -- $(MDLINT) nixie: ## Validate Mermaid diagrams find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(NIXIE) diff --git a/docs/developers-guide.md b/docs/developers-guide.md new file mode 100644 index 00000000..b8ab584c --- /dev/null +++ b/docs/developers-guide.md @@ -0,0 +1,86 @@ +# Development + +## argv assembly pattern + +`rust-build-release` builds process invocations as plain argv lists before +handing them to plumbum. `_build_cross_command` assembles the final `cross` +argv, including manifest, release, target, and feature arguments, validates that +list, and then resolves `executor[cmd[1:]]` because plumbum already supplies the +executable. `_build_cargo_command` follows the same list-first pattern for +`cargo`, inserting the configured cargo toolchain override only after all normal +arguments have been assembled. + +This keeps ordering explicit, makes tests assert the exact command shape, and +avoids hidden post-construction mutations of plumbum command objects. + +## Cross command validation guard + +`_assert_cross_command_has_no_toolchain_override` rejects any `cross` argv that +contains a `+` argument after the executable. `cross` must not +receive a rustup toolchain override in argv; the toolchain is controlled by +`rust-toolchain.toml` or `RUSTUP_TOOLCHAIN` instead. + +The guard raises `ValueError` so command construction fails before execution. +`main()` catches that error, emits a GitHub Actions `::error::` annotation that +includes the affected target, and exits with status 1. + +## Makefile tool discovery + +The Makefile resolves `ACTION_VALIDATOR` and `RUFF` from candidate path lists +before falling back to the variable value provided by the caller. The action +validator candidates include common Cargo, Bun, and system installation paths. +The Ruff candidates include the local virtual environment, user, and system +installation paths. + +This lets CI and local developer machines find tools even when their package +manager does not place shims on `PATH`. + +## gha adapter module + +`src/gha.py` provides three thin wrappers — `debug`, `warning`, and `error` -- +that prepend the appropriate GitHub Actions workflow command prefix +(`::debug::`, `::warning::`, `::error::`) before delegating to an injected +`echo` callable (defaulting to `typer.echo`). All annotation emission in +`main.py` is routed through this module so the formatting is consistent and +testable. + + +## _assemble_build_command and _check_target_support helpers + +`_assemble_build_command` is a pure query: it returns either `(cmd, None)` on +success or `(None, error_message)` on validation failure. All side-effects +(annotation emission and process exit) are the responsibility of `main()`. + +`_check_target_support` checks whether a given toolchain can build a requested +target without cross; it raises `typer.Exit(1)` when the target is unsupported +and cross is disabled. This side-effect is acceptable here because target +support is a hard precondition, not a recoverable error. + +## ValueError-to-annotation flow + +When `_build_cross_command` raises `ValueError` (e.g. because the guard +detects a `+` token), `_assemble_build_command` returns the error +message as the second tuple element. `main()` then calls `gha_error` with that +message and raises `typer.Exit(1)`. This keeps the domain logic free of +framework concerns whilst preserving rich error context in the annotation. + +## _CommandWrapper refactoring + +`_CommandWrapper` was refactored to be a pure value object: it holds no +injected echo callable, and `formulate()` is a side-effect-free query. A +`_validate_formulation` module-level helper raises `TypeError` when the +wrapped command does not expose a callable `formulate()`, and is called from +`__init__` and `with_env`. + +## Test strategy + +The test suite uses: + +- **pytest** for unit and integration tests. +- **Hypothesis** (`hypothesis>=6.100`) for property-based tests that validate + the `+` guard invariant across randomised argv shapes. +- **syrupy** (`syrupy>=4.0`) for snapshot tests that record the exact + `::debug:: cross argv:` line emitted by `main()`, ensuring format stability + across refactors. +- **`unittest.mock.patch`** as a wrapping spy to verify the guard is called + exactly once during `_build_cross_command` without replacing its behaviour. diff --git a/pyproject.toml b/pyproject.toml index c6f11101..e8037e3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,11 @@ classifiers = [ ] [dependency-groups] dev = [ + "hypothesis>=6.100,<7.0", "lxml-stubs>=0.5.1", "pytest>=8.0,<9.0", "pyyaml>=6.0,<7.0", + "syrupy>=4.0,<5.0", "ty>=0.0.1a20", "uuid6>=2025.0.1", "cmd-mox==0.2.0",