From 862e80152e481eda55bb41ea2682dd9d25c79ace Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 12:01:22 +0200 Subject: [PATCH 1/2] Add scheduled mutation testing to generated repositories Generated projects now ship a thin mutation-testing caller workflow pinned to the shared reusable workflows: mutmut for the Python package and, when the Rust extension is enabled, cargo-mutants for the rust_extension crate. The templated pyproject gains the matching [tool.mutmut] configuration, with a note on also_copy for tests that read repository-root paths. --- .../workflows/mutation-testing.yml.jinja | 57 +++++++++++++++++++ template/pyproject.toml.jinja | 8 +++ 2 files changed, 65 insertions(+) create mode 100644 template/.github/workflows/mutation-testing.yml.jinja diff --git a/template/.github/workflows/mutation-testing.yml.jinja b/template/.github/workflows/mutation-testing.yml.jinja new file mode 100644 index 0000000..90513f3 --- /dev/null +++ b/template/.github/workflows/mutation-testing.yml.jinja @@ -0,0 +1,57 @@ +name: Mutation testing + +# Thin caller of the shared mutation-testing reusable workflow; caller +# guide: leynos/shared-actions docs/mutation-mutmut-workflow.md. +# Scheduled runs mutate only files changed within the detection window; +# manual dispatch runs mutate everything. To test a branch, select it in +# the Actions "Run workflow" control. + +on: + schedule: + # Stagger this cron per repository so estate runs do not bunch up; + # existing repositories occupy the 03:05-09:05 UTC band. + - cron: "30 9 * * *" # Daily, 09:30 UTC + workflow_dispatch: + +# Default the token to no scopes; jobs opt in explicitly. +permissions: {} + +# Serialize runs per ref: a dispatch during a scheduled run (or vice +# versa) queues rather than racing. Runs are informational, so queued +# runs wait instead of cancelling. +concurrency: + group: mutation-testing-${{ "{{" }} github.ref {{ "}}" }} + cancel-in-progress: false + +jobs: + mutation: + permissions: + contents: read + id-token: write # OIDC workflow-source resolution + uses: leynos/shared-actions/.github/workflows/mutation-mutmut.yml@2b09d10192627fd6e1034e7c12625dd266b45503 + with: + # Flat package layout: the package directory sits at the + # repository root, so no module prefix is stripped. + paths: "{{ package_name }}/" + module-prefix-strip: "" + python-version: "{{ python_version }}" + {% if use_rust %} + + mutation-rust: + # Caller guide: leynos/shared-actions docs/mutation-cargo-workflow.md. + permissions: + contents: read + id-token: write # OIDC workflow-source resolution + uses: leynos/shared-actions/.github/workflows/mutation-cargo.yml@2b09d10192627fd6e1034e7c12625dd266b45503 + with: + # The extension crate is the only Cargo target and lives outside + # the repository root, so it is declared as an extra crate and the + # root path list is emptied. + paths: "" + extra-crate-dirs: "rust_extension" + # The shared workflow always fans the root target out on manual + # dispatch, and this repository has no root crate, so that leg + # fails; keep it to a single shard. Scheduled scoped runs and the + # rust_extension target are unaffected. + shard-count: 1 + {% endif %} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 9f2fddf..72c1ce8 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -294,6 +294,14 @@ enable = [ "too-many-statements", ] +[tool.mutmut] +# mutmut copies the tree before running the suite: tests that read +# repository-root paths outside tests/ must either add those paths to +# `also_copy` or guard themselves with a skipif. See the shared-actions +# caller guide (docs/mutation-mutmut-workflow.md). +source_paths = ["{{ package_name }}/"] +pytest_add_cli_args_test_selection = ["tests/"] + [tool.pytest.ini_options] # Tests automatically killed after seconds elapsed timeout = 30 From 3a34cba51d65cc1cf54daccb941170e863c30279 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 12:45:55 +0200 Subject: [PATCH 2/2] Gate mutmut on a 3.13 baseline and bump the default to 3.12 Review found that the caller passed the template's default python-version (3.10) into the shared mutmut workflow, whose helper scripts require 3.13 or greater: setup-uv exported UV_PYTHON job-wide and change detection failed before any mutation testing ran. Address this per maintainer direction: - bump copier.yml's python_version default from 3.10 to 3.12 (the estate baselines libraries for external consumption at 3.12), and update everything derived from the default (test helpers and the requires-python contract assertion); - add a hidden computed mutmut_supported answer (baseline minor >= 13, numeric comparison) and render the mutmut job and the [tool.mutmut] pyproject section only when it holds; - keep the cargo-mutants job gated on use_rust independently, and use a conditional filename so the workflow file is not generated at all when both gates are off; - bump the shared workflow pin to 859416a90eb3987b46a57682c5d6b8964ad3f0a6, which fails fast on sub-3.13 python-version values; - add contract tests covering the four gating combinations. The mutmut job keeps python-version set to the project's baseline, which is now 3.13 or greater by construction whenever the job exists. --- copier.yml | 9 +- ...t %}mutation-testing.yml{% endif %}.jinja} | 19 ++-- template/pyproject.toml.jinja | 3 + tests/helpers/pyproject_contracts.py | 2 +- tests/helpers/rendering.py | 4 +- tests/test_template.py | 96 +++++++++++++++++++ 6 files changed, 122 insertions(+), 11 deletions(-) rename template/.github/workflows/{mutation-testing.yml.jinja => {% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja} (79%) diff --git a/copier.yml b/copier.yml index 65a53e7..988fd12 100644 --- a/copier.yml +++ b/copier.yml @@ -18,5 +18,12 @@ use_rust: python_version: type: str - default: "3.10" + default: "3.12" help: Minimum supported Python version + +# Computed: mutmut mutation testing needs a base interpreter of 3.13 or +# greater because the shared workflow's helper scripts require >= 3.13. +mutmut_supported: + type: bool + default: "{{ python_version.split('.')[1] | int >= 13 }}" + when: false diff --git a/template/.github/workflows/mutation-testing.yml.jinja b/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja similarity index 79% rename from template/.github/workflows/mutation-testing.yml.jinja rename to template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja index 90513f3..e2de1f6 100644 --- a/template/.github/workflows/mutation-testing.yml.jinja +++ b/template/.github/workflows/{% if mutmut_supported or use_rust %}mutation-testing.yml{% endif %}.jinja @@ -1,7 +1,8 @@ name: Mutation testing -# Thin caller of the shared mutation-testing reusable workflow; caller -# guide: leynos/shared-actions docs/mutation-mutmut-workflow.md. +# Thin caller of the shared mutation-testing reusable workflows; caller +# guides: leynos/shared-actions docs/mutation-mutmut-workflow.md and +# docs/mutation-cargo-workflow.md. # Scheduled runs mutate only files changed within the detection window; # manual dispatch runs mutate everything. To test a branch, select it in # the Actions "Run workflow" control. @@ -24,25 +25,29 @@ concurrency: cancel-in-progress: false jobs: +{%- if mutmut_supported %} mutation: permissions: contents: read id-token: write # OIDC workflow-source resolution - uses: leynos/shared-actions/.github/workflows/mutation-mutmut.yml@2b09d10192627fd6e1034e7c12625dd266b45503 + uses: leynos/shared-actions/.github/workflows/mutation-mutmut.yml@859416a90eb3987b46a57682c5d6b8964ad3f0a6 with: # Flat package layout: the package directory sits at the # repository root, so no module prefix is stripped. paths: "{{ package_name }}/" module-prefix-strip: "" + # Always >= 3.13 here: this job renders only when the project's + # baseline interpreter satisfies the shared workflow's helper + # scripts. python-version: "{{ python_version }}" - {% if use_rust %} +{%- endif %} +{%- if use_rust %} mutation-rust: - # Caller guide: leynos/shared-actions docs/mutation-cargo-workflow.md. permissions: contents: read id-token: write # OIDC workflow-source resolution - uses: leynos/shared-actions/.github/workflows/mutation-cargo.yml@2b09d10192627fd6e1034e7c12625dd266b45503 + uses: leynos/shared-actions/.github/workflows/mutation-cargo.yml@859416a90eb3987b46a57682c5d6b8964ad3f0a6 with: # The extension crate is the only Cargo target and lives outside # the repository root, so it is declared as an extra crate and the @@ -54,4 +59,4 @@ jobs: # fails; keep it to a single shard. Scheduled scoped runs and the # rust_extension target are unaffected. shard-count: 1 - {% endif %} +{%- endif %} diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 72c1ce8..e3510ff 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -294,6 +294,8 @@ enable = [ "too-many-statements", ] +{%- if mutmut_supported %} + [tool.mutmut] # mutmut copies the tree before running the suite: tests that read # repository-root paths outside tests/ must either add those paths to @@ -301,6 +303,7 @@ enable = [ # caller guide (docs/mutation-mutmut-workflow.md). source_paths = ["{{ package_name }}/"] pytest_add_cli_args_test_selection = ["tests/"] +{%- endif %} [tool.pytest.ini_options] # Tests automatically killed after seconds elapsed diff --git a/tests/helpers/pyproject_contracts.py b/tests/helpers/pyproject_contracts.py index 4425daf..1e37b8a 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -20,7 +20,7 @@ def _assert_pyproject_contracts( """Assert generated Python packaging contracts.""" project = require_mapping(pyproject, "project", "pyproject.toml") assert project.get("name"), "expected generated project metadata to include a name" - assert project.get("requires-python") == ">=3.10", ( + assert project.get("requires-python") == ">=3.12", ( "expected generated pyproject.toml to use the requested Python version" ) dependency_groups = require_mapping( diff --git a/tests/helpers/rendering.py b/tests/helpers/rendering.py index 2409931..169abe0 100644 --- a/tests/helpers/rendering.py +++ b/tests/helpers/rendering.py @@ -49,7 +49,7 @@ def render_project( project_name: str, package_name: str, use_rust: bool = False, - python_version: str = "3.10", + python_version: str = "3.12", ) -> CopierProject: """Render a generated Python project with explicit template answers. @@ -65,7 +65,7 @@ def render_project( Python import package name answer passed to Copier. use_rust : bool, default=False Whether to include the optional PyO3 extension. - python_version : str, default="3.10" + python_version : str, default="3.12" Minimum supported Python version answer passed to Copier. Returns diff --git a/tests/test_template.py b/tests/test_template.py index a266f11..ad28e96 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -24,7 +24,9 @@ from tests.helpers.generated_files import ( parse_toml_file, + parse_yaml_mapping, read_generated_text, + require_mapping, ) from tests.helpers.rendering import ( check_generated_import, @@ -494,3 +496,97 @@ def test_generated_github_workflows_match_act_validation_contract( package_name=package_name, use_rust=use_rust, ) + + +MUTATION_WORKFLOW_PIN = "859416a90eb3987b46a57682c5d6b8964ad3f0a6" + + +@pytest.mark.parametrize( + ("target_dir", "use_rust", "python_version", "expect_mutmut"), + [ + ("mutation-312-pure", False, "3.12", False), + ("mutation-312-rust", True, "3.12", False), + ("mutation-313-pure", False, "3.13", True), + ("mutation-314-rust", True, "3.14", True), + ], +) +def test_generated_mutation_testing_gating( + copier: CopierFixture, + tmp_path: Path, + target_dir: str, + use_rust: bool, + python_version: str, + expect_mutmut: bool, +) -> None: + """Rendered mutation testing follows the interpreter and Rust gates. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path + Temporary directory where the rendered project is created. + target_dir + Temporary project directory name for the rendered variant. + use_rust + Whether the rendered variant includes the optional Rust extension. + python_version + Minimum supported Python version answer passed to Copier. + expect_mutmut + Whether the baseline interpreter supports the mutmut workflow + (3.13 or greater). + + Returns + ------- + None + The test passes when the mutmut job and ``[tool.mutmut]`` section + render only for baselines of 3.13 or greater, the cargo-mutants job + renders only with the Rust extension, and the workflow file is + absent when both gates are off. + """ + project = copier.copy( + tmp_path / target_dir, + project_name="MutationProj", + package_name="mutation_pkg", + use_rust=use_rust, + python_version=python_version, + ) + pyproject = parse_toml_file(project / "pyproject.toml") + mutmut_config = pyproject.get("tool", {}).get("mutmut") + if expect_mutmut: + assert mutmut_config == { + "source_paths": ["mutation_pkg/"], + "pytest_add_cli_args_test_selection": ["tests/"], + }, "expected mutmut configuration for baselines of 3.13 or greater" + else: + assert mutmut_config is None, ( + "expected no mutmut configuration below a 3.13 baseline" + ) + + workflow_path = project / ".github" / "workflows" / "mutation-testing.yml" + if not expect_mutmut and not use_rust: + assert not workflow_path.exists(), ( + "expected no mutation workflow when both gates are off" + ) + return + workflow = parse_yaml_mapping( + read_generated_text(workflow_path), "mutation workflow" + ) + jobs = require_mapping(workflow, "jobs", "mutation workflow") + assert ("mutation" in jobs) == expect_mutmut, ( + "expected the mutmut job only for baselines of 3.13 or greater" + ) + assert ("mutation-rust" in jobs) == use_rust, ( + "expected the cargo-mutants job only for Rust variants" + ) + if expect_mutmut: + mutation_job = require_mapping(jobs, "mutation", "mutation workflow jobs") + mutation_inputs = require_mapping(mutation_job, "with", "mutation job") + assert mutation_inputs.get("python-version") == python_version, ( + "expected the mutmut job to run on the project's baseline Python" + ) + for job_name, job in jobs.items(): + uses = str(job.get("uses", "")) if isinstance(job, dict) else "" + assert uses.endswith(f"@{MUTATION_WORKFLOW_PIN}"), ( + f"expected {job_name} to pin the shared mutation workflow" + )