diff --git a/.github/actions/DEVELOPMENT.md b/.github/actions/DEVELOPMENT.md new file mode 100644 index 00000000..af1ac2b9 --- /dev/null +++ b/.github/actions/DEVELOPMENT.md @@ -0,0 +1,101 @@ +# Developer Guide + +This document explains the internal action architecture added across the +toolchain-selection and coverage-overrides work on `rust-build-release` and +`generate-coverage`. + +## Toolchain Resolution (rust-build-release) + +`rust-build-release` resolves the build toolchain in four levels, from most +specific to least specific. + +1. The explicit `toolchain` input passed to the action. +2. The nearest `rust-toolchain.toml` or legacy `rust-toolchain` file found by + walking upward from the manifest directory toward the repository boundary. +3. `package.rust-version` from the manifest, or + `workspace.package.rust-version` for workspace manifests. +4. The action's bundled `TOOLCHAIN_VERSION` file. + +`resolve_requested_toolchain` is the public entry point for this precedence +chain. It trims the explicit input first, then falls back through the +repository toolchain, the manifest MSRV, and finally the bundled default. + +`read_repo_toolchain` handles repository-level discovery. It resolves the +manifest path relative to the project directory, starts from the manifest's +parent directory, and returns the first matching toolchain declaration it finds. + +`_iter_toolchain_search_dirs` performs the upward walk. It yields each search +directory in order, stopping at the first `.git` directory it encounters, at +the filesystem root, or at the optional `stop_at` boundary when one is +provided. `read_repo_toolchain` passes the project directory as that boundary so +the search stays inside the checked-out repository. + +`_parse_toolchain_file` reads each candidate file. It parses TOML +`rust-toolchain.toml` files via `tomllib`, and only falls back to the legacy +line-based format for files literally named `rust-toolchain`. + +`read_manifest_rust_version` is the manifest-level fallback. It loads the Cargo +manifest, checks `package.rust-version`, and if that is absent checks +`workspace.package.rust-version`. + +`read_default_toolchain` is the final fallback. It reads the action's +`TOOLCHAIN_VERSION` file and returns that bundled default string unchanged. + +The result of this chain is used both by the action setup helper and by the +runtime build path, so explicit overrides, repository declarations, direct +Python entry points, and CLI invocation all follow the same resolution model. + +## Cranelift Coverage Override (generate-coverage) + +`cargo llvm-cov` requires LLVM-backed compilation. Projects that enable the +Cranelift backend for development or test profiles can compile normally with +plain Cargo, but `cargo llvm-cov` and the child Cargo commands it spawns cannot +produce usable coverage data with Cranelift enabled. + +`_uses_cranelift_backend(manifest_path)` is the detection entry point. It first +checks the manifest itself through `_manifest_uses_cranelift`, which looks for +`[profile.*].codegen-backend` entries in `Cargo.toml`. If the manifest does not +declare Cranelift, it then walks upward from the manifest directory and scans +`.cargo/config.toml` and `.cargo/config` in each directory. + +`get_cargo_coverage_env(manifest_path)` converts that detection result into the +environment overrides used for coverage runs. It returns an empty mapping for +normal projects. For Cranelift-configured projects it returns a copy of the +override environment containing `CARGO_PROFILE_DEV_CODEGEN_BACKEND=llvm` and +`CARGO_PROFILE_TEST_CODEGEN_BACKEND=llvm`. + +`_run_cargo(args, *, extra_env)` is the subprocess boundary that applies those +overrides. It calls `_build_cargo_command(extra_env)`, which uses +`cargo.with_env(**extra_env)` when overrides are present. That means the +coverage environment is attached to the `cargo llvm-cov` process itself rather +than being passed as outer `cargo --config` flags. Because the environment is +part of the process state, child Cargo invocations spawned by `cargo llvm-cov` +inherit the LLVM backend override automatically. + +This is the important distinction: the action does not try to rewrite profile +settings in the command line. It forces the two Cargo profile environment +variables at process launch so both `cargo llvm-cov` and its child Cargo +processes stay on LLVM for the duration of the coverage run. + +## Adding a New Action + +Keep the action self-contained under `.github/actions//` with its +own `action.yml`, `README.md`, tests, and `CHANGELOG.md`. + +Use test fixtures when the action needs realistic manifests, workflow files, +archives, or directory trees. Keep those fixtures close to the tests, usually +under `tests/fixtures/`, and prefer small, purpose-built examples over copying +large real projects. + +Update the action-local `CHANGELOG.md` for user-visible behavior changes. The +repository uses per-action changelogs rather than one shared release log. + +Keep the action `README.md` complete. At minimum it should explain what the +action does, list inputs and outputs, show an example usage block, and document +behavioral details that users need in order to debug configuration-sensitive +paths such as toolchain resolution or coverage overrides. + +When you add or change action logic, make the docs and fixtures move together. +The README should describe externally visible behavior, the changelog should +record the release-facing change, and the fixtures should cover the branch that +made the change necessary. diff --git a/.github/actions/generate-coverage/CHANGELOG.md b/.github/actions/generate-coverage/CHANGELOG.md index 8d2ba92f..5ec7456e 100644 --- a/.github/actions/generate-coverage/CHANGELOG.md +++ b/.github/actions/generate-coverage/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v1.3.13 (2026-04-16) + +- Override Cranelift coverage builds via + `CARGO_PROFILE_DEV_CODEGEN_BACKEND=llvm` and + `CARGO_PROFILE_TEST_CODEGEN_BACKEND=llvm` so `cargo llvm-cov` child cargo + processes inherit the LLVM backend. +- Remove the outer `cargo --config profile.*.codegen-backend="llvm"` prefix + workaround from Rust coverage command construction. + ## v1.3.12 (2026-02-18) - Add optional `cargo-manifest` input for repositories where `Cargo.toml` diff --git a/.github/actions/generate-coverage/README.md b/.github/actions/generate-coverage/README.md index a223d0de..02dcf711 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -14,6 +14,12 @@ installed automatically. If both configuration files are present, coverage is run for each language and the Cobertura reports are merged using `uvx merge-cobertura`. +If a Rust project enables Cranelift — via `.cargo/config.toml`, +`.cargo/config`, or a `[profile.*].codegen-backend` key in `Cargo.toml` — +the action automatically exports `CARGO_PROFILE_DEV_CODEGEN_BACKEND=llvm` +and `CARGO_PROFILE_TEST_CODEGEN_BACKEND=llvm` for the coverage runs so +`cargo llvm-cov` and its child Cargo processes stay on LLVM. + ## Flow ```mermaid diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index a6c7566f..4fd7fb26 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -17,6 +17,7 @@ import subprocess import sys import threading +import tomllib import traceback import typing as typ from decimal import ROUND_HALF_UP, Decimal @@ -99,22 +100,66 @@ """ -_LLVM_CODEGEN_OVERRIDE = [ - "--config", - 'profile.dev.codegen-backend="llvm"', - "--config", - 'profile.test.codegen-backend="llvm"', -] +_LLVM_CODEGEN_ENV = { + "CARGO_PROFILE_DEV_CODEGEN_BACKEND": "llvm", + "CARGO_PROFILE_TEST_CODEGEN_BACKEND": "llvm", +} + + +def _is_cranelift_backend(value: object) -> bool: + """Return ``True`` when *value* declares the Cranelift codegen backend.""" + if not isinstance(value, str): + return False + return value.strip().casefold() == "cranelift" + + +def _toml_uses_cranelift_backend(content: str) -> bool: + """Return ``True`` when parsed TOML enables Cranelift for dev or test.""" + try: + data = tomllib.loads(content) + except tomllib.TOMLDecodeError: + return False + profile = data.get("profile") + if not isinstance(profile, dict): + return False + for profile_name in ("dev", "test"): + section = profile.get(profile_name) + if not isinstance(section, dict): + continue + if _is_cranelift_backend(section.get("codegen-backend")): + return True + return False + + +def _manifest_uses_cranelift(manifest_path: Path) -> bool: + """Return ``True`` when *manifest_path* declares cranelift in any profile.""" + try: + data = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + return False + profiles = data.get("profile") + if not isinstance(profiles, dict): + return False + return any( + isinstance(section, dict) + and _is_cranelift_backend(section.get("codegen-backend")) + for section in profiles.values() + ) def _uses_cranelift_backend(manifest_path: Path) -> bool: """Return ``True`` when the project configures the Cranelift codegen backend. - Searches from the manifest directory upward for ``.cargo/config.toml`` + Checks ``[profile.*].codegen-backend`` in *manifest_path* itself, then + searches from the manifest directory upward for ``.cargo/config.toml`` (or ``.cargo/config``) and checks whether any profile sets ``codegen-backend = "cranelift"``. """ - search_dir = manifest_path.resolve().parent + resolved = manifest_path.resolve() + if _manifest_uses_cranelift(resolved): + return True + + search_dir = resolved.parent while True: for name in ("config.toml", "config"): candidate = search_dir / ".cargo" / name @@ -123,11 +168,7 @@ def _uses_cranelift_backend(manifest_path: Path) -> bool: content = candidate.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue - if re.search( - r'^[ \t]*codegen-backend\s*=\s*["\']cranelift["\']', - content, - flags=re.MULTILINE, - ): + if _toml_uses_cranelift_backend(content): return True parent = search_dir.parent if parent == search_dir: @@ -147,8 +188,6 @@ def get_cargo_coverage_cmd( ) -> list[str]: """Return the cargo llvm-cov command arguments.""" args: list[str] = [] - if _uses_cranelift_backend(manifest_path): - args += _LLVM_CODEGEN_OVERRIDE args.append("llvm-cov") if use_nextest: args.append("nextest") @@ -161,6 +200,13 @@ def get_cargo_coverage_cmd( return args +def get_cargo_coverage_env(manifest_path: Path) -> dict[str, str]: + """Return extra environment overrides needed for coverage runs.""" + if _uses_cranelift_backend(manifest_path): + return dict(_LLVM_CODEGEN_ENV) + return {} + + def extract_percent(output: str) -> str: """Return the coverage percentage extracted from ``output``.""" match = re.search( @@ -341,10 +387,68 @@ def _pump_cargo_output(proc: subprocess.Popen[str]) -> list[str]: return stdout_lines -def _run_cargo(args: list[str]) -> str: +def _build_cargo_command( + extra_env: dict[str, str] | None, +) -> tuple[str, typ.Any]: + """Return *(display_prefix, cargo_cmd)* reflecting *extra_env*.""" + if not extra_env: + return "", cargo + env_prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in extra_env.items()) + " " + return env_prefix, cargo.with_env(**extra_env) + + +def _abort_on_missing_streams(proc: subprocess.Popen[str]) -> None: + """Kill *proc* and exit with an error if its output streams were not captured.""" + if proc.stdout is not None and proc.stderr is not None: + return + missing_streams = [] + if proc.stdout is None: + missing_streams.append("stdout") + if proc.stderr is None: + missing_streams.append("stderr") + missing = ", ".join(missing_streams) + message = f"cargo output streams not captured: missing {missing}" + with contextlib.suppress(Exception): + proc.kill() + with contextlib.suppress(Exception): + proc.wait(timeout=5) + _safe_close_text_stream(typ.cast("typ.TextIO | None", proc.stdout)) + _safe_close_text_stream(typ.cast("typ.TextIO | None", proc.stderr)) + typer.echo(f"::error::{message}", err=True) + raise typer.Exit(1) from None + + +def _wait_for_cargo( + proc: subprocess.Popen[str], + args: list[str], + wait_timeout: float, +) -> None: + """Wait for *proc* to finish, raising ``typer.Exit`` on timeout or failure.""" + try: + retcode = proc.wait(timeout=wait_timeout) + except subprocess.TimeoutExpired: + typer.echo( + f"::error::cargo did not exit within {wait_timeout}s; killing", + err=True, + ) + with contextlib.suppress(Exception): + proc.kill() + with contextlib.suppress(Exception): + proc.wait(timeout=5) + raise typer.Exit(1) from None + if retcode != 0: + typer.echo( + f"cargo {shlex.join(args)} failed with code {retcode}", + err=True, + ) + raise typer.Exit(code=retcode or 1) + + +def _run_cargo(args: list[str], *, extra_env: dict[str, str] | None = None) -> str: """Run ``cargo`` with ``args`` streaming output and return ``stdout``.""" - typer.echo(f"$ cargo {shlex.join(args)}") - proc = cargo[args].popen( + env_prefix, cargo_cmd = _build_cargo_command(extra_env) + typer.echo(f"$ {env_prefix}cargo {shlex.join(args)}") + proc = cargo_cmd[args].popen( stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -353,42 +457,10 @@ def _run_cargo(args: list[str]) -> str: errors="replace", ) try: - if proc.stdout is None or proc.stderr is None: - missing_streams = [] - if proc.stdout is None: - missing_streams.append("stdout") - if proc.stderr is None: - missing_streams.append("stderr") - missing = ", ".join(missing_streams) - message = f"cargo output streams not captured: missing {missing}" - with contextlib.suppress(Exception): - proc.kill() - with contextlib.suppress(Exception): - proc.wait(timeout=5) - _safe_close_text_stream(proc.stdout) - _safe_close_text_stream(proc.stderr) - typer.echo(f"::error::{message}", err=True) - raise typer.Exit(1) from None + _abort_on_missing_streams(proc) stdout_lines = _pump_cargo_output(proc) wait_timeout = float(os.getenv("RUN_RUST_CARGO_WAIT_TIMEOUT", "600")) - try: - retcode = proc.wait(timeout=wait_timeout) - except subprocess.TimeoutExpired: - typer.echo( - f"::error::cargo did not exit within {wait_timeout}s; killing", - err=True, - ) - with contextlib.suppress(Exception): - proc.kill() - with contextlib.suppress(Exception): - proc.wait(timeout=5) - raise typer.Exit(1) from None - if retcode != 0: - typer.echo( - f"cargo {shlex.join(args)} failed with code {retcode}", - err=True, - ) - raise typer.Exit(code=retcode or 1) + _wait_for_cargo(proc, args, wait_timeout) return "\n".join(stdout_lines) finally: _safe_close_text_stream(proc.stdout) @@ -429,6 +501,7 @@ def run_cucumber_rs_coverage( use_nextest: bool, cucumber_rs_features: str, cucumber_rs_args: str, + extra_env: dict[str, str] | None = None, ) -> None: """Run cucumber.rs coverage and merge results into ``out``.""" cucumber_file = out.with_name(f"{out.stem}.cucumber{out.suffix}") @@ -452,7 +525,7 @@ def run_cucumber_rs_coverage( if cucumber_rs_args: c_args += shlex.split(cucumber_rs_args) - _run_cargo(c_args) + _run_cargo(c_args, extra_env=extra_env) if fmt == "cobertura": from plumbum.cmd import uvx @@ -552,11 +625,12 @@ def main( with_default=with_default, use_nextest=use_nextest, ) + extra_env = get_cargo_coverage_env(manifest_path) config_context = ( ensure_nextest_config() if use_nextest else contextlib.nullcontext() ) with config_context: - stdout = _run_cargo(args) + stdout = _run_cargo(args, extra_env=extra_env) if with_cucumber_rs and cucumber_rs_features: run_cucumber_rs_coverage( @@ -568,6 +642,7 @@ def main( use_nextest=use_nextest, cucumber_rs_features=cucumber_rs_features, cucumber_rs_args=cucumber_rs_args, + extra_env=extra_env, ) if fmt == "lcov": percent = get_line_coverage_percent_from_lcov(out) diff --git a/.github/actions/generate-coverage/tests/fixtures/cargo-toml-cranelift-project/Cargo.toml b/.github/actions/generate-coverage/tests/fixtures/cargo-toml-cranelift-project/Cargo.toml new file mode 100644 index 00000000..1432af5b --- /dev/null +++ b/.github/actions/generate-coverage/tests/fixtures/cargo-toml-cranelift-project/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "nightly-cranelift-project" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" + +[profile.dev] +codegen-backend = "cranelift" diff --git a/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/.cargo/config.toml b/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/.cargo/config.toml new file mode 100644 index 00000000..51897251 --- /dev/null +++ b/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/.cargo/config.toml @@ -0,0 +1,8 @@ +[unstable] +codegen-backend = true + +[profile.dev] +codegen-backend = "cranelift" + +[profile.test] +codegen-backend = "cranelift" diff --git a/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/Cargo.toml b/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/Cargo.toml new file mode 100644 index 00000000..2f8b205d --- /dev/null +++ b/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "nightly-cranelift-project" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" diff --git a/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml b/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml new file mode 100644 index 00000000..aafd1470 --- /dev/null +++ b/.github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2026-03-26" diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 8bc51d90..ab5de733 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -9,6 +9,7 @@ import io import itertools import os +import shutil import sys import typing as typ from pathlib import Path @@ -29,12 +30,10 @@ RunResult = import_cmd_utils().RunResult -_LLVM_CONFIG_PREFIX = [ - "--config", - 'profile.dev.codegen-backend="llvm"', - "--config", - 'profile.test.codegen-backend="llvm"', -] +_LLVM_CODEGEN_ENV = { + "CARGO_PROFILE_DEV_CODEGEN_BACKEND": "llvm", + "CARGO_PROFILE_TEST_CODEGEN_BACKEND": "llvm", +} def _exit_code(exc: BaseException) -> int | None: @@ -173,7 +172,7 @@ def _run_rust_coverage_test( config: RustCoverageConfig, *, monkeypatch: pytest.MonkeyPatch | None = None, -) -> tuple[list[str], Path, Path]: +) -> tuple[list[str], dict[str, str], Path, Path]: """Run ``run_rust.py`` with shared setup and return cargo argv + paths.""" out = tmp_path / "cov.lcov" gh = tmp_path / "gh.txt" @@ -209,12 +208,12 @@ def _run_rust_coverage_test( calls = shell_stubs.calls_of("cargo") assert len(calls) == 1 - return calls[0].argv, out, gh + return calls[0].argv, calls[0].env, out, gh def test_run_rust_success(tmp_path: Path, shell_stubs: StubManager) -> None: """Happy path for ``run_rust.py``.""" - cargo_args, out, gh = _run_rust_coverage_test( + cargo_args, cargo_env, out, gh = _run_rust_coverage_test( tmp_path, shell_stubs, RustCoverageConfig( @@ -237,6 +236,8 @@ def test_run_rust_success(tmp_path: Path, shell_stubs: StubManager) -> None: str(out), ] assert cargo_args == expected_args + assert cargo_env.get("CARGO_PROFILE_DEV_CODEGEN_BACKEND") is None + assert cargo_env.get("CARGO_PROFILE_TEST_CODEGEN_BACKEND") is None data = gh.read_text().splitlines() assert f"file={out}" in data @@ -249,7 +250,7 @@ def test_run_rust_nextest_command( monkeypatch: pytest.MonkeyPatch, ) -> None: """``run_rust.py`` uses cargo llvm-cov nextest when enabled.""" - cargo_args, out, _gh = _run_rust_coverage_test( + cargo_args, _cargo_env, out, _gh = _run_rust_coverage_test( tmp_path, shell_stubs, RustCoverageConfig(use_nextest=True), @@ -274,7 +275,7 @@ def test_run_rust_uses_detected_manifest_path( tmp_path: Path, shell_stubs: StubManager ) -> None: """Detected manifest path is propagated to cargo llvm-cov.""" - cargo_args, _out, _gh = _run_rust_coverage_test( + cargo_args, _cargo_env, _out, _gh = _run_rust_coverage_test( tmp_path, shell_stubs, RustCoverageConfig(use_nextest=False, manifest_path="rust-toy-app/Cargo.toml"), @@ -353,10 +354,13 @@ def _run_rust_main_variant( output = tmp_path / "cov.lcov" output.write_text("LF:10\nLH:10\n") github_output = tmp_path / "gh.txt" - recorded: dict[str, list[str]] = {} + recorded_args: list[str] = [] - def fake_run_cargo(args: list[str]) -> str: - recorded["args"] = args + def fake_run_cargo( + args: list[str], *, extra_env: dict[str, str] | None = None + ) -> str: + recorded_args[:] = args + _ = extra_env return "Coverage: 100%" monkeypatch.setattr(run_rust_module, "_run_cargo", fake_run_cargo) @@ -375,7 +379,7 @@ def fake_run_cargo(args: list[str]) -> str: with_cucumber_rs=False, baseline_file=None, ) - return recorded["args"], github_output, output + return recorded_args, github_output, output @pytest.mark.parametrize("use_nextest", [True, False]) @@ -413,38 +417,167 @@ def test_run_rust_cranelift_project_uses_llvm_codegen( shell_stubs: StubManager, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Coverage forces LLVM codegen even when project configures Cranelift. - - When a Rust project uses the Cranelift codegen backend (configured in - .cargo/config.toml), the coverage action must still invoke cargo with - --config flags that override the codegen backend to LLVM, because - source-based code coverage (-C instrument-coverage) is an LLVM-only - feature. - - In a real-world scenario, the Cranelift component would be installed via - ``rustup component add rustc-codegen-cranelift-preview``. - """ - # Simulate a Cranelift-configured project - cargo_config_dir = tmp_path / ".cargo" - cargo_config_dir.mkdir() - (cargo_config_dir / "config.toml").write_text( - "[unstable]\ncodegen-backend = true\n\n" - '[profile.dev]\ncodegen-backend = "cranelift"\n\n' - '[profile.test]\ncodegen-backend = "cranelift"\n', + """Coverage uses environment overrides for Cranelift-configured projects.""" + fixture_dir = ( + Path(__file__).resolve().parent / "fixtures" / "nightly-cranelift-project" ) + shutil.copytree(fixture_dir, tmp_path, dirs_exist_ok=True) - cargo_args, _out, _gh = _run_rust_coverage_test( + cargo_args, cargo_env, _out, _gh = _run_rust_coverage_test( tmp_path, shell_stubs, RustCoverageConfig(use_nextest=True), monkeypatch=monkeypatch, ) - # The config prefix must appear before llvm-cov to override Cranelift - prefix_len = len(_LLVM_CONFIG_PREFIX) - assert cargo_args[:prefix_len] == _LLVM_CONFIG_PREFIX - assert cargo_args[prefix_len] == "llvm-cov" - assert cargo_args[prefix_len + 1] == "nextest" + assert cargo_args[:2] == ["llvm-cov", "nextest"] + assert "--config" not in cargo_args + for key, value in _LLVM_CODEGEN_ENV.items(): + assert cargo_env[key] == value + + +def test_get_cargo_coverage_env_detects_cranelift_fixture( + run_rust_module: ModuleType, +) -> None: + """Cranelift fixtures resolve to cargo environment overrides.""" + fixture_dir = ( + Path(__file__).resolve().parent / "fixtures" / "nightly-cranelift-project" + ) + env = run_rust_module.get_cargo_coverage_env(fixture_dir / "Cargo.toml") + assert env == _LLVM_CODEGEN_ENV + + +def test_uses_cranelift_backend_detects_manifest_fixture( + run_rust_module: ModuleType, +) -> None: + """Cargo.toml profile settings alone trigger Cranelift detection.""" + fixture_dir = ( + Path(__file__).resolve().parent / "fixtures" / "cargo-toml-cranelift-project" + ) + assert run_rust_module._uses_cranelift_backend(fixture_dir / "Cargo.toml") is True + + +def test_get_cargo_coverage_env_non_cranelift_is_empty( + run_rust_module: ModuleType, tmp_path: Path +) -> None: + """Non-Cranelift projects do not receive extra cargo env overrides.""" + manifest_path = tmp_path / "Cargo.toml" + manifest_path.write_text("[package]\nname='demo'\nversion='0.1.0'\n") + assert run_rust_module.get_cargo_coverage_env(manifest_path) == {} + + +@pytest.mark.parametrize("profile_name", ["dev", "test"]) +def test_get_cargo_coverage_env_detects_manifest_only_cranelift( + run_rust_module: ModuleType, + tmp_path: Path, + profile_name: str, +) -> None: + """Manifest profile settings alone trigger LLVM codegen env overrides.""" + manifest_path = tmp_path / "Cargo.toml" + manifest_path.write_text( + "\n".join( + [ + "[package]", + "name='demo'", + "version='0.1.0'", + f"[profile.{profile_name}]", + 'codegen-backend = " Cranelift "', + "", + ] + ), + encoding="utf-8", + ) + + assert run_rust_module._uses_cranelift_backend(manifest_path) is True + assert run_rust_module.get_cargo_coverage_env(manifest_path) == _LLVM_CODEGEN_ENV + + +@dataclasses.dataclass(frozen=True, slots=True) +class _CucumberEnvScenario: + manifest_path: Path + extra_env: dict[str, str] | None + + +def _run_cucumber_coverage_and_capture_env( + run_rust_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + scenario: _CucumberEnvScenario, +) -> dict[str, str] | None: + """Stub ``_run_cargo``, invoke ``run_cucumber_rs_coverage``, return captured env.""" + out = tmp_path / "coverage.lcov" + out.write_text("TN:\nend_of_record\n", encoding="utf-8") + captured_env: dict[str, str] | None = None + + def fake_run_cargo( + _args: list[str], *, extra_env: dict[str, str] | None = None + ) -> str: + nonlocal captured_env + captured_env = extra_env + out.with_name(f"{out.stem}.cucumber{out.suffix}").write_text( + "TN:\nend_of_record\n", + encoding="utf-8", + ) + return "" + + monkeypatch.setattr(run_rust_module, "_run_cargo", fake_run_cargo) + run_rust_module.run_cucumber_rs_coverage( + out, + "lcov", + "", + manifest_path=scenario.manifest_path, + with_default=True, + use_nextest=False, + cucumber_rs_features="cucumber", + cucumber_rs_args="", + extra_env=scenario.extra_env, + ) + return captured_env + + +def test_run_cucumber_rs_coverage_passes_extra_env_for_cranelift( + run_rust_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """cucumber.rs coverage forwards Cranelift env overrides to ``_run_cargo``.""" + manifest_path = ( + Path(__file__).resolve().parent + / "fixtures" + / "nightly-cranelift-project" + / "Cargo.toml" + ) + captured_env = _run_cucumber_coverage_and_capture_env( + run_rust_module, + monkeypatch, + tmp_path, + _CucumberEnvScenario( + manifest_path=manifest_path, + extra_env=run_rust_module.get_cargo_coverage_env(manifest_path), + ), + ) + assert captured_env == _LLVM_CODEGEN_ENV + + +def test_run_cucumber_rs_coverage_passes_extra_env_for_non_cranelift( + run_rust_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """cucumber.rs coverage forwards explicit non-Cranelift env unchanged.""" + manifest_path = tmp_path / "Cargo.toml" + manifest_path.write_text( + "[package]\nname='demo'\nversion='0.1.0'\n", + encoding="utf-8", + ) + extra_env = {"FOO": "BAR", "BAZ": "QUX"} + captured_env = _run_cucumber_coverage_and_capture_env( + run_rust_module, + monkeypatch, + tmp_path, + _CucumberEnvScenario(manifest_path=manifest_path, extra_env=extra_env), + ) + assert captured_env == extra_env def test_nextest_config_is_temporary( @@ -1197,31 +1330,24 @@ def test_merge_cobertura(tmp_path: Path, shell_stubs: StubManager) -> None: assert calls[0].argv[:1] == ["merge-cobertura"] -def test_lcov_zero_lines_found(tmp_path: Path, run_rust_module: ModuleType) -> None: - """``get_line_coverage_percent_from_lcov`` returns 0.00 when no lines are found.""" - lcov = tmp_path / "zero.lcov" - lcov.write_text("LF:0\nLH:0\n") - assert run_rust_module.get_line_coverage_percent_from_lcov(lcov) == "0.00" - - -def test_lcov_empty_file(tmp_path: Path, run_rust_module: ModuleType) -> None: - """Empty lcov files report zero coverage.""" - lcov = tmp_path / "empty.lcov" - lcov.write_text("") - assert run_rust_module.get_line_coverage_percent_from_lcov(lcov) == "0.00" - - -def test_lcov_missing_lh_tag(tmp_path: Path, run_rust_module: ModuleType) -> None: - """``get_line_coverage_percent_from_lcov`` handles files missing ``LH`` tags.""" - lcov = tmp_path / "missing.lcov" - lcov.write_text("LF:100\n") - assert run_rust_module.get_line_coverage_percent_from_lcov(lcov) == "0.00" - - -def test_lcov_malformed_file(tmp_path: Path, run_rust_module: ModuleType) -> None: - """``get_line_coverage_percent_from_lcov`` returns 0.00 for malformed files.""" - lcov = tmp_path / "bad.lcov" - lcov.write_text("LF:abc\nLH:xyz\n") +@pytest.mark.parametrize( + ("filename", "content"), + [ + pytest.param("zero.lcov", "LF:0\nLH:0\n", id="zero-lines"), + pytest.param("empty.lcov", "", id="empty-file"), + pytest.param("missing.lcov", "LF:100\n", id="missing-lh-tag"), + pytest.param("bad.lcov", "LF:abc\nLH:xyz\n", id="malformed"), + ], +) +def test_lcov_zero_coverage_variants( + tmp_path: Path, + run_rust_module: ModuleType, + filename: str, + content: str, +) -> None: + """``get_line_coverage_percent_from_lcov`` returns 0.00 for degenerate inputs.""" + lcov = tmp_path / filename + lcov.write_text(content) assert run_rust_module.get_line_coverage_percent_from_lcov(lcov) == "0.00" diff --git a/.github/actions/rust-build-release/CHANGELOG.md b/.github/actions/rust-build-release/CHANGELOG.md index d489f78c..c8f26447 100644 --- a/.github/actions/rust-build-release/CHANGELOG.md +++ b/.github/actions/rust-build-release/CHANGELOG.md @@ -16,10 +16,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Require containerized `cross` builds for FreeBSD targets on non-FreeBSD hosts to enable `x86_64-unknown-freebsd` cross-compilation. - Automatically export `CROSS_CONTAINER_ENGINE` for the detected container runtime when running FreeBSD builds with `cross`. - Add a `manifest-path` input for selecting an alternate Cargo manifest. +- Add a `toolchain` input for explicitly overriding the resolved build toolchain. ### Fixed - Pin `setup-rust` to the commit behind `setup-rust-v1`, so toolchain inputs and OS guards apply when invoked from external repositories. +- Resolve toolchains from the target repository before falling back to the action's bundled default: explicit input first, then `rust-toolchain.toml` or `rust-toolchain`, then manifest `rust-version`. ## [0.1.0] - 2025-09-10 diff --git a/.github/actions/rust-build-release/README.md b/.github/actions/rust-build-release/README.md index 9b3c2b8e..ebb8aca7 100644 --- a/.github/actions/rust-build-release/README.md +++ b/.github/actions/rust-build-release/README.md @@ -23,11 +23,16 @@ duration of the build so that `cross` automatically uses the available engine. The `uv` Python package manager is installed automatically to execute the build script. +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. + ## Inputs | Name | Type | Default | Description | Required | | ------------- | ------ | -------------------------- | ------------------------------------------------------------------ | -------- | | target | string | `x86_64-unknown-linux-gnu` | Target triple to build | no | +| toolchain | string | (empty) | Explicit Rust toolchain override; otherwise the toolchain is resolved from the target repository before falling back to the action default | no | | project-dir | string | `.` | Path to the Rust project to build | no | | manifest-path | string | `Cargo.toml` | Path to the Cargo manifest (relative to `project-dir` or absolute) | no | | bin-name | string | `rust-toy-app` | Binary name produced by the build | no | @@ -60,6 +65,7 @@ None. - uses: ./.github/actions/rust-build-release with: target: x86_64-unknown-linux-gnu + toolchain: nightly-2026-03-26 project-dir: rust-toy-app bin-name: rust-toy-app features: "verbose,experimental" diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index 582c9264..f2227dea 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -4,6 +4,10 @@ inputs: target: description: Target triple to build default: x86_64-unknown-linux-gnu + toolchain: + description: Explicit Rust toolchain override; otherwise resolve from the target repository before falling back to the action default + required: false + default: "" project-dir: description: Path to the Rust project to build required: false @@ -34,10 +38,13 @@ runs: validate "${{ inputs.target }}" - name: Determine toolchain shell: bash + working-directory: ${{ inputs.project-dir }} run: | set -euo pipefail TOOLCHAIN="$(uv run --script "$GITHUB_ACTION_PATH/src/action_setup.py" \ toolchain \ + --toolchain "${{ inputs.toolchain }}" \ + --manifest-path "${{ inputs.manifest-path }}" \ --target "${{ inputs.target }}" \ --runner-os "${{ runner.os }}" \ --runner-arch "${{ runner.arch }}")" diff --git a/.github/actions/rust-build-release/src/action_setup.py b/.github/actions/rust-build-release/src/action_setup.py index 924e6bda..f1379731 100644 --- a/.github/actions/rust-build-release/src/action_setup.py +++ b/.github/actions/rust-build-release/src/action_setup.py @@ -60,12 +60,40 @@ def bootstrap_environment() -> tuple[Path, Path]: _ACTION_PATH, _REPO_ROOT = bootstrap_environment() import typer -from toolchain import read_default_toolchain +from toolchain import read_default_toolchain, resolve_requested_toolchain TARGET_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$") +DEFAULT_MANIFEST_PATH = Path("Cargo.toml") +TOOLCHAIN_OVERRIDE_OPT = typer.Option("", "--toolchain") +MANIFEST_PATH_OPT = typer.Option(DEFAULT_MANIFEST_PATH, "--manifest-path") app = typer.Typer(add_completion=False) +_TRIPLE_OS_COMPONENTS = { + "linux", + "windows", + "darwin", + "freebsd", + "netbsd", + "openbsd", + "dragonfly", + "solaris", + "android", + "ios", + "emscripten", + "haiku", + "hermit", + "fuchsia", + "wasi", + "redox", + "illumos", + "uefi", + "macabi", + "rumprun", + "vita", + "psp", +} + class TargetValidationError(ValueError): """Raised when a provided target triple is invalid.""" @@ -75,6 +103,25 @@ class ToolchainResolutionError(ValueError): """Raised when the action cannot resolve a toolchain.""" +def _looks_like_target_triple(candidate: str) -> bool: + """Return ``True`` when *candidate* resembles an embedded target triple.""" + components = [part for part in candidate.split("-") if part] + if len(components) < 3: + return False + return any(component in _TRIPLE_OS_COMPONENTS for component in components[1:]) + + +def _has_embedded_target_triple(toolchain: str) -> bool: + """Return ``True`` when *toolchain* already includes a target triple.""" + parts = toolchain.split("-") + for suffix_parts in (4, 3): + if len(parts) < suffix_parts + 1: + continue + if _looks_like_target_triple("-".join(parts[-suffix_parts:])): + return True + return False + + def validate_target(target: str) -> None: """Validate *target* and raise :class:`TargetValidationError` on failure.""" if not target: @@ -94,6 +141,8 @@ def resolve_toolchain( ) -> str: """Return the toolchain identifier for the provided runner metadata.""" if runner_os == "Windows" and target.endswith("-pc-windows-gnu"): + if _has_embedded_target_triple(default_toolchain): + return default_toolchain arch_map = {"X64": "x86_64", "ARM64": "aarch64"} try: host_arch = arch_map[runner_arch] @@ -117,14 +166,20 @@ def validate(target: str = typer.Argument(...)) -> None: raise typer.Exit(1) from exc -@app.command() -def toolchain( - target: str = typer.Option(..., "--target"), - runner_os: str = typer.Option(..., "--runner-os"), - runner_arch: str = typer.Option(..., "--runner-arch"), +def _resolve_default_toolchain(toolchain_override: str, manifest_path: Path) -> str: + """Return the default toolchain, respecting override and manifest sources.""" + return resolve_requested_toolchain( + toolchain_override, + project_dir=Path.cwd(), + manifest_path=manifest_path, + fallback_toolchain=read_default_toolchain(), + ) + + +def _emit_resolved_toolchain( + target: str, runner_os: str, runner_arch: str, default_toolchain: str ) -> None: - """CLI entry point that prints the resolved toolchain.""" - default_toolchain = read_default_toolchain() + """Resolve the runner-specific toolchain and print it, or exit on error.""" try: resolved = resolve_toolchain(default_toolchain, target, runner_os, runner_arch) except ToolchainResolutionError as exc: @@ -133,5 +188,22 @@ def toolchain( typer.echo(resolved) +@app.command() +def toolchain( + target: str = typer.Option(..., "--target"), + runner_os: str = typer.Option(..., "--runner-os"), + runner_arch: str = typer.Option(..., "--runner-arch"), + toolchain: str = TOOLCHAIN_OVERRIDE_OPT, + manifest_path: Path = MANIFEST_PATH_OPT, +) -> None: + """CLI entry point that prints the resolved toolchain.""" + _emit_resolved_toolchain( + target, + runner_os, + runner_arch, + _resolve_default_toolchain(toolchain, manifest_path), + ) + + if __name__ == "__main__": app() diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index 88ef4284..a6fe0836 100755 --- a/.github/actions/rust-build-release/src/main.py +++ b/.github/actions/rust-build-release/src/main.py @@ -32,7 +32,11 @@ DEFAULT_HOST_TARGET, runtime_available, ) -from toolchain import configure_windows_linkers, read_default_toolchain +from toolchain import ( + configure_windows_linkers, + read_default_toolchain, + resolve_requested_toolchain, +) from utils import ( UnexpectedExecutableError, ensure_allowed_executable, @@ -199,6 +203,13 @@ def _list_installed_toolchains(rustup_exec: str) -> list[str]: return [line.split()[0] for line in installed if line.strip()] +def _matches_toolchain_channel(name: str, toolchain: str) -> bool: + """Return True if *name* matches *toolchain* exactly or by channel/dotted prefix.""" + channel_prefix = f"{toolchain}-" + dotted_prefix = f"{toolchain}." + return name == toolchain or name.startswith((channel_prefix, dotted_prefix)) + + def _resolve_toolchain_name( toolchain: str, target: str, installed_names: list[str] ) -> str: @@ -207,9 +218,8 @@ def _resolve_toolchain_name( for name in installed_names: if name in preferred: return name - channel_prefix = f"{toolchain}-" for name in installed_names: - if name == toolchain or name.startswith(channel_prefix): + if _matches_toolchain_channel(name, toolchain): return name return "" @@ -289,12 +299,11 @@ def _ensure_rustup_exec() -> str: def _fallback_toolchain_name(toolchain: str, installed_names: list[str]) -> str: """Return a toolchain matching *toolchain* or its channel prefix.""" - channel_prefix = f"{toolchain}-" return next( ( name for name in installed_names - if name == toolchain or name.startswith(channel_prefix) + if _matches_toolchain_channel(name, toolchain) ), "", ) @@ -556,6 +565,8 @@ def _restore_container_engine( def _normalize_features(features: str) -> str: """Normalize comma-separated feature lists for --features arguments.""" + if not isinstance(features, str): + return "" parts = [part.strip() for part in features.split(",")] normalized = [part for part in parts if part] return ",".join(normalized) @@ -670,9 +681,9 @@ def _manifest_argument(manifest_path: Path) -> Path: def main( target: str = typer.Argument("", help="Target triple to build"), toolchain: str = typer.Option( - DEFAULT_TOOLCHAIN, + "", envvar="RBR_TOOLCHAIN", - help="Rust toolchain version", + help="Rust toolchain version override", ), features: str = typer.Option( "", @@ -682,9 +693,16 @@ def main( ) -> None: """Build the project for *target* using *toolchain*.""" target_to_build = _resolve_target_argument(target) + manifest_path = _resolve_manifest_path() + requested_toolchain = toolchain.strip() or resolve_requested_toolchain( + toolchain, + project_dir=Path.cwd(), + manifest_path=manifest_path, + fallback_toolchain=DEFAULT_TOOLCHAIN, + ) rustup_exec = _ensure_rustup_exec() toolchain_name, installed_names = _resolve_toolchain( - rustup_exec, toolchain, target_to_build + rustup_exec, requested_toolchain, target_to_build ) target_installed = _ensure_target_installed( rustup_exec, toolchain_name, target_to_build @@ -713,7 +731,6 @@ def main( previous_engine, applied_engine = _configure_cross_container_engine(decision) - manifest_path = _resolve_manifest_path() manifest_argument = _manifest_argument(manifest_path) if decision.use_cross: build_cmd = _build_cross_command( diff --git a/.github/actions/rust-build-release/src/toolchain.py b/.github/actions/rust-build-release/src/toolchain.py index bb2adc82..a2117c69 100644 --- a/.github/actions/rust-build-release/src/toolchain.py +++ b/.github/actions/rust-build-release/src/toolchain.py @@ -6,6 +6,8 @@ import shutil import subprocess import sys +import tomllib +import typing as typ from pathlib import Path from utils import ensure_allowed_executable, run_validated @@ -18,6 +20,142 @@ def read_default_toolchain() -> str: return TOOLCHAIN_VERSION_FILE.read_text(encoding="utf-8").strip() +def _resolve_manifest_path(project_dir: Path, manifest_path: Path) -> Path: + """Resolve *manifest_path* relative to *project_dir* when needed.""" + candidate = manifest_path.expanduser() + if not candidate.is_absolute(): + candidate = project_dir / candidate + return candidate.resolve() + + +def _strip_optional(value: str | None) -> str | None: + """Return a trimmed string or ``None`` when the input is blank.""" + if value is None: + return None + trimmed = value.strip() + return trimmed or None + + +def _parse_legacy_toolchain_file(raw: str) -> str | None: + """Return the first non-blank, non-comment line from a legacy toolchain file.""" + for line in raw.splitlines(): + if channel := _strip_optional(line.partition("#")[0]): + return channel + return None + + +def _extract_toml_channel(data: dict) -> str | None: + """Return ``[toolchain].channel`` from parsed TOML data, if any.""" + toolchain = data.get("toolchain") + if not isinstance(toolchain, dict): + return None + channel = toolchain.get("channel") + if isinstance(channel, str): + return _strip_optional(channel) + return None + + +def _parse_toolchain_file(path: Path) -> str | None: + """Return the declared toolchain channel from *path*, if any.""" + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return None + + try: + data = tomllib.loads(raw) + except tomllib.TOMLDecodeError: + if path.name != "rust-toolchain": + return None + return _parse_legacy_toolchain_file(raw) + + return _extract_toml_channel(data) + + +def _iter_toolchain_search_dirs( + start: Path, stop_at: Path | None = None +) -> typ.Iterator[Path]: + """Yield directories to search for repository toolchain declarations. + + Stops at the first ``.git`` directory encountered, at the filesystem root, + or at *stop_at* (inclusive) when provided. + """ + search_dir = start.resolve() + stop = stop_at.resolve() if stop_at is not None else None + while True: + yield search_dir + if stop is not None and search_dir == stop: + return + if (search_dir / ".git").exists(): + return + parent = search_dir.parent + if parent == search_dir: + return + search_dir = parent + + +def read_repo_toolchain(project_dir: Path, manifest_path: Path) -> str | None: + """Return the repo-declared toolchain nearest the target manifest, if any.""" + resolved_manifest = _resolve_manifest_path(project_dir, manifest_path) + for directory in _iter_toolchain_search_dirs(resolved_manifest.parent, project_dir): + for filename in ("rust-toolchain.toml", "rust-toolchain"): + if toolchain := _parse_toolchain_file(directory / filename): + return toolchain + return None + + +def _section_rust_version(section: object) -> str | None: + """Return ``rust-version`` from a ``[package]``-like TOML mapping, if present.""" + if not isinstance(section, dict): + return None + mapping = typ.cast("dict[str, object]", section) + rust_version = mapping.get("rust-version") + if isinstance(rust_version, str): + return _strip_optional(rust_version) + return None + + +def _workspace_rust_version(manifest_data: dict) -> str | None: + """Return ``rust-version`` from ``[workspace.package]``, if declared.""" + workspace = manifest_data.get("workspace") + if not isinstance(workspace, dict): + return None + return _section_rust_version(workspace.get("package")) + + +def read_manifest_rust_version(project_dir: Path, manifest_path: Path) -> str | None: + """Return ``rust-version`` from the manifest when it is declared.""" + try: + manifest_data = tomllib.loads( + _resolve_manifest_path(project_dir, manifest_path).read_text( + encoding="utf-8" + ) + ) + except (OSError, tomllib.TOMLDecodeError): + return None + + return _section_rust_version( + manifest_data.get("package") + ) or _workspace_rust_version(manifest_data) + + +def resolve_requested_toolchain( + explicit_toolchain: str | None, + *, + project_dir: Path, + manifest_path: Path, + fallback_toolchain: str, +) -> str: + """Resolve the toolchain using explicit input, repo config, MSRV, then fallback.""" + if toolchain := _strip_optional(explicit_toolchain): + return toolchain + if repo_toolchain := read_repo_toolchain(project_dir, manifest_path): + return repo_toolchain + if rust_version := read_manifest_rust_version(project_dir, manifest_path): + return rust_version + return fallback_toolchain + + def toolchain_triple(toolchain: str) -> str | None: """Return the target triple embedded in *toolchain*, if present.""" parts = toolchain.split("-") diff --git a/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/.cargo/config.toml b/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/.cargo/config.toml new file mode 100644 index 00000000..51897251 --- /dev/null +++ b/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/.cargo/config.toml @@ -0,0 +1,8 @@ +[unstable] +codegen-backend = true + +[profile.dev] +codegen-backend = "cranelift" + +[profile.test] +codegen-backend = "cranelift" diff --git a/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/Cargo.toml b/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/Cargo.toml new file mode 100644 index 00000000..2f8b205d --- /dev/null +++ b/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "nightly-cranelift-project" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" diff --git a/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml b/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml new file mode 100644 index 00000000..aafd1470 --- /dev/null +++ b/.github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2026-03-26" diff --git a/.github/actions/rust-build-release/tests/test_action_setup.py b/.github/actions/rust-build-release/tests/test_action_setup.py index aa379a48..4ea01076 100644 --- a/.github/actions/rust-build-release/tests/test_action_setup.py +++ b/.github/actions/rust-build-release/tests/test_action_setup.py @@ -25,6 +25,8 @@ SCRIPT_PATH = Path(__file__).resolve().parents[1] / "src" / "action_setup.py" TOOLCHAIN_PATH = Path(__file__).resolve().parents[1] / "src" / "toolchain.py" +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" +NIGHTLY_CRANELIFT_PROJECT = FIXTURES_DIR / "nightly-cranelift-project" def _load_action_setup_from_layout( @@ -230,6 +232,19 @@ def test_resolve_toolchain_windows_known_arch( assert resolved == "1.89.0-aarch64-pc-windows-gnu" +def test_resolve_toolchain_windows_preserves_qualified_toolchain( + action_setup_module: ModuleType, +) -> None: + """Qualified Windows GNU toolchains are returned unchanged.""" + resolved = action_setup_module.resolve_toolchain( + "nightly-2026-03-26-x86_64-pc-windows-gnu", + "aarch64-pc-windows-gnu", + "Windows", + "ARM64", + ) + assert resolved == "nightly-2026-03-26-x86_64-pc-windows-gnu" + + def test_cli_toolchain_outputs_value( action_setup_module: ModuleType, toolchain_module: ModuleType, @@ -263,6 +278,43 @@ def test_cli_toolchain_outputs_value( assert result.stdout.strip() == "1.99.0" +@pytest.mark.parametrize( + ("extra_args", "expected_toolchain"), + [ + pytest.param([], "nightly-2026-03-26", id="repo-declared-nightly"), + pytest.param(["--toolchain", "beta"], "beta", id="cli-override-wins"), + ], +) +def test_cli_toolchain_resolution_from_nightly_project( + action_setup_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + extra_args: list[str], + expected_toolchain: str, +) -> None: + """Toolchain resolution respects explicit override and repo-declared nightly.""" + monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) + + result = runner.invoke( + action_setup_module.app, + [ + "toolchain", + *extra_args, + "--target", + "aarch64-unknown-linux-gnu", + "--manifest-path", + "Cargo.toml", + "--runner-os", + "Linux", + "--runner-arch", + "X64", + ], + prog_name="action-setup", + ) + + assert result.exit_code == 0 + assert result.stdout.strip() == expected_toolchain + + def test_cli_validate_emits_error(action_setup_module: ModuleType) -> None: """CLI validation command reports errors via Typer exit codes.""" result = runner.invoke( diff --git a/.github/actions/rust-build-release/tests/test_manifest_input_step.py b/.github/actions/rust-build-release/tests/test_manifest_input_step.py index cbf6c5ea..4349648c 100644 --- a/.github/actions/rust-build-release/tests/test_manifest_input_step.py +++ b/.github/actions/rust-build-release/tests/test_manifest_input_step.py @@ -31,6 +31,16 @@ def test_manifest_path_input_declared() -> None: assert manifest_input.get("default") == "Cargo.toml" +def test_toolchain_input_declared() -> None: + """The toolchain override input must exist with an empty default.""" + manifest = _load_action_manifest() + inputs = manifest["inputs"] + assert "toolchain" in inputs + toolchain_input = inputs["toolchain"] + assert toolchain_input.get("required", False) is False + assert toolchain_input.get("default") == "" + + def test_build_step_exports_manifest_path_env() -> None: """Build step should pass manifest-path via RBR_MANIFEST_PATH.""" manifest = _load_action_manifest() @@ -39,3 +49,15 @@ def test_build_step_exports_manifest_path_env() -> None: env = build_step.get("env") assert isinstance(env, dict) assert env.get("RBR_MANIFEST_PATH") == "${{ inputs.manifest-path }}" + + +def test_determine_toolchain_step_uses_project_lookup_inputs() -> None: + """Toolchain lookup must run in project-dir and receive both override inputs.""" + manifest = _load_action_manifest() + steps: list[dict[str, object]] = manifest["runs"]["steps"] + determine_step = _find_step(steps, "Determine toolchain") + assert determine_step.get("working-directory") == "${{ inputs.project-dir }}" + run_script = determine_step.get("run") + assert isinstance(run_script, str) + assert '--toolchain "${{ inputs.toolchain }}"' in run_script + assert '--manifest-path "${{ inputs.manifest-path }}"' in run_script diff --git a/.github/actions/rust-build-release/tests/test_manifest_path.py b/.github/actions/rust-build-release/tests/test_manifest_path.py index 198c811d..42ea03d1 100644 --- a/.github/actions/rust-build-release/tests/test_manifest_path.py +++ b/.github/actions/rust-build-release/tests/test_manifest_path.py @@ -22,6 +22,8 @@ EchoRecorder = cabc.Callable[[ModuleType], list[tuple[str, bool]]] +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" +NIGHTLY_CRANELIFT_PROJECT = FIXTURES_DIR / "nightly-cranelift-project" def _unexpected(message: str) -> cabc.Callable[..., None]: @@ -285,6 +287,46 @@ def test_main_errors_when_manifest_missing( assert harness.calls == [] +def test_main_prefers_repo_declared_toolchain( + build_main_context: BuildMainContext, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """main() resolves repo toolchains before falling back to the action default.""" + context = build_main_context + harness = context.harness + captured: dict[str, object] = {} + monkeypatch.delenv("RBR_MANIFEST_PATH", raising=False) + monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) + + harness.patch_attr("_resolve_target_argument", lambda value: value) + + def fake_resolve_toolchain( + _rustup_exec: str, toolchain_arg: str, target_arg: str + ) -> tuple[str, list[str]]: + captured["toolchain"] = toolchain_arg + captured["target"] = target_arg + return toolchain_arg, [toolchain_arg] + + harness.patch_attr("_resolve_toolchain", fake_resolve_toolchain) + decision = context.cross_decision_factory(context.main_module, use_cross=False) + harness.patch_attr("_decide_cross_usage", lambda *_, **__: decision) + + def fake_cargo( + _spec: str, _target_arg: str, manifest_arg: Path, features_arg: str + ) -> object: + captured["manifest"] = manifest_arg + captured["features"] = features_arg + return context.dummy_command_factory("cargo-build") + + harness.patch_attr("_build_cargo_command", fake_cargo) + + context.main_module.main("aarch64-unknown-linux-gnu") + + assert captured["toolchain"] == "nightly-2026-03-26" + assert captured["target"] == "aarch64-unknown-linux-gnu" + assert captured["manifest"] == Path("Cargo.toml") + + @pytest.mark.parametrize( ("builder", "target"), [ diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index b9b6bcd5..149a3115 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -27,6 +27,9 @@ from .conftest import HarnessFactory +pytestmark = pytest.mark.usefixtures("setup_manifest") + + def _assert_no_timeout_trace(output: str) -> None: """Ensure TimeoutExpired tracebacks do not leak into CLI output.""" assert "TimeoutExpired" not in output, output @@ -381,7 +384,9 @@ def fake_which(name: str) -> str | None: app_env.patch_shutil_which(fake_which) app_env.patch_attr("ensure_cross", lambda *_: (cross_path, "0.2.5")) app_env.patch_attr("runtime_available", lambda runtime: runtime == "docker") - app_env.monkeypatch.delenv("CROSS_CONTAINER_ENGINE", raising=False) + isolated_env = dict(os.environ) + isolated_env.pop("CROSS_CONTAINER_ENGINE", None) + app_env.monkeypatch.setattr(os, "environ", isolated_env) engines: list[str | None] = [] @@ -396,7 +401,6 @@ def record_engine(cmd: list[str]) -> None: cmd_mox.verify() assert engines == ["docker"] - assert "CROSS_CONTAINER_ENGINE" not in os.environ @CMD_MOX_UNSUPPORTED @@ -428,7 +432,9 @@ def fake_which(name: str) -> str | None: app_env.patch_shutil_which(fake_which) app_env.patch_attr("ensure_cross", lambda *_: (cross_path, "0.2.5")) app_env.patch_attr("runtime_available", lambda runtime: runtime == "podman") - app_env.monkeypatch.delenv("CROSS_CONTAINER_ENGINE", raising=False) + isolated_env = dict(os.environ) + isolated_env.pop("CROSS_CONTAINER_ENGINE", None) + app_env.monkeypatch.setattr(os, "environ", isolated_env) engines: list[str | None] = [] @@ -443,7 +449,7 @@ def record_engine(cmd: list[str]) -> None: cmd_mox.verify() assert engines == ["podman"] - assert "CROSS_CONTAINER_ENGINE" not in os.environ + assert os.environ.get("CROSS_CONTAINER_ENGINE") != "podman" @CMD_MOX_UNSUPPORTED diff --git a/.github/actions/rust-build-release/tests/test_toolchain_helpers.py b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py index 1f747545..03e16065 100644 --- a/.github/actions/rust-build-release/tests/test_toolchain_helpers.py +++ b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py @@ -3,14 +3,18 @@ from __future__ import annotations import typing as typ +from pathlib import Path if typ.TYPE_CHECKING: - from pathlib import Path from types import ModuleType import pytest +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" +NIGHTLY_CRANELIFT_PROJECT = FIXTURES_DIR / "nightly-cranelift-project" + + def test_read_default_toolchain_uses_config( toolchain_module: ModuleType, monkeypatch: pytest.MonkeyPatch, @@ -35,3 +39,179 @@ def test_toolchain_triple_returns_none_for_short_spec( """toolchain_triple returns None when no triple is embedded.""" assert toolchain_module.toolchain_triple("stable") is None assert toolchain_module.toolchain_triple("1.89.0-x86_64") is None + + +def test_read_repo_toolchain_prefers_repo_declared_nightly( + toolchain_module: ModuleType, +) -> None: + """Repo toolchain files outrank the action fallback.""" + toolchain = toolchain_module.read_repo_toolchain( + NIGHTLY_CRANELIFT_PROJECT, + Path("Cargo.toml"), + ) + assert toolchain == "nightly-2026-03-26" + + +def test_read_manifest_rust_version_reads_package_msrv( + toolchain_module: ModuleType, +) -> None: + """Manifest fallback reads the package rust-version field.""" + rust_version = toolchain_module.read_manifest_rust_version( + NIGHTLY_CRANELIFT_PROJECT, + Path("Cargo.toml"), + ) + assert rust_version == "1.88" + + +def test_read_manifest_rust_version_reads_workspace_package_msrv( + toolchain_module: ModuleType, + tmp_path: Path, +) -> None: + """Manifest fallback reads the workspace.package rust-version field.""" + project_dir = tmp_path / "workspace-project" + project_dir.mkdir() + (project_dir / "Cargo.toml").write_text( + "\n".join( + [ + "[workspace]", + "members = []", + "[workspace.package]", + 'rust-version = "1.88"', + "", + ] + ), + encoding="utf-8", + ) + + rust_version = toolchain_module.read_manifest_rust_version( + project_dir, + Path("Cargo.toml"), + ) + + assert rust_version == "1.88" + + +def test_read_repo_toolchain_ignores_parent_toolchains_outside_project_dir( + toolchain_module: ModuleType, + tmp_path: Path, +) -> None: + """Toolchain discovery stays bounded to the supplied project directory.""" + outer = tmp_path / "outer" + outer.mkdir() + (outer / "rust-toolchain.toml").write_text( + "[toolchain]\nchannel='nightly-2099-01-01'\n", + encoding="utf-8", + ) + project_dir = outer / "project" + project_dir.mkdir() + (project_dir / "Cargo.toml").write_text( + "[package]\nname='demo'\nversion='0.1.0'\n", + encoding="utf-8", + ) + + toolchain = toolchain_module.read_repo_toolchain(project_dir, Path("Cargo.toml")) + + assert toolchain is None + + +def test_iter_toolchain_search_dirs_stops_at_boundary( + toolchain_module: ModuleType, + tmp_path: Path, +) -> None: + """stop_at causes the iterator to halt at the given directory.""" + deep = tmp_path / "a" / "b" / "c" + deep.mkdir(parents=True) + + dirs = list( + toolchain_module._iter_toolchain_search_dirs( + deep, + stop_at=tmp_path / "a", + ) + ) + + assert dirs[-1] == (tmp_path / "a").resolve() + assert tmp_path.resolve() not in dirs + + +def test_read_repo_toolchain_ignores_malformed_rust_toolchain_toml( + toolchain_module: ModuleType, + tmp_path: Path, +) -> None: + """Malformed ``rust-toolchain.toml`` files do not fall back to legacy parsing.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "Cargo.toml").write_text( + "[package]\nname='demo'\nversion='0.1.0'\n", + encoding="utf-8", + ) + (project_dir / "rust-toolchain.toml").write_text( + "nightly-2099-01-01\ninvalid = [\n", + encoding="utf-8", + ) + + toolchain = toolchain_module.read_repo_toolchain(project_dir, Path("Cargo.toml")) + + assert toolchain is None + + +def test_resolve_requested_toolchain_precedence( + toolchain_module: ModuleType, + tmp_path: Path, +) -> None: + """Explicit input, repo toolchain, MSRV, then fallback are used in order.""" + manifest_dir = tmp_path / "project" + manifest_dir.mkdir() + manifest = manifest_dir / "Cargo.toml" + manifest.write_text( + "[package]\nname='demo'\nversion='0.1.0'\nedition='2024'\nrust-version='1.77'\n", + encoding="utf-8", + ) + + explicit = toolchain_module.resolve_requested_toolchain( + "nightly-2026-03-26", + project_dir=manifest_dir, + manifest_path=Path("Cargo.toml"), + fallback_toolchain="1.89.0", + ) + assert explicit == "nightly-2026-03-26" + + whitespace_explicit = toolchain_module.resolve_requested_toolchain( + " ", + project_dir=manifest_dir, + manifest_path=Path("Cargo.toml"), + fallback_toolchain="1.89.0", + ) + assert whitespace_explicit == "1.77" + + (manifest_dir / "rust-toolchain.toml").write_text( + "[toolchain]\nchannel='nightly-2026-03-27'\n", + encoding="utf-8", + ) + repo_declared = toolchain_module.resolve_requested_toolchain( + "", + project_dir=manifest_dir, + manifest_path=Path("Cargo.toml"), + fallback_toolchain="1.89.0", + ) + assert repo_declared == "nightly-2026-03-27" + + (manifest_dir / "rust-toolchain.toml").unlink() + manifest_declared = toolchain_module.resolve_requested_toolchain( + "", + project_dir=manifest_dir, + manifest_path=Path("Cargo.toml"), + fallback_toolchain="1.89.0", + ) + assert manifest_declared == "1.77" + + manifest.write_text( + "[package]\nname='demo'\nversion='0.1.0'\nedition='2024'\n", + encoding="utf-8", + ) + fallback = toolchain_module.resolve_requested_toolchain( + "", + project_dir=manifest_dir, + manifest_path=Path("Cargo.toml"), + fallback_toolchain="1.89.0", + ) + assert fallback == "1.89.0"