diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2d5c289..ec8410e 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -69,6 +69,54 @@ and the tracking issue. The `mutants` dev-dependency exists solely so these test-only attributes resolve. Do not skip mutants that can be exercised by the workflow's feature set; strengthen the relevant tests instead. +### Workflow contract tests + +`.github/workflows/mutation-testing.yml` is a thin caller of the shared +reusable workflow `leynos/shared-actions/.github/workflows/mutation-cargo.yml`. +The heavy lifting — running `cargo-mutants` and summarizing survivors — lives in +`shared-actions`; this repository carries only declarative configuration. The +run is informational only: it never gates a pull request, and survivors are +reported through the job summary and downloadable artefacts for triage into +tests rather than enforced as a blocking check. + +The caller currently sets three inputs: + +- `exclude-globs: "src/test_support.rs"` — keeps survivors from the unit-test + scaffolding module out of the report, since they are noise rather than + genuine test gaps. +- `extra-args: "--all-features"` — mirrors the repository's canonical test + baseline (`make test` runs with all features enabled), so feature-gated code + is compiled and exercised against mutants. +- `setup-commands` — pins `PG_PASSWORD` for the embedded PostgreSQL cluster. + The `pg-embed-setup-unpriv` library otherwise generates a fresh random + superuser password per run while cluster state persists between runs, so a + second plain `cargo test` in the same job would fail to authenticate; a fixed + password keeps every per-mutant run consistent with the baseline run. + +Every other input keeps the shared workflow's default, including `paths`, which +this repository does not set. + +`tests/workflow_contracts/mutation_testing_test.py` parses the caller with +PyYAML and pins the shape it must uphold, failing the pull request when the +caller drifts rather than letting the breakage surface only in a scheduled run. +Run it locally with `make test-workflow-contracts` (this wraps +`uv run --with 'pytest>=8' --with 'pyyaml>=6' pytest tests/workflow_contracts -q`). +The test validates: + +- job permissions are exactly least-privilege (`contents: read`, + `id-token: write`), and the workflow-level default token scope is empty; +- `concurrency` serializes runs per ref (`cancel-in-progress: false`); +- the daily 09:35 UTC schedule remains, and `workflow_dispatch` is present + without the legacy `branch` input; and +- `uses:` names the shared `mutation-cargo.yml` workflow and ends with a + 40-character hexadecimal commit SHA; and +- the `with:` block carries exactly the three inputs above and no others. + +The test validates only the SHA's shape, not its value. Dependabot continues to +manage the exact pin, so its updates do not require a matching test change; the +contract merely prevents the reusable workflow from being repointed at a +branch, tag, or abbreviated revision. + ## Compile-fail UI tests Compile-time contracts for macros and type-level behaviour are covered by the diff --git a/tests/workflow_contracts/mutation_testing_test.py b/tests/workflow_contracts/mutation_testing_test.py index 97da37c..5965d1e 100644 --- a/tests/workflow_contracts/mutation_testing_test.py +++ b/tests/workflow_contracts/mutation_testing_test.py @@ -13,6 +13,7 @@ from __future__ import annotations +import re from pathlib import Path import yaml @@ -38,6 +39,11 @@ } +USES_RE = re.compile( + r"^leynos/shared-actions/.github/workflows/mutation-cargo\.yml@[0-9A-Fa-f]{40}$" +) + + def _load() -> dict[str, object]: """Parse the workflow file.""" return yaml.safe_load(WORKFLOW_PATH.read_text(encoding="utf-8")) @@ -109,6 +115,16 @@ def test_triggers_keep_schedule_and_plain_dispatch() -> None: ) +def test_mutation_job_uses_a_pinned_shared_workflow() -> None: + """The reusable workflow reference keeps a full commit-SHA pin.""" + uses = _mutation_job(_load()).get("uses") + assert isinstance(uses, str), "jobs.mutation.uses must be a string" + assert USES_RE.fullmatch(uses), ( + "jobs.mutation.uses must reference mutation-cargo.yml at a " + f"40-character commit SHA, got {uses!r}" + ) + + def test_with_block_carries_the_caller_configuration() -> None: """The caller passes exactly the scaffolding exclusion and feature args.""" with_block = _mutation_job(_load()).get("with") diff --git a/typos.local.toml b/typos.local.toml index 269b761..61076b7 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -12,7 +12,7 @@ accepted = ["Center"] [words.corrections] [patterns] -ignore = [] +ignore = ["`[^`\\n]+`"] [files] exclude = [] diff --git a/typos.toml b/typos.toml index 9f11fbe..7e18720 100644 --- a/typos.toml +++ b/typos.toml @@ -1700,6 +1700,24 @@ extend-ignore-re = [ "pluralizers" = "pluralizers" "pluralizes" = "pluralizes" "pluralizing" = "pluralizing" +"polymerisable" = "polymerizable" +"polymerisation" = "polymerization" +"polymerisations" = "polymerizations" +"polymerise" = "polymerize" +"polymerised" = "polymerized" +"polymeriser" = "polymerizer" +"polymerisers" = "polymerizers" +"polymerises" = "polymerizes" +"polymerising" = "polymerizing" +"polymerizable" = "polymerizable" +"polymerization" = "polymerization" +"polymerizations" = "polymerizations" +"polymerize" = "polymerize" +"polymerized" = "polymerized" +"polymerizer" = "polymerizer" +"polymerizers" = "polymerizers" +"polymerizes" = "polymerizes" +"polymerizing" = "polymerizing" "popularisable" = "popularizable" "popularisation" = "popularization" "popularisations" = "popularizations"