From 2e9e7f801d0f6adb75dd4df8743cd404fd62247e Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 12:01:22 +0200 Subject: [PATCH 01/10] 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 9c4ed4d..99012cc 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 ff64ce93e26a2a14695d9fbfc5585497c3a2340e Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 7 Jul 2026 12:45:55 +0200 Subject: [PATCH 02/10] 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 | 10 +- ...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 | 92 +++++++++++++++++++ 6 files changed, 119 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 7f861d9..f7f5c66 100644 --- a/copier.yml +++ b/copier.yml @@ -18,9 +18,17 @@ 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 + codescene_project_id: type: str default: "" 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 99012cc..061a844 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 adf603a..fe53362 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 aa77494..0e6931a 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, @@ -507,3 +509,93 @@ def test_generated_github_workflows_match_act_validation_contract( package_name=package_name, use_rust=use_rust, ) + +@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" + ) From f7829924fcbf56212dd08ea58fd0778a991fa3ce Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 13 Jul 2026 21:39:50 +0200 Subject: [PATCH 03/10] Harden mutation template review boundaries Validate the Python baseline before deriving mutation support so malformed answers fail at the Copier boundary. Keep rendering tests on the shared helper and document the generated scheduled mutation workflows for users and maintainers. --- copier.yml | 9 ++++++- docs/developers-guide.md | 8 +++++++ template/docs/developers-guide.md | 14 +++++++++++ template/docs/users-guide.md.jinja | 19 +++++++++++++++ tests/helpers/rendering.py | 10 ++++---- tests/test_template.py | 38 +++++++++++++++++++++++++++++- 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/copier.yml b/copier.yml index f7f5c66..0c8fc22 100644 --- a/copier.yml +++ b/copier.yml @@ -20,13 +20,20 @@ python_version: type: str default: "3.12" help: Minimum supported Python version + validator: >- + {% if not (python_version | regex_search('^3[.][0-9]+$')) %} + Python version must use the 3.X format (for example, 3.12). + {% endif %} # 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 }}" + default: >- + {{ python_version is string + and (python_version | regex_search('^3[.][0-9]+$')) + and (python_version.split('.')[1] | int >= 13) }} when: false codescene_project_id: diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f2db147..9bfac60 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -100,6 +100,14 @@ Docker availability, and runs `make test WITH_ACT=1`. Rust-enabled workflows pass `rust_extension/Cargo.toml` to the coverage action because the generated Python project root does not contain a Rust manifest. +The mutation-testing workflow template is rendered when either mutation engine +is available. Its mutmut job requires a minimum Python version of 3.13 or newer; +the hidden `mutmut_supported` Copier answer enforces that gate. The cargo-mutants +job is gated independently by `use_rust`, so a Rust-enabled project with a +Python 3.12 baseline still receives Rust mutation testing. Both jobs delegate to +the reusable workflows pinned at +`leynos/shared-actions@859416a90eb3987b46a57682c5d6b8964ad3f0a6`. + ### Workflow pins and Dependabot Dependabot owns the upgrade of GitHub Actions and reusable workflows, including diff --git a/template/docs/developers-guide.md b/template/docs/developers-guide.md index dadf5da..a4f7a47 100644 --- a/template/docs/developers-guide.md +++ b/template/docs/developers-guide.md @@ -62,6 +62,20 @@ actions under `.github/`. - `.github/workflows/get-codescene-sha.yml` is manually dispatched. It fetches the CodeScene coverage CLI installer, computes its SHA-256 digest, and writes the result to the `CODESCENE_CLI_SHA256` repository variable. +{% if mutmut_supported or use_rust %} +- `.github/workflows/mutation-testing.yml` runs daily at 09:30 UTC and supports + manual dispatch. It delegates to reusable workflows from `leynos/shared-actions`; + stagger the generated cron schedule before adopting it alongside other + repositories. +{% if mutmut_supported %} + The mutmut job is generated only when the minimum Python version is 3.13 or + newer, because the shared workflow helpers require Python 3.13 or newer. +{% endif %} +{% if use_rust %} + Rust-enabled projects also receive a cargo-mutants job for `rust_extension/`, + independently of the Python version baseline. +{% endif %} +{% endif %} - `.github/actions/build-wheels` wraps `cibuildwheel` with `uvx` and uploads architecture-specific wheel artefacts. - `.github/actions/pure-python-wheel` builds a pure Python wheel with diff --git a/template/docs/users-guide.md.jinja b/template/docs/users-guide.md.jinja index 3c7607a..b8e95bf 100644 --- a/template/docs/users-guide.md.jinja +++ b/template/docs/users-guide.md.jinja @@ -49,6 +49,25 @@ Dependabot pull requests; a weekly scheduled audit on the default branch is the compensating control. Rust-enabled projects also run `cargo audit` from the `rust_extension` crate directory. +{% if mutmut_supported or use_rust %} +## Scheduled Mutation Testing + +The `.github/workflows/mutation-testing.yml` workflow runs mutation testing +daily at 09:30 UTC and can also be started manually from GitHub Actions. Adjust +the generated cron schedule to stagger it against other repositories. + +{% if mutmut_supported %} +For projects whose minimum Python version is 3.13 or newer, the workflow runs +mutmut against `{{ package_name }}/`. The Python mutation job is omitted for +older baselines because the shared workflow helpers require Python 3.13 or +newer. +{% endif %} +{% if use_rust %} +Rust-enabled projects also run cargo-mutants against the crate in +`rust_extension/`, independently of the Python version baseline. +{% endif %} +{% endif %} + ## Rust Test Behaviour Rust-enabled projects use `cargo nextest run` when `cargo-nextest` is available. diff --git a/tests/helpers/rendering.py b/tests/helpers/rendering.py index 169abe0..a6ac6a3 100644 --- a/tests/helpers/rendering.py +++ b/tests/helpers/rendering.py @@ -43,7 +43,7 @@ def run_quality_gates(project: CopierProject) -> None: def render_project( - tmp_path: Path, + destination: Path, copier: CopierFixture, *, project_name: str, @@ -55,8 +55,10 @@ def render_project( Parameters ---------- - tmp_path : pathlib.Path - Temporary directory used as the generated project destination. + destination : pathlib.Path + Destination directory for the generated project. Callers may pass a + subdirectory of their pytest temporary directory to distinguish + rendered variants. copier : CopierFixture ``pytest-copier`` fixture bound to this template repository. project_name : str @@ -79,7 +81,7 @@ def render_project( Propagates rendering failures raised by Copier or ``pytest-copier``. """ return copier.copy( - tmp_path, + destination, project_name=project_name, package_name=package_name, use_rust=use_rust, diff --git a/tests/test_template.py b/tests/test_template.py index 0e6931a..df34823 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -553,8 +553,9 @@ def test_generated_mutation_testing_gating( renders only with the Rust extension, and the workflow file is absent when both gates are off. """ - project = copier.copy( + project = render_project( tmp_path / target_dir, + copier, project_name="MutationProj", package_name="mutation_pkg", use_rust=use_rust, @@ -599,3 +600,38 @@ def test_generated_mutation_testing_gating( assert uses.endswith(f"@{MUTATION_WORKFLOW_PIN}"), ( f"expected {job_name} to pin the shared mutation workflow" ) + +@pytest.mark.parametrize("python_version", ["3", "three.13", "3.13.1", "4.0"]) +def test_python_version_rejects_unexpected_formats( + copier: CopierFixture, + tmp_path: Path, + python_version: str, +) -> None: + """Reject malformed baseline versions at the Copier answer boundary. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path + Temporary directory where the rendered project would be created. + python_version + Invalid version answer supplied to Copier. + + Returns + ------- + None + The test passes when Copier reports the version-format validation + error before evaluating dependent template answers. + """ + with pytest.raises( + ValueError, + match=r"Python version must use the 3\.X format", + ): + render_project( + tmp_path / "invalid-python-version", + copier, + project_name="InvalidVersion", + package_name="invalid_version", + python_version=python_version, + ) From 91b6bc972512e0219a42ebc53992defbb6b1f9cf Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 13 Jul 2026 23:58:29 +0200 Subject: [PATCH 04/10] Repair mutation branch after main rebase Restore the shared mutation workflow pin constant used by the gating test after replaying the branch onto main. Refresh generated `typos.toml` from the current shared en-GB-oxendict dictionary so the committed config contract stays in sync with the generator. --- tests/test_template.py | 4 ++++ typos.toml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/tests/test_template.py b/tests/test_template.py index df34823..0783a93 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -40,6 +40,8 @@ assert_generated_tooling_contracts, ) +MUTATION_WORKFLOW_PIN = "859416a90eb3987b46a57682c5d6b8964ad3f0a6" + def test_python_only_help_output_snapshot( copier: CopierFixture, tmp_path: Path, snapshot: SnapshotAssertion @@ -510,6 +512,7 @@ def test_generated_github_workflows_match_act_validation_contract( use_rust=use_rust, ) + @pytest.mark.parametrize( ("target_dir", "use_rust", "python_version", "expect_mutmut"), [ @@ -601,6 +604,7 @@ def test_generated_mutation_testing_gating( f"expected {job_name} to pin the shared mutation workflow" ) + @pytest.mark.parametrize("python_version", ["3", "three.13", "3.13.1", "4.0"]) def test_python_version_rejects_unexpected_formats( copier: CopierFixture, diff --git a/typos.toml b/typos.toml index d337ea4..106664a 100644 --- a/typos.toml +++ b/typos.toml @@ -148,6 +148,8 @@ extend-ignore-re = [ "apologizers" = "apologizers" "apologizes" = "apologizes" "apologizing" = "apologizing" +"artifact" = "artifact" +"artifacts" = "artifacts" "atomisable" = "atomizable" "atomisation" = "atomization" "atomisations" = "atomizations" From 10e20e27045996e6d902467998404fe01e0481f6 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Jul 2026 23:52:09 +0200 Subject: [PATCH 05/10] Decouple action pins from contracts and documentation Keep GitHub Actions revisions owned solely by Dependabot in workflow templates. Validate action identities and commit-pin structure without duplicating mutable SHA values in tests or documentation. Refresh the generated spelling configuration from the current shared dictionary so the repository contract remains current. --- docs/developers-guide.md | 5 +++-- tests/test_helpers.py | 37 ++++++++++++++++++++++++------------- tests/test_template.py | 15 +++++++++++---- typos.toml | 2 -- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 9bfac60..5fc1faf 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -105,8 +105,9 @@ is available. Its mutmut job requires a minimum Python version of 3.13 or newer; the hidden `mutmut_supported` Copier answer enforces that gate. The cargo-mutants job is gated independently by `use_rust`, so a Rust-enabled project with a Python 3.12 baseline still receives Rust mutation testing. Both jobs delegate to -the reusable workflows pinned at -`leynos/shared-actions@859416a90eb3987b46a57682c5d6b8964ad3f0a6`. +reusable `leynos/shared-actions` workflows. Dependabot owns their pinned commit +SHAs; contract tests and documentation must not duplicate those values because +Dependabot cannot update them outside the workflow template. ### Workflow pins and Dependabot diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 3bda56d..43a6055 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -41,6 +41,19 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +"""Validate direct helper-module error handling and edge cases. +The tests in this module exercise support helpers without rendering a full +Copier project. They keep helper fallibility contracts explicit by checking +``pytest.fail`` conversion paths, generated-file schema helpers, and tooling +contract assertions directly. +""" +if TYPE_CHECKING: + from pytest_copier.plugin import CopierProject +REPO_ROOT = Path(__file__).resolve().parent.parent +_TEST_ACTION_SHA = "0" * 40 +_DEPENDABOT_UPDATED_TEST_ACTION_SHA = "1" * 40 + + @pytest.mark.parametrize( ("output", "expected"), [ @@ -422,8 +435,8 @@ def test_ci_coverage_action_contract_accepts_any_pinned_sha_rejects_branch_ref() ) dependabot_bumped_workflow = workflow.replace( - "927edd45ae77be4251a8a18ca9eb5613a2e32cbd", - "0123456789abcdef0123456789abcdef01234567", + _TEST_ACTION_SHA, + _DEPENDABOT_UPDATED_TEST_ACTION_SHA, ) assert_ci_coverage_action_contract( ci_workflow=dependabot_bumped_workflow, @@ -431,9 +444,7 @@ def test_ci_coverage_action_contract_accepts_any_pinned_sha_rejects_branch_ref() use_rust=False, ) - branch_ref_workflow = workflow.replace( - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", "@main" - ) + branch_ref_workflow = workflow.replace(f"@{_TEST_ACTION_SHA}", "@main") with pytest.raises( AssertionError, match="expected CI to use the shared coverage action pinned to a 40-hex", @@ -614,7 +625,7 @@ def _coverage_main_workflow(*, guard: str, rust_setup: str = "") -> str: steps: {rust_setup}\ - name: Generate coverage - uses: leynos/shared-actions/.github/actions/generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd + uses: leynos/shared-actions/.github/actions/generate-coverage@{_TEST_ACTION_SHA} with: output-path: coverage.xml format: cobertura @@ -622,7 +633,7 @@ def _coverage_main_workflow(*, guard: str, rust_setup: str = "") -> str: with-ratchet: 'true' - name: Upload coverage data to CodeScene if: {guard} - uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd + uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@{_TEST_ACTION_SHA} with: format: cobertura path: coverage.xml @@ -691,7 +702,7 @@ def test_coverage_main_workflow_contract_requires_rust_setup() -> None: rust_setup = ( " - name: Set up Rust\n" " uses: leynos/shared-actions/.github/actions/setup-rust" - "@927edd45ae77be4251a8a18ca9eb5613a2e32cbd\n" + f"@{_TEST_ACTION_SHA}\n" ) rust_manifest = " cargo-manifest: rust_extension/Cargo.toml\n" workflow = _coverage_main_workflow( @@ -727,8 +738,8 @@ def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_r workflow = _coverage_main_workflow(guard="env.CS_ACCESS_TOKEN != ''") dependabot_bumped_workflow = workflow.replace( - "927edd45ae77be4251a8a18ca9eb5613a2e32cbd", - "0123456789abcdef0123456789abcdef01234567", + _TEST_ACTION_SHA, + _DEPENDABOT_UPDATED_TEST_ACTION_SHA, ) assert_coverage_main_workflow_contract( coverage_main_workflow=dependabot_bumped_workflow, @@ -737,7 +748,7 @@ def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_r ) branch_ref_workflow = workflow.replace( - "generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + f"generate-coverage@{_TEST_ACTION_SHA}", "generate-coverage@main", ) with pytest.raises( @@ -752,7 +763,7 @@ def test_coverage_main_workflow_contract_accepts_any_pinned_sha_rejects_branch_r ) upload_branch_ref_workflow = workflow.replace( - "upload-codescene-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd", + f"upload-codescene-coverage@{_TEST_ACTION_SHA}", "upload-codescene-coverage@main", ) with pytest.raises( @@ -779,7 +790,7 @@ def _ci_workflow(*, persist_credentials: str, coverage_inputs: str) -> str: persist-credentials: {persist_credentials} - name: Test and Measure Coverage if: ${{{{ github.event_name == 'pull_request' }}}} - uses: leynos/shared-actions/.github/actions/generate-coverage@927edd45ae77be4251a8a18ca9eb5613a2e32cbd + uses: leynos/shared-actions/.github/actions/generate-coverage@{_TEST_ACTION_SHA} with: output-path: coverage.xml format: cobertura diff --git a/tests/test_template.py b/tests/test_template.py index 0783a93..49cde0a 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -14,6 +14,7 @@ from __future__ import annotations import ast +import re import shutil import subprocess from pathlib import Path @@ -40,8 +41,6 @@ assert_generated_tooling_contracts, ) -MUTATION_WORKFLOW_PIN = "859416a90eb3987b46a57682c5d6b8964ad3f0a6" - def test_python_only_help_output_snapshot( copier: CopierFixture, tmp_path: Path, snapshot: SnapshotAssertion @@ -598,10 +597,18 @@ def test_generated_mutation_testing_gating( assert mutation_inputs.get("python-version") == python_version, ( "expected the mutmut job to run on the project's baseline Python" ) + expected_workflows = { + "mutation": "mutation-mutmut.yml", + "mutation-rust": "mutation-cargo.yml", + } 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" + workflow, separator, revision = uses.partition("@") + assert workflow == ( + f"leynos/shared-actions/.github/workflows/{expected_workflows[job_name]}" + ), f"expected {job_name} to use its shared mutation workflow" + assert separator and re.fullmatch(r"[0-9a-f]{40}", revision), ( + f"expected {job_name} to pin the shared mutation workflow to a commit SHA" ) diff --git a/typos.toml b/typos.toml index 106664a..d337ea4 100644 --- a/typos.toml +++ b/typos.toml @@ -148,8 +148,6 @@ extend-ignore-re = [ "apologizers" = "apologizers" "apologizes" = "apologizes" "apologizing" = "apologizing" -"artifact" = "artifact" -"artifacts" = "artifacts" "atomisable" = "atomizable" "atomisation" = "atomization" "atomisations" = "atomizations" From 1ecb086856017184708798f038a6fb5d70556475 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 19 Jul 2026 11:41:11 +0200 Subject: [PATCH 06/10] Test conditional mutation documentation rendering Render the generated developer guide as a Jinja template so its mutation guidance follows the Python and Rust gates instead of shipping template control tags. Assert the exact developer and user guide sections across every existing gate combination, and report malformed workflow separators independently from malformed commit revisions. --- ...ers-guide.md => developers-guide.md.jinja} | 2 +- tests/test_template.py | 79 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) rename template/docs/{developers-guide.md => developers-guide.md.jinja} (98%) diff --git a/template/docs/developers-guide.md b/template/docs/developers-guide.md.jinja similarity index 98% rename from template/docs/developers-guide.md rename to template/docs/developers-guide.md.jinja index a4f7a47..6d0c056 100644 --- a/template/docs/developers-guide.md +++ b/template/docs/developers-guide.md.jinja @@ -69,7 +69,7 @@ actions under `.github/`. repositories. {% if mutmut_supported %} The mutmut job is generated only when the minimum Python version is 3.13 or - newer, because the shared workflow helpers require Python 3.13 or newer. + newer because the shared workflow helpers require Python 3.13 or newer. {% endif %} {% if use_rust %} Rust-enabled projects also receive a cargo-mutants job for `rust_extension/`, diff --git a/tests/test_template.py b/tests/test_template.py index 49cde0a..259eb6f 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -42,6 +42,41 @@ ) +DEVELOPER_MUTATION_INTRO = """\ +- `.github/workflows/mutation-testing.yml` runs daily at 09:30 UTC and supports + manual dispatch. It delegates to reusable workflows from `leynos/shared-actions`; + stagger the generated cron schedule before adopting it alongside other + repositories.""" +DEVELOPER_MUTMUT_GUIDANCE = """\ + The mutmut job is generated only when the minimum Python version is 3.13 or + newer because the shared workflow helpers require Python 3.13 or newer.""" +DEVELOPER_RUST_MUTATION_GUIDANCE = """\ + Rust-enabled projects also receive a cargo-mutants job for `rust_extension/`, + independently of the Python version baseline.""" +USER_MUTATION_INTRO = """\ +## Scheduled Mutation Testing + +The `.github/workflows/mutation-testing.yml` workflow runs mutation testing +daily at 09:30 UTC and can also be started manually from GitHub Actions. Adjust +the generated cron schedule to stagger it against other repositories.""" +USER_MUTMUT_GUIDANCE = """\ +For projects whose minimum Python version is 3.13 or newer, the workflow runs +mutmut against `mutation_pkg/`. The Python mutation job is omitted for +older baselines because the shared workflow helpers require Python 3.13 or +newer.""" +USER_RUST_MUTATION_GUIDANCE = """\ +Rust-enabled projects also run cargo-mutants against the crate in +`rust_extension/`, independently of the Python version baseline.""" + + +def _rendered_section(text: str, *, start: str, end: str) -> str: + """Return an optional rendered section with Jinja-only blank lines collapsed.""" + if start not in text: + return "" + section = text.partition(start)[2].partition(end)[0] + return re.sub(r"\n{3,}", "\n\n", f"{start}{section}").strip() + + def test_python_only_help_output_snapshot( copier: CopierFixture, tmp_path: Path, snapshot: SnapshotAssertion ) -> None: @@ -563,6 +598,42 @@ def test_generated_mutation_testing_gating( use_rust=use_rust, python_version=python_version, ) + developer_guide = read_generated_text(project / "docs" / "developers-guide.md") + users_guide = read_generated_text(project / "docs" / "users-guide.md") + expected_developer_guidance = "\n\n".join( + section + for section, enabled in ( + (DEVELOPER_MUTATION_INTRO, expect_mutmut or use_rust), + (DEVELOPER_MUTMUT_GUIDANCE, expect_mutmut), + (DEVELOPER_RUST_MUTATION_GUIDANCE, use_rust), + ) + if enabled + ) + expected_user_guidance = "\n\n".join( + section + for section, enabled in ( + (USER_MUTATION_INTRO, expect_mutmut or use_rust), + (USER_MUTMUT_GUIDANCE, expect_mutmut), + (USER_RUST_MUTATION_GUIDANCE, use_rust), + ) + if enabled + ) + assert ( + _rendered_section( + developer_guide, + start="- `.github/workflows/mutation-testing.yml`", + end="- `.github/actions/build-wheels`", + ) + == expected_developer_guidance + ), "expected developer mutation guidance to match the active mutation gates" + assert ( + _rendered_section( + users_guide, + start="## Scheduled Mutation Testing", + end="## Rust Test Behaviour", + ) + == expected_user_guidance + ), "expected user mutation guidance to match the active mutation gates" pyproject = parse_toml_file(project / "pyproject.toml") mutmut_config = pyproject.get("tool", {}).get("mutmut") if expect_mutmut: @@ -607,8 +678,12 @@ def test_generated_mutation_testing_gating( assert workflow == ( f"leynos/shared-actions/.github/workflows/{expected_workflows[job_name]}" ), f"expected {job_name} to use its shared mutation workflow" - assert separator and re.fullmatch(r"[0-9a-f]{40}", revision), ( - f"expected {job_name} to pin the shared mutation workflow to a commit SHA" + assert separator == "@", ( + f"expected {job_name} shared mutation workflow reference to contain @" + ) + assert re.fullmatch(r"[0-9a-f]{40}", revision), ( + f"expected {job_name} shared mutation workflow revision to be a " + "40-character hexadecimal commit SHA" ) From 3984b1ca9ad60957d9eff6b02c1af4d9501b68e3 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 23 Jul 2026 21:46:24 +0200 Subject: [PATCH 07/10] Add property coverage for the mutmut version gate Generate bounded Python 3 minor versions and render each example in an isolated destination. Assert that the derived gate controls both the mutation workflow job and `[tool.mutmut]` configuration at the 3.13 boundary. Retain the explicit malformed-version cases and document the grammar shape covered by each example. --- tests/test_template.py | 80 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/tests/test_template.py b/tests/test_template.py index 259eb6f..470d801 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -20,6 +20,8 @@ from pathlib import Path import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st from pytest_copier.plugin import CopierFixture from syrupy.assertion import SnapshotAssertion @@ -687,6 +689,79 @@ def test_generated_mutation_testing_gating( ) +@settings( + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) +@given(minor=st.integers(min_value=0, max_value=20)) +@example(minor=0) +@example(minor=12) +@example(minor=13) +@example(minor=14) +def test_python_version_minor_controls_mutmut_generation( + copier: CopierFixture, + tmp_path_factory: pytest.TempPathFactory, + minor: int, +) -> None: + """Every valid Python 3 minor consistently controls mutmut generation. + + Parameters + ---------- + copier + ``pytest-copier`` fixture used to render the template. + tmp_path_factory + Factory creating an isolated destination for every generated example. + minor + Generated Python minor version in the representative range 0 through + 20. + + Returns + ------- + None + The property holds when Python 3.13 and newer render the mutmut + workflow job and configuration, while older minors render neither. + + Notes + ----- + Hypothesis's function-scoped fixture health check is suppressed because + ``pytest-copier`` supplies ``copier`` at function scope. Each example is + isolated by a fresh destination from ``tmp_path_factory``. + """ + project = render_project( + tmp_path_factory.mktemp(f"mutation-property-{minor}"), + copier, + project_name=f"MutationProperty{minor}", + package_name=f"mutation_property_{minor}", + use_rust=False, + python_version=f"3.{minor}", + ) + workflow_path = project / ".github" / "workflows" / "mutation-testing.yml" + pyproject = parse_toml_file(project / "pyproject.toml") + mutmut_config = pyproject.get("tool", {}).get("mutmut") + + if minor >= 13: + assert workflow_path.exists(), ( + "expected supported Python minor to render the mutation workflow" + ) + workflow = parse_yaml_mapping( + read_generated_text(workflow_path), "mutation workflow" + ) + jobs = require_mapping(workflow, "jobs", "mutation workflow") + assert "mutation" in jobs, ( + "expected supported Python minor to render the mutmut job" + ) + assert mutmut_config is not None, ( + "expected supported Python minor to render [tool.mutmut]" + ) + else: + assert not workflow_path.exists(), ( + "expected unsupported Python minor to omit the mutation workflow" + ) + assert mutmut_config is None, ( + "expected unsupported Python minor to omit [tool.mutmut]" + ) + + @pytest.mark.parametrize("python_version", ["3", "three.13", "3.13.1", "4.0"]) def test_python_version_rejects_unexpected_formats( copier: CopierFixture, @@ -707,8 +782,9 @@ def test_python_version_rejects_unexpected_formats( Returns ------- None - The test passes when Copier reports the version-format validation - error before evaluating dependent template answers. + The examples cover every rejected grammar shape: a missing minor, + non-numeric components, an extra patch component, and a non-3 major. + Copier reports the format error before evaluating dependent answers. """ with pytest.raises( ValueError, From 9073542870a671ae0b6a972edae543ad7bec6592 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 26 Jul 2026 23:33:09 +0200 Subject: [PATCH 08/10] Assert mutation-workflow metadata and parameterise version contract Address review feedback on the scheduled mutation-testing template tests. - Parameterise `_assert_pyproject_contracts` with `python_version` and thread it through `assert_generated_tooling_contracts` so the `requires-python` check matches the version each caller renders, rather than a hard-coded `>=3.12`. - Split `test_generated_mutation_testing_gating` into focused helpers for documentation guidance, `[tool.mutmut]` configuration, workflow metadata, job gating and inputs, and shared-workflow SHA validation. - Assert the previously unchecked workflow surface: the `30 9 * * *` schedule, empty top-level `permissions`, per-ref `concurrency` with `cancel-in-progress: false`, the mutmut job `paths`/`module-prefix-strip` inputs, and the cargo-mutants job `paths`/`extra-crate-dirs`/`shard-count` inputs. - Cap the `test_python_version_minor_controls_mutmut_generation` property test at `max_examples=25` to keep CI runtime predictable while retaining the explicit boundary examples. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/helpers/pyproject_contracts.py | 8 +- tests/helpers/tooling_contracts.py | 5 + tests/test_template.py | 239 +++++++++++++++++++-------- 3 files changed, 184 insertions(+), 68 deletions(-) diff --git a/tests/helpers/pyproject_contracts.py b/tests/helpers/pyproject_contracts.py index fe53362..e7ea0ce 100644 --- a/tests/helpers/pyproject_contracts.py +++ b/tests/helpers/pyproject_contracts.py @@ -15,12 +15,16 @@ def _assert_pyproject_contracts( - *, package_name: str, pyproject: dict[str, Any], use_rust: bool + *, + package_name: str, + pyproject: dict[str, Any], + use_rust: bool, + python_version: str, ) -> None: """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.12", ( + assert project.get("requires-python") == f">={python_version}", ( "expected generated pyproject.toml to use the requested Python version" ) dependency_groups = require_mapping( diff --git a/tests/helpers/tooling_contracts.py b/tests/helpers/tooling_contracts.py index d56f6f3..ceb244c 100644 --- a/tests/helpers/tooling_contracts.py +++ b/tests/helpers/tooling_contracts.py @@ -53,6 +53,7 @@ def assert_generated_tooling_contracts( build_wheels_action: str, pure_wheel_action: str, use_rust: bool, + python_version: str = "3.12", ) -> None: """Assert generated Python/Rust tooling contracts from one validator. @@ -60,6 +61,9 @@ def assert_generated_tooling_contracts( ---------- package_name : str Generated Python import package name. + python_version : str, default="3.12" + Minimum supported Python version used to render the project, matched + against the generated ``requires-python`` constraint. agents : str UTF-8 text of the generated ``AGENTS.md`` file. pyproject : dict[str, Any] @@ -118,6 +122,7 @@ def assert_generated_tooling_contracts( package_name=package_name, pyproject=pyproject, use_rust=use_rust, + python_version=python_version, ) _assert_agents_contracts(agents) _assert_agents_make_targets_mirror_makefile( diff --git a/tests/test_template.py b/tests/test_template.py index 470d801..07c2071 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -18,6 +18,7 @@ import shutil import subprocess from pathlib import Path +from typing import Any import pytest from hypothesis import HealthCheck, example, given, settings @@ -446,12 +447,14 @@ def test_generated_tooling_contracts( None The test passes when the generated tooling contracts are satisfied. """ + python_version = "3.12" project = render_project( tmp_path / target_dir, copier, project_name=project_name, package_name=package_name, use_rust=use_rust, + python_version=python_version, ) run_quality_gates(project) @@ -549,59 +552,14 @@ def test_generated_github_workflows_match_act_validation_contract( ) -@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, +def _assert_mutation_documentation( + *, + developer_guide: str, + users_guide: str, expect_mutmut: bool, + use_rust: 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 = render_project( - tmp_path / target_dir, - copier, - project_name="MutationProj", - package_name="mutation_pkg", - use_rust=use_rust, - python_version=python_version, - ) - developer_guide = read_generated_text(project / "docs" / "developers-guide.md") - users_guide = read_generated_text(project / "docs" / "users-guide.md") + """Assert developer and user guidance matches the active mutation gates.""" expected_developer_guidance = "\n\n".join( section for section, enabled in ( @@ -636,11 +594,16 @@ def test_generated_mutation_testing_gating( ) == expected_user_guidance ), "expected user mutation guidance to match the active mutation gates" - pyproject = parse_toml_file(project / "pyproject.toml") + + +def _assert_mutmut_pyproject_config( + *, pyproject: dict[str, Any], package_name: str, expect_mutmut: bool +) -> None: + """Assert ``[tool.mutmut]`` renders only for baselines of 3.13 or greater.""" mutmut_config = pyproject.get("tool", {}).get("mutmut") if expect_mutmut: assert mutmut_config == { - "source_paths": ["mutation_pkg/"], + "source_paths": [f"{package_name}/"], "pytest_add_cli_args_test_selection": ["tests/"], }, "expected mutmut configuration for baselines of 3.13 or greater" else: @@ -648,16 +611,41 @@ def test_generated_mutation_testing_gating( "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" + +def _assert_mutation_workflow_metadata(workflow: dict[str, Any]) -> None: + """Assert the schedule, permissions, and concurrency shared by all callers.""" + # PyYAML parses the ``on:`` key as the boolean ``True``. + triggers = workflow.get(True) + assert isinstance(triggers, dict), ( + "expected the mutation workflow to declare on: triggers" ) - jobs = require_mapping(workflow, "jobs", "mutation workflow") + assert triggers.get("schedule") == [{"cron": "30 9 * * *"}], ( + "expected the mutation workflow to run daily at 09:30 UTC" + ) + assert "workflow_dispatch" in triggers, ( + "expected the mutation workflow to allow manual dispatch" + ) + assert workflow.get("permissions") == {}, ( + "expected the mutation workflow to default the token to no scopes" + ) + concurrency = require_mapping(workflow, "concurrency", "mutation workflow") + assert concurrency.get("group") == "mutation-testing-${{ github.ref }}", ( + "expected per-ref concurrency serialization for the mutation workflow" + ) + assert concurrency.get("cancel-in-progress") is False, ( + "expected queued mutation runs to wait rather than cancel in progress" + ) + + +def _assert_mutation_job_gating( + *, + jobs: dict[str, Any], + expect_mutmut: bool, + use_rust: bool, + package_name: str, + python_version: str, +) -> None: + """Assert job presence and shared-workflow inputs for the active gates.""" assert ("mutation" in jobs) == expect_mutmut, ( "expected the mutmut job only for baselines of 3.13 or greater" ) @@ -665,19 +653,47 @@ def test_generated_mutation_testing_gating( "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") + mutation_inputs = require_mapping( + require_mapping(jobs, "mutation", "mutation workflow jobs"), + "with", + "mutmut job", + ) assert mutation_inputs.get("python-version") == python_version, ( "expected the mutmut job to run on the project's baseline Python" ) + assert mutation_inputs.get("paths") == f"{package_name}/", ( + "expected the mutmut job to mutate the flat-layout package directory" + ) + assert mutation_inputs.get("module-prefix-strip") == "", ( + "expected the mutmut job to strip no module prefix for a flat layout" + ) + if use_rust: + rust_inputs = require_mapping( + require_mapping(jobs, "mutation-rust", "mutation workflow jobs"), + "with", + "cargo-mutants job", + ) + assert rust_inputs.get("paths") == "", ( + "expected the cargo-mutants job to empty the root path list" + ) + assert rust_inputs.get("extra-crate-dirs") == "rust_extension", ( + "expected the cargo-mutants job to target the extension crate directory" + ) + assert rust_inputs.get("shard-count") == 1, ( + "expected the cargo-mutants job to confine the root target to one shard" + ) + + +def _assert_shared_workflow_shas(jobs: dict[str, Any]) -> None: + """Assert every job pins its shared mutation workflow to a commit SHA.""" expected_workflows = { "mutation": "mutation-mutmut.yml", "mutation-rust": "mutation-cargo.yml", } for job_name, job in jobs.items(): uses = str(job.get("uses", "")) if isinstance(job, dict) else "" - workflow, separator, revision = uses.partition("@") - assert workflow == ( + workflow_ref, separator, revision = uses.partition("@") + assert workflow_ref == ( f"leynos/shared-actions/.github/workflows/{expected_workflows[job_name]}" ), f"expected {job_name} to use its shared mutation workflow" assert separator == "@", ( @@ -689,7 +705,98 @@ def test_generated_mutation_testing_gating( ) +@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, the workflow file is absent when + both gates are off, and the rendered schedule, permissions, + concurrency, and shared-workflow job inputs match the template + contract. + """ + package_name = "mutation_pkg" + project = render_project( + tmp_path / target_dir, + copier, + project_name="MutationProj", + package_name=package_name, + use_rust=use_rust, + python_version=python_version, + ) + _assert_mutation_documentation( + developer_guide=read_generated_text(project / "docs" / "developers-guide.md"), + users_guide=read_generated_text(project / "docs" / "users-guide.md"), + expect_mutmut=expect_mutmut, + use_rust=use_rust, + ) + _assert_mutmut_pyproject_config( + pyproject=parse_toml_file(project / "pyproject.toml"), + package_name=package_name, + expect_mutmut=expect_mutmut, + ) + + 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" + ) + _assert_mutation_workflow_metadata(workflow) + jobs = require_mapping(workflow, "jobs", "mutation workflow") + _assert_mutation_job_gating( + jobs=jobs, + expect_mutmut=expect_mutmut, + use_rust=use_rust, + package_name=package_name, + python_version=python_version, + ) + _assert_shared_workflow_shas(jobs) + + @settings( + # Each example renders a full Copier project, so cap the run to keep CI + # runtime predictable; the explicit ``@example`` cases still pin the + # boundary minors (0, 12, 13, 14). + max_examples=25, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture], ) From ce869043aabc150ada4bd76c814501cabbaa5ba9 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Jul 2026 20:59:09 +0200 Subject: [PATCH 09/10] Normalize mutation guide Markdown after rebase Rewrap the mutation-testing section of the parent developers' guide with `make fmt` (mdformat/mdtablefix/markdownlint) so it matches the Markdown formatting convention adopted on main in "Format Markdown sources". Pure reflow of existing prose; no content change. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/developers-guide.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5fc1faf..884832f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -101,13 +101,14 @@ Rust-enabled workflows pass `rust_extension/Cargo.toml` to the coverage action because the generated Python project root does not contain a Rust manifest. The mutation-testing workflow template is rendered when either mutation engine -is available. Its mutmut job requires a minimum Python version of 3.13 or newer; -the hidden `mutmut_supported` Copier answer enforces that gate. The cargo-mutants -job is gated independently by `use_rust`, so a Rust-enabled project with a -Python 3.12 baseline still receives Rust mutation testing. Both jobs delegate to -reusable `leynos/shared-actions` workflows. Dependabot owns their pinned commit -SHAs; contract tests and documentation must not duplicate those values because -Dependabot cannot update them outside the workflow template. +is available. Its mutmut job requires a minimum Python version of 3.13 or +newer; the hidden `mutmut_supported` Copier answer enforces that gate. The +cargo-mutants job is gated independently by `use_rust`, so a Rust-enabled +project with a Python 3.12 baseline still receives Rust mutation testing. Both +jobs delegate to reusable `leynos/shared-actions` workflows. Dependabot owns +their pinned commit SHAs; contract tests and documentation must not duplicate +those values because Dependabot cannot update them outside the workflow +template. ### Workflow pins and Dependabot From a4f4b3e435c764414ff235daedbb6b1bda6bff08 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 28 Jul 2026 21:03:20 +0200 Subject: [PATCH 10/10] Track parent Makefile fmt target in phony contract Main's "Format Markdown sources" commit added a `fmt` target to the parent Makefile's `.PHONY` line but left `test_parent_makefile_test_target_uses_ requisite_pytest_command` asserting the pre-`fmt` target list, so the substring check failed after the rebase. Update the expected `.PHONY` line to include `fmt`, matching the Makefile now carried from main. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 43a6055..b3bd4fe 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -507,7 +507,7 @@ def test_parent_makefile_test_target_uses_requisite_pytest_command() -> None: """ makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") - assert ".PHONY: help check-fmt lint spelling test typecheck" in makefile, ( + assert ".PHONY: help check-fmt fmt lint spelling test typecheck" in makefile, ( "expected parent Makefile to mark documented gate targets as phony" ) assert "check-fmt: ## Verify template test formatting" in makefile, (