From 6c8b6ad436b3aefefe72e604eb3a8734b541ee51 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 11:16:37 +0200 Subject: [PATCH 01/26] Add rustflags passthrough to setup-rust and rust-build-release `actions-rust-lang/setup-rust-toolchain` exports `RUSTFLAGS="-D warnings"` into the job environment whenever the variable is unset. Because an ambient `RUSTFLAGS` overrides Cargo's `build.rustflags` configuration, consumers whose builds require specific flags in `.cargo/config.toml` (for example netsuke's `-Zpolonius=next`, see leynos/netsuke#465) find them silently stripped by the setup step. - `setup-rust` gains a `rustflags` input forwarded to all three nested `setup-rust-toolchain` invocations. The default preserves the historical `-D warnings`; the empty string leaves `RUSTFLAGS` unset so the project's Cargo configuration applies. - `rust-build-release` gains a `rustflags` input exported (via a GITHUB_ENV heredoc, no template expansion in the script) before its internally pinned setup-rust step, whose nested toolchain setup only applies its default when `RUSTFLAGS` is unset. A pre-existing `RUSTFLAGS` still wins. This avoids bumping the internal setup-rust-v1 pin. Manifest tests cover the new inputs, the forwarding, the export step's env indirection, and its ordering before toolchain setup. Co-Authored-By: Claude Fable 5 --- .../actions/rust-build-release/CHANGELOG.md | 6 ++++ .github/actions/rust-build-release/README.md | 1 + .github/actions/rust-build-release/action.yml | 31 ++++++++++++++++ .../tests/test_manifest_input_step.py | 36 +++++++++++++++++++ .github/actions/setup-rust/CHANGELOG.md | 8 +++++ .github/actions/setup-rust/README.md | 1 + .github/actions/setup-rust/action.yml | 13 +++++++ .../tests/test_setup_rust_manifest.py | 24 +++++++++++++ 8 files changed, 120 insertions(+) diff --git a/.github/actions/rust-build-release/CHANGELOG.md b/.github/actions/rust-build-release/CHANGELOG.md index b7f4aad3..c5c71286 100644 --- a/.github/actions/rust-build-release/CHANGELOG.md +++ b/.github/actions/rust-build-release/CHANGELOG.md @@ -10,6 +10,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Add a `rustflags` input exported before the toolchain setup step so + builds that require specific flags (for example `-Zpolonius=next`) are + not stripped by the nested setup step's `-D warnings` default, which + shadows the project's `build.rustflags` configuration. A pre-existing + `RUSTFLAGS` environment variable still takes precedence. + - Cross-compile and stage `x86_64-unknown-illumos` artefacts from Linux runners. - Provide shared packaging fixtures and helpers that build the sample project once and produce `.deb` and `.rpm` artefacts for the integration tests. diff --git a/.github/actions/rust-build-release/README.md b/.github/actions/rust-build-release/README.md index 2ae61878..78337ceb 100644 --- a/.github/actions/rust-build-release/README.md +++ b/.github/actions/rust-build-release/README.md @@ -40,6 +40,7 @@ manifest `rust-version`, then the action's bundled fallback version. | bin-name | string | `rust-toy-app` | Binary name produced by the build | no | | features | string | (empty) | Comma-separated Cargo features | no | | skip-man-page-discovery | boolean | `false` | Post-build man opt-out | no | +| rustflags | string | (empty) | RUSTFLAGS exported pre-setup | no | When `toolchain` is empty, the action resolves the toolchain from the target repository before falling back to the action default. `manifest-path` may be diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index ec8651db..325fc38a 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -32,6 +32,18 @@ inputs: the existing clap_mangen/build.rs discovery behaviour. required: false default: "false" + rustflags: + description: > + RUSTFLAGS to export before the toolchain setup step. Leave empty + (default) to keep the environment untouched, in which case the nested + setup-rust step exports its own "-D warnings" default whenever + RUSTFLAGS is unset — note that this shadows the project's + build.rustflags in .cargo/config.toml. Provide a value here (for + example a required -Z flag) to make the build honour it instead. A + pre-existing RUSTFLAGS environment variable takes precedence over + this input. + required: false + default: "" runs: using: composite steps: @@ -56,6 +68,25 @@ runs: --runner-os "${{ runner.os }}" \ --runner-arch "${{ runner.arch }}")" echo "RBR_TOOLCHAIN=$TOOLCHAIN" >> "$GITHUB_ENV" + - name: Export caller RUSTFLAGS + # Runs before the toolchain setup so its nested setup-rust-toolchain + # step, which only exports its "-D warnings" default when RUSTFLAGS is + # unset, defers to the caller's value. + if: inputs.rustflags != '' + shell: bash + env: + RBR_RUSTFLAGS: ${{ inputs.rustflags }} + run: | + set -euo pipefail + if [[ -v RUSTFLAGS ]]; then + echo "RUSTFLAGS already set; leaving the inherited value in place" >&2 + else + { + echo "RUSTFLAGS<<__RBR_RUSTFLAGS_EOF__" + printf '%s\n' "$RBR_RUSTFLAGS" + echo "__RBR_RUSTFLAGS_EOF__" + } >> "$GITHUB_ENV" + fi - name: Setup Rust toolchain # setup-rust-v1 # Update this SHA when setup-rust publishes a new release: run 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 68d09a2f..7bf3ee67 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 @@ -91,3 +91,39 @@ def test_stage_artefacts_step_uses_stable_manpage_path() -> None: assert 'if [[ ! -f "${man_path}" ]]; then' in run_script assert "release/build" in run_script assert "man_matches" in run_script + + +def test_rustflags_input_declared() -> None: + """The rustflags input must exist with an empty default.""" + manifest = _load_action_manifest() + inputs = manifest["inputs"] + assert "rustflags" in inputs + rustflags_input = inputs["rustflags"] + assert rustflags_input.get("required", False) is False + assert rustflags_input.get("default") == "" + + +def test_export_rustflags_step_wiring() -> None: + """The export step must gate on the input and defer to an inherited value.""" + manifest = _load_action_manifest() + steps: list[dict[str, object]] = manifest["runs"]["steps"] + export_step = _find_step(steps, "Export caller RUSTFLAGS") + assert export_step.get("if") == "inputs.rustflags != ''" + env = export_step.get("env") + assert isinstance(env, dict) + assert env.get("RBR_RUSTFLAGS") == "${{ inputs.rustflags }}" + run_script = export_step.get("run") + assert isinstance(run_script, str) + # The value must flow through the environment, not template expansion, + # and an inherited RUSTFLAGS must win over the input. + assert "-v RUSTFLAGS" in run_script + assert '"$RBR_RUSTFLAGS"' in run_script + assert "${{" not in run_script + + +def test_export_rustflags_step_precedes_toolchain_setup() -> None: + """The export must run before the nested setup-rust toolchain step.""" + manifest = _load_action_manifest() + steps: list[dict[str, object]] = manifest["runs"]["steps"] + names = [step.get("name") for step in steps] + assert names.index("Export caller RUSTFLAGS") < names.index("Setup Rust toolchain") diff --git a/.github/actions/setup-rust/CHANGELOG.md b/.github/actions/setup-rust/CHANGELOG.md index 8de728fe..0ab14a65 100644 --- a/.github/actions/setup-rust/CHANGELOG.md +++ b/.github/actions/setup-rust/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.15 - 2026-07-29 + +- Add `rustflags` input forwarded to `actions-rust-lang/setup-rust-toolchain`. + The default keeps the existing `-D warnings` behaviour; pass extra flags + (for example `-D warnings -Zpolonius=next`) or the empty string to leave + `RUSTFLAGS` unset so the project's `build.rustflags` configuration + applies. + ## v1.0.14 - 2026-01-16 - Pin sccache to v0.12.0 on macOS x86_64 runners (x86_64-apple-darwin binaries diff --git a/.github/actions/setup-rust/README.md b/.github/actions/setup-rust/README.md index 63776744..aeb0047d 100644 --- a/.github/actions/setup-rust/README.md +++ b/.github/actions/setup-rust/README.md @@ -20,6 +20,7 @@ require them, and set up macOS or OpenBSD cross-compilers. | darwin-sdk-version | macOS SDK version for osxcross | no | `12.3` | | with-openbsd | Build OpenBSD std library for cross-compilation | no | `false` | | openbsd-nightly | Pinned nightly Rust for OpenBSD | no | `nightly-2025-07-20` | +| rustflags | `RUSTFLAGS` exported by the toolchain setup step. Set to the empty string to leave `RUSTFLAGS` unset so an inherited value or the project's `build.rustflags` applies. | no | `-D warnings` | diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 68b177e5..e49539fb 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -38,6 +38,16 @@ inputs: description: Nightly toolchain version for OpenBSD build required: false default: 'nightly-2025-07-20' + rustflags: + description: > + Value exported as the RUSTFLAGS environment variable by the toolchain + setup step. Set to the empty string to leave RUSTFLAGS unset so an + inherited value or the project's Cargo configuration + (build.rustflags in .cargo/config.toml) applies. A pre-existing + RUSTFLAGS environment variable always takes precedence over this + input. + required: false + default: '-D warnings' runs: using: composite steps: @@ -49,6 +59,7 @@ runs: toolchain: ${{ inputs.toolchain }} components: rustfmt, clippy, llvm-tools-preview cache: false + rustflags: ${{ inputs.rustflags }} - name: Install rust (rust-toolchain file) if: ${{ inputs.toolchain == '' && hashFiles('rust-toolchain.toml', 'rust-toolchain') != '' }} uses: actions-rust-lang/setup-rust-toolchain@9d7e65c320fdb52dcd45ffaa68deb6c02c8754d9 @@ -56,6 +67,7 @@ runs: override: false components: rustfmt, clippy, llvm-tools-preview cache: false + rustflags: ${{ inputs.rustflags }} - name: Install rust (stable default) if: ${{ inputs.toolchain == '' && hashFiles('rust-toolchain.toml', 'rust-toolchain') == '' }} uses: actions-rust-lang/setup-rust-toolchain@9d7e65c320fdb52dcd45ffaa68deb6c02c8754d9 @@ -64,6 +76,7 @@ runs: toolchain: stable components: rustfmt, clippy, llvm-tools-preview cache: false + rustflags: ${{ inputs.rustflags }} - name: Install cargo-binstall if: ${{ inputs.install-binstall == 'true' }} run: | diff --git a/.github/actions/setup-rust/tests/test_setup_rust_manifest.py b/.github/actions/setup-rust/tests/test_setup_rust_manifest.py index 4491ff2e..91a2b03c 100644 --- a/.github/actions/setup-rust/tests/test_setup_rust_manifest.py +++ b/.github/actions/setup-rust/tests/test_setup_rust_manifest.py @@ -375,3 +375,27 @@ def test_install_binstall_script_does_not_duplicate_path_entry( assert entries.count(cargo_home_bin) == 1, ( f"Expected {cargo_home_bin!r} to appear exactly once; got: {resulting_path!r}" ) + + +def test_rustflags_input_defaults_to_deny_warnings() -> None: + """The rustflags input must exist and keep the historical default.""" + manifest = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) + rustflags_input = manifest["inputs"]["rustflags"] + assert rustflags_input.get("required", False) is False + assert rustflags_input.get("default") == "-D warnings" + + +@pytest.mark.parametrize( + "step_name", + [ + "Install rust (explicit toolchain)", + "Install rust (rust-toolchain file)", + "Install rust (stable default)", + ], +) +def test_install_steps_forward_rustflags(step_name: str) -> None: + """Every toolchain install step must forward the rustflags input.""" + step = _get_step(step_name) + with_block = step.get("with") + assert isinstance(with_block, dict), f"Step has no with block: {step_name}" + assert with_block.get("rustflags") == "${{ inputs.rustflags }}" From 79055bac40acc6591efa8586b1ff0b2638b2214b Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 12:13:03 +0200 Subject: [PATCH 02/26] Format Python blocks in Markdown for ruff 0.16 ruff 0.16 formats Python code blocks embedded in Markdown, so CI's unpinned `uv tool run ruff format --check` now covers the `.rules/` and `docs/` prose. Apply that formatting and move `None` to the end of the `JsonValue` union so the newly enforced RUF036 passes. Co-Authored-By: Claude Opus 5 (1M context) --- .rules/python-00.md | 1 + .rules/python-context-managers.md | 3 ++ ...ion-design-raising-handling-and-logging.md | 13 +++-- .rules/python-generators.md | 6 +-- .rules/python-return.md | 4 ++ .rules/python-typing.md | 11 ++++- docs/cmd-mox-users-guide.md | 11 +++-- ...cture-enforcement-to-orchestration-code.md | 3 ++ docs/execplans/support-cranelift-codegen.md | 2 +- ...n-of-github-actions-with-act-and-pytest.md | 1 + docs/python-action-scripts.md | 3 +- docs/scripting-standards.md | 48 +++++++++++-------- workflow_scripts/graphql_client.py | 2 +- 13 files changed, 69 insertions(+), 39 deletions(-) diff --git a/.rules/python-00.md b/.rules/python-00.md index 11d89b61..9837b169 100644 --- a/.rules/python-00.md +++ b/.rules/python-00.md @@ -127,6 +127,7 @@ def login_user(username: str, password: str) -> bool: def test_login_success(): assert login_user("alice", "correct-password") is True + def test_login_failure(): assert not login_user("alice", "wrong-password") ``` diff --git a/.rules/python-context-managers.md b/.rules/python-context-managers.md index 49fb600f..dd524b44 100644 --- a/.rules/python-context-managers.md +++ b/.rules/python-context-managers.md @@ -23,6 +23,7 @@ Use this for straightforward procedural setup/teardown: ```python from contextlib import contextmanager + @contextmanager def managed_file(path: str, mode: str): f = open(path, mode) @@ -31,6 +32,7 @@ def managed_file(path: str, mode: str): finally: f.close() + # Usage: with managed_file("/tmp/data.txt", "w") as f: f.write("hello") @@ -53,6 +55,7 @@ class Resource: def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() + # Usage: with Resource() as conn: conn.send("ping") diff --git a/.rules/python-exception-design-raising-handling-and-logging.md b/.rules/python-exception-design-raising-handling-and-logging.md index e39da2e0..12219863 100644 --- a/.rules/python-exception-design-raising-handling-and-logging.md +++ b/.rules/python-exception-design-raising-handling-and-logging.md @@ -14,6 +14,7 @@ class enables callers to catch all domain failures without vendor leakage. class PaymentsError(Exception): """All payment-layer errors.""" + class CardDeclinedError(PaymentsError): # ✅ ends with Error (N818) def __init__(self, code: str, *, retry_after: int | None = None): super().__init__(f"Card declined ({code})") @@ -114,13 +115,14 @@ clarifies intent. ```python import logging + logger = logging.getLogger(__name__) # ❌ LOG issues -logging.warning(f"failed for {user_id}") # f-string (LOG004/LOG014) -logging.warning("failed for %s" % user_id) # %-formatting (LOG007) -logging.warn("deprecated") # warn() (LOG009) -logging.error("bad root logger") # root logger usage (LOG015) +logging.warning(f"failed for {user_id}") # f-string (LOG004/LOG014) +logging.warning("failed for %s" % user_id) # %-formatting (LOG007) +logging.warn("deprecated") # warn() (LOG009) +logging.error("bad root logger") # root logger usage (LOG015) # ✅ Correct logger.warning("Failed for user_id=%s", user_id) # lazy interpolation @@ -203,7 +205,7 @@ def charge(amount_pennies: int, card_token: str) -> str: try: return gateway.charge(amount_pennies, card_token) except gateway.Timeout as exc: - raise PaymentsError("Gateway timeout") from exc # ✅ TRY201 + raise PaymentsError("Gateway timeout") from exc # ✅ TRY201 except gateway.CardDeclined as exc: raise CardDeclinedError(exc.code, retry_after=60) from exc ``` @@ -227,6 +229,7 @@ def must_have_key(d: dict, key: str) -> None: msg = f"Missing required key: {key!r}" raise KeyError(msg) + logger.info("Dispatching order_id=%s to shop_id=%s", order_id, shop_id) # structured ``` diff --git a/.rules/python-generators.md b/.rules/python-generators.md index 2abbc161..527e3384 100644 --- a/.rules/python-generators.md +++ b/.rules/python-generators.md @@ -34,6 +34,7 @@ def iter_user_names(users): if user.active and user.name: yield user.name.upper() + def get_names(users): return list(iter_user_names(users)) ``` @@ -50,11 +51,10 @@ def get_names(users): ```python from itertools import islice + def top_active_emails(users): emails = ( - user.email.lower() - for user in users - if user.active and user.email is not None + user.email.lower() for user in users if user.active and user.email is not None ) return list(islice(emails, 10)) ``` diff --git a/.rules/python-return.md b/.rules/python-return.md index e6953931..7879439b 100644 --- a/.rules/python-return.md +++ b/.rules/python-return.md @@ -11,6 +11,7 @@ Follow these rules: def func(): return None + # GOOD: def func(): return @@ -30,6 +31,7 @@ def func(x): return x # implicitly returns None (bad) + # GOOD: def func(x): if x > 0: @@ -50,6 +52,7 @@ def func(x): return x # no return (bad) + # GOOD: def func(x): if x > 0: @@ -69,6 +72,7 @@ def func(): result = compute() return result + # GOOD: def func(): return compute() diff --git a/.rules/python-typing.md b/.rules/python-typing.md index e9b1d34b..83e29903 100644 --- a/.rules/python-typing.md +++ b/.rules/python-typing.md @@ -13,14 +13,17 @@ with integers or strings is required (e.g. for database or JSON serialization). ```python import enum + class Status(enum.Enum): PENDING = enum.auto() COMPLETE = enum.auto() + class ErrorCode(enum.IntEnum): OK = 0 NOT_FOUND = 404 + class Role(enum.StrEnum): ADMIN = enum.auto() GUEST = enum.auto() @@ -65,6 +68,7 @@ returns the same instance. ```python import typing + class Builder: def add(self, value: int) -> typing.Self: self.values.append(value) @@ -81,9 +85,10 @@ enables static analysis tools to detect typos and signature mismatches. ```python import typing + class Base: - def run(self) -> None: - ... + def run(self) -> None: ... + class Child(Base): @typing.override @@ -101,6 +106,7 @@ checkers. ```python import typing + def is_str_list(val: list[object]) -> typing.TypeIs[list[str]]: return all(isinstance(x, str) for x in val) ``` @@ -116,6 +122,7 @@ type is provided. ```python T = typing.TypeVar("T", default=int) + class Box[T]: def __init__(self, value: T = T()): self.value = value diff --git a/docs/cmd-mox-users-guide.md b/docs/cmd-mox-users-guide.md index 3c6356ce..529a9dc0 100644 --- a/docs/cmd-mox-users-guide.md +++ b/docs/cmd-mox-users-guide.md @@ -80,9 +80,9 @@ behaviour. Combine methods to describe how a command should be invoked: ```python -cmd_mox.mock("git") \ - .with_args("clone", "https://example.com/repo.git") \ - .returns(exit_code=0) +cmd_mox.mock("git").with_args("clone", "https://example.com/repo.git").returns( + exit_code=0 +) ``` Arguments can be matched more flexibly using comparators: @@ -90,8 +90,9 @@ Arguments can be matched more flexibly using comparators: ```python from cmd_mox import Regex, Contains -cmd_mox.mock("curl") \ - .with_matching_args(Regex(r"--header=User-Agent:.*"), Contains("example")) +cmd_mox.mock("curl").with_matching_args( + Regex(r"--header=User-Agent:.*"), Contains("example") +) ``` The design document lists the available comparators: diff --git a/docs/execplans/2-4-5-extend-architecture-enforcement-to-orchestration-code.md b/docs/execplans/2-4-5-extend-architecture-enforcement-to-orchestration-code.md index 8ec7a05e..058d780b 100644 --- a/docs/execplans/2-4-5-extend-architecture-enforcement-to-orchestration-code.md +++ b/docs/execplans/2-4-5-extend-architecture-enforcement-to-orchestration-code.md @@ -730,6 +730,7 @@ The implementation introduces one new error type and a guard function: ```python class ArchitectureBoundaryError(Exception): """Raised when orchestration code violates hexagonal architecture boundaries.""" + pass ``` @@ -757,6 +758,7 @@ from episodic.orchestration._checkpoint_payload import ( _planner_result_from_payload, ) + @given( # Strategy TBD based on actual DTO types st.just(...) @@ -765,6 +767,7 @@ def test_checkpoint_payload_round_trip(payload: dict) -> None: """Assert checkpoint payloads round-trip without data loss.""" # Implementation TBD + def test_checkpoint_payload_boundary_purity(payload: dict) -> None: """Assert checkpoint payloads contain no adapter types.""" # Implementation TBD diff --git a/docs/execplans/support-cranelift-codegen.md b/docs/execplans/support-cranelift-codegen.md index 16411e0e..fe7049f8 100644 --- a/docs/execplans/support-cranelift-codegen.md +++ b/docs/execplans/support-cranelift-codegen.md @@ -484,7 +484,7 @@ Inspect each log file. All must pass. If any fail, fix the issue and re-run. cargo_config_dir = tmp_path / ".cargo" cargo_config_dir.mkdir() (cargo_config_dir / "config.toml").write_text( - '[unstable]\ncodegen-backend = true\n\n' + "[unstable]\ncodegen-backend = true\n\n" '[profile.dev]\ncodegen-backend = "cranelift"\n\n' '[profile.test]\ncodegen-backend = "cranelift"\n', ) diff --git a/docs/local-validation-of-github-actions-with-act-and-pytest.md b/docs/local-validation-of-github-actions-with-act-and-pytest.md index 8b5559fb..ba271e08 100644 --- a/docs/local-validation-of-github-actions-with-act-and-pytest.md +++ b/docs/local-validation-of-github-actions-with-act-and-pytest.md @@ -211,6 +211,7 @@ loop: ```python from cmd_mox import CmdMox + def test_record(tmp_path: Path) -> None: artifact_dir = tmp_path / "act-artifacts" with CmdMox() as mox: diff --git a/docs/python-action-scripts.md b/docs/python-action-scripts.md index b5a2bbe0..97e7aab6 100644 --- a/docs/python-action-scripts.md +++ b/docs/python-action-scripts.md @@ -35,8 +35,7 @@ app.config = (*tuple(getattr(app, "config", ())), _env_config) @app.default -def main(*, bin_name: str, version: str, formats: list[str] | None = None) -> None: - ... +def main(*, bin_name: str, version: str, formats: list[str] | None = None) -> None: ... if __name__ == "__main__": diff --git a/docs/scripting-standards.md b/docs/scripting-standards.md index ed094422..956a85de 100644 --- a/docs/scripting-standards.md +++ b/docs/scripting-standards.md @@ -100,16 +100,16 @@ def main( # Required parameters bin_name: Annotated[str, Parameter(required=True)], version: Annotated[str, Parameter(required=True)], - # Optional scalars package_name: Optional[str] = None, target: Optional[str] = None, outdir: Optional[Path] = None, dry_run: bool = False, - # Lists (whitespace/newline separated by default) formats: list[str] | None = None, - man_paths: Annotated[list[Path] | None, Parameter(env_var="INPUT_MAN_PATHS")] = None, + man_paths: Annotated[ + list[Path] | None, Parameter(env_var="INPUT_MAN_PATHS") + ] = None, deb_depends: list[str] | None = None, rpm_depends: list[str] | None = None, ): @@ -119,16 +119,18 @@ def main( build_dir = (outdir or (project_root / "dist")) / name if dry_run: - print({ - "name": name, - "version": version, - "target": target, - "formats": formats, - "man_paths": [str(p) for p in (man_paths or [])], - "deb_depends": deb_depends, - "rpm_depends": rpm_depends, - "build_dir": str(build_dir), - }) + print( + { + "name": name, + "version": version, + "target": target, + "formats": formats, + "man_paths": [str(p) for p in (man_paths or [])], + "deb_depends": deb_depends, + "rpm_depends": rpm_depends, + "build_dir": str(build_dir), + } + ) return build_dir.mkdir(parents=True, exist_ok=True) @@ -257,7 +259,9 @@ f.write_text("1.2.3\n", encoding="utf-8") version = f.read_text(encoding="utf-8").strip() # Atomic write pattern (tmp → replace) -with tempfile.NamedTemporaryFile("w", delete=False, dir=f.parent, encoding="utf-8") as tmp: +with tempfile.NamedTemporaryFile( + "w", delete=False, dir=f.parent, encoding="utf-8" +) as tmp: tmp.write("new-contents\n") tmp_path = Path(tmp.name) @@ -302,6 +306,7 @@ from plumbum.cmd import git app = App(config=cyclopts.config.Env("INPUT_", command=False)) + @app.default def main( *, @@ -319,12 +324,15 @@ def main( with local.cwd(project_root): (git["tag", f"v{version}"] & FG) - print({ - "bin_name": bin_name, - "version": version, - "formats": formats or [], - "dist": str(dist), - }) + print( + { + "bin_name": bin_name, + "version": version, + "formats": formats or [], + "dist": str(dist), + } + ) + if __name__ == "__main__": app() diff --git a/workflow_scripts/graphql_client.py b/workflow_scripts/graphql_client.py index f214ba40..a7fdbeec 100644 --- a/workflow_scripts/graphql_client.py +++ b/workflow_scripts/graphql_client.py @@ -20,7 +20,7 @@ # Type alias for JSON-compatible values (parsed from json.loads) type JsonValue = ( - str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue] + str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None ) From 645d4c1422b871ca382f3e03a8f22ea096010a87 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 12:13:13 +0200 Subject: [PATCH 03/26] Locate release-to-pypi-uv test scripts from the test file `_helpers.py` derived the action's `scripts/` directory from `GITHUB_ACTION_PATH`, but that variable names whichever action is currently executing. The Makefile exports it as the repository root, so under `make test` the helper resolved `SCRIPTS_DIR` to the repository's top-level `scripts/` and every module load failed with `FileNotFoundError`. In CI the variable points inside `.github/actions`, where the root conftest re-pins it, which masked the breakage. Derive both paths from the helper's own location instead, and declare `OctalInt._octal_width` so `ty` can resolve the attribute assigned in `__new__`. Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/linux-packages/scripts/package.py | 2 ++ .../release-to-pypi-uv/tests/_helpers.py | 23 ++++++------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/.github/actions/linux-packages/scripts/package.py b/.github/actions/linux-packages/scripts/package.py index 83102832..2a116dae 100644 --- a/.github/actions/linux-packages/scripts/package.py +++ b/.github/actions/linux-packages/scripts/package.py @@ -114,6 +114,8 @@ def _fail(message: str, *, code: int = 2) -> typ.NoReturn: class OctalInt(int): """Integer subclass that renders as a zero-padded octal literal.""" + _octal_width: int + def __new__(cls, value: int, *, width: int = 4) -> OctalInt: """Initialize the integer and remember the desired octal width.""" obj = super().__new__(cls, value) diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index 758bc728..eb51888b 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib.util -import os import sys import typing as typ from pathlib import Path @@ -11,21 +10,13 @@ if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only from types import ModuleType -if _ACTION_PATH := os.environ.get("GITHUB_ACTION_PATH"): - _action_root = Path(_ACTION_PATH).resolve() - scripts_candidate = _action_root / "scripts" - if scripts_candidate.is_dir(): - SCRIPTS_DIR = scripts_candidate - try: - REPO_ROOT = _action_root.parents[2] - except IndexError: - REPO_ROOT = scripts_candidate.parents[3] - else: - SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" - REPO_ROOT = SCRIPTS_DIR.parents[3] -else: - SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" - REPO_ROOT = SCRIPTS_DIR.parents[3] +# Resolve the action's scripts directory from this file's location rather than +# from ``GITHUB_ACTION_PATH``. That variable describes whichever action is +# currently executing — the Makefile points it at the repository root and a +# composite action run points it at that action's directory — so trusting it +# here makes the helper load another action's scripts (or none at all). +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +REPO_ROOT = SCRIPTS_DIR.parents[3] def load_script_module(name: str) -> ModuleType: From 74b43ab6e9bb8d045e23776ac1613b7d45c30fdf Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 14:56:35 +0200 Subject: [PATCH 04/26] Generate a collision-safe RUSTFLAGS heredoc delimiter A caller-supplied `rustflags` value containing a line equal to the fixed `__RBR_RUSTFLAGS_EOF__` marker closed the environment-file block early, leaving the rest of the value to be parsed as further environment-file commands. That could fail the action with an invalid format or set unintended variables for the setup and build steps that follow. Derive the delimiter from 16 random bytes and confirm the value does not contain it before writing, failing the step if no free delimiter can be found. Cover the fragment with tests that execute it against an ordinary value, a value carrying the old marker, and an inherited `RUSTFLAGS`. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/rust-build-release/action.yml | 28 ++++- .../tests/test_manifest_input_step.py | 118 ++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index 325fc38a..57504eef 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -80,13 +80,29 @@ runs: set -euo pipefail if [[ -v RUSTFLAGS ]]; then echo "RUSTFLAGS already set; leaving the inherited value in place" >&2 - else - { - echo "RUSTFLAGS<<__RBR_RUSTFLAGS_EOF__" - printf '%s\n' "$RBR_RUSTFLAGS" - echo "__RBR_RUSTFLAGS_EOF__" - } >> "$GITHUB_ENV" + exit 0 fi + # A value containing the heredoc delimiter on a line of its own would + # close the block early and leave the remaining caller-supplied lines + # to be parsed as further environment-file commands, so derive a random + # delimiter and confirm the value does not contain it. + delimiter="" + for _ in 1 2 3; do + candidate="__RBR_RUSTFLAGS_EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')__" + if ! printf '%s\n' "$RBR_RUSTFLAGS" | grep -qxF -- "$candidate"; then + delimiter="$candidate" + break + fi + done + if [[ -z "$delimiter" ]]; then + echo "::error::could not derive a RUSTFLAGS delimiter absent from the value" >&2 + exit 1 + fi + { + echo "RUSTFLAGS<<$delimiter" + printf '%s\n' "$RBR_RUSTFLAGS" + echo "$delimiter" + } >> "$GITHUB_ENV" - name: Setup Rust toolchain # setup-rust-v1 # Update this SHA when setup-rust publishes a new release: run 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 7bf3ee67..fbdfb430 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 @@ -2,11 +2,19 @@ from __future__ import annotations +import os +import shutil +import subprocess from pathlib import Path +import pytest import yaml ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" +# A value crafted to close a fixed heredoc delimiter early and have the +# remainder read back as further environment-file commands. +INJECTION_MARKER = "__RBR_RUSTFLAGS_EOF__" +INJECTED_RUSTFLAGS = f"-Zpolonius=next\n{INJECTION_MARKER}\nRBR_INJECTED=1" def _load_action_manifest() -> dict[str, object]: @@ -21,6 +29,70 @@ def _find_step(steps: list[dict[str, object]], name: str) -> dict[str, object]: raise AssertionError(message) +def _export_rustflags_run_script() -> str: + """Return the shell fragment that exports the caller's RUSTFLAGS.""" + steps: list[dict[str, object]] = _load_action_manifest()["runs"]["steps"] + run_script = _find_step(steps, "Export caller RUSTFLAGS").get("run") + assert isinstance(run_script, str), "export step has no run script" + return run_script + + +def _run_export_script( + tmp_path: Path, rustflags: str, *, inherited: str | None = None +) -> tuple[subprocess.CompletedProcess[str], str]: + """Run the export fragment and return its result with the env-file text.""" + bash = shutil.which("bash") + if bash is None: # pragma: no cover - bash is present on supported runners + pytest.skip("bash not found on PATH") + tmp_path.mkdir(parents=True, exist_ok=True) + github_env = tmp_path / "github-env" + github_env.touch() + env = {key: value for key, value in os.environ.items() if key != "RUSTFLAGS"} + env["GITHUB_ENV"] = github_env.as_posix() + env["RBR_RUSTFLAGS"] = rustflags + if inherited is not None: + env["RUSTFLAGS"] = inherited + result = subprocess.run( # noqa: S603,TID251 - exercise the bash fragment. + [bash, "-c", _export_rustflags_run_script()], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + return result, github_env.read_text(encoding="utf-8") + + +def _parse_env_file(text: str) -> dict[str, str]: + """Parse ``GITHUB_ENV`` content, honouring heredoc-delimited values.""" + values: dict[str, str] = {} + lines = text.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + index += 1 + if not line: + continue + name, separator, remainder = line.partition("=") + if separator: + values[name] = remainder + continue + name, separator, delimiter = line.partition("<<") + if not separator: + message = f"unparsable environment-file line: {line!r}" + raise AssertionError(message) + collected: list[str] = [] + while index < len(lines) and lines[index] != delimiter: + collected.append(lines[index]) + index += 1 + if index >= len(lines): + message = f"unterminated heredoc for {name}" + raise AssertionError(message) + index += 1 + values[name] = "\n".join(collected) + return values + + def test_manifest_path_input_declared() -> None: """The manifest-path input must exist with a Cargo.toml default.""" manifest = _load_action_manifest() @@ -121,6 +193,52 @@ def test_export_rustflags_step_wiring() -> None: assert "${{" not in run_script +def test_export_rustflags_step_uses_a_generated_delimiter() -> None: + """The heredoc delimiter must not be a fixed literal in the manifest.""" + run_script = _export_rustflags_run_script() + assert f"RUSTFLAGS<<{INJECTION_MARKER}" not in run_script + assert 'echo "RUSTFLAGS<<$delimiter"' in run_script + + +def test_export_rustflags_writes_single_line_value(tmp_path: Path) -> None: + """An ordinary value round-trips through the environment file.""" + result, env_text = _run_export_script(tmp_path, "-Zpolonius=next") + + assert result.returncode == 0, result.stderr + assert _parse_env_file(env_text) == {"RUSTFLAGS": "-Zpolonius=next"} + + +def test_export_rustflags_contains_delimiter_lookalike(tmp_path: Path) -> None: + """A value carrying the old fixed marker must not escape its heredoc.""" + result, env_text = _run_export_script(tmp_path, INJECTED_RUSTFLAGS) + + assert result.returncode == 0, result.stderr + parsed = _parse_env_file(env_text) + # The marker stays inside RUSTFLAGS rather than closing it, so nothing + # after it is read back as a separate environment-file assignment. + assert parsed == {"RUSTFLAGS": INJECTED_RUSTFLAGS} + assert "RBR_INJECTED" not in parsed + + +def test_export_rustflags_delimiter_differs_between_runs(tmp_path: Path) -> None: + """Delimiters are generated per run so callers cannot predict them.""" + _, first = _run_export_script(tmp_path / "first", "-Zpolonius=next") + _, second = _run_export_script(tmp_path / "second", "-Zpolonius=next") + + assert first.splitlines()[0] != second.splitlines()[0] + + +def test_export_rustflags_defers_to_inherited_value(tmp_path: Path) -> None: + """An inherited RUSTFLAGS wins and nothing is written to the env file.""" + result, env_text = _run_export_script( + tmp_path, "-Zpolonius=next", inherited="-D warnings" + ) + + assert result.returncode == 0, result.stderr + assert env_text == "" + assert "leaving the inherited value in place" in result.stderr + + def test_export_rustflags_step_precedes_toolchain_setup() -> None: """The export must run before the nested setup-rust toolchain step.""" manifest = _load_action_manifest() From 198bfecb0fda95de02498ea44edd24890ebfefcb Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 29 Jul 2026 23:17:28 +0200 Subject: [PATCH 05/26] Extract heredoc parsing from the env-file test helper `_parse_env_file` carried both the line-dispatch loop and the heredoc collection loop, which CodeScene flagged as a Complex Method (cc = 10). Move the inner loop and its unterminated-heredoc check into `_parse_heredoc_value`, which returns the body with the index past the closing delimiter. Behaviour and the assertion messages are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_manifest_input_step.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) 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 fbdfb430..57dcf84a 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 @@ -63,6 +63,20 @@ def _run_export_script( return result, github_env.read_text(encoding="utf-8") +def _parse_heredoc_value( + lines: list[str], index: int, name: str, delimiter: str +) -> tuple[str, int]: + """Collect a heredoc body, returning it with the index past its delimiter.""" + collected: list[str] = [] + while index < len(lines) and lines[index] != delimiter: + collected.append(lines[index]) + index += 1 + if index >= len(lines): + message = f"unterminated heredoc for {name}" + raise AssertionError(message) + return "\n".join(collected), index + 1 + + def _parse_env_file(text: str) -> dict[str, str]: """Parse ``GITHUB_ENV`` content, honouring heredoc-delimited values.""" values: dict[str, str] = {} @@ -81,15 +95,7 @@ def _parse_env_file(text: str) -> dict[str, str]: if not separator: message = f"unparsable environment-file line: {line!r}" raise AssertionError(message) - collected: list[str] = [] - while index < len(lines) and lines[index] != delimiter: - collected.append(lines[index]) - index += 1 - if index >= len(lines): - message = f"unterminated heredoc for {name}" - raise AssertionError(message) - index += 1 - values[name] = "\n".join(collected) + values[name], index = _parse_heredoc_value(lines, index, name, delimiter) return values From 217cc0604c0315af76f8d0fedc82dc3ecf90c86f Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 02:11:30 +0200 Subject: [PATCH 06/26] Stop tracking the coverage data file `.coverage` is a coverage.py SQLite database that `make spelling` rewrites on every run, and it embeds the absolute path of whichever machine produced it. Nothing reads the committed copy: the ratchet uses `.coverage-baseline`, and CI uploads `coverage.xml` from the generate-coverage action. Untrack it and add it to the ignore list so local gate runs stop dirtying the working tree. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 53248 -> 0 bytes .gitignore | 3 +++ 2 files changed, 3 insertions(+) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 84c8fe98409c6394831e9ec884d85761264e0f6b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)O-~y~7zglOo8TCXmMV=biX!F`ffX;tGzi)r8rmW?YNRGDX?m(NUXSqxd)L_= z2RKx%X{1!W^j`JaevW>F+;UD&%_&k<<&;F~GdsIB1{@IyDG>cvwps7a%s%s*HyeY$ zzH!Z!g4O+?#U*>+m^4h&xWbrW7+Jbc&^_5Qv@?=Cp`YfS{b9RVWB!+SC#-Lb+~~K4 z_1(mKmYv(k{X719ZY6tj+|3lT^K=3m1Rwwb2)s1{-5a?~ern3x{a*5FQ^e|TxAB46(1CtcuGXm_#}8^M`f9*Ery;^3 zKE=5$b!`K`Rgykyx?WUGFLb5rdrWK#rz1t}&zwhm_UQC8 zACfap9%rsoaGsyA_A{zWQ|2gUuhXg$`JGOytr~}N=nG4KSk7U2r5{)J$Zc~-TlVWa z(3}amYEhl5=WHfFGh;ru8+8=LQ>nT#?CoR+I!*8JkglW75AQc>lnizfHHw-|=GUVk zqjEFdIMkc>j+s(|!2>nWm>(ykNZ)90Q^8UUtlIRegMBUPF`=LVLUTcqtlse)HB>@X z2gkoB0=^;USzJv|VzI?@mM3$bJ0<4u}P+T0Cj$kH6FR{*9G);J~RRlg&n{;Qrw-BYbS{djc& z{pX22r7A?pX?S1v!_cK=RV`cLhsjYk+u)&^g~C}@_5G&co?aU$$JLOo>4q$d;oz$D zaledkN0qU0Z#If4SzOdKzfOZL8DF&1YH_a?uUBdr)}_+iuLh^Oj-Es)Bb%i`8PX8l zS7uUZd{)g^N%`|_GEt?rcufy?)hK&P+-ise#p%`PGcJX6TY{-1Q7}=*+TtN|0zoI^ zwKNKh#2Wjf(#3pEXOcNo*QPL=epTO5OTeoiQ`{OP9=UCmz9ia;k*% zN}$D)y2eja13hcs$$lsMl4faoiL|$KDeygp>Z!hhnh|xgS2bOdDt>`9R#0#7P3yTq zH*64q00bZa0SG_<0uX=z1Rwwb2)um)X)|SJ)cgOG_1LhU(gQXKKmY;|fB*y_009U< z00Izz00d5@KrWTOU?qPRvM_0;r>Ek-0a%_dFU>EGQLR$e6T^C9Jv)^ZM9>g`00bZa z0SG_<0uX=z1Rwwb2=oMU=?iA^Yk<^PdO92b7C`^~|GHtV_mW}<0uX=z1Rwwb2tWV= z5P$##AOL|Q2y_ePh_QRCBwOv0)8w6+u*(Yz3njbZw}jmkJDwlf*8~5KaAatELWZ49 zVQ=}trVNA#ZC>-+(%x#gl5dKjv|JWTRldApFRqm9_T~E0ioLQ}U9m4ajTdd94&S==)r1HrHQ@qRzN&8J6Tu+Yc*&-)zzsP*ms(2%Js&k_GkoziHhx z=!Ojf5P$##AOHafKmY;|fB*y_0D;#L=oUtHZ)H!#`+t1@-|I9i(n0_N5P$##AOHaf zKmY;|fB*zeoj}@58x#8T{}+b!;?%{DfFS?@2tWV=5P$##AOHafKmY;|ID$YrJ(1O) z|35OUM@PtjBoKfA1Rwwb2tWV=5P$##AOHafoFIWYb0nKxzhb<6_P6olL$m$cpMU&n z7?1y(d0Dvl&&X4?|L~$={8dTm&;Oqr*7Fk-K0<*21Rwwb2tWV=5P$##AOHafK;XCp zM(B40(dYlh>WF2tWV= w5P$##AOHafKmY>AC!qfSAMgK<&l4p<00Izz00bZa0SG_<0uX=z1WuyB|4v7_`~Uy| diff --git a/.gitignore b/.gitignore index 4bb8ceaa..705ac458 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,9 @@ venv/ .pytest_cache/ .mypy_cache/ *.egg-info/ +# coverage.py data; regenerated by `make spelling` and embeds absolute paths +.coverage +.coverage.* # Rust artefacts target/ From 6e7e05ba81ccbbb4dab5ffc57ff855e0c5bd776a Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 02:11:46 +0200 Subject: [PATCH 07/26] Guard inherited RUSTFLAGS without bash 4.2 `[[ -v NAME ]]` needs bash 4.2, but macOS runners ship bash 3.2 as /bin/bash, so the export step would fail there for any caller that sets `rustflags`. Use `[[ -n "${RUSTFLAGS+x}" ]]`, which is portable to bash 3.2 and keeps the existing semantics: an inherited empty value still counts as set. Cover that distinction with a test for an inherited empty `RUSTFLAGS`; the `${VAR:-}` idiom used elsewhere in the repository would have overwritten it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/rust-build-release/action.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index 57504eef..b76c48ac 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -78,7 +78,10 @@ runs: RBR_RUSTFLAGS: ${{ inputs.rustflags }} run: | set -euo pipefail - if [[ -v RUSTFLAGS ]]; then + # ${RUSTFLAGS+x} rather than [[ -v RUSTFLAGS ]]: the latter needs bash + # 4.2, and macOS runners ship bash 3.2. Both treat an inherited empty + # value as set. + if [[ -n "${RUSTFLAGS+x}" ]]; then echo "RUSTFLAGS already set; leaving the inherited value in place" >&2 exit 0 fi From ade1c79ac2dfaa6e581dc670b662053ad529d175 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 02:11:46 +0200 Subject: [PATCH 08/26] Add failure messages to the rustflags manifest tests The rustflags assertions compared parsed manifest fragments without saying what the expectation meant, so a failure reported only the operator. Give each one a message naming the manifest, script, or round-trip expectation, and pass `check=False` explicitly when running the shell fragment. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_manifest_input_step.py | 92 +++++++++++++++---- .../tests/test_setup_rust_manifest.py | 15 ++- 2 files changed, 85 insertions(+), 22 deletions(-) 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 57dcf84a..61e8c391 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 @@ -59,6 +59,7 @@ def _run_export_script( capture_output=True, text=True, timeout=30, + check=False, ) return result, github_env.read_text(encoding="utf-8") @@ -175,10 +176,15 @@ def test_rustflags_input_declared() -> None: """The rustflags input must exist with an empty default.""" manifest = _load_action_manifest() inputs = manifest["inputs"] - assert "rustflags" in inputs + assert "rustflags" in inputs, f"rustflags input missing; declared: {sorted(inputs)}" rustflags_input = inputs["rustflags"] - assert rustflags_input.get("required", False) is False - assert rustflags_input.get("default") == "" + assert rustflags_input.get("required", False) is False, ( + "rustflags must stay optional so existing callers need no change" + ) + assert rustflags_input.get("default") == "", ( + "the default must be empty so the environment is left untouched; " + f"got {rustflags_input.get('default')!r}" + ) def test_export_rustflags_step_wiring() -> None: @@ -186,24 +192,41 @@ def test_export_rustflags_step_wiring() -> None: manifest = _load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] export_step = _find_step(steps, "Export caller RUSTFLAGS") - assert export_step.get("if") == "inputs.rustflags != ''" + assert export_step.get("if") == "inputs.rustflags != ''", ( + "the step must be skipped entirely when no rustflags input is given; " + f"got {export_step.get('if')!r}" + ) env = export_step.get("env") - assert isinstance(env, dict) - assert env.get("RBR_RUSTFLAGS") == "${{ inputs.rustflags }}" + assert isinstance(env, dict), "export step declares no env block" + assert env.get("RBR_RUSTFLAGS") == "${{ inputs.rustflags }}", ( + f"rustflags must reach the script via RBR_RUSTFLAGS; got {env!r}" + ) run_script = export_step.get("run") - assert isinstance(run_script, str) + assert isinstance(run_script, str), "export step has no run script" # The value must flow through the environment, not template expansion, # and an inherited RUSTFLAGS must win over the input. - assert "-v RUSTFLAGS" in run_script - assert '"$RBR_RUSTFLAGS"' in run_script - assert "${{" not in run_script + assert '"${RUSTFLAGS+x}"' in run_script, ( + "the inherited-value guard must use the bash 3.2 compatible " + "${RUSTFLAGS+x} form rather than [[ -v ]]" + ) + assert '"$RBR_RUSTFLAGS"' in run_script, ( + "the script must read the value from the environment variable" + ) + assert "${{" not in run_script, ( + "the caller's value must not be interpolated into the script by the " + "expression template engine" + ) def test_export_rustflags_step_uses_a_generated_delimiter() -> None: """The heredoc delimiter must not be a fixed literal in the manifest.""" run_script = _export_rustflags_run_script() - assert f"RUSTFLAGS<<{INJECTION_MARKER}" not in run_script - assert 'echo "RUSTFLAGS<<$delimiter"' in run_script + assert f"RUSTFLAGS<<{INJECTION_MARKER}" not in run_script, ( + "the manifest must not pin a fixed delimiter a caller could reproduce" + ) + assert 'echo "RUSTFLAGS<<$delimiter"' in run_script, ( + "the heredoc must open with the generated delimiter variable" + ) def test_export_rustflags_writes_single_line_value(tmp_path: Path) -> None: @@ -211,7 +234,9 @@ def test_export_rustflags_writes_single_line_value(tmp_path: Path) -> None: result, env_text = _run_export_script(tmp_path, "-Zpolonius=next") assert result.returncode == 0, result.stderr - assert _parse_env_file(env_text) == {"RUSTFLAGS": "-Zpolonius=next"} + assert _parse_env_file(env_text) == {"RUSTFLAGS": "-Zpolonius=next"}, ( + f"the value must round-trip unchanged; env file held {env_text!r}" + ) def test_export_rustflags_contains_delimiter_lookalike(tmp_path: Path) -> None: @@ -222,8 +247,12 @@ def test_export_rustflags_contains_delimiter_lookalike(tmp_path: Path) -> None: parsed = _parse_env_file(env_text) # The marker stays inside RUSTFLAGS rather than closing it, so nothing # after it is read back as a separate environment-file assignment. - assert parsed == {"RUSTFLAGS": INJECTED_RUSTFLAGS} - assert "RBR_INJECTED" not in parsed + assert parsed == {"RUSTFLAGS": INJECTED_RUSTFLAGS}, ( + f"the marker must stay inside the value; env file held {env_text!r}" + ) + assert "RBR_INJECTED" not in parsed, ( + "text after the marker must not become its own environment variable" + ) def test_export_rustflags_delimiter_differs_between_runs(tmp_path: Path) -> None: @@ -231,7 +260,10 @@ def test_export_rustflags_delimiter_differs_between_runs(tmp_path: Path) -> None _, first = _run_export_script(tmp_path / "first", "-Zpolonius=next") _, second = _run_export_script(tmp_path / "second", "-Zpolonius=next") - assert first.splitlines()[0] != second.splitlines()[0] + first_header, second_header = first.splitlines()[0], second.splitlines()[0] + assert first_header != second_header, ( + f"two runs reused the delimiter {first_header!r}" + ) def test_export_rustflags_defers_to_inherited_value(tmp_path: Path) -> None: @@ -241,8 +273,25 @@ def test_export_rustflags_defers_to_inherited_value(tmp_path: Path) -> None: ) assert result.returncode == 0, result.stderr - assert env_text == "" - assert "leaving the inherited value in place" in result.stderr + assert env_text == "", ( + f"an inherited RUSTFLAGS must not be overwritten; wrote {env_text!r}" + ) + assert "leaving the inherited value in place" in result.stderr, ( + f"expected the deferral notice on stderr; got {result.stderr!r}" + ) + + +def test_export_rustflags_defers_to_inherited_empty_value(tmp_path: Path) -> None: + """An inherited but empty RUSTFLAGS counts as set and is left alone.""" + result, env_text = _run_export_script(tmp_path, "-Zpolonius=next", inherited="") + + assert result.returncode == 0, result.stderr + assert env_text == "", ( + f"an inherited empty RUSTFLAGS must not be overwritten; wrote {env_text!r}" + ) + assert "leaving the inherited value in place" in result.stderr, ( + f"expected the deferral notice on stderr; got {result.stderr!r}" + ) def test_export_rustflags_step_precedes_toolchain_setup() -> None: @@ -250,4 +299,9 @@ def test_export_rustflags_step_precedes_toolchain_setup() -> None: manifest = _load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] names = [step.get("name") for step in steps] - assert names.index("Export caller RUSTFLAGS") < names.index("Setup Rust toolchain") + assert names.index("Export caller RUSTFLAGS") < names.index( + "Setup Rust toolchain" + ), ( + "the export must precede toolchain setup, whose nested step only " + f"defers to an already-set RUSTFLAGS; step order was {names}" + ) diff --git a/.github/actions/setup-rust/tests/test_setup_rust_manifest.py b/.github/actions/setup-rust/tests/test_setup_rust_manifest.py index 91a2b03c..db9e4cda 100644 --- a/.github/actions/setup-rust/tests/test_setup_rust_manifest.py +++ b/.github/actions/setup-rust/tests/test_setup_rust_manifest.py @@ -381,8 +381,13 @@ def test_rustflags_input_defaults_to_deny_warnings() -> None: """The rustflags input must exist and keep the historical default.""" manifest = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) rustflags_input = manifest["inputs"]["rustflags"] - assert rustflags_input.get("required", False) is False - assert rustflags_input.get("default") == "-D warnings" + assert rustflags_input.get("required", False) is False, ( + "rustflags must stay optional so existing callers need no change" + ) + assert rustflags_input.get("default") == "-D warnings", ( + "the default must preserve the historical -D warnings behaviour; " + f"got {rustflags_input.get('default')!r}" + ) @pytest.mark.parametrize( @@ -398,4 +403,8 @@ def test_install_steps_forward_rustflags(step_name: str) -> None: step = _get_step(step_name) with_block = step.get("with") assert isinstance(with_block, dict), f"Step has no with block: {step_name}" - assert with_block.get("rustflags") == "${{ inputs.rustflags }}" + assert with_block.get("rustflags") == "${{ inputs.rustflags }}", ( + f"{step_name} must forward the rustflags input to setup-rust-toolchain, " + f"otherwise it re-exports the -D warnings default; got " + f"{with_block.get('rustflags')!r}" + ) From 037d22c6298233f333db43a8c6e51307f5a6d56b Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 02:11:46 +0200 Subject: [PATCH 09/26] Punctuate the rustflags description Add the comma before "so" in the result clause of the setup-rust rustflags description, in the manifest, the README table, and the changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/setup-rust/CHANGELOG.md | 2 +- .github/actions/setup-rust/README.md | 2 +- .github/actions/setup-rust/action.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-rust/CHANGELOG.md b/.github/actions/setup-rust/CHANGELOG.md index 0ab14a65..0b1ba1e2 100644 --- a/.github/actions/setup-rust/CHANGELOG.md +++ b/.github/actions/setup-rust/CHANGELOG.md @@ -5,7 +5,7 @@ - Add `rustflags` input forwarded to `actions-rust-lang/setup-rust-toolchain`. The default keeps the existing `-D warnings` behaviour; pass extra flags (for example `-D warnings -Zpolonius=next`) or the empty string to leave - `RUSTFLAGS` unset so the project's `build.rustflags` configuration + `RUSTFLAGS` unset, so the project's `build.rustflags` configuration applies. ## v1.0.14 - 2026-01-16 diff --git a/.github/actions/setup-rust/README.md b/.github/actions/setup-rust/README.md index aeb0047d..b514ea62 100644 --- a/.github/actions/setup-rust/README.md +++ b/.github/actions/setup-rust/README.md @@ -20,7 +20,7 @@ require them, and set up macOS or OpenBSD cross-compilers. | darwin-sdk-version | macOS SDK version for osxcross | no | `12.3` | | with-openbsd | Build OpenBSD std library for cross-compilation | no | `false` | | openbsd-nightly | Pinned nightly Rust for OpenBSD | no | `nightly-2025-07-20` | -| rustflags | `RUSTFLAGS` exported by the toolchain setup step. Set to the empty string to leave `RUSTFLAGS` unset so an inherited value or the project's `build.rustflags` applies. | no | `-D warnings` | +| rustflags | `RUSTFLAGS` exported by the toolchain setup step. Set to the empty string to leave `RUSTFLAGS` unset, so an inherited value or the project's `build.rustflags` applies. | no | `-D warnings` | diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index e49539fb..f19aec36 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -41,7 +41,7 @@ inputs: rustflags: description: > Value exported as the RUSTFLAGS environment variable by the toolchain - setup step. Set to the empty string to leave RUSTFLAGS unset so an + setup step. Set to the empty string to leave RUSTFLAGS unset, so an inherited value or the project's Cargo configuration (build.rustflags in .cargo/config.toml) applies. A pre-existing RUSTFLAGS environment variable always takes precedence over this From 8d3a70d1e0cac151ad0c67ffd6088d218bb09014 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 31 Jul 2026 02:11:46 +0200 Subject: [PATCH 10/26] Stop rejecting module globs for a segment named py `test_module_globs_are_deduplicated_module_patterns` asserted that ".py" never appears in a glob, to catch unstripped suffixes. That is too broad: a directory segment may itself be named "py", so "src/a/py.py" correctly yields "a.py.*", and Hypothesis eventually generated exactly that. Compare against the expected module globs instead, which checks suffix stripping precisely. Co-Authored-By: Claude Opus 5 (1M context) --- workflow_scripts/tests/test_mutation_properties.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/workflow_scripts/tests/test_mutation_properties.py b/workflow_scripts/tests/test_mutation_properties.py index a375708a..c320fcab 100644 --- a/workflow_scripts/tests/test_mutation_properties.py +++ b/workflow_scripts/tests/test_mutation_properties.py @@ -57,11 +57,20 @@ def test_bucket_files_partitions_without_loss_or_overlap( def test_module_globs_are_deduplicated_module_patterns(paths: list[str]) -> None: """Globs are unique module patterns with no path or suffix residue.""" globs = run_mutmut.files_to_module_globs(" ".join(paths), "src/") + # Compare against the expected module path rather than asserting that + # ".py" is absent: a directory segment may itself be named "py", so + # "src/a/py.py" correctly yields "a.py.*". + expected = list( + dict.fromkeys( + path.removeprefix("src/").removesuffix(".py").replace("/", ".") + ".*" + for path in paths + ) + ) + assert globs == expected assert len(globs) == len(set(globs)) for glob in globs: assert glob.endswith(".*") assert "/" not in glob - assert ".py" not in glob assert len(globs) <= len(paths) From cb2c023b5a86b6b4a7d089891264b5db861a46cf Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 00:56:38 +0200 Subject: [PATCH 11/26] Use the bare conditional for the RUSTFLAGS guard `[[ -n "${RUSTFLAGS+x}" ]]` and `[[ ${RUSTFLAGS+x} ]]` are equivalent, since a bare string inside `[[ ]]` is true when non-empty. Prefer the shorter form and assert on the whole guard rather than the expansion alone, so the comment above it cannot satisfy the test. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/rust-build-release/action.yml | 2 +- .../rust-build-release/tests/test_manifest_input_step.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index b76c48ac..e57a61f1 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -81,7 +81,7 @@ runs: # ${RUSTFLAGS+x} rather than [[ -v RUSTFLAGS ]]: the latter needs bash # 4.2, and macOS runners ship bash 3.2. Both treat an inherited empty # value as set. - if [[ -n "${RUSTFLAGS+x}" ]]; then + if [[ ${RUSTFLAGS+x} ]]; then echo "RUSTFLAGS already set; leaving the inherited value in place" >&2 exit 0 fi 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 61e8c391..9c870426 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 @@ -205,9 +205,9 @@ def test_export_rustflags_step_wiring() -> None: assert isinstance(run_script, str), "export step has no run script" # The value must flow through the environment, not template expansion, # and an inherited RUSTFLAGS must win over the input. - assert '"${RUSTFLAGS+x}"' in run_script, ( + assert "if [[ ${RUSTFLAGS+x} ]]; then" in run_script, ( "the inherited-value guard must use the bash 3.2 compatible " - "${RUSTFLAGS+x} form rather than [[ -v ]]" + "${RUSTFLAGS+x} form rather than [[ -v ]], which macOS bash cannot parse" ) assert '"$RBR_RUSTFLAGS"' in run_script, ( "the script must read the value from the environment variable" From 28b72a26ebbf243c03c5e0783fecdb1a6b10077c Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 01:13:22 +0200 Subject: [PATCH 12/26] Check RUSTFLAGS delimiter safety as a property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delimiter guard is a claim about every possible payload, but only worked examples covered it. Add Hypothesis coverage over payloads built from the fragments most likely to break an environment file — the old fixed marker, assignment and heredoc syntax, quoting, and embedded newlines — asserting that each round-trips as exactly one variable, and that an inherited value always wins regardless of payload. The example count is bounded and the deadline disabled because each example spawns bash; the two tests add about half a second. The env-file helper now truncates rather than touches, since a property test reuses one tmp_path and the step appends. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_manifest_input_step.py | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) 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 9c870426..7a774905 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 @@ -9,6 +9,8 @@ import pytest import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" # A value crafted to close a fixed heredoc delimiter early and have the @@ -16,6 +18,39 @@ INJECTION_MARKER = "__RBR_RUSTFLAGS_EOF__" INJECTED_RUSTFLAGS = f"-Zpolonius=next\n{INJECTION_MARKER}\nRBR_INJECTED=1" +# Fragments chosen to provoke the environment-file parser: the old fixed +# marker, assignment and heredoc syntax, quoting, and newlines that could +# split a value across lines. Carriage returns and the other exotic +# separators Python's str.splitlines honours are excluded, because the +# runner splits environment files on newlines alone. +_PAYLOAD_FRAGMENTS = st.sampled_from( + [ + "-D warnings", + "-Zpolonius=next", + INJECTION_MARKER, + "RBR_INJECTED=1", + "RUSTFLAGS< dict[str, object]: return yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) @@ -46,7 +81,9 @@ def _run_export_script( pytest.skip("bash not found on PATH") tmp_path.mkdir(parents=True, exist_ok=True) github_env = tmp_path / "github-env" - github_env.touch() + # Truncate rather than touch: a property test reuses one tmp_path across + # examples, and the step appends, so a stale file would leak between them. + github_env.write_text("", encoding="utf-8") env = {key: value for key, value in os.environ.items() if key != "RUSTFLAGS"} env["GITHUB_ENV"] = github_env.as_posix() env["RBR_RUSTFLAGS"] = rustflags @@ -294,6 +331,38 @@ def test_export_rustflags_defers_to_inherited_empty_value(tmp_path: Path) -> Non ) +@EXPORT_PROPERTY_SETTINGS +@given(payload=RUSTFLAGS_PAYLOADS) +def test_exported_rustflags_round_trip_for_any_payload( + tmp_path: Path, payload: str +) -> None: + """Any payload survives the environment file as exactly one variable.""" + result, env_text = _run_export_script(tmp_path / "roundtrip", payload) + + assert result.returncode == 0, result.stderr + # Exact equality is the delimiter-safety invariant: a value that closed + # its heredoc early would either lose text or contribute extra names. + assert _parse_env_file(env_text) == {"RUSTFLAGS": payload}, ( + f"payload {payload!r} did not round-trip; env file held {env_text!r}" + ) + + +@EXPORT_PROPERTY_SETTINGS +@given(payload=RUSTFLAGS_PAYLOADS, inherited=RUSTFLAGS_PAYLOADS | st.just("")) +def test_inherited_rustflags_always_wins( + tmp_path: Path, payload: str, inherited: str +) -> None: + """No payload can displace an inherited RUSTFLAGS, empty or otherwise.""" + result, env_text = _run_export_script( + tmp_path / "precedence", payload, inherited=inherited + ) + + assert result.returncode == 0, result.stderr + assert env_text == "", ( + f"payload {payload!r} overwrote inherited {inherited!r}; wrote {env_text!r}" + ) + + def test_export_rustflags_step_precedes_toolchain_setup() -> None: """The export must run before the nested setup-rust toolchain step.""" manifest = _load_action_manifest() From 3d037d09b1f9cbc6a52476ac2063c421e1266d1d Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 01:13:22 +0200 Subject: [PATCH 13/26] Document the RUSTFLAGS export behaviour The rustflags inputs were only terse table rows, so the behaviour that matters to a caller was undocumented: the empty default, why the input exists, and that an inherited value wins even when empty. Add the explanatory paragraph the README already uses for other nuanced inputs, and a design-doc subsection covering the export step, its precedence rules, the bash 3.2 guard, and the generated heredoc delimiter. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/rust-build-release/README.md | 9 +++++++++ docs/rust-build-release-pipeline.md | 21 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/actions/rust-build-release/README.md b/.github/actions/rust-build-release/README.md index 78337ceb..b6651fa3 100644 --- a/.github/actions/rust-build-release/README.md +++ b/.github/actions/rust-build-release/README.md @@ -46,6 +46,15 @@ When `toolchain` is empty, the action resolves the toolchain from the target repository before falling back to the action default. `manifest-path` may be relative to `project-dir` or absolute. +`rustflags` defaults to empty, which leaves the environment untouched. Left +empty, the nested `setup-rust` step exports its own `-D warnings` default +whenever `RUSTFLAGS` is unset, and an ambient `RUSTFLAGS` shadows the +project's `build.rustflags` in `.cargo/config.toml` — this input exists to +solve that problem. Setting a value exports it before toolchain setup so the +build honours it (for example, a required `-Z` flag such as +`-Zpolonius=next`). A pre-existing `RUSTFLAGS` in the environment always +wins, including when it is deliberately set to the empty string. + By default, Linux and illumos staging discovers man pages generated during `cargo build` at `target/generated-man//release/.1`, then falls back to Cargo `OUT_DIR` output from `build.rs`. Set diff --git a/docs/rust-build-release-pipeline.md b/docs/rust-build-release-pipeline.md index 8cab2000..20e01893 100644 --- a/docs/rust-build-release-pipeline.md +++ b/docs/rust-build-release-pipeline.md @@ -275,6 +275,27 @@ sequenceDiagram deactivate Cargo ``` +#### 3.1.3 Caller-Controlled `RUSTFLAGS` + +The nested `actions-rust-lang/setup-rust-toolchain` step exports +`RUSTFLAGS="-D warnings"` whenever `RUSTFLAGS` is unset, and an ambient +`RUSTFLAGS` overrides Cargo's `build.rustflags`. Projects that need specific +flags — for example a required `-Z` flag such as `-Zpolonius=next` — could +not get them honoured. The `rust-build-release` action's `rustflags` input +exists to solve this: an "Export caller RUSTFLAGS" step runs before toolchain +setup and is skipped entirely when the input is empty. + +Precedence favours a pre-existing `RUSTFLAGS`, including one deliberately set +to the empty string, which always wins over the input. The guard uses +`[[ ${RUSTFLAGS+x} ]]` rather than `[[ -v RUSTFLAGS ]]`, because macOS +runners ship Bash 3.2, which cannot parse the `-v` conditional primary. + +The value is passed to the step via an environment variable and written to +`GITHUB_ENV` with a randomly generated heredoc delimiter, verified absent +from the value. This prevents a caller-supplied value that happens to +contain the delimiter from terminating the block early and having its +remainder parsed as further environment-file commands. + ### 3.2 Release Stage: Declarative Packaging with GoReleaser The release stage uses the `goreleaser/goreleaser-action` to unify packaging. From 50f5f3004cbcfde7e98f8ac0d4e3e0bd909d463f Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 21:54:10 +0200 Subject: [PATCH 14/26] Cover the RUSTFLAGS delimiter collision branches The retry loop and its give-up branch were unreachable from the tests: the delimiter carries 128 bits of entropy, so no payload can collide with it by chance. Stub `od` on PATH, the way the setup-rust tests stub `curl`, so the scripted candidate can be made to collide on demand. Two tests follow: a colliding first candidate is discarded and the second used, with the payload still round-tripping; and three colliding candidates abort the step with its diagnostic, leaving the environment file untouched. Both fail if the retry loop or the collision check is removed. A PATH stub rather than cmd-mox: cmd-mox feeds shim paths to Python callers through shutil.which, never to a bash fragment run as a subprocess, and it is unavailable on Windows, where this suite also runs. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_manifest_input_step.py | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) 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 7a774905..40638d1e 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 @@ -17,6 +17,10 @@ # remainder read back as further environment-file commands. INJECTION_MARKER = "__RBR_RUSTFLAGS_EOF__" INJECTED_RUSTFLAGS = f"-Zpolonius=next\n{INJECTION_MARKER}\nRBR_INJECTED=1" +# Scripted `od` output, shaped like the real 16-byte hex dump, used to make a +# delimiter collision reachable. +COLLIDING_OD_HEX = "0" * 32 +SAFE_OD_HEX = "1" * 32 # Fragments chosen to provoke the environment-file parser: the old fixed # marker, assignment and heredoc syntax, quoting, and newlines that could @@ -72,8 +76,44 @@ def _export_rustflags_run_script() -> str: return run_script +def _delimiter_for(od_hex: str) -> str: + """Return the delimiter the step derives from a given ``od`` output.""" + return f"__RBR_RUSTFLAGS_EOF_{od_hex}__" + + +def _write_od_stub(stubs_dir: Path) -> Path: + """Install an ``od`` stub yielding one scripted value per invocation. + + The step derives its delimiter from ``od``, so replacing ``od`` on PATH is + the only way to make a collision reachable; real output carries 128 bits of + entropy. Values come from ``FAKE_OD_VALUES`` (one per line) and the last is + repeated once exhausted, so a single value collides on every attempt. + """ + stubs_dir.mkdir(parents=True, exist_ok=True) + stub = stubs_dir / "od" + stub.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "count=0\n" + 'if [ -f "$FAKE_OD_CALLS" ]; then count="$(cat "$FAKE_OD_CALLS")"; fi\n' + 'printf \'%s\' "$((count + 1))" > "$FAKE_OD_CALLS"\n' + 'value="$(printf \'%s\\n\' "$FAKE_OD_VALUES" | sed -n "$((count + 1))p")"\n' + 'if [ -z "$value" ]; then\n' + ' value="$(printf \'%s\\n\' "$FAKE_OD_VALUES" | tail -n 1)"\n' + "fi\n" + "printf '%s\\n' \"$value\"\n", + encoding="utf-8", + ) + stub.chmod(0o755) + return stub + + def _run_export_script( - tmp_path: Path, rustflags: str, *, inherited: str | None = None + tmp_path: Path, + rustflags: str, + *, + inherited: str | None = None, + od_hex_values: tuple[str, ...] | None = None, ) -> tuple[subprocess.CompletedProcess[str], str]: """Run the export fragment and return its result with the env-file text.""" bash = shutil.which("bash") @@ -89,6 +129,12 @@ def _run_export_script( env["RBR_RUSTFLAGS"] = rustflags if inherited is not None: env["RUSTFLAGS"] = inherited + if od_hex_values is not None: + stubs_dir = tmp_path / "stubs" + _write_od_stub(stubs_dir) + env["PATH"] = f"{stubs_dir}{os.pathsep}{env['PATH']}" + env["FAKE_OD_VALUES"] = "\n".join(od_hex_values) + env["FAKE_OD_CALLS"] = (tmp_path / "od-calls").as_posix() result = subprocess.run( # noqa: S603,TID251 - exercise the bash fragment. [bash, "-c", _export_rustflags_run_script()], cwd=tmp_path, @@ -363,6 +409,50 @@ def test_inherited_rustflags_always_wins( ) +def test_export_rustflags_retries_after_a_delimiter_collision(tmp_path: Path) -> None: + """A candidate present in the value is discarded and another drawn.""" + payload = f"-Zpolonius=next\n{_delimiter_for(COLLIDING_OD_HEX)}" + result, env_text = _run_export_script( + tmp_path, + payload, + od_hex_values=(COLLIDING_OD_HEX, SAFE_OD_HEX), + ) + + assert result.returncode == 0, result.stderr + assert env_text.startswith(f"RUSTFLAGS<<{_delimiter_for(SAFE_OD_HEX)}"), ( + f"the second candidate should have been used; env file held {env_text!r}" + ) + assert _parse_env_file(env_text) == {"RUSTFLAGS": payload}, ( + f"the payload must still round-trip after a retry; got {env_text!r}" + ) + assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "2", ( + "exactly two candidates should have been drawn" + ) + + +def test_export_rustflags_fails_after_three_colliding_candidates( + tmp_path: Path, +) -> None: + """Three unusable candidates abort the step rather than corrupt the file.""" + payload = f"-Zpolonius=next\n{_delimiter_for(COLLIDING_OD_HEX)}" + result, env_text = _run_export_script( + tmp_path, payload, od_hex_values=(COLLIDING_OD_HEX,) + ) + + assert result.returncode == 1, ( + f"the step must fail rather than write an unsafe delimiter; {result.stderr!r}" + ) + assert "could not derive a RUSTFLAGS delimiter" in result.stderr, ( + f"expected the give-up diagnostic on stderr; got {result.stderr!r}" + ) + assert env_text == "", ( + f"nothing may reach the environment file on failure; wrote {env_text!r}" + ) + assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "3", ( + "the loop should try exactly three candidates before giving up" + ) + + def test_export_rustflags_step_precedes_toolchain_setup() -> None: """The export must run before the nested setup-rust toolchain step.""" manifest = _load_action_manifest() From 80b01ae6796be856d76c95a744b487479ff2de2a Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 21:54:10 +0200 Subject: [PATCH 15/26] Explain the module glob comparison on failure The list comparison is the assertion most likely to fail cryptically, so say what the expected patterns are and which paths produced them. Co-Authored-By: Claude Opus 5 (1M context) --- workflow_scripts/tests/test_mutation_properties.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/workflow_scripts/tests/test_mutation_properties.py b/workflow_scripts/tests/test_mutation_properties.py index c320fcab..539159a1 100644 --- a/workflow_scripts/tests/test_mutation_properties.py +++ b/workflow_scripts/tests/test_mutation_properties.py @@ -66,7 +66,10 @@ def test_module_globs_are_deduplicated_module_patterns(paths: list[str]) -> None for path in paths ) ) - assert globs == expected + assert globs == expected, ( + f"module globs must be the deduplicated module patterns {expected}; " + f"got {globs} for {paths}" + ) assert len(globs) == len(set(globs)) for glob in globs: assert glob.endswith(".*") From 35d49190cdc89f709fa4f909c7b475037dfa9621 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 1 Aug 2026 21:54:10 +0200 Subject: [PATCH 16/26] Add a users' guide for the rustflags inputs Both actions' rustflags inputs were documented in their READMEs and the pipeline design doc, but there was no task-oriented guide showing a caller how to choose between them. Cover the problem the inputs solve, each default, the precedence rule, and copy-pasteable examples. Precedence is stated per action rather than jointly: rust-build-release guards against overwriting an inherited value, whereas setup-rust forwards the input unconditionally and leaves that decision to the nested action. Co-Authored-By: Claude Opus 5 (1M context) --- docs/users-guide.md | 124 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/users-guide.md diff --git a/docs/users-guide.md b/docs/users-guide.md new file mode 100644 index 00000000..a620b9c0 --- /dev/null +++ b/docs/users-guide.md @@ -0,0 +1,124 @@ +# Users' Guide: Controlling `RUSTFLAGS` in the Rust Actions + +This guide explains the `rustflags` input exposed by the `setup-rust` and +`rust-build-release` composite actions, why it exists, and how to configure it +for common scenarios. + +## Related documents + +- [Rust Build and Release Pipeline design](./rust-build-release-pipeline.md) + – see "3.1.3 Caller-Controlled `RUSTFLAGS`" for the underlying design + rationale and implementation notes. +- [`setup-rust` README](../.github/actions/setup-rust/README.md) – full input + and output tables. +- [`rust-build-release` README](../.github/actions/rust-build-release/README.md) + – full input and output tables. + +## The problem + +The nested `actions-rust-lang/setup-rust-toolchain` action exports +`RUSTFLAGS="-D warnings"` whenever `RUSTFLAGS` is unset. An ambient +`RUSTFLAGS` environment variable overrides Cargo's `build.rustflags` setting +in `.cargo/config.toml`. Together these two behaviours mean that a project +whose source tree needs specific compiler flags — the motivating case is a +`-Zpolonius=next` borrow-checker flag — silently loses them in every step +after toolchain setup, because the setup step's default (or an inherited +value) takes precedence over the project's own configuration. + +Both actions expose a `rustflags` input so callers can control this +behaviour explicitly. + +## `setup-rust`'s `rustflags` input + +`setup-rust` forwards `rustflags` directly to the nested +`actions-rust-lang/setup-rust-toolchain` step, which exports it as +`RUSTFLAGS`. + +- The default is `-D warnings`, preserving the action's historical + behaviour. +- Set it to extra flags to replace that default. +- Set it to the empty string to leave `RUSTFLAGS` unset, so an inherited + value or the project's `build.rustflags` in `.cargo/config.toml` applies + instead. + +## `rust-build-release`'s `rustflags` input + +`rust-build-release` pins its own nested `setup-rust` step. Its `rustflags` +input defaults to empty, meaning the environment is left untouched: the +nested `setup-rust` step still applies its own `-D warnings` default in that +case. Setting a value exports it as `RUSTFLAGS` in an "Export caller +RUSTFLAGS" step that runs *before* toolchain setup, so the nested +`setup-rust-toolchain` step defers to it and the build honours it. + +## Precedence + +`rust-build-release` never overwrites a `RUSTFLAGS` value the caller has +already exported: its export step checks whether the variable is set at all, +so an inherited value wins over the `rustflags` input even when that +inherited value is the empty string. + +`setup-rust` has no such guard. It forwards `rustflags` to +`actions-rust-lang/setup-rust-toolchain` unconditionally, so what happens to +an inherited `RUSTFLAGS` is that action's decision, not this one's. Pass the +empty string to have it leave `RUSTFLAGS` alone. + +## Usage examples + +### `setup-rust` + +Keep the default `-D warnings` behaviour (no input needed): + +```yaml +- uses: ./.github/actions/setup-rust + with: + toolchain: stable +``` + +Pass an extra flag, replacing the default: + +```yaml +- uses: ./.github/actions/setup-rust + with: + toolchain: nightly + rustflags: "-D warnings -Zpolonius=next" +``` + +Defer to the project's `.cargo/config.toml`: + +```yaml +- uses: ./.github/actions/setup-rust + with: + toolchain: stable + rustflags: "" +``` + +### `rust-build-release` + +Keep the default (environment untouched, `-D warnings` still applies via the +nested `setup-rust` step): + +```yaml +- uses: ./.github/actions/rust-build-release + with: + target: x86_64-unknown-linux-gnu + project-dir: rust-toy-app + bin-name: rust-toy-app +``` + +Pass an extra flag required by the source tree: + +```yaml +- uses: ./.github/actions/rust-build-release + with: + target: x86_64-unknown-linux-gnu + project-dir: rust-toy-app + bin-name: rust-toy-app + rustflags: "-Zpolonius=next" +``` + +## Which should I use? + +- Use `setup-rust`'s `rustflags` input when calling that action directly. +- Use `rust-build-release`'s `rustflags` input when using the build action, + which pins its own nested `setup-rust` step and exports the value before + that step runs. From 7bea3ec67b40ecd48d673c17fdb240315b18b0ef Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 13:44:59 +0200 Subject: [PATCH 17/26] Split the RUSTFLAGS export tests from the manifest tests The module was titled for manifest-path input wiring but had accumulated a bash-fragment harness, an environment-file parser, an `od` stub and Hypothesis strategies, taking it past the 400-line limit. Move everything that executes the export fragment into test_rustflags_export.py and leave the manifest-shape assertions behind. The manifest loader and step lookup both modules need go to the existing sibling helper module rather than conftest.py, which is already larger than either. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/rust_build_release_test_helpers.py | 28 ++ .../tests/test_manifest_input_step.py | 365 +----------------- .../tests/test_rustflags_export.py | 322 +++++++++++++++ 3 files changed, 370 insertions(+), 345 deletions(-) create mode 100644 .github/actions/rust-build-release/tests/test_rustflags_export.py diff --git a/.github/actions/rust-build-release/tests/rust_build_release_test_helpers.py b/.github/actions/rust-build-release/tests/rust_build_release_test_helpers.py index d8d53d64..e51e321f 100644 --- a/.github/actions/rust-build-release/tests/rust_build_release_test_helpers.py +++ b/.github/actions/rust-build-release/tests/rust_build_release_test_helpers.py @@ -2,8 +2,36 @@ from __future__ import annotations +from pathlib import Path + +import yaml + +ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" + def assert_no_toolchain_override(parts: list[str]) -> None: """Assert that a cross command does not inject a +toolchain override.""" assert parts[1] == "build" # noqa: S101 assert all(not part.startswith("+") for part in parts[1:]) # noqa: S101 + + +def load_action_manifest() -> dict[str, object]: + """Return the parsed composite action manifest.""" + return yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) + + +def find_step(steps: list[dict[str, object]], name: str) -> dict[str, object]: + """Return the named step, failing clearly when the manifest lacks it.""" + for step in steps: + if step.get("name") == name: + return step + message = f"step '{name}' missing from action" + raise AssertionError(message) + + +def export_rustflags_run_script() -> str: + """Return the shell fragment that exports the caller's RUSTFLAGS.""" + steps: list[dict[str, object]] = load_action_manifest()["runs"]["steps"] + run_script = find_step(steps, "Export caller RUSTFLAGS").get("run") + assert isinstance(run_script, str), "export step has no run script" # noqa: S101 + return run_script 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 40638d1e..6f83a488 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 @@ -1,191 +1,18 @@ -"""Tests for manifest-path input wiring in the composite action.""" - -from __future__ import annotations - -import os -import shutil -import subprocess -from pathlib import Path - -import pytest -import yaml -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st - -ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" -# A value crafted to close a fixed heredoc delimiter early and have the -# remainder read back as further environment-file commands. -INJECTION_MARKER = "__RBR_RUSTFLAGS_EOF__" -INJECTED_RUSTFLAGS = f"-Zpolonius=next\n{INJECTION_MARKER}\nRBR_INJECTED=1" -# Scripted `od` output, shaped like the real 16-byte hex dump, used to make a -# delimiter collision reachable. -COLLIDING_OD_HEX = "0" * 32 -SAFE_OD_HEX = "1" * 32 - -# Fragments chosen to provoke the environment-file parser: the old fixed -# marker, assignment and heredoc syntax, quoting, and newlines that could -# split a value across lines. Carriage returns and the other exotic -# separators Python's str.splitlines honours are excluded, because the -# runner splits environment files on newlines alone. -_PAYLOAD_FRAGMENTS = st.sampled_from( - [ - "-D warnings", - "-Zpolonius=next", - INJECTION_MARKER, - "RBR_INJECTED=1", - "RUSTFLAGS< dict[str, object]: - return yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) - - -def _find_step(steps: list[dict[str, object]], name: str) -> dict[str, object]: - for step in steps: - if step.get("name") == name: - return step - message = f"step '{name}' missing from action" - raise AssertionError(message) - - -def _export_rustflags_run_script() -> str: - """Return the shell fragment that exports the caller's RUSTFLAGS.""" - steps: list[dict[str, object]] = _load_action_manifest()["runs"]["steps"] - run_script = _find_step(steps, "Export caller RUSTFLAGS").get("run") - assert isinstance(run_script, str), "export step has no run script" - return run_script - - -def _delimiter_for(od_hex: str) -> str: - """Return the delimiter the step derives from a given ``od`` output.""" - return f"__RBR_RUSTFLAGS_EOF_{od_hex}__" - - -def _write_od_stub(stubs_dir: Path) -> Path: - """Install an ``od`` stub yielding one scripted value per invocation. - - The step derives its delimiter from ``od``, so replacing ``od`` on PATH is - the only way to make a collision reachable; real output carries 128 bits of - entropy. Values come from ``FAKE_OD_VALUES`` (one per line) and the last is - repeated once exhausted, so a single value collides on every attempt. - """ - stubs_dir.mkdir(parents=True, exist_ok=True) - stub = stubs_dir / "od" - stub.write_text( - "#!/usr/bin/env bash\n" - "set -euo pipefail\n" - "count=0\n" - 'if [ -f "$FAKE_OD_CALLS" ]; then count="$(cat "$FAKE_OD_CALLS")"; fi\n' - 'printf \'%s\' "$((count + 1))" > "$FAKE_OD_CALLS"\n' - 'value="$(printf \'%s\\n\' "$FAKE_OD_VALUES" | sed -n "$((count + 1))p")"\n' - 'if [ -z "$value" ]; then\n' - ' value="$(printf \'%s\\n\' "$FAKE_OD_VALUES" | tail -n 1)"\n' - "fi\n" - "printf '%s\\n' \"$value\"\n", - encoding="utf-8", - ) - stub.chmod(0o755) - return stub +"""Tests for input wiring declared in the composite action manifest. +These assert the manifest's declared shape only. The RUSTFLAGS export step's +runtime behaviour lives in ``test_rustflags_export.py``, which executes its +shell fragment. +""" -def _run_export_script( - tmp_path: Path, - rustflags: str, - *, - inherited: str | None = None, - od_hex_values: tuple[str, ...] | None = None, -) -> tuple[subprocess.CompletedProcess[str], str]: - """Run the export fragment and return its result with the env-file text.""" - bash = shutil.which("bash") - if bash is None: # pragma: no cover - bash is present on supported runners - pytest.skip("bash not found on PATH") - tmp_path.mkdir(parents=True, exist_ok=True) - github_env = tmp_path / "github-env" - # Truncate rather than touch: a property test reuses one tmp_path across - # examples, and the step appends, so a stale file would leak between them. - github_env.write_text("", encoding="utf-8") - env = {key: value for key, value in os.environ.items() if key != "RUSTFLAGS"} - env["GITHUB_ENV"] = github_env.as_posix() - env["RBR_RUSTFLAGS"] = rustflags - if inherited is not None: - env["RUSTFLAGS"] = inherited - if od_hex_values is not None: - stubs_dir = tmp_path / "stubs" - _write_od_stub(stubs_dir) - env["PATH"] = f"{stubs_dir}{os.pathsep}{env['PATH']}" - env["FAKE_OD_VALUES"] = "\n".join(od_hex_values) - env["FAKE_OD_CALLS"] = (tmp_path / "od-calls").as_posix() - result = subprocess.run( # noqa: S603,TID251 - exercise the bash fragment. - [bash, "-c", _export_rustflags_run_script()], - cwd=tmp_path, - env=env, - capture_output=True, - text=True, - timeout=30, - check=False, - ) - return result, github_env.read_text(encoding="utf-8") - - -def _parse_heredoc_value( - lines: list[str], index: int, name: str, delimiter: str -) -> tuple[str, int]: - """Collect a heredoc body, returning it with the index past its delimiter.""" - collected: list[str] = [] - while index < len(lines) and lines[index] != delimiter: - collected.append(lines[index]) - index += 1 - if index >= len(lines): - message = f"unterminated heredoc for {name}" - raise AssertionError(message) - return "\n".join(collected), index + 1 - +from __future__ import annotations -def _parse_env_file(text: str) -> dict[str, str]: - """Parse ``GITHUB_ENV`` content, honouring heredoc-delimited values.""" - values: dict[str, str] = {} - lines = text.splitlines() - index = 0 - while index < len(lines): - line = lines[index] - index += 1 - if not line: - continue - name, separator, remainder = line.partition("=") - if separator: - values[name] = remainder - continue - name, separator, delimiter = line.partition("<<") - if not separator: - message = f"unparsable environment-file line: {line!r}" - raise AssertionError(message) - values[name], index = _parse_heredoc_value(lines, index, name, delimiter) - return values +from rust_build_release_test_helpers import find_step, load_action_manifest def test_manifest_path_input_declared() -> None: """The manifest-path input must exist with a Cargo.toml default.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() inputs = manifest["inputs"] assert "manifest-path" in inputs manifest_input = inputs["manifest-path"] @@ -195,7 +22,7 @@ def test_manifest_path_input_declared() -> None: def test_toolchain_input_declared() -> None: """The toolchain override input must exist with an empty default.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() inputs = manifest["inputs"] assert "toolchain" in inputs toolchain_input = inputs["toolchain"] @@ -205,7 +32,7 @@ def test_toolchain_input_declared() -> None: def test_skip_man_page_discovery_input_declared() -> None: """The opt-out input must preserve discovery by default.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() inputs = manifest["inputs"] assert "skip-man-page-discovery" in inputs skip_input = inputs["skip-man-page-discovery"] @@ -216,9 +43,9 @@ def test_skip_man_page_discovery_input_declared() -> None: def test_build_step_exports_manifest_path_env() -> None: """Build step should pass manifest-path via RBR_MANIFEST_PATH.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] - build_step = _find_step(steps, "Build release") + build_step = find_step(steps, "Build release") env = build_step.get("env") assert isinstance(env, dict) assert env.get("RBR_MANIFEST_PATH") == "${{ inputs.manifest-path }}" @@ -226,9 +53,9 @@ def test_build_step_exports_manifest_path_env() -> None: 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() + manifest = load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] - determine_step = _find_step(steps, "Determine toolchain") + 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) @@ -238,9 +65,9 @@ def test_determine_toolchain_step_uses_project_lookup_inputs() -> None: def test_stage_artefacts_step_uses_stable_manpage_path() -> None: """Packaging should prefer generated-man before falling back to Cargo output.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] - stage_step = _find_step(steps, "Stage artefacts") + stage_step = find_step(steps, "Stage artefacts") run_script = stage_step.get("run") assert isinstance(run_script, str) assert ( @@ -257,7 +84,7 @@ def test_stage_artefacts_step_uses_stable_manpage_path() -> None: def test_rustflags_input_declared() -> None: """The rustflags input must exist with an empty default.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() inputs = manifest["inputs"] assert "rustflags" in inputs, f"rustflags input missing; declared: {sorted(inputs)}" rustflags_input = inputs["rustflags"] @@ -272,9 +99,9 @@ def test_rustflags_input_declared() -> None: def test_export_rustflags_step_wiring() -> None: """The export step must gate on the input and defer to an inherited value.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] - export_step = _find_step(steps, "Export caller RUSTFLAGS") + export_step = find_step(steps, "Export caller RUSTFLAGS") assert export_step.get("if") == "inputs.rustflags != ''", ( "the step must be skipped entirely when no rustflags input is given; " f"got {export_step.get('if')!r}" @@ -301,161 +128,9 @@ def test_export_rustflags_step_wiring() -> None: ) -def test_export_rustflags_step_uses_a_generated_delimiter() -> None: - """The heredoc delimiter must not be a fixed literal in the manifest.""" - run_script = _export_rustflags_run_script() - assert f"RUSTFLAGS<<{INJECTION_MARKER}" not in run_script, ( - "the manifest must not pin a fixed delimiter a caller could reproduce" - ) - assert 'echo "RUSTFLAGS<<$delimiter"' in run_script, ( - "the heredoc must open with the generated delimiter variable" - ) - - -def test_export_rustflags_writes_single_line_value(tmp_path: Path) -> None: - """An ordinary value round-trips through the environment file.""" - result, env_text = _run_export_script(tmp_path, "-Zpolonius=next") - - assert result.returncode == 0, result.stderr - assert _parse_env_file(env_text) == {"RUSTFLAGS": "-Zpolonius=next"}, ( - f"the value must round-trip unchanged; env file held {env_text!r}" - ) - - -def test_export_rustflags_contains_delimiter_lookalike(tmp_path: Path) -> None: - """A value carrying the old fixed marker must not escape its heredoc.""" - result, env_text = _run_export_script(tmp_path, INJECTED_RUSTFLAGS) - - assert result.returncode == 0, result.stderr - parsed = _parse_env_file(env_text) - # The marker stays inside RUSTFLAGS rather than closing it, so nothing - # after it is read back as a separate environment-file assignment. - assert parsed == {"RUSTFLAGS": INJECTED_RUSTFLAGS}, ( - f"the marker must stay inside the value; env file held {env_text!r}" - ) - assert "RBR_INJECTED" not in parsed, ( - "text after the marker must not become its own environment variable" - ) - - -def test_export_rustflags_delimiter_differs_between_runs(tmp_path: Path) -> None: - """Delimiters are generated per run so callers cannot predict them.""" - _, first = _run_export_script(tmp_path / "first", "-Zpolonius=next") - _, second = _run_export_script(tmp_path / "second", "-Zpolonius=next") - - first_header, second_header = first.splitlines()[0], second.splitlines()[0] - assert first_header != second_header, ( - f"two runs reused the delimiter {first_header!r}" - ) - - -def test_export_rustflags_defers_to_inherited_value(tmp_path: Path) -> None: - """An inherited RUSTFLAGS wins and nothing is written to the env file.""" - result, env_text = _run_export_script( - tmp_path, "-Zpolonius=next", inherited="-D warnings" - ) - - assert result.returncode == 0, result.stderr - assert env_text == "", ( - f"an inherited RUSTFLAGS must not be overwritten; wrote {env_text!r}" - ) - assert "leaving the inherited value in place" in result.stderr, ( - f"expected the deferral notice on stderr; got {result.stderr!r}" - ) - - -def test_export_rustflags_defers_to_inherited_empty_value(tmp_path: Path) -> None: - """An inherited but empty RUSTFLAGS counts as set and is left alone.""" - result, env_text = _run_export_script(tmp_path, "-Zpolonius=next", inherited="") - - assert result.returncode == 0, result.stderr - assert env_text == "", ( - f"an inherited empty RUSTFLAGS must not be overwritten; wrote {env_text!r}" - ) - assert "leaving the inherited value in place" in result.stderr, ( - f"expected the deferral notice on stderr; got {result.stderr!r}" - ) - - -@EXPORT_PROPERTY_SETTINGS -@given(payload=RUSTFLAGS_PAYLOADS) -def test_exported_rustflags_round_trip_for_any_payload( - tmp_path: Path, payload: str -) -> None: - """Any payload survives the environment file as exactly one variable.""" - result, env_text = _run_export_script(tmp_path / "roundtrip", payload) - - assert result.returncode == 0, result.stderr - # Exact equality is the delimiter-safety invariant: a value that closed - # its heredoc early would either lose text or contribute extra names. - assert _parse_env_file(env_text) == {"RUSTFLAGS": payload}, ( - f"payload {payload!r} did not round-trip; env file held {env_text!r}" - ) - - -@EXPORT_PROPERTY_SETTINGS -@given(payload=RUSTFLAGS_PAYLOADS, inherited=RUSTFLAGS_PAYLOADS | st.just("")) -def test_inherited_rustflags_always_wins( - tmp_path: Path, payload: str, inherited: str -) -> None: - """No payload can displace an inherited RUSTFLAGS, empty or otherwise.""" - result, env_text = _run_export_script( - tmp_path / "precedence", payload, inherited=inherited - ) - - assert result.returncode == 0, result.stderr - assert env_text == "", ( - f"payload {payload!r} overwrote inherited {inherited!r}; wrote {env_text!r}" - ) - - -def test_export_rustflags_retries_after_a_delimiter_collision(tmp_path: Path) -> None: - """A candidate present in the value is discarded and another drawn.""" - payload = f"-Zpolonius=next\n{_delimiter_for(COLLIDING_OD_HEX)}" - result, env_text = _run_export_script( - tmp_path, - payload, - od_hex_values=(COLLIDING_OD_HEX, SAFE_OD_HEX), - ) - - assert result.returncode == 0, result.stderr - assert env_text.startswith(f"RUSTFLAGS<<{_delimiter_for(SAFE_OD_HEX)}"), ( - f"the second candidate should have been used; env file held {env_text!r}" - ) - assert _parse_env_file(env_text) == {"RUSTFLAGS": payload}, ( - f"the payload must still round-trip after a retry; got {env_text!r}" - ) - assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "2", ( - "exactly two candidates should have been drawn" - ) - - -def test_export_rustflags_fails_after_three_colliding_candidates( - tmp_path: Path, -) -> None: - """Three unusable candidates abort the step rather than corrupt the file.""" - payload = f"-Zpolonius=next\n{_delimiter_for(COLLIDING_OD_HEX)}" - result, env_text = _run_export_script( - tmp_path, payload, od_hex_values=(COLLIDING_OD_HEX,) - ) - - assert result.returncode == 1, ( - f"the step must fail rather than write an unsafe delimiter; {result.stderr!r}" - ) - assert "could not derive a RUSTFLAGS delimiter" in result.stderr, ( - f"expected the give-up diagnostic on stderr; got {result.stderr!r}" - ) - assert env_text == "", ( - f"nothing may reach the environment file on failure; wrote {env_text!r}" - ) - assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "3", ( - "the loop should try exactly three candidates before giving up" - ) - - def test_export_rustflags_step_precedes_toolchain_setup() -> None: """The export must run before the nested setup-rust toolchain step.""" - manifest = _load_action_manifest() + manifest = load_action_manifest() steps: list[dict[str, object]] = manifest["runs"]["steps"] names = [step.get("name") for step in steps] assert names.index("Export caller RUSTFLAGS") < names.index( diff --git a/.github/actions/rust-build-release/tests/test_rustflags_export.py b/.github/actions/rust-build-release/tests/test_rustflags_export.py new file mode 100644 index 00000000..2988924f --- /dev/null +++ b/.github/actions/rust-build-release/tests/test_rustflags_export.py @@ -0,0 +1,322 @@ +"""Behavioural tests for the RUSTFLAGS export step's environment-file write. + +These run the composite action's shell fragment as a subprocess, so they cover +the heredoc delimiter mechanism rather than the manifest's declared shape, +which ``test_manifest_input_step.py`` covers. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import typing as typ + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from rust_build_release_test_helpers import export_rustflags_run_script + +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from pathlib import Path + +# A value crafted to close a fixed heredoc delimiter early and have the +# remainder read back as further environment-file commands. +INJECTION_MARKER = "__RBR_RUSTFLAGS_EOF__" +INJECTED_RUSTFLAGS = f"-Zpolonius=next\n{INJECTION_MARKER}\nRBR_INJECTED=1" +# Scripted `od` output, shaped like the real 16-byte hex dump, used to make a +# delimiter collision reachable. +COLLIDING_OD_HEX = "0" * 32 +SAFE_OD_HEX = "1" * 32 + +# Fragments chosen to provoke the environment-file parser: the old fixed +# marker, assignment and heredoc syntax, quoting, and newlines that could +# split a value across lines. Carriage returns and the other exotic +# separators Python's str.splitlines honours are excluded, because the +# runner splits environment files on newlines alone. +_PAYLOAD_FRAGMENTS = st.sampled_from( + [ + "-D warnings", + "-Zpolonius=next", + INJECTION_MARKER, + "RBR_INJECTED=1", + "RUSTFLAGS< str: + """Return the delimiter the step derives from a given ``od`` output.""" + return f"__RBR_RUSTFLAGS_EOF_{od_hex}__" + + +def _write_od_stub(stubs_dir: Path) -> Path: + """Install an ``od`` stub yielding one scripted value per invocation. + + The step derives its delimiter from ``od``, so replacing ``od`` on PATH is + the only way to make a collision reachable; real output carries 128 bits of + entropy. Values come from ``FAKE_OD_VALUES`` (one per line) and the last is + repeated once exhausted, so a single value collides on every attempt. + """ + stubs_dir.mkdir(parents=True, exist_ok=True) + stub = stubs_dir / "od" + stub.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "count=0\n" + 'if [ -f "$FAKE_OD_CALLS" ]; then count="$(cat "$FAKE_OD_CALLS")"; fi\n' + 'printf \'%s\' "$((count + 1))" > "$FAKE_OD_CALLS"\n' + 'value="$(printf \'%s\\n\' "$FAKE_OD_VALUES" | sed -n "$((count + 1))p")"\n' + 'if [ -z "$value" ]; then\n' + ' value="$(printf \'%s\\n\' "$FAKE_OD_VALUES" | tail -n 1)"\n' + "fi\n" + "printf '%s\\n' \"$value\"\n", + encoding="utf-8", + ) + stub.chmod(0o755) + return stub + + +def _run_export_script( + tmp_path: Path, + rustflags: str, + *, + inherited: str | None = None, + od_hex_values: tuple[str, ...] | None = None, +) -> tuple[subprocess.CompletedProcess[str], str]: + """Run the export fragment and return its result with the env-file text.""" + bash = shutil.which("bash") + if bash is None: # pragma: no cover - bash is present on supported runners + pytest.skip("bash not found on PATH") + tmp_path.mkdir(parents=True, exist_ok=True) + github_env = tmp_path / "github-env" + # Truncate rather than touch: a property test reuses one tmp_path across + # examples, and the step appends, so a stale file would leak between them. + github_env.write_text("", encoding="utf-8") + env = {key: value for key, value in os.environ.items() if key != "RUSTFLAGS"} + env["GITHUB_ENV"] = github_env.as_posix() + env["RBR_RUSTFLAGS"] = rustflags + if inherited is not None: + env["RUSTFLAGS"] = inherited + if od_hex_values is not None: + stubs_dir = tmp_path / "stubs" + _write_od_stub(stubs_dir) + env["PATH"] = f"{stubs_dir}{os.pathsep}{env['PATH']}" + env["FAKE_OD_VALUES"] = "\n".join(od_hex_values) + env["FAKE_OD_CALLS"] = (tmp_path / "od-calls").as_posix() + result = subprocess.run( # noqa: S603,TID251 - exercise the bash fragment. + [bash, "-c", export_rustflags_run_script()], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + return result, github_env.read_text(encoding="utf-8") + + +def _parse_heredoc_value( + lines: list[str], index: int, name: str, delimiter: str +) -> tuple[str, int]: + """Collect a heredoc body, returning it with the index past its delimiter.""" + collected: list[str] = [] + while index < len(lines) and lines[index] != delimiter: + collected.append(lines[index]) + index += 1 + if index >= len(lines): + message = f"unterminated heredoc for {name}" + raise AssertionError(message) + return "\n".join(collected), index + 1 + + +def _parse_env_file(text: str) -> dict[str, str]: + """Parse ``GITHUB_ENV`` content, honouring heredoc-delimited values.""" + values: dict[str, str] = {} + lines = text.splitlines() + index = 0 + while index < len(lines): + line = lines[index] + index += 1 + if not line: + continue + name, separator, remainder = line.partition("=") + if separator: + values[name] = remainder + continue + name, separator, delimiter = line.partition("<<") + if not separator: + message = f"unparsable environment-file line: {line!r}" + raise AssertionError(message) + values[name], index = _parse_heredoc_value(lines, index, name, delimiter) + return values + + +def test_export_rustflags_step_uses_a_generated_delimiter() -> None: + """The heredoc delimiter must not be a fixed literal in the manifest.""" + run_script = export_rustflags_run_script() + assert f"RUSTFLAGS<<{INJECTION_MARKER}" not in run_script, ( + "the manifest must not pin a fixed delimiter a caller could reproduce" + ) + assert 'echo "RUSTFLAGS<<$delimiter"' in run_script, ( + "the heredoc must open with the generated delimiter variable" + ) + + +def test_export_rustflags_writes_single_line_value(tmp_path: Path) -> None: + """An ordinary value round-trips through the environment file.""" + result, env_text = _run_export_script(tmp_path, "-Zpolonius=next") + + assert result.returncode == 0, result.stderr + assert _parse_env_file(env_text) == {"RUSTFLAGS": "-Zpolonius=next"}, ( + f"the value must round-trip unchanged; env file held {env_text!r}" + ) + + +def test_export_rustflags_contains_delimiter_lookalike(tmp_path: Path) -> None: + """A value carrying the old fixed marker must not escape its heredoc.""" + result, env_text = _run_export_script(tmp_path, INJECTED_RUSTFLAGS) + + assert result.returncode == 0, result.stderr + parsed = _parse_env_file(env_text) + # The marker stays inside RUSTFLAGS rather than closing it, so nothing + # after it is read back as a separate environment-file assignment. + assert parsed == {"RUSTFLAGS": INJECTED_RUSTFLAGS}, ( + f"the marker must stay inside the value; env file held {env_text!r}" + ) + assert "RBR_INJECTED" not in parsed, ( + "text after the marker must not become its own environment variable" + ) + + +def test_export_rustflags_delimiter_differs_between_runs(tmp_path: Path) -> None: + """Delimiters are generated per run so callers cannot predict them.""" + _, first = _run_export_script(tmp_path / "first", "-Zpolonius=next") + _, second = _run_export_script(tmp_path / "second", "-Zpolonius=next") + + first_header, second_header = first.splitlines()[0], second.splitlines()[0] + assert first_header != second_header, ( + f"two runs reused the delimiter {first_header!r}" + ) + + +def test_export_rustflags_defers_to_inherited_value(tmp_path: Path) -> None: + """An inherited RUSTFLAGS wins and nothing is written to the env file.""" + result, env_text = _run_export_script( + tmp_path, "-Zpolonius=next", inherited="-D warnings" + ) + + assert result.returncode == 0, result.stderr + assert env_text == "", ( + f"an inherited RUSTFLAGS must not be overwritten; wrote {env_text!r}" + ) + assert "leaving the inherited value in place" in result.stderr, ( + f"expected the deferral notice on stderr; got {result.stderr!r}" + ) + + +def test_export_rustflags_defers_to_inherited_empty_value(tmp_path: Path) -> None: + """An inherited but empty RUSTFLAGS counts as set and is left alone.""" + result, env_text = _run_export_script(tmp_path, "-Zpolonius=next", inherited="") + + assert result.returncode == 0, result.stderr + assert env_text == "", ( + f"an inherited empty RUSTFLAGS must not be overwritten; wrote {env_text!r}" + ) + assert "leaving the inherited value in place" in result.stderr, ( + f"expected the deferral notice on stderr; got {result.stderr!r}" + ) + + +@EXPORT_PROPERTY_SETTINGS +@given(payload=RUSTFLAGS_PAYLOADS) +def test_exported_rustflags_round_trip_for_any_payload( + tmp_path: Path, payload: str +) -> None: + """Any payload survives the environment file as exactly one variable.""" + result, env_text = _run_export_script(tmp_path / "roundtrip", payload) + + assert result.returncode == 0, result.stderr + # Exact equality is the delimiter-safety invariant: a value that closed + # its heredoc early would either lose text or contribute extra names. + assert _parse_env_file(env_text) == {"RUSTFLAGS": payload}, ( + f"payload {payload!r} did not round-trip; env file held {env_text!r}" + ) + + +@EXPORT_PROPERTY_SETTINGS +@given(payload=RUSTFLAGS_PAYLOADS, inherited=RUSTFLAGS_PAYLOADS | st.just("")) +def test_inherited_rustflags_always_wins( + tmp_path: Path, payload: str, inherited: str +) -> None: + """No payload can displace an inherited RUSTFLAGS, empty or otherwise.""" + result, env_text = _run_export_script( + tmp_path / "precedence", payload, inherited=inherited + ) + + assert result.returncode == 0, result.stderr + assert env_text == "", ( + f"payload {payload!r} overwrote inherited {inherited!r}; wrote {env_text!r}" + ) + + +def test_export_rustflags_retries_after_a_delimiter_collision(tmp_path: Path) -> None: + """A candidate present in the value is discarded and another drawn.""" + payload = f"-Zpolonius=next\n{_delimiter_for(COLLIDING_OD_HEX)}" + result, env_text = _run_export_script( + tmp_path, + payload, + od_hex_values=(COLLIDING_OD_HEX, SAFE_OD_HEX), + ) + + assert result.returncode == 0, result.stderr + assert env_text.startswith(f"RUSTFLAGS<<{_delimiter_for(SAFE_OD_HEX)}"), ( + f"the second candidate should have been used; env file held {env_text!r}" + ) + assert _parse_env_file(env_text) == {"RUSTFLAGS": payload}, ( + f"the payload must still round-trip after a retry; got {env_text!r}" + ) + assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "2", ( + "exactly two candidates should have been drawn" + ) + + +def test_export_rustflags_fails_after_three_colliding_candidates( + tmp_path: Path, +) -> None: + """Three unusable candidates abort the step rather than corrupt the file.""" + payload = f"-Zpolonius=next\n{_delimiter_for(COLLIDING_OD_HEX)}" + result, env_text = _run_export_script( + tmp_path, payload, od_hex_values=(COLLIDING_OD_HEX,) + ) + + assert result.returncode == 1, ( + f"the step must fail rather than write an unsafe delimiter; {result.stderr!r}" + ) + assert "could not derive a RUSTFLAGS delimiter" in result.stderr, ( + f"expected the give-up diagnostic on stderr; got {result.stderr!r}" + ) + assert env_text == "", ( + f"nothing may reach the environment file on failure; wrote {env_text!r}" + ) + assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "3", ( + "the loop should try exactly three candidates before giving up" + ) From b9ec77d1fbd9a7e04f11a78edc85008c9a02c36a Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 14:09:39 +0200 Subject: [PATCH 18/26] Report RUSTFLAGS export progress and retries The step logged only the inherited-value deferral and the terminal failure, so a successful export and any delimiter collision along the way were invisible. Number each collision against a fixed retry budget and announce the successful export. Neither diagnostic names the candidate or the value. A candidate only collides because the value contains it, so echoing one would leak a line of the caller's RUSTFLAGS into the log; the tests assert that it does not. `setup-rust` gains no diagnostics: it forwards the input to the nested action without a shell step, so logging would mean adding a step solely to log. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/rust-build-release/action.yml | 12 ++++++++++-- .../tests/test_rustflags_export.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index e57a61f1..eddd9aa6 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -89,16 +89,23 @@ runs: # close the block early and leave the remaining caller-supplied lines # to be parsed as further environment-file commands, so derive a random # delimiter and confirm the value does not contain it. + # Diagnostics never name the candidate or the value: a candidate only + # collides because the value contains it, so echoing it would leak a + # line of the caller's RUSTFLAGS into the log. delimiter="" - for _ in 1 2 3; do + attempts=3 + attempt=0 + while [[ $attempt -lt $attempts ]]; do + attempt=$((attempt + 1)) candidate="__RBR_RUSTFLAGS_EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')__" if ! printf '%s\n' "$RBR_RUSTFLAGS" | grep -qxF -- "$candidate"; then delimiter="$candidate" break fi + echo "RUSTFLAGS delimiter attempt $attempt of $attempts collided with the value; retrying" >&2 done if [[ -z "$delimiter" ]]; then - echo "::error::could not derive a RUSTFLAGS delimiter absent from the value" >&2 + echo "::error::could not derive a RUSTFLAGS delimiter absent from the value after $attempts attempts" >&2 exit 1 fi { @@ -106,6 +113,7 @@ runs: printf '%s\n' "$RBR_RUSTFLAGS" echo "$delimiter" } >> "$GITHUB_ENV" + echo "RUSTFLAGS exported from the rustflags input on attempt $attempt of $attempts" >&2 - name: Setup Rust toolchain # setup-rust-v1 # Update this SHA when setup-rust publishes a new release: run diff --git a/.github/actions/rust-build-release/tests/test_rustflags_export.py b/.github/actions/rust-build-release/tests/test_rustflags_export.py index 2988924f..ff0a5633 100644 --- a/.github/actions/rust-build-release/tests/test_rustflags_export.py +++ b/.github/actions/rust-build-release/tests/test_rustflags_export.py @@ -189,6 +189,12 @@ def test_export_rustflags_writes_single_line_value(tmp_path: Path) -> None: assert _parse_env_file(env_text) == {"RUSTFLAGS": "-Zpolonius=next"}, ( f"the value must round-trip unchanged; env file held {env_text!r}" ) + assert "RUSTFLAGS exported from the rustflags input" in result.stderr, ( + f"the successful export should be announced; got {result.stderr!r}" + ) + assert "-Zpolonius=next" not in result.stderr, ( + f"the exported value must stay out of the log; got {result.stderr!r}" + ) def test_export_rustflags_contains_delimiter_lookalike(tmp_path: Path) -> None: @@ -297,6 +303,13 @@ def test_export_rustflags_retries_after_a_delimiter_collision(tmp_path: Path) -> assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "2", ( "exactly two candidates should have been drawn" ) + assert "attempt 1 of 3 collided" in result.stderr, ( + f"the discarded candidate should be reported; got {result.stderr!r}" + ) + assert _delimiter_for(COLLIDING_OD_HEX) not in result.stderr, ( + "a colliding candidate is a line of the caller's value, so it must " + f"never be logged; got {result.stderr!r}" + ) def test_export_rustflags_fails_after_three_colliding_candidates( @@ -320,3 +333,9 @@ def test_export_rustflags_fails_after_three_colliding_candidates( assert (tmp_path / "od-calls").read_text(encoding="utf-8") == "3", ( "the loop should try exactly three candidates before giving up" ) + assert result.stderr.count("collided with the value") == 3, ( + f"every attempt should be reported before giving up; got {result.stderr!r}" + ) + assert _delimiter_for(COLLIDING_OD_HEX) not in result.stderr, ( + f"the colliding candidate must never be logged; got {result.stderr!r}" + ) From 080823f344a30c6ba79c0db2aae180eb0e4fe663 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 14:09:39 +0200 Subject: [PATCH 19/26] Describe the whole manifest module in its docstring The docstring still named only manifest-path wiring, though the module also covers the rustflags input, the export step's gating and guard, and its ordering before toolchain setup. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_manifest_input_step.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 6f83a488..3b9b2dd4 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 @@ -1,8 +1,14 @@ -"""Tests for input wiring declared in the composite action manifest. +"""Tests for the inputs and steps declared in the composite action manifest. -These assert the manifest's declared shape only. The RUSTFLAGS export step's -runtime behaviour lives in ``test_rustflags_export.py``, which executes its -shell fragment. +Covers the manifest-path, toolchain, skip-man-page-discovery and rustflags +inputs; the environment wiring of the build, toolchain-lookup and artefact +staging steps; the RUSTFLAGS export step's gating condition, environment +indirection and inherited-value guard; and that the export step precedes +toolchain setup. + +Every assertion here reads the manifest. The export step's runtime behaviour — +safe heredoc handling, inherited-value precedence and delimiter retries — is +covered by ``test_rustflags_export.py``, which executes its shell fragment. """ from __future__ import annotations From 48befa0eaecaeb67dc61290d6d919dfceba5e5a3 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 14:09:39 +0200 Subject: [PATCH 20/26] Document the RUSTFLAGS export for maintainers The users' guide covers the inputs and the pipeline design doc covers the rationale, but neither tells a maintainer editing these shell fragments what they must preserve: the precedence guard, the bash 3.2 constraint, the delimiter safety property, and what the diagnostics may not log. Co-Authored-By: Claude Opus 5 (1M context) --- docs/developers-guide.md | 80 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 22e9ae8a..4f182c59 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -476,3 +476,83 @@ it, and runs it under bash. Five scenarios are covered: stable path present, legacy fallback, missing man page (error), multiple legacy matches (error), and skip mode (no man-page staging, binary only). Tests are automatically skipped on Windows. + +### RUSTFLAGS Export + +Both `setup-rust` and `rust-build-release` expose a `rustflags` input, but +they wire it differently. `setup-rust` forwards the input straight through to +each of its three `actions-rust-lang/setup-rust-toolchain` invocations, so +what happens to an inherited `RUSTFLAGS` is that nested action's decision. +`rust-build-release` instead exports the value itself, in an "Export caller +RUSTFLAGS" step that runs *before* its own pinned nested `setup-rust` step +(see `.github/actions/rust-build-release/action.yml`), so that step's +`setup-rust-toolchain` — which only applies its `-D warnings` default when +`RUSTFLAGS` is unset — defers to the caller's value. The design rationale for +this split lives in section 3.1.3, "Caller-Controlled `RUSTFLAGS`", of the +[Rust Build and Release Pipeline design](rust-build-release-pipeline.md); the +caller-facing usage is in the [users' guide](users-guide.md). This section +covers the implementation detail a maintainer needs to change the export step +safely. + +#### Precedence guard + +The export step is skipped entirely by `if: inputs.rustflags != ''`, but even +when it runs it must not clobber a `RUSTFLAGS` the caller already exported. +It guards with `[[ ${RUSTFLAGS+x} ]]`, which is true whenever `RUSTFLAGS` is +set, including to the empty string, so an inherited value — empty or not — +always wins over the input. `setup-rust` has no equivalent guard; forwarding +the empty string to it leaves `RUSTFLAGS` alone only because +`setup-rust-toolchain` treats an empty forwarded value as "unset". + +#### Bash 3.2 compatibility + +`[[ ${RUSTFLAGS+x} ]]` is used rather than the more idiomatic +`[[ -v RUSTFLAGS ]]` because `-v` needs Bash 4.2 and macOS runners ship Bash +3.2, which cannot parse that conditional primary. Both forms treat an +inherited empty value as set. Keep this constraint in mind for any future +edit to this or similar shell fragments in the two actions: parameter +expansion of the `${NAME+x}` form, not `-v`, is the portable way to test "is +this variable set". + +#### `GITHUB_ENV` heredoc safety + +The step writes `RUSTFLAGS` to `GITHUB_ENV` as a heredoc rather than a plain +assignment, because the value may contain newlines. The delimiter is derived +from 16 random bytes (`od -An -N16 -tx1 /dev/urandom`) and checked against +the value with `grep -qxF` before use. If a value contained the delimiter on +a line of its own, that line would close the heredoc block early, and +whatever followed would be read back by the runner as further +environment-file commands — an injection route, not just a formatting bug. +The step retries with a fresh candidate up to three times and fails the step, +rather than writing an unsafe delimiter, if all three collide. + +#### RUSTFLAGS export observability + +The step logs three kinds of event, all to `stderr`: + +- deferral to an inherited value ("RUSTFLAGS already set; leaving the + inherited value in place"); +- each delimiter-collision attempt, numbered out of the fixed retry budget + ("RUSTFLAGS delimiter attempt `N` of 3 collided with the value; retrying"); +- the successful export, also numbered ("RUSTFLAGS exported from the + rustflags input on attempt `N` of 3"). + +It deliberately never logs the `RUSTFLAGS` value itself or a colliding +delimiter candidate: a candidate only collides because the value contains it +as a substring, so echoing the candidate would leak a line of the caller's +`RUSTFLAGS` into the CI log. + +#### RUSTFLAGS export testing + +`.github/actions/rust-build-release/tests/test_rustflags_export.py` extracts +and runs the export step's shell fragment under bash. It covers the +precedence guard (including an inherited empty value), the heredoc +round-trip for adversarial payloads via Hypothesis properties, the +delimiter-collision retry and give-up paths (using a stubbed `od` to make a +collision reachable), and that neither the value nor a colliding candidate +reaches the log. +`.github/actions/rust-build-release/tests/test_manifest_input_step.py` checks +the manifest's declared shape instead: the `rustflags` input's empty default, +the export step's `if` condition and `RBR_RUSTFLAGS` wiring, the +`${RUSTFLAGS+x}` guard's presence in the run script, and that the export step +precedes toolchain setup. From baca0e2b324721adca60082cb1f3e75f1aea73a9 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 14:09:49 +0200 Subject: [PATCH 21/26] Exercise the rustflags export through act The shell-fragment tests run the export step against a fake GITHUB_ENV, so nothing checked that the heredoc it writes is one a real runner accepts, nor that the resulting RUSTFLAGS reaches a later step. Add two opt-in act tests covering propagation and inherited-value precedence. The workflow runs on the release event because the nested setup-rust skips sccache for releases, and the sccache post-step fails under act. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-rustflags-export.yml | 47 +++++++++++ tests/workflows/fixtures/release.event.json | 8 ++ .../test_rustflags_export_workflow.py | 84 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 .github/workflows/test-rustflags-export.yml create mode 100644 tests/workflows/fixtures/release.event.json create mode 100644 tests/workflows/test_rustflags_export_workflow.py diff --git a/.github/workflows/test-rustflags-export.yml b/.github/workflows/test-rustflags-export.yml new file mode 100644 index 00000000..5c02a6bf --- /dev/null +++ b/.github/workflows/test-rustflags-export.yml @@ -0,0 +1,47 @@ +name: Test rustflags export +# Exercises the rustflags inputs through the composite-action boundary so the +# GITHUB_ENV heredoc the export step writes is parsed by a real runner and the +# resulting RUSTFLAGS is observed by a later step. +# +# Driven on the release event because the nested setup-rust skips sccache for +# releases, and the sccache post-step is unreliable under act. +on: + workflow_dispatch: + release: + types: [published] + +jobs: + # The caller's value reaches a step that runs after the action. + rust-build-release-exports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build with caller rustflags + uses: ./.github/actions/rust-build-release + with: + target: x86_64-unknown-linux-gnu + project-dir: rust-toy-app + bin-name: rust-toy-app + rustflags: -D warnings -C debuginfo=0 + - name: Observe exported RUSTFLAGS + shell: bash + run: echo "rbr_rustflags=[${RUSTFLAGS-unset}]" + + # An inherited value wins over the input, and nothing is overwritten. + rust-build-release-defers-to-inherited: + runs-on: ubuntu-latest + env: + RUSTFLAGS: -D warnings + steps: + - uses: actions/checkout@v4 + - name: Build with a conflicting rustflags input + uses: ./.github/actions/rust-build-release + with: + target: x86_64-unknown-linux-gnu + project-dir: rust-toy-app + bin-name: rust-toy-app + rustflags: -D warnings -C debuginfo=2 + - name: Observe inherited RUSTFLAGS + shell: bash + run: echo "inherited_rustflags=[${RUSTFLAGS-unset}]" + diff --git a/tests/workflows/fixtures/release.event.json b/tests/workflows/fixtures/release.event.json new file mode 100644 index 00000000..de9ad208 --- /dev/null +++ b/tests/workflows/fixtures/release.event.json @@ -0,0 +1,8 @@ +{ + "action": "published", + "release": { + "tag_name": "v0.0.0-test", + "draft": false, + "prerelease": false + } +} diff --git a/tests/workflows/test_rustflags_export_workflow.py b/tests/workflows/test_rustflags_export_workflow.py new file mode 100644 index 00000000..adeeae83 --- /dev/null +++ b/tests/workflows/test_rustflags_export_workflow.py @@ -0,0 +1,84 @@ +"""Act-backed tests for the rustflags inputs at the composite-action boundary. + +The unit tests run the export step's shell fragment directly against a fake +``GITHUB_ENV``. These run the actions through a real runner instead, so they +cover what that cannot: that the heredoc the step writes is accepted by the +runner's environment-file parser and that the resulting ``RUSTFLAGS`` is +visible to a later step. +""" + +from __future__ import annotations + +import re +import typing as typ + +import pytest + +from .conftest import ( + FIXTURES_DIR, + ActConfig, + run_act, + skip_unless_act, + skip_unless_workflow_tests, +) + +if typ.TYPE_CHECKING: + from pathlib import Path + +WORKFLOW = "test-rustflags-export.yml" +# The workflow runs on the release event because the nested setup-rust skips +# sccache for releases, whose post-step is unreliable under act. +EVENT = "release" + + +@pytest.fixture +def artefact_dir(tmp_path: Path) -> Path: + """Return a temporary directory for act artefacts.""" + return tmp_path / "act-artefacts" + + +def _run(job: str, artefact_dir: Path) -> str: + """Run one job of the rustflags workflow and return its logs.""" + config = ActConfig( + artefact_dir=artefact_dir, + event_path=FIXTURES_DIR / f"{EVENT}.event.json", + timeout=600, + ) + code, logs = run_act(WORKFLOW, EVENT, job, config) + assert code == 0, f"act failed:\n{logs}" + return logs + + +@skip_unless_act +@skip_unless_workflow_tests +def test_rust_build_release_exports_rustflags_to_later_steps( + artefact_dir: Path, +) -> None: + """The exported value reaches a step running after the action.""" + logs = _run("rust-build-release-exports", artefact_dir) + + assert re.search(r"rbr_rustflags=\[-D warnings -C debuginfo=0\]", logs), ( + f"the caller's rustflags did not reach a later step:\n{logs}" + ) + assert "RUSTFLAGS exported from the rustflags input" in logs, ( + f"the export step did not report a successful export:\n{logs}" + ) + + +@skip_unless_act +@skip_unless_workflow_tests +def test_rust_build_release_defers_to_inherited_rustflags( + artefact_dir: Path, +) -> None: + """A job-level RUSTFLAGS survives a conflicting rustflags input.""" + logs = _run("rust-build-release-defers-to-inherited", artefact_dir) + + assert re.search(r"inherited_rustflags=\[-D warnings\]", logs), ( + f"the inherited RUSTFLAGS was not preserved:\n{logs}" + ) + assert "debuginfo=2" not in logs.split("inherited_rustflags=")[-1], ( + f"the input displaced the inherited value:\n{logs}" + ) + assert "leaving the inherited value in place" in logs, ( + f"the export step did not report deferring to the inherited value:\n{logs}" + ) From 3aad7580432c193a10cb6e820a1086b027073f19 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 14:20:31 +0200 Subject: [PATCH 22/26] Parametrize the inherited RUSTFLAGS example tests The empty and non-empty cases differed only in the inherited value, so fold them into one parametrized test. Each case gets its own temporary directory, since the helper truncates the environment file per invocation. The property test stays separate: it asserts the same precedence across generated payloads rather than the two worked examples. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_rustflags_export.py | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/.github/actions/rust-build-release/tests/test_rustflags_export.py b/.github/actions/rust-build-release/tests/test_rustflags_export.py index ff0a5633..983f6daa 100644 --- a/.github/actions/rust-build-release/tests/test_rustflags_export.py +++ b/.github/actions/rust-build-release/tests/test_rustflags_export.py @@ -224,28 +224,27 @@ def test_export_rustflags_delimiter_differs_between_runs(tmp_path: Path) -> None ) -def test_export_rustflags_defers_to_inherited_value(tmp_path: Path) -> None: - """An inherited RUSTFLAGS wins and nothing is written to the env file.""" - result, env_text = _run_export_script( - tmp_path, "-Zpolonius=next", inherited="-D warnings" - ) +@pytest.mark.parametrize( + ("case", "inherited"), + [("non-empty", "-D warnings"), ("empty", "")], + ids=["non-empty", "empty"], +) +def test_export_rustflags_defers_to_inherited_value( + tmp_path: Path, case: str, inherited: str +) -> None: + """An inherited RUSTFLAGS wins and nothing is written to the env file. - assert result.returncode == 0, result.stderr - assert env_text == "", ( - f"an inherited RUSTFLAGS must not be overwritten; wrote {env_text!r}" - ) - assert "leaving the inherited value in place" in result.stderr, ( - f"expected the deferral notice on stderr; got {result.stderr!r}" + The empty case is not a degenerate one: an empty value is still set, so it + must take precedence over the input just as a non-empty value does. + """ + result, env_text = _run_export_script( + tmp_path / case, "-Zpolonius=next", inherited=inherited ) - -def test_export_rustflags_defers_to_inherited_empty_value(tmp_path: Path) -> None: - """An inherited but empty RUSTFLAGS counts as set and is left alone.""" - result, env_text = _run_export_script(tmp_path, "-Zpolonius=next", inherited="") - assert result.returncode == 0, result.stderr assert env_text == "", ( - f"an inherited empty RUSTFLAGS must not be overwritten; wrote {env_text!r}" + f"an inherited RUSTFLAGS of {inherited!r} must not be overwritten; " + f"wrote {env_text!r}" ) assert "leaving the inherited value in place" in result.stderr, ( f"expected the deferral notice on stderr; got {result.stderr!r}" From 07e444066147c4c7fdda92f773cdfe14edf7d69a Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 14:49:17 +0200 Subject: [PATCH 23/26] Exercise the local setup-rust through act rust-build-release pins a remote setup-rust revision, so the existing act jobs never ran the action this branch changes. Add two jobs that use the local action directly, with an explicit toolchain so exactly one of its three install paths is taken. The first shows the input reaching a later step. The second pins the deferral to an inherited value: setup-rust forwards the input unconditionally, so that behaviour belongs to the nested setup-rust-toolchain and a version bump could otherwise change it silently. Both were observed under act before the assertions were written. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-rustflags-export.yml | 35 +++++++++++++++++++ .../test_rustflags_export_workflow.py | 35 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/.github/workflows/test-rustflags-export.yml b/.github/workflows/test-rustflags-export.yml index 5c02a6bf..09d65837 100644 --- a/.github/workflows/test-rustflags-export.yml +++ b/.github/workflows/test-rustflags-export.yml @@ -45,3 +45,38 @@ jobs: shell: bash run: echo "inherited_rustflags=[${RUSTFLAGS-unset}]" + # rust-build-release pins a remote setup-rust, so the jobs above never run + # the local action. These two do, via an explicit toolchain so exactly one + # of setup-rust's three install paths is taken. + setup-rust-exports: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Rust with caller rustflags + uses: ./.github/actions/setup-rust + with: + toolchain: stable + install-binstall: "false" + use-sccache: "false" + rustflags: -D warnings -C debuginfo=0 + - name: Observe forwarded RUSTFLAGS + shell: bash + run: echo "setup_rust_rustflags=[${RUSTFLAGS-unset}]" + + setup-rust-with-inherited: + runs-on: ubuntu-latest + env: + RUSTFLAGS: -D warnings + steps: + - uses: actions/checkout@v4 + - name: Setup Rust with a conflicting rustflags input + uses: ./.github/actions/setup-rust + with: + toolchain: stable + install-binstall: "false" + use-sccache: "false" + rustflags: -D warnings -C debuginfo=2 + - name: Observe RUSTFLAGS alongside an inherited value + shell: bash + run: echo "setup_rust_inherited_rustflags=[${RUSTFLAGS-unset}]" + diff --git a/tests/workflows/test_rustflags_export_workflow.py b/tests/workflows/test_rustflags_export_workflow.py index adeeae83..b22b1534 100644 --- a/tests/workflows/test_rustflags_export_workflow.py +++ b/tests/workflows/test_rustflags_export_workflow.py @@ -82,3 +82,38 @@ def test_rust_build_release_defers_to_inherited_rustflags( assert "leaving the inherited value in place" in logs, ( f"the export step did not report deferring to the inherited value:\n{logs}" ) + + +@skip_unless_act +@skip_unless_workflow_tests +def test_setup_rust_forwards_rustflags_to_later_steps(artefact_dir: Path) -> None: + """setup-rust's own input reaches a step running after the action. + + The jobs above pin a remote setup-rust revision, so this is the only + coverage that runs the local action. + """ + logs = _run("setup-rust-exports", artefact_dir) + + assert re.search(r"setup_rust_rustflags=\[-D warnings -C debuginfo=0\]", logs), ( + f"setup-rust did not forward its rustflags input:\n{logs}" + ) + + +@skip_unless_act +@skip_unless_workflow_tests +def test_setup_rust_leaves_an_inherited_rustflags_alone(artefact_dir: Path) -> None: + """An inherited RUSTFLAGS survives a conflicting setup-rust input. + + setup-rust forwards the input unconditionally, so the deferral is the + nested setup-rust-toolchain's doing rather than a guard of our own. This + pins that behaviour, which a toolchain-action bump could otherwise change + silently. + """ + logs = _run("setup-rust-with-inherited", artefact_dir) + + assert re.search(r"setup_rust_inherited_rustflags=\[-D warnings\]", logs), ( + f"the inherited RUSTFLAGS was not preserved:\n{logs}" + ) + assert "debuginfo=2" not in logs.split("setup_rust_inherited_rustflags=")[-1], ( + f"the input displaced the inherited value:\n{logs}" + ) From 4254b01e86c54638258eed745f43f2e0ad783671 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 15:24:01 +0200 Subject: [PATCH 24/26] Reject line breaks in the setup-rust rustflags input The pinned setup-rust-toolchain writes its rustflags input to GITHUB_ENV as `echo "RUSTFLAGS=$NEW_RUSTFLAGS"`, a plain assignment. Before this branch that value was the action's own hardcoded default; adding the rustflags input made it caller-controlled, so a line break in it would append further environment-file entries and set variables the caller never asked for. The nested action cannot be changed, so reject CR and LF before forwarding. The rejected value is never echoed. Tests cover the three line-break forms and pin the guard to the sink it protects by reproducing that echo and showing it does create a second entry. Move the rustflags tests into their own module to stay within the file size limit, with the manifest lookups they share in a sibling helper. Co-Authored-By: Claude Opus 5 (1M context) --- .github/actions/setup-rust/action.yml | 19 +- .../tests/setup_rust_test_helpers.py | 33 ++++ .../setup-rust/tests/test_rustflags_input.py | 164 ++++++++++++++++++ .../tests/test_setup_rust_manifest.py | 33 ---- 4 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 .github/actions/setup-rust/tests/setup_rust_test_helpers.py create mode 100644 .github/actions/setup-rust/tests/test_rustflags_input.py diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index f19aec36..0d4765b9 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -45,12 +45,29 @@ inputs: inherited value or the project's Cargo configuration (build.rustflags in .cargo/config.toml) applies. A pre-existing RUSTFLAGS environment variable always takes precedence over this - input. + input. The value must be a single line. required: false default: '-D warnings' runs: using: composite steps: + - name: Validate rustflags + # The nested toolchain action writes this value to GITHUB_ENV as a plain + # "RUSTFLAGS=" line, so a line break in it would start a further + # environment-file entry and set variables the caller never asked for. + # Reject line breaks here rather than forwarding them. The value itself + # is never echoed. + shell: bash + env: + SR_RUSTFLAGS: ${{ inputs.rustflags }} + run: | + set -euo pipefail + case "$SR_RUSTFLAGS" in + *$'\n'*|*$'\r'*) + echo "::error::rustflags must not contain line breaks" >&2 + exit 1 + ;; + esac - name: Install rust (explicit toolchain) if: ${{ inputs.toolchain != '' }} uses: actions-rust-lang/setup-rust-toolchain@9d7e65c320fdb52dcd45ffaa68deb6c02c8754d9 diff --git a/.github/actions/setup-rust/tests/setup_rust_test_helpers.py b/.github/actions/setup-rust/tests/setup_rust_test_helpers.py new file mode 100644 index 00000000..9434e1ac --- /dev/null +++ b/.github/actions/setup-rust/tests/setup_rust_test_helpers.py @@ -0,0 +1,33 @@ +"""Shared helpers for the setup-rust manifest tests.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +import yaml + +ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml" + + +def load_steps() -> list[dict[str, object]]: + """Load the composite action steps from the setup-rust manifest.""" + manifest = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) + return manifest["runs"]["steps"] + + +def get_step(step_name: str) -> dict[str, object]: + """Return a named composite action step, failing clearly if it is absent.""" + steps = load_steps() + step = next((step for step in steps if step.get("name") == step_name), None) + assert step is not None, f"Missing setup-rust step: {step_name}" # noqa: S101 + return step + + +def requires_bash() -> str: + """Return a usable bash path or skip shell-fragment tests.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not found on PATH") + return bash diff --git a/.github/actions/setup-rust/tests/test_rustflags_input.py b/.github/actions/setup-rust/tests/test_rustflags_input.py new file mode 100644 index 00000000..cfb02d4f --- /dev/null +++ b/.github/actions/setup-rust/tests/test_rustflags_input.py @@ -0,0 +1,164 @@ +"""Tests for the setup-rust rustflags input and its validation. + +The input is forwarded to a pinned third-party toolchain action that writes it +to ``GITHUB_ENV`` as a plain assignment, so these cover both the forwarding and +the guard that stops a line break turning into further environment entries. +""" + +from __future__ import annotations + +import os +import subprocess +import typing as typ + +import pytest +import yaml +from setup_rust_test_helpers import ACTION_PATH, get_step, load_steps, requires_bash + +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from pathlib import Path + + +def test_rustflags_input_defaults_to_deny_warnings() -> None: + """The rustflags input must exist and keep the historical default.""" + manifest = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) + rustflags_input = manifest["inputs"]["rustflags"] + assert rustflags_input.get("required", False) is False, ( + "rustflags must stay optional so existing callers need no change" + ) + assert rustflags_input.get("default") == "-D warnings", ( + "the default must preserve the historical -D warnings behaviour; " + f"got {rustflags_input.get('default')!r}" + ) + + +@pytest.mark.parametrize( + "step_name", + [ + "Install rust (explicit toolchain)", + "Install rust (rust-toolchain file)", + "Install rust (stable default)", + ], +) +def test_install_steps_forward_rustflags(step_name: str) -> None: + """Every toolchain install step must forward the rustflags input.""" + step = get_step(step_name) + with_block = step.get("with") + assert isinstance(with_block, dict), f"Step has no with block: {step_name}" + assert with_block.get("rustflags") == "${{ inputs.rustflags }}", ( + f"{step_name} must forward the rustflags input to setup-rust-toolchain, " + f"otherwise it re-exports the -D warnings default; got " + f"{with_block.get('rustflags')!r}" + ) + + +def _validate_rustflags_run_script() -> str: + """Return the rustflags validation step's shell script.""" + run_script = get_step("Validate rustflags").get("run") + assert isinstance(run_script, str), "Validate rustflags step has no run script" + return run_script + + +def _run_validate_rustflags( + tmp_path: Path, rustflags: str +) -> subprocess.CompletedProcess[str]: + """Run the rustflags validation fragment against a candidate value.""" + bash = requires_bash() + return subprocess.run( # noqa: S603,TID251 - exercise the bash fragment. + [bash, "-c", _validate_rustflags_run_script()], + cwd=tmp_path, + env={**os.environ, "SR_RUSTFLAGS": rustflags}, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + +def test_validate_rustflags_precedes_the_install_steps() -> None: + """Validation must run before any step forwards the value.""" + names = [step.get("name") for step in load_steps()] + assert names.index("Validate rustflags") < names.index( + "Install rust (explicit toolchain)" + ), f"validation must precede the install steps; order was {names}" + + +@pytest.mark.parametrize( + "rustflags", + ["-D warnings", "-D warnings -C debuginfo=0", ""], + ids=["default", "extra-flag", "empty"], +) +def test_validate_rustflags_accepts_single_line_values( + tmp_path: Path, rustflags: str +) -> None: + """Ordinary single-line values pass validation untouched.""" + result = _run_validate_rustflags(tmp_path, rustflags) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "separator", + ["\n", "\r\n", "\r"], + ids=["lf", "crlf", "cr"], +) +def test_validate_rustflags_rejects_line_breaks(tmp_path: Path, separator: str) -> None: + """A line break is rejected before it can reach the nested action. + + The pinned setup-rust-toolchain writes the value as a plain + ``RUSTFLAGS=`` line, so a line break would append further + environment-file entries and set variables the caller never asked for. + """ + payload = f"-D warnings{separator}SR_INJECTED=1" + result = _run_validate_rustflags(tmp_path, payload) + + assert result.returncode != 0, ( + f"a line break must fail the step; wrote {result.stdout!r}" + ) + assert "must not contain line breaks" in result.stderr, ( + f"expected the rejection diagnostic; got {result.stderr!r}" + ) + assert "SR_INJECTED" not in result.stderr, ( + f"the rejected value must not be echoed; got {result.stderr!r}" + ) + + +def test_injected_rustflags_cannot_reach_the_environment_file(tmp_path: Path) -> None: + """Validation stops the payload the nested action would have written. + + This pins the mitigation to the sink it protects: the nested action's + ``echo "RUSTFLAGS=$NEW_RUSTFLAGS" >> $GITHUB_ENV`` would turn the second + line into its own entry, so the guard must reject the value first. + """ + payload = "-D warnings\nSR_INJECTED=1" + github_env = tmp_path / "github-env" + github_env.write_text("", encoding="utf-8") + + guard = _run_validate_rustflags(tmp_path, payload) + assert guard.returncode != 0, "the guard must reject the payload" + + # Show what the guard prevents: the nested action's write, run on the same + # payload, does create a second entry. + bash = requires_bash() + subprocess.run( # noqa: S603,TID251 - reproduce the nested action's sink. + [bash, "-c", 'echo "RUSTFLAGS=$NEW_RUSTFLAGS" >> "$GITHUB_ENV"'], + cwd=tmp_path, + env={ + **os.environ, + "NEW_RUSTFLAGS": payload, + "GITHUB_ENV": github_env.as_posix(), + }, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + entries = [ + line.split("=", 1)[0] + for line in github_env.read_text(encoding="utf-8").splitlines() + if "=" in line + ] + assert entries == ["RUSTFLAGS", "SR_INJECTED"], ( + "the sink is expected to be injectable, which is why the guard exists; " + f"got {entries}" + ) diff --git a/.github/actions/setup-rust/tests/test_setup_rust_manifest.py b/.github/actions/setup-rust/tests/test_setup_rust_manifest.py index db9e4cda..4491ff2e 100644 --- a/.github/actions/setup-rust/tests/test_setup_rust_manifest.py +++ b/.github/actions/setup-rust/tests/test_setup_rust_manifest.py @@ -375,36 +375,3 @@ def test_install_binstall_script_does_not_duplicate_path_entry( assert entries.count(cargo_home_bin) == 1, ( f"Expected {cargo_home_bin!r} to appear exactly once; got: {resulting_path!r}" ) - - -def test_rustflags_input_defaults_to_deny_warnings() -> None: - """The rustflags input must exist and keep the historical default.""" - manifest = yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8")) - rustflags_input = manifest["inputs"]["rustflags"] - assert rustflags_input.get("required", False) is False, ( - "rustflags must stay optional so existing callers need no change" - ) - assert rustflags_input.get("default") == "-D warnings", ( - "the default must preserve the historical -D warnings behaviour; " - f"got {rustflags_input.get('default')!r}" - ) - - -@pytest.mark.parametrize( - "step_name", - [ - "Install rust (explicit toolchain)", - "Install rust (rust-toolchain file)", - "Install rust (stable default)", - ], -) -def test_install_steps_forward_rustflags(step_name: str) -> None: - """Every toolchain install step must forward the rustflags input.""" - step = _get_step(step_name) - with_block = step.get("with") - assert isinstance(with_block, dict), f"Step has no with block: {step_name}" - assert with_block.get("rustflags") == "${{ inputs.rustflags }}", ( - f"{step_name} must forward the rustflags input to setup-rust-toolchain, " - f"otherwise it re-exports the -D warnings default; got " - f"{with_block.get('rustflags')!r}" - ) From 7e26f6109faa61396fa9b2dff53ccfda91deaab3 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 15:24:01 +0200 Subject: [PATCH 25/26] Harden the rustflags test workflow Pin the checkouts to the SHA the rest of the repository uses, drop their credentials, restrict the token to contents: read, and cancel superseded runs. The workflow was the only one of eighteen with no permissions block at all. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-rustflags-export.yml | 24 ++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-rustflags-export.yml b/.github/workflows/test-rustflags-export.yml index 09d65837..dcb6606a 100644 --- a/.github/workflows/test-rustflags-export.yml +++ b/.github/workflows/test-rustflags-export.yml @@ -10,12 +10,21 @@ on: release: types: [published] +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # The caller's value reaches a step that runs after the action. rust-build-release-exports: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Build with caller rustflags uses: ./.github/actions/rust-build-release with: @@ -33,7 +42,9 @@ jobs: env: RUSTFLAGS: -D warnings steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Build with a conflicting rustflags input uses: ./.github/actions/rust-build-release with: @@ -51,7 +62,9 @@ jobs: setup-rust-exports: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup Rust with caller rustflags uses: ./.github/actions/setup-rust with: @@ -68,7 +81,9 @@ jobs: env: RUSTFLAGS: -D warnings steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup Rust with a conflicting rustflags input uses: ./.github/actions/setup-rust with: @@ -79,4 +94,3 @@ jobs: - name: Observe RUSTFLAGS alongside an inherited value shell: bash run: echo "setup_rust_inherited_rustflags=[${RUSTFLAGS-unset}]" - From ceb3d499f760c1c6f6163df53da67d424257b6cc Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 2 Aug 2026 15:24:01 +0200 Subject: [PATCH 26/26] Explain the packaging helper re-export and tidy a clause Co-Authored-By: Claude Opus 5 (1M context) --- .../actions/rust-build-release/tests/_packaging_utils.py | 8 ++++++++ docs/developers-guide.md | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/actions/rust-build-release/tests/_packaging_utils.py b/.github/actions/rust-build-release/tests/_packaging_utils.py index 1c9806f3..2a19d015 100644 --- a/.github/actions/rust-build-release/tests/_packaging_utils.py +++ b/.github/actions/rust-build-release/tests/_packaging_utils.py @@ -1,3 +1,11 @@ +"""Re-export the linux-packages packaging test helpers. + +The packaging fixtures live with the linux-packages tests, but the +rust-build-release suite needs the same sample project and artefact builders. +The tests directories are not packages, so the module is loaded by path and its +public names are rebound here rather than imported. +""" + from __future__ import annotations import importlib.util as _ilus diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 4f182c59..cbef68ce 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -517,7 +517,7 @@ this variable set". #### `GITHUB_ENV` heredoc safety The step writes `RUSTFLAGS` to `GITHUB_ENV` as a heredoc rather than a plain -assignment, because the value may contain newlines. The delimiter is derived +assignment because the value may contain newlines. The delimiter is derived from 16 random bytes (`od -An -N16 -tx1 /dev/urandom`) and checked against the value with `grep -qxF` before use. If a value contained the delimiter on a line of its own, that line would close the heredoc block early, and