diff --git a/.github/workflows/mutation-cargo.yml b/.github/workflows/mutation-cargo.yml index 4935b7c2..3d77f229 100644 --- a/.github/workflows/mutation-cargo.yml +++ b/.github/workflows/mutation-cargo.yml @@ -247,6 +247,30 @@ jobs: cache-dependency-glob: | **/workflow_scripts/*.py + - name: Expose libpython for PyO3 test binaries + if: ${{ env.ACT != 'true' }} + shell: bash + run: | + set -euo pipefail + # PyO3 embedding crates (the auto-initialize pattern) link their + # test binaries against the interpreter PyO3's build script resolves + # from PATH. "Setup uv" places a uv-managed CPython ahead of the + # system python3, and its libpython lives under uv's private install + # directory, which is not on the dynamic loader's default search + # path. The baseline `cargo test` binary then aborts at run time with + # "libpython3.NN.so.1.0: cannot open shared object file" and no mutant + # is ever scored (issue #372). Add the resolved interpreter's LIBDIR + # to LD_LIBRARY_PATH for every later step. This is harmless for + # non-PyO3 callers: no test binary links libpython, so nothing loads + # from the added directory. + libdir="$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR") or "")')" + if [[ -z "${libdir}" || ! -d "${libdir}" ]]; then + echo "No Python LIBDIR resolved (${libdir:-empty}); leaving LD_LIBRARY_PATH unchanged" >&2 + exit 0 + fi + echo "Adding ${libdir} to LD_LIBRARY_PATH for PyO3 test binaries" + echo "LD_LIBRARY_PATH=${libdir}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" >> "${GITHUB_ENV}" + - name: Relocate workflow source # Move the workflow checkout out of the caller's workspace so # tree-scanning tests in the caller's suite (manifest sweeps, diff --git a/docs/mutation-cargo-workflow.md b/docs/mutation-cargo-workflow.md index 56473dcb..229b25c1 100644 --- a/docs/mutation-cargo-workflow.md +++ b/docs/mutation-cargo-workflow.md @@ -99,6 +99,11 @@ jobs: - GitHub disables cron triggers after 60 days of repository inactivity; a quiet repository silently stops running, which is an acceptable failure mode for an informational workflow. +- Callers with PyO3 embedding crates (the `auto-initialize` pattern) + are handled automatically: the workflow adds the uv-provisioned + interpreter's `LIBDIR` to `LD_LIBRARY_PATH` before running + cargo-mutants, so test binaries linked against `libpython` load it at + run time. No `setup-commands` workaround is needed. ## Local validation diff --git a/workflow_scripts/tests/test_mutation_workflow_shape.py b/workflow_scripts/tests/test_mutation_workflow_shape.py index 78618042..23e9d54f 100644 --- a/workflow_scripts/tests/test_mutation_workflow_shape.py +++ b/workflow_scripts/tests/test_mutation_workflow_shape.py @@ -55,6 +55,38 @@ def _step_names(steps: list[dict[str, object]]) -> list[object]: return [step.get("name") for step in steps] +def test_mutants_job_exposes_libpython_before_running() -> None: + """The mutants job puts libpython on LD_LIBRARY_PATH before cargo-mutants. + + PyO3 embedding crates link their test binaries against the uv-managed + interpreter that "Setup uv" places on PATH, whose libpython is not on the + loader's default search path. Without exporting its LIBDIR onto + LD_LIBRARY_PATH the baseline `cargo test` binary aborts at run time and no + mutant is scored (issue #372). The export must land before the + "Run mutation testing" step that builds and runs those binaries. + """ + jobs = _jobs("mutation-cargo.yml") + assert "mutants" in jobs, "mutation-cargo.yml must declare a mutants job" + steps = _steps(jobs["mutants"]) + names = _step_names(steps) + exposers = [ + index + for index, step in enumerate(steps) + if isinstance(step.get("run"), str) + and "LD_LIBRARY_PATH=" in typ.cast("str", step.get("run")) + and 'get_config_var("LIBDIR")' in typ.cast("str", step.get("run")) + ] + assert exposers, ( + "mutation-cargo.yml mutants job must export the interpreter LIBDIR " + "onto LD_LIBRARY_PATH for PyO3 test binaries (issue #372)" + ) + assert "Run mutation testing" in names, "mutants job must run cargo-mutants" + run_index = names.index("Run mutation testing") + assert min(exposers) < run_index, ( + "the LD_LIBRARY_PATH export must precede the Run mutation testing step" + ) + + @pytest.mark.parametrize("workflow_name", WORKFLOW_NAMES) def test_every_workflow_checkout_is_followed_by_relocation( workflow_name: str,