From ad45b4e811b3a13fd3e33b318069bf842a541a3d Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:08:53 +0100 Subject: [PATCH 01/18] Propagate LLVM coverage overrides via environment Export `CARGO_PROFILE_DEV_CODEGEN_BACKEND=llvm` and `CARGO_PROFILE_TEST_CODEGEN_BACKEND=llvm` for Cranelift-configured projects so `cargo llvm-cov` child cargo processes inherit the LLVM backend. Remove the outer `cargo --config ... codegen-backend=\"llvm\"` workaround, add fixture-backed regression coverage, and document the behaviour in the action changelog and README. --- .../actions/generate-coverage/CHANGELOG.md | 9 ++ .github/actions/generate-coverage/README.md | 5 ++ .../generate-coverage/scripts/run_rust.py | 40 ++++++--- .../.cargo/config.toml | 8 ++ .../nightly-cranelift-project/Cargo.toml | 5 ++ .../rust-toolchain.toml | 2 + .../generate-coverage/tests/test_scripts.py | 87 ++++++++++--------- 7 files changed, 104 insertions(+), 52 deletions(-) create mode 100644 .github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/.cargo/config.toml create mode 100644 .github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/Cargo.toml create mode 100644 .github/actions/generate-coverage/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml 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..c03dc781 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -14,6 +14,11 @@ 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 in `.cargo/config.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..ac34b3c8 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -99,12 +99,10 @@ """ -_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 _uses_cranelift_backend(manifest_path: Path) -> bool: @@ -147,8 +145,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 +157,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 +344,18 @@ def _pump_cargo_output(proc: subprocess.Popen[str]) -> list[str]: return stdout_lines -def _run_cargo(args: list[str]) -> str: +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 = cargo + if extra_env: + env_prefix = " ".join( + f"{key}={shlex.quote(value)}" for key, value in extra_env.items() + ) + env_prefix += " " + cargo_cmd = cargo.with_env(**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, @@ -429,6 +440,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 +464,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 +564,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 +581,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/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..be497234 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,43 @@ 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_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) == {} def test_nextest_config_is_temporary( From b857f546d1a152e0dc91f61cf3954d5c624ccc94 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:15:04 +0100 Subject: [PATCH 02/18] Resolve build toolchains from the target repository Add repository-aware toolchain resolution for `rust-build-release` so the composite action prefers an explicit `toolchain` input, then repo `rust-toolchain.toml` or `rust-toolchain`, then manifest `rust-version`, and only falls back to the bundled `TOOLCHAIN_VERSION`. Pass the resolved toolchain through the action setup step using the target `project-dir`, add nightly-plus-Cranelift fixture coverage, and keep the explicit override path from forcing an early manifest lookup. --- .../actions/rust-build-release/CHANGELOG.md | 2 + .github/actions/rust-build-release/README.md | 6 + .github/actions/rust-build-release/action.yml | 7 ++ .../rust-build-release/src/action_setup.py | 14 ++- .../actions/rust-build-release/src/main.py | 40 +++++-- .../rust-build-release/src/toolchain.py | 108 ++++++++++++++++++ .../.cargo/config.toml | 8 ++ .../nightly-cranelift-project/Cargo.toml | 5 + .../rust-toolchain.toml | 2 + .../tests/test_action_setup.py | 29 +++++ .../tests/test_manifest_input_step.py | 22 ++++ .../tests/test_manifest_path.py | 43 +++++++ .../tests/test_toolchain_helpers.py | 83 +++++++++++++- 13 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 .github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/.cargo/config.toml create mode 100644 .github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/Cargo.toml create mode 100644 .github/actions/rust-build-release/tests/fixtures/nightly-cranelift-project/rust-toolchain.toml 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..be9882b6 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 resolve 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..2e24dac2 100644 --- a/.github/actions/rust-build-release/src/action_setup.py +++ b/.github/actions/rust-build-release/src/action_setup.py @@ -60,9 +60,12 @@ 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) @@ -122,9 +125,16 @@ 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.""" - default_toolchain = read_default_toolchain() + default_toolchain = resolve_requested_toolchain( + toolchain, + project_dir=Path.cwd(), + manifest_path=manifest_path, + fallback_toolchain=read_default_toolchain(), + ) try: resolved = resolve_toolchain(default_toolchain, target, runner_os, runner_arch) except ToolchainResolutionError as exc: diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index 88ef4284..06d76da3 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, @@ -208,8 +212,13 @@ def _resolve_toolchain_name( if name in preferred: return name channel_prefix = f"{toolchain}-" + dotted_prefix = f"{toolchain}." for name in installed_names: - if name == toolchain or name.startswith(channel_prefix): + if ( + name == toolchain + or name.startswith(channel_prefix) + or name.startswith(dotted_prefix) + ): return name return "" @@ -290,11 +299,16 @@ 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}-" + dotted_prefix = f"{toolchain}." return next( ( name for name in installed_names - if name == toolchain or name.startswith(channel_prefix) + if ( + name == toolchain + or name.startswith(channel_prefix) + or name.startswith(dotted_prefix) + ) ), "", ) @@ -670,9 +684,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 +696,19 @@ def main( ) -> None: """Build the project for *target* using *toolchain*.""" target_to_build = _resolve_target_argument(target) + manifest_path: Path | None = None + requested_toolchain = toolchain.strip() + if not requested_toolchain: + manifest_path = _resolve_manifest_path() + requested_toolchain = 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,8 +737,8 @@ def main( previous_engine, applied_engine = _configure_cross_container_engine(decision) - manifest_path = _resolve_manifest_path() - manifest_argument = _manifest_argument(manifest_path) + manifest_location = manifest_path or _resolve_manifest_path() + manifest_argument = _manifest_argument(manifest_location) if decision.use_cross: build_cmd = _build_cross_command( decision, target_to_build, manifest_argument, features diff --git a/.github/actions/rust-build-release/src/toolchain.py b/.github/actions/rust-build-release/src/toolchain.py index bb2adc82..d6d82195 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,112 @@ 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_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: + for line in raw.splitlines(): + if channel := _strip_optional(line.partition("#")[0]): + return channel + return None + + toolchain = data.get("toolchain") + if isinstance(toolchain, dict): + channel = toolchain.get("channel") + if isinstance(channel, str): + return _strip_optional(channel) + return None + + +def _iter_toolchain_search_dirs(start: Path) -> typ.Iterator[Path]: + """Yield directories to search for repository toolchain declarations.""" + search_dir = start.resolve() + while True: + yield search_dir + 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): + for filename in ("rust-toolchain.toml", "rust-toolchain"): + if toolchain := _parse_toolchain_file(directory / filename): + return toolchain + return None + + +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 + + package = manifest_data.get("package") + if isinstance(package, dict): + rust_version = package.get("rust-version") + if isinstance(rust_version, str): + return _strip_optional(rust_version) + + workspace = manifest_data.get("workspace") + if isinstance(workspace, dict): + workspace_package = workspace.get("package") + if isinstance(workspace_package, dict): + rust_version = workspace_package.get("rust-version") + if isinstance(rust_version, str): + return _strip_optional(rust_version) + return None + + +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..0d5c9ae3 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( @@ -263,6 +265,33 @@ def test_cli_toolchain_outputs_value( assert result.stdout.strip() == "1.99.0" +def test_cli_toolchain_prefers_repo_declared_nightly( + action_setup_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Repo toolchain files override the action fallback when no input is set.""" + monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) + + result = runner.invoke( + action_setup_module.app, + [ + "toolchain", + "--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() == "nightly-2026-03-26" + + 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..bc650a13 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,47 @@ def test_main_errors_when_manifest_missing( assert harness.calls == [] +def test_main_prefers_repo_declared_toolchain( + main_module: ModuleType, + patch_common_main_deps: ModuleHarness, + cross_decision_factory: CrossDecisionFactory, + dummy_command_factory: DummyCommandFactory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """main() resolves repo toolchains before falling back to the action default.""" + harness = patch_common_main_deps + captured: dict[str, object] = {} + 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 = cross_decision_factory(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 dummy_command_factory("cargo-build") + + harness.patch_attr("_build_cargo_command", fake_cargo) + + 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_toolchain_helpers.py b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py index 1f747545..909ec0bb 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,80 @@ 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_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" + + (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" From 0737d4ac3fefbdfd688f80dea5a8e19b847e4ef8 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:43:25 +0100 Subject: [PATCH 03/18] Split toolchain resolution in action setup Extract the default-toolchain lookup and the runner-specific resolution into dedicated helpers above the `toolchain` CLI entry point. Keep the CLI surface unchanged while reducing the command's argument count and separating the override/manifest lookup from the runner-mapping step. --- .../rust-build-release/src/action_setup.py | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/actions/rust-build-release/src/action_setup.py b/.github/actions/rust-build-release/src/action_setup.py index 2e24dac2..d3119886 100644 --- a/.github/actions/rust-build-release/src/action_setup.py +++ b/.github/actions/rust-build-release/src/action_setup.py @@ -120,21 +120,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"), - toolchain: str = TOOLCHAIN_OVERRIDE_OPT, - manifest_path: Path = MANIFEST_PATH_OPT, -) -> None: - """CLI entry point that prints the resolved toolchain.""" - default_toolchain = resolve_requested_toolchain( - toolchain, +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: + """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: @@ -143,5 +142,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() From b8cffab7ab9cce629364321ba54eaee0ebc5d1b1 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:43:30 +0100 Subject: [PATCH 04/18] Extract cargo process helpers in generate coverage Split `_run_cargo` into focused private helpers for cargo command construction, missing-stream abort handling, and process wait/error handling. Preserve the observable behaviour while lowering the function's cyclomatic complexity and keeping the existing output and exit paths. --- .../generate-coverage/scripts/run_rust.py | 102 ++++++++++-------- 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index ac34b3c8..5a828443 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -344,16 +344,66 @@ def _pump_cargo_output(proc: subprocess.Popen[str]) -> list[str]: return stdout_lines +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``.""" - env_prefix = "" - cargo_cmd = cargo - if extra_env: - env_prefix = " ".join( - f"{key}={shlex.quote(value)}" for key, value in extra_env.items() - ) - env_prefix += " " - cargo_cmd = cargo.with_env(**extra_env) + 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, @@ -364,42 +414,10 @@ def _run_cargo(args: list[str], *, extra_env: dict[str, str] | None = None) -> s 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) From 7d6855387b9c128c65c620d72b1ef3560396d586 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:46:55 +0100 Subject: [PATCH 05/18] Use build context fixture in manifest path test Replace the individual `main()` wiring fixtures in `test_main_prefers_repo_declared_toolchain` with the existing `build_main_context` bundle. Keep the assertions and behaviour unchanged while reducing the test function's argument count to the required threshold. --- .../rust-build-release/tests/test_manifest_path.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 bc650a13..9e84a188 100644 --- a/.github/actions/rust-build-release/tests/test_manifest_path.py +++ b/.github/actions/rust-build-release/tests/test_manifest_path.py @@ -288,14 +288,12 @@ def test_main_errors_when_manifest_missing( def test_main_prefers_repo_declared_toolchain( - main_module: ModuleType, - patch_common_main_deps: ModuleHarness, - cross_decision_factory: CrossDecisionFactory, - dummy_command_factory: DummyCommandFactory, + build_main_context: BuildMainContext, monkeypatch: pytest.MonkeyPatch, ) -> None: """main() resolves repo toolchains before falling back to the action default.""" - harness = patch_common_main_deps + context = build_main_context + harness = context.harness captured: dict[str, object] = {} monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) @@ -309,7 +307,7 @@ def fake_resolve_toolchain( return toolchain_arg, [toolchain_arg] harness.patch_attr("_resolve_toolchain", fake_resolve_toolchain) - decision = cross_decision_factory(main_module, use_cross=False) + decision = context.cross_decision_factory(context.main_module, use_cross=False) harness.patch_attr("_decide_cross_usage", lambda *_, **__: decision) def fake_cargo( @@ -317,11 +315,11 @@ def fake_cargo( ) -> object: captured["manifest"] = manifest_arg captured["features"] = features_arg - return dummy_command_factory("cargo-build") + return context.dummy_command_factory("cargo-build") harness.patch_attr("_build_cargo_command", fake_cargo) - main_module.main("aarch64-unknown-linux-gnu") + context.main_module.main("aarch64-unknown-linux-gnu") assert captured["toolchain"] == "nightly-2026-03-26" assert captured["target"] == "aarch64-unknown-linux-gnu" From 9185d0690e62367e9a1a577d243c5730b555966f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:50:29 +0100 Subject: [PATCH 06/18] Extract toolchain channel matching helper Factor the repeated channel-prefix predicate out of `_resolve_toolchain_name` and `_fallback_toolchain_name` into a single private helper in `main.py`. Keep the existing resolution behaviour unchanged while reducing the complexity of the duplicated conditional logic. --- .../actions/rust-build-release/src/main.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index 06d76da3..cbfbe140 100755 --- a/.github/actions/rust-build-release/src/main.py +++ b/.github/actions/rust-build-release/src/main.py @@ -203,6 +203,17 @@ 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) + or name.startswith(dotted_prefix) + ) + + def _resolve_toolchain_name( toolchain: str, target: str, installed_names: list[str] ) -> str: @@ -211,14 +222,8 @@ def _resolve_toolchain_name( for name in installed_names: if name in preferred: return name - channel_prefix = f"{toolchain}-" - dotted_prefix = f"{toolchain}." for name in installed_names: - if ( - name == toolchain - or name.startswith(channel_prefix) - or name.startswith(dotted_prefix) - ): + if _matches_toolchain_channel(name, toolchain): return name return "" @@ -298,17 +303,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}-" - dotted_prefix = f"{toolchain}." return next( ( name for name in installed_names - if ( - name == toolchain - or name.startswith(channel_prefix) - or name.startswith(dotted_prefix) - ) + if _matches_toolchain_channel(name, toolchain) ), "", ) From c98da834f7690c4d1ab363b4c8b613a93b9ebe46 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 00:54:16 +0100 Subject: [PATCH 07/18] Extract manifest rust-version helpers Split the nested ``[package]`` and ``[workspace.package]`` rust-version lookups in `read_manifest_rust_version` into two private helpers. Keep the existing parse guard and lookup behaviour unchanged while flattening the control flow in `toolchain.py`. --- .../rust-build-release/src/toolchain.py | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/actions/rust-build-release/src/toolchain.py b/.github/actions/rust-build-release/src/toolchain.py index d6d82195..20566147 100644 --- a/.github/actions/rust-build-release/src/toolchain.py +++ b/.github/actions/rust-build-release/src/toolchain.py @@ -82,6 +82,25 @@ def read_repo_toolchain(project_dir: Path, manifest_path: Path) -> str | None: return None +def _extract_rust_version_from_package(section: object) -> str | None: + """Return ``rust-version`` from a TOML ``[package]`` mapping, if declared.""" + 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_package(manifest_data: dict) -> object: + """Return the ``[workspace.package]`` table from *manifest_data*, if present.""" + workspace = manifest_data.get("workspace") + if isinstance(workspace, dict): + return workspace.get("package") + return None + + def read_manifest_rust_version(project_dir: Path, manifest_path: Path) -> str | None: """Return ``rust-version`` from the manifest when it is declared.""" try: @@ -93,20 +112,9 @@ def read_manifest_rust_version(project_dir: Path, manifest_path: Path) -> str | except (OSError, tomllib.TOMLDecodeError): return None - package = manifest_data.get("package") - if isinstance(package, dict): - rust_version = package.get("rust-version") - if isinstance(rust_version, str): - return _strip_optional(rust_version) - - workspace = manifest_data.get("workspace") - if isinstance(workspace, dict): - workspace_package = workspace.get("package") - if isinstance(workspace_package, dict): - rust_version = workspace_package.get("rust-version") - if isinstance(rust_version, str): - return _strip_optional(rust_version) - return None + return _extract_rust_version_from_package( + manifest_data.get("package") + ) or _extract_rust_version_from_package(_workspace_package(manifest_data)) def resolve_requested_toolchain( From 4884fb85645c87d02175c6fbc7b70ac22b07fd83 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:00:31 +0100 Subject: [PATCH 08/18] Flatten toolchain file parsing helpers Extract the legacy line parser and TOML channel reader out of `_parse_toolchain_file`, and replace the nested manifest rust-version lookups with the requested section and workspace helpers. Keep the existing behaviour and public surface unchanged while removing the duplicated nested conditional paths in `toolchain.py`. --- .../rust-build-release/src/toolchain.py | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/actions/rust-build-release/src/toolchain.py b/.github/actions/rust-build-release/src/toolchain.py index 20566147..b483aa34 100644 --- a/.github/actions/rust-build-release/src/toolchain.py +++ b/.github/actions/rust-build-release/src/toolchain.py @@ -36,6 +36,25 @@ def _strip_optional(value: str | None) -> str | None: 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: @@ -46,17 +65,9 @@ def _parse_toolchain_file(path: Path) -> str | None: try: data = tomllib.loads(raw) except tomllib.TOMLDecodeError: - for line in raw.splitlines(): - if channel := _strip_optional(line.partition("#")[0]): - return channel - return None + return _parse_legacy_toolchain_file(raw) - toolchain = data.get("toolchain") - if isinstance(toolchain, dict): - channel = toolchain.get("channel") - if isinstance(channel, str): - return _strip_optional(channel) - return None + return _extract_toml_channel(data) def _iter_toolchain_search_dirs(start: Path) -> typ.Iterator[Path]: @@ -82,8 +93,8 @@ def read_repo_toolchain(project_dir: Path, manifest_path: Path) -> str | None: return None -def _extract_rust_version_from_package(section: object) -> str | None: - """Return ``rust-version`` from a TOML ``[package]`` mapping, if declared.""" +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) @@ -93,12 +104,12 @@ def _extract_rust_version_from_package(section: object) -> str | None: return None -def _workspace_package(manifest_data: dict) -> object: - """Return the ``[workspace.package]`` table from *manifest_data*, if present.""" +def _workspace_rust_version(manifest_data: dict) -> str | None: + """Return ``rust-version`` from ``[workspace.package]``, if declared.""" workspace = manifest_data.get("workspace") - if isinstance(workspace, dict): - return workspace.get("package") - return None + 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: @@ -112,9 +123,9 @@ def read_manifest_rust_version(project_dir: Path, manifest_path: Path) -> str | except (OSError, tomllib.TOMLDecodeError): return None - return _extract_rust_version_from_package( + return _section_rust_version( manifest_data.get("package") - ) or _extract_rust_version_from_package(_workspace_package(manifest_data)) + ) or _workspace_rust_version(manifest_data) def resolve_requested_toolchain( From 4817b80dc6d5fa1c3f56d9a2f6adaddb1bfa6518 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:30:28 +0100 Subject: [PATCH 09/18] Harden toolchain and coverage manifest detection Verify the reported findings against the current tree and apply only the ones that still reproduced. Expand Cranelift detection so `generate-coverage` honors manifest profile settings as well as `.cargo` config files, and add regression coverage for manifest-only projects. Bound repository toolchain discovery to the target project, stop treating malformed `rust-toolchain.toml` files as legacy channel files, and keep the existing toolchain-channel matching logic concise. Stabilize the rust-build-release tests by clearing ambient manifest state, providing deterministic manifest setup for target-install paths, and isolating `CROSS_CONTAINER_ENGINE` assertions from suite-level environment pollution. --- .../generate-coverage/scripts/run_rust.py | 39 +++++++++++++--- .../generate-coverage/tests/test_scripts.py | 26 +++++++++++ .../actions/rust-build-release/src/main.py | 8 ++-- .../rust-build-release/src/toolchain.py | 11 +++-- .../tests/test_manifest_path.py | 3 +- .../tests/test_target_install.py | 14 ++++-- .../tests/test_toolchain_helpers.py | 44 +++++++++++++++++++ 7 files changed, 127 insertions(+), 18 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index 5a828443..2aa644e7 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 @@ -105,6 +106,31 @@ } +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 _uses_cranelift_backend(manifest_path: Path) -> bool: """Return ``True`` when the project configures the Cranelift codegen backend. @@ -112,6 +138,13 @@ def _uses_cranelift_backend(manifest_path: Path) -> bool: (or ``.cargo/config``) and checks whether any profile sets ``codegen-backend = "cranelift"``. """ + try: + manifest_content = manifest_path.resolve().read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + manifest_content = None + if manifest_content is not None and _toml_uses_cranelift_backend(manifest_content): + return True + search_dir = manifest_path.resolve().parent while True: for name in ("config.toml", "config"): @@ -121,11 +154,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: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index be497234..9731f3e6 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -456,6 +456,32 @@ def test_get_cargo_coverage_env_non_cranelift_is_empty( 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 + + def test_nextest_config_is_temporary( tmp_path: Path, run_rust_module: ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index cbfbe140..443aff80 100755 --- a/.github/actions/rust-build-release/src/main.py +++ b/.github/actions/rust-build-release/src/main.py @@ -207,11 +207,7 @@ 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) - or name.startswith(dotted_prefix) - ) + return name == toolchain or name.startswith((channel_prefix, dotted_prefix)) def _resolve_toolchain_name( @@ -569,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) diff --git a/.github/actions/rust-build-release/src/toolchain.py b/.github/actions/rust-build-release/src/toolchain.py index b483aa34..e7affd70 100644 --- a/.github/actions/rust-build-release/src/toolchain.py +++ b/.github/actions/rust-build-release/src/toolchain.py @@ -65,17 +65,22 @@ def _parse_toolchain_file(path: Path) -> str | 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) -> typ.Iterator[Path]: +def _iter_toolchain_search_dirs(start: Path, stop_at: Path) -> typ.Iterator[Path]: """Yield directories to search for repository toolchain declarations.""" search_dir = start.resolve() + repo_root = stop_at.resolve() + if search_dir != repo_root and repo_root not in search_dir.parents: + return while True: yield search_dir - if (search_dir / ".git").exists(): + if search_dir == repo_root or (search_dir / ".git").exists(): return parent = search_dir.parent if parent == search_dir: @@ -86,7 +91,7 @@ def _iter_toolchain_search_dirs(start: Path) -> typ.Iterator[Path]: 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): + 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 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 9e84a188..42ea03d1 100644 --- a/.github/actions/rust-build-release/tests/test_manifest_path.py +++ b/.github/actions/rust-build-release/tests/test_manifest_path.py @@ -295,6 +295,7 @@ def test_main_prefers_repo_declared_toolchain( 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) @@ -311,7 +312,7 @@ def fake_resolve_toolchain( harness.patch_attr("_decide_cross_usage", lambda *_, **__: decision) def fake_cargo( - _spec: str, target_arg: str, manifest_arg: Path, features_arg: str + _spec: str, _target_arg: str, manifest_arg: Path, features_arg: str ) -> object: captured["manifest"] = manifest_arg captured["features"] = features_arg 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 909ec0bb..42e2b60f 100644 --- a/.github/actions/rust-build-release/tests/test_toolchain_helpers.py +++ b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py @@ -63,6 +63,50 @@ def test_read_manifest_rust_version_reads_package_msrv( 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_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, From 3aa70d181f3ccbe3f18570cc15411e15aba512cd Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:35:21 +0100 Subject: [PATCH 10/18] Preserve qualified Windows GNU toolchains Stop `action_setup.resolve_toolchain()` from appending a second GNU host suffix when the requested or repo-declared toolchain already embeds a real target triple. Add a regression test that exercises a fully qualified Windows GNU nightly so the setup step keeps the exact toolchain string instead of producing an invalid doubled identifier. --- .../rust-build-release/src/action_setup.py | 46 +++++++++++++++++++ .../tests/test_action_setup.py | 13 ++++++ 2 files changed, 59 insertions(+) diff --git a/.github/actions/rust-build-release/src/action_setup.py b/.github/actions/rust-build-release/src/action_setup.py index d3119886..f1379731 100644 --- a/.github/actions/rust-build-release/src/action_setup.py +++ b/.github/actions/rust-build-release/src/action_setup.py @@ -69,6 +69,31 @@ def bootstrap_environment() -> tuple[Path, 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.""" @@ -78,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: @@ -97,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] 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 0d5c9ae3..42ecb565 100644 --- a/.github/actions/rust-build-release/tests/test_action_setup.py +++ b/.github/actions/rust-build-release/tests/test_action_setup.py @@ -232,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, From 65aedce6b7696d90de73f78a0ff0297d4578a01f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:43:23 +0100 Subject: [PATCH 11/18] Add review coverage for toolchain and cucumber paths Address the remaining review comments with narrow follow-up changes. Add focused coverage for `run_cucumber_rs_coverage()` env forwarding, workspace-level manifest MSRV lookup, whitespace-only explicit toolchain values, and CLI override precedence. Simplify `main()` by resolving the manifest once, and tighten the README wording for the `toolchain` input description. --- .../generate-coverage/tests/test_scripts.py | 86 +++++++++++++++++++ .github/actions/rust-build-release/README.md | 2 +- .../actions/rust-build-release/src/main.py | 20 ++--- .../tests/test_action_setup.py | 29 +++++++ .../tests/test_toolchain_helpers.py | 36 ++++++++ 5 files changed, 160 insertions(+), 13 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 9731f3e6..3d4c2cbd 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -482,6 +482,92 @@ def test_get_cargo_coverage_env_detects_manifest_only_cranelift( assert run_rust_module.get_cargo_coverage_env(manifest_path) == _LLVM_CODEGEN_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``.""" + fixture_dir = ( + Path(__file__).resolve().parent / "fixtures" / "nightly-cranelift-project" + ) + manifest_path = fixture_dir / "Cargo.toml" + 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=manifest_path, + with_default=True, + use_nextest=False, + cucumber_rs_features="cucumber", + cucumber_rs_args="", + 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", + ) + out = tmp_path / "coverage.lcov" + out.write_text("TN:\nend_of_record\n", encoding="utf-8") + captured_env: dict[str, str] | None = None + extra_env = {"FOO": "BAR", "BAZ": "QUX"} + + 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=manifest_path, + with_default=True, + use_nextest=False, + cucumber_rs_features="cucumber", + cucumber_rs_args="", + extra_env=extra_env, + ) + + assert captured_env == extra_env + + def test_nextest_config_is_temporary( tmp_path: Path, run_rust_module: ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/.github/actions/rust-build-release/README.md b/.github/actions/rust-build-release/README.md index be9882b6..ebb8aca7 100644 --- a/.github/actions/rust-build-release/README.md +++ b/.github/actions/rust-build-release/README.md @@ -32,7 +32,7 @@ manifest `rust-version`, then the action's bundled fallback version. | 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 resolve from the target repository before falling back to the action default | 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 | diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index 443aff80..a6fe0836 100755 --- a/.github/actions/rust-build-release/src/main.py +++ b/.github/actions/rust-build-release/src/main.py @@ -693,16 +693,13 @@ def main( ) -> None: """Build the project for *target* using *toolchain*.""" target_to_build = _resolve_target_argument(target) - manifest_path: Path | None = None - requested_toolchain = toolchain.strip() - if not requested_toolchain: - manifest_path = _resolve_manifest_path() - requested_toolchain = resolve_requested_toolchain( - toolchain, - project_dir=Path.cwd(), - manifest_path=manifest_path, - fallback_toolchain=DEFAULT_TOOLCHAIN, - ) + 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, requested_toolchain, target_to_build @@ -734,8 +731,7 @@ def main( previous_engine, applied_engine = _configure_cross_container_engine(decision) - manifest_location = manifest_path or _resolve_manifest_path() - manifest_argument = _manifest_argument(manifest_location) + manifest_argument = _manifest_argument(manifest_path) if decision.use_cross: build_cmd = _build_cross_command( decision, target_to_build, manifest_argument, features 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 42ecb565..6262f1cb 100644 --- a/.github/actions/rust-build-release/tests/test_action_setup.py +++ b/.github/actions/rust-build-release/tests/test_action_setup.py @@ -305,6 +305,35 @@ def test_cli_toolchain_prefers_repo_declared_nightly( assert result.stdout.strip() == "nightly-2026-03-26" +def test_cli_toolchain_override_wins_over_repo_declared_nightly( + action_setup_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Explicit CLI toolchain overrides repo rust-toolchain and manifest MSRV.""" + monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) + + result = runner.invoke( + action_setup_module.app, + [ + "toolchain", + "--toolchain", + "beta", + "--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() == "beta" + + 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_toolchain_helpers.py b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py index 42e2b60f..e3396e3b 100644 --- a/.github/actions/rust-build-release/tests/test_toolchain_helpers.py +++ b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py @@ -63,6 +63,34 @@ def test_read_manifest_rust_version_reads_package_msrv( 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, @@ -128,6 +156,14 @@ def test_resolve_requested_toolchain_precedence( ) 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", From 07715e865835122a533f88fd35f59bbc24a8fd0f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:52:42 +0100 Subject: [PATCH 12/18] Tighten toolchain search and manifest Cranelift detection Bound repository toolchain discovery with an optional `stop_at` limit and cover the inclusive boundary behaviour in the helper tests. Extract manifest-side Cranelift detection into a private helper, keep the existing tolerant matching for profile values, and add fixture-backed coverage for manifest-only `Cargo.toml` profile declarations. --- .../generate-coverage/scripts/run_rust.py | 28 ++++++++++++++----- .../cargo-toml-cranelift-project/Cargo.toml | 8 ++++++ .../generate-coverage/tests/test_scripts.py | 10 +++++++ .../rust-build-release/src/toolchain.py | 18 ++++++++---- .../tests/test_toolchain_helpers.py | 19 +++++++++++++ 5 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 .github/actions/generate-coverage/tests/fixtures/cargo-toml-cranelift-project/Cargo.toml diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index 2aa644e7..4fd7fb26 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -131,21 +131,35 @@ def _toml_uses_cranelift_backend(content: str) -> bool: 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"``. """ - try: - manifest_content = manifest_path.resolve().read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - manifest_content = None - if manifest_content is not None and _toml_uses_cranelift_backend(manifest_content): + resolved = manifest_path.resolve() + if _manifest_uses_cranelift(resolved): return True - search_dir = manifest_path.resolve().parent + search_dir = resolved.parent while True: for name in ("config.toml", "config"): candidate = search_dir / ".cargo" / name 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/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 3d4c2cbd..e3fbcc83 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -447,6 +447,16 @@ def test_get_cargo_coverage_env_detects_cranelift_fixture( 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: diff --git a/.github/actions/rust-build-release/src/toolchain.py b/.github/actions/rust-build-release/src/toolchain.py index e7affd70..a2117c69 100644 --- a/.github/actions/rust-build-release/src/toolchain.py +++ b/.github/actions/rust-build-release/src/toolchain.py @@ -72,15 +72,21 @@ def _parse_toolchain_file(path: Path) -> str | None: return _extract_toml_channel(data) -def _iter_toolchain_search_dirs(start: Path, stop_at: Path) -> typ.Iterator[Path]: - """Yield directories to search for repository toolchain declarations.""" +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() - repo_root = stop_at.resolve() - if search_dir != repo_root and repo_root not in search_dir.parents: - return + stop = stop_at.resolve() if stop_at is not None else None while True: yield search_dir - if search_dir == repo_root or (search_dir / ".git").exists(): + if stop is not None and search_dir == stop: + return + if (search_dir / ".git").exists(): return parent = search_dir.parent if parent == search_dir: 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 e3396e3b..03e16065 100644 --- a/.github/actions/rust-build-release/tests/test_toolchain_helpers.py +++ b/.github/actions/rust-build-release/tests/test_toolchain_helpers.py @@ -114,6 +114,25 @@ def test_read_repo_toolchain_ignores_parent_toolchains_outside_project_dir( 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, From 5dc01193b08d9c1ef53ae0bac4a554f5bb0d3d93 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:54:05 +0100 Subject: [PATCH 13/18] Clarify Cranelift detection sources in coverage README Document that the `generate-coverage` action scans `.cargo/config.toml`, `.cargo/config`, and `Cargo.toml` profile settings when deciding whether to export the LLVM codegen backend overrides for coverage runs. --- .github/actions/generate-coverage/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/actions/generate-coverage/README.md b/.github/actions/generate-coverage/README.md index c03dc781..02dcf711 100644 --- a/.github/actions/generate-coverage/README.md +++ b/.github/actions/generate-coverage/README.md @@ -14,10 +14,11 @@ 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 in `.cargo/config.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. +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 From 4913f985333fc8e9bc638281a126a74181173c77 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 01:55:45 +0100 Subject: [PATCH 14/18] Add shared actions developer guide Document the internal toolchain-resolution flow in `rust-build-release` and the Cranelift coverage override path in `generate-coverage`. Also add brief contributor guidance for fixtures, changelog updates, and README expectations when introducing new actions. --- .github/actions/DEVELOPMENT.md | 101 +++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/actions/DEVELOPMENT.md 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. From 0bb9a37873b0cc1ad505910632ca48756a13afb4 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 11:13:19 +0100 Subject: [PATCH 15/18] Extract cucumber coverage test helper Remove duplicated `_run_cargo` stubbing from the two cucumber coverage environment-propagation tests in `test_scripts.py`. Keep the assertions and execution path unchanged while moving the shared setup into a private helper. --- .../generate-coverage/tests/test_scripts.py | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index e3fbcc83..a47b601b 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -492,16 +492,14 @@ def test_get_cargo_coverage_env_detects_manifest_only_cranelift( assert run_rust_module.get_cargo_coverage_env(manifest_path) == _LLVM_CODEGEN_ENV -def test_run_cucumber_rs_coverage_passes_extra_env_for_cranelift( +def _run_cucumber_coverage_and_capture_env( run_rust_module: ModuleType, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, -) -> None: - """cucumber.rs coverage forwards Cranelift env overrides to ``_run_cargo``.""" - fixture_dir = ( - Path(__file__).resolve().parent / "fixtures" / "nightly-cranelift-project" - ) - manifest_path = fixture_dir / "Cargo.toml" + manifest_path: Path, + extra_env: dict[str, str] | None, +) -> 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 @@ -518,7 +516,6 @@ def fake_run_cargo( return "" monkeypatch.setattr(run_rust_module, "_run_cargo", fake_run_cargo) - run_rust_module.run_cucumber_rs_coverage( out, "lcov", @@ -528,9 +525,30 @@ def fake_run_cargo( use_nextest=False, cucumber_rs_features="cucumber", cucumber_rs_args="", - extra_env=run_rust_module.get_cargo_coverage_env(manifest_path), + extra_env=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, + manifest_path, + run_rust_module.get_cargo_coverage_env(manifest_path), + ) assert captured_env == _LLVM_CODEGEN_ENV @@ -545,36 +563,10 @@ def test_run_cucumber_rs_coverage_passes_extra_env_for_non_cranelift( "[package]\nname='demo'\nversion='0.1.0'\n", encoding="utf-8", ) - out = tmp_path / "coverage.lcov" - out.write_text("TN:\nend_of_record\n", encoding="utf-8") - captured_env: dict[str, str] | None = None extra_env = {"FOO": "BAR", "BAZ": "QUX"} - - 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=manifest_path, - with_default=True, - use_nextest=False, - cucumber_rs_features="cucumber", - cucumber_rs_args="", - extra_env=extra_env, + captured_env = _run_cucumber_coverage_and_capture_env( + run_rust_module, monkeypatch, tmp_path, manifest_path, extra_env ) - assert captured_env == extra_env From a63307f3aa2d0ae530414dc7955814ea2ce1e5c8 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 11:17:14 +0100 Subject: [PATCH 16/18] Parameterize nightly toolchain CLI test Replace the two structurally identical nightly-project CLI toolchain tests in `test_action_setup.py` with a single parameterized test. Keep both behaviors covered and preserve explicit pytest case IDs for the repo-declared nightly path and the CLI override path. --- .../tests/test_action_setup.py | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) 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 6262f1cb..4ea01076 100644 --- a/.github/actions/rust-build-release/tests/test_action_setup.py +++ b/.github/actions/rust-build-release/tests/test_action_setup.py @@ -278,46 +278,27 @@ def test_cli_toolchain_outputs_value( assert result.stdout.strip() == "1.99.0" -def test_cli_toolchain_prefers_repo_declared_nightly( - action_setup_module: ModuleType, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Repo toolchain files override the action fallback when no input is set.""" - monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) - - result = runner.invoke( - action_setup_module.app, - [ - "toolchain", - "--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() == "nightly-2026-03-26" - - -def test_cli_toolchain_override_wins_over_repo_declared_nightly( +@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: - """Explicit CLI toolchain overrides repo rust-toolchain and manifest MSRV.""" + """Toolchain resolution respects explicit override and repo-declared nightly.""" monkeypatch.chdir(NIGHTLY_CRANELIFT_PROJECT) result = runner.invoke( action_setup_module.app, [ "toolchain", - "--toolchain", - "beta", + *extra_args, "--target", "aarch64-unknown-linux-gnu", "--manifest-path", @@ -331,7 +312,7 @@ def test_cli_toolchain_override_wins_over_repo_declared_nightly( ) assert result.exit_code == 0 - assert result.stdout.strip() == "beta" + assert result.stdout.strip() == expected_toolchain def test_cli_validate_emits_error(action_setup_module: ModuleType) -> None: From a39b5119665538c69a2adc30fc8a538e722c052b Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 11:35:04 +0100 Subject: [PATCH 17/18] Use scenario object for cucumber coverage helper Reduce the helper argument count in generate-coverage test scaffolding by bundling the manifest path and extra environment into a private dataclass. --- .../generate-coverage/tests/test_scripts.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index a47b601b..542ac703 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -492,12 +492,17 @@ def test_get_cargo_coverage_env_detects_manifest_only_cranelift( 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, - manifest_path: Path, - extra_env: dict[str, str] | None, + scenario: _CucumberEnvScenario, ) -> dict[str, str] | None: """Stub ``_run_cargo``, invoke ``run_cucumber_rs_coverage``, return captured env.""" out = tmp_path / "coverage.lcov" @@ -520,12 +525,12 @@ def fake_run_cargo( out, "lcov", "", - manifest_path=manifest_path, + manifest_path=scenario.manifest_path, with_default=True, use_nextest=False, cucumber_rs_features="cucumber", cucumber_rs_args="", - extra_env=extra_env, + extra_env=scenario.extra_env, ) return captured_env @@ -546,8 +551,10 @@ def test_run_cucumber_rs_coverage_passes_extra_env_for_cranelift( run_rust_module, monkeypatch, tmp_path, - manifest_path, - run_rust_module.get_cargo_coverage_env(manifest_path), + _CucumberEnvScenario( + manifest_path=manifest_path, + extra_env=run_rust_module.get_cargo_coverage_env(manifest_path), + ), ) assert captured_env == _LLVM_CODEGEN_ENV @@ -565,7 +572,10 @@ def test_run_cucumber_rs_coverage_passes_extra_env_for_non_cranelift( ) extra_env = {"FOO": "BAR", "BAZ": "QUX"} captured_env = _run_cucumber_coverage_and_capture_env( - run_rust_module, monkeypatch, tmp_path, manifest_path, extra_env + run_rust_module, + monkeypatch, + tmp_path, + _CucumberEnvScenario(manifest_path=manifest_path, extra_env=extra_env), ) assert captured_env == extra_env From 42c97f1b9edfd950c07d09c759eb27a929de8ccf Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 17 Apr 2026 11:59:59 +0100 Subject: [PATCH 18/18] Parameterize zero-coverage LCOV tests Collapse the four structurally identical zero-coverage LCOV tests into one parametrized test while preserving the per-case pytest ids. --- .../generate-coverage/tests/test_scripts.py | 43 ++++++++----------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 542ac703..ab5de733 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -1330,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"