Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6c8b6ad
Add rustflags passthrough to setup-rust and rust-build-release
leynos Jul 29, 2026
79055ba
Format Python blocks in Markdown for ruff 0.16
Jul 29, 2026
645d4c1
Locate release-to-pypi-uv test scripts from the test file
Jul 29, 2026
74b43ab
Generate a collision-safe RUSTFLAGS heredoc delimiter
leynos Jul 29, 2026
198bfec
Extract heredoc parsing from the env-file test helper
leynos Jul 29, 2026
217cc06
Stop tracking the coverage data file
leynos Jul 31, 2026
6e7e05b
Guard inherited RUSTFLAGS without bash 4.2
leynos Jul 31, 2026
ade1c79
Add failure messages to the rustflags manifest tests
leynos Jul 31, 2026
037d22c
Punctuate the rustflags description
leynos Jul 31, 2026
8d3a70d
Stop rejecting module globs for a segment named py
leynos Jul 31, 2026
cb2c023
Use the bare conditional for the RUSTFLAGS guard
leynos Jul 31, 2026
28b72a2
Check RUSTFLAGS delimiter safety as a property
leynos Jul 31, 2026
3d037d0
Document the RUSTFLAGS export behaviour
leynos Jul 31, 2026
50f5f30
Cover the RUSTFLAGS delimiter collision branches
leynos Aug 1, 2026
80b01ae
Explain the module glob comparison on failure
leynos Aug 1, 2026
35d4919
Add a users' guide for the rustflags inputs
leynos Aug 1, 2026
7bea3ec
Split the RUSTFLAGS export tests from the manifest tests
leynos Aug 2, 2026
b9ec77d
Report RUSTFLAGS export progress and retries
leynos Aug 2, 2026
080823f
Describe the whole manifest module in its docstring
leynos Aug 2, 2026
48befa0
Document the RUSTFLAGS export for maintainers
leynos Aug 2, 2026
baca0e2
Exercise the rustflags export through act
leynos Aug 2, 2026
3aad758
Parametrize the inherited RUSTFLAGS example tests
leynos Aug 2, 2026
07e4440
Exercise the local setup-rust through act
leynos Aug 2, 2026
4254b01
Reject line breaks in the setup-rust rustflags input
leynos Aug 2, 2026
7e26f61
Harden the rustflags test workflow
leynos Aug 2, 2026
ceb3d49
Explain the packaging helper re-export and tidy a clause
leynos Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .coverage
Binary file not shown.
2 changes: 2 additions & 0 deletions .github/actions/linux-packages/scripts/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 7 additions & 16 deletions .github/actions/release-to-pypi-uv/tests/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,20 @@
from __future__ import annotations

import importlib.util
import os
import sys
import typing as typ
from pathlib import Path

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:
Expand Down
6 changes: 6 additions & 0 deletions .github/actions/rust-build-release/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions .github/actions/rust-build-release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,21 @@ 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
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/<target>/release/<bin>.1`, then falls
back to Cargo `OUT_DIR` output from `build.rs`. Set
Expand Down
58 changes: 58 additions & 0 deletions .github/actions/rust-build-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -56,6 +68,52 @@ 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
# ${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 [[ ${RUSTFLAGS+x} ]]; then
echo "RUSTFLAGS already set; leaving the inherited value in place" >&2
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.
# 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=""
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 after $attempts attempts" >&2
exit 1
fi
{
echo "RUSTFLAGS<<$delimiter"
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
106 changes: 80 additions & 26 deletions .github/actions/rust-build-release/tests/test_manifest_input_step.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
"""Tests for manifest-path input wiring in the composite action."""
"""Tests for the inputs and steps declared in the composite action manifest.

from __future__ import annotations

from pathlib import Path

import yaml
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.

ACTION_PATH = Path(__file__).resolve().parents[1] / "action.yml"


def _load_action_manifest() -> dict[str, object]:
return yaml.safe_load(ACTION_PATH.read_text(encoding="utf-8"))
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

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)
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"]
Expand All @@ -33,7 +28,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"]
Expand All @@ -43,7 +38,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"]
Expand All @@ -54,19 +49,19 @@ 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 }}"


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)
Expand All @@ -76,9 +71,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 (
Expand All @@ -91,3 +86,62 @@ 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, f"rustflags input missing; declared: {sorted(inputs)}"
rustflags_input = 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") == "", (
"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:
"""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 != ''", (
"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), "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), "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 "if [[ ${RUSTFLAGS+x} ]]; then" in run_script, (
"the inherited-value guard must use the bash 3.2 compatible "
"${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"
)
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_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"
), (
"the export must precede toolchain setup, whose nested step only "
f"defers to an already-set RUSTFLAGS; step order was {names}"
)
Loading
Loading