From 7881a9ee5bfbf390220c349bc94e889e831dc8c4 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:47 -0600 Subject: [PATCH 01/13] fix(tests): render the template head, not the released tag The generation-test render fixture called copier.run_copy with no vcs_ref, so copier used its default and copied the latest release tag (v0.1.0) instead of HEAD. Every template change made since that tag was silently excluded from the rendered project, so the suite validated a frozen release rather than the working tree -- and this cleanup's template changes are the first since the tag to expose it. Pin the fixture to vcs_ref="HEAD" so the suite renders the current template (local dirty changes included, per copier's HEAD behavior). test_update_roundtrip keeps its explicit v0.1.0 pin; it deliberately updates from the first release. --- tests/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index ce65a73..80f0533 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -101,6 +101,10 @@ def _render(data: Mapping[str, object], dst: Path) -> Path: str(template_root), str(dst), data={"enable_precommit_install": False, **data}, + # Copy the working template (HEAD), not copier's default of the latest + # release tag — otherwise the suite would validate the released template + # and silently ignore every change since. (run_update tests pin their ref.) + vcs_ref="HEAD", defaults=True, unsafe=True, overwrite=True, From 62fcd9bcb0b046277a54c5e759a6409dde5fe00f Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:47 -0600 Subject: [PATCH 02/13] fix: run ruff check --fix before ruff format in the fmt recipe `ruff format` must run last: ruff's lint autofixes (import sorting and the SIM/UP/C4 rewrites) can emit unformatted code, so formatting first and lint-fixing after could leave a tree that `fmt-check` (ruff format --check) then rejects -- the recipe's own output failing the gate it feeds. Applies to both the shipped template recipe and this repo's maintainer harness; the AGENTS.md `just fmt` description is corrected to match. --- AGENTS.md | 2 +- justfile | 4 +++- template/justfile.jinja | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7c6743..de8796d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ The maintainer harness is held to the same `recommended` bar the template ships. ```bash just lint # ruff check . — select=["ALL"] over tests/ (config-derived, audited ignores) -just fmt # ruff format . + ruff check --fix . (apply safe fixes) +just fmt # ruff check --fix . + ruff format . (apply safe fixes) just fmt-check # ruff format --check . (CI's format gate) ``` diff --git a/justfile b/justfile index ec116c6..7f26747 100644 --- a/justfile +++ b/justfile @@ -16,9 +16,11 @@ typecheck: uv run basedpyright # Auto-format + apply safe lint fixes to this repo's own tooling (the tests/ harness). +# Lint-fix BEFORE format: ruff's fixes (import sort, SIM/UP/C4 rewrites) can emit +# unformatted code, so the formatter must run last or `fmt-check` may reject `fmt`'s output. fmt: - uv run ruff format . uv run ruff check --fix . + uv run ruff format . # CI's format gate: fail if anything is unformatted. fmt-check: diff --git a/template/justfile.jinja b/template/justfile.jinja index f1f5f12..205fbfd 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -9,9 +9,11 @@ ci: fmt-check lint typecheck test{% if enable_property_tests %} fuzz{% endif %}{ verify: ci +# Lint-fix BEFORE format: ruff's fixes (import sort, SIM/UP/C4 rewrites) can emit +# unformatted code, so the formatter must run last or `fmt-check` may reject `fmt`'s output. fmt: - uv run ruff format . uv run ruff check --fix . + uv run ruff format . fmt-check: uv run ruff format --check . From 91bb457981f72bb2c3ee2e852251d5758d048daf Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:47 -0600 Subject: [PATCH 03/13] fix(template): gate the mccabe config on the all ruleset `[tool.ruff.lint.mccabe] max-complexity` only governs C901 (the C90 prefix), which the curated ruleset does not select, so it rendered as dead config there -- inconsistent with the already-gated pydocstyle block. Gate it on ruff_ruleset == "all". The generation test now asserts the block is absent under curated and present under all. --- template/pyproject.toml.jinja | 4 ++++ tests/test_generation.py | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 7b03a3e..c452d8c 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -60,9 +60,13 @@ ignore = [ select = ["E", "F", "UP", "B", "SIM", "I", "ANN", "S", "C4", "DTZ", "PTH", "RUF", "PL"] {%- endif %} +{% if ruff_ruleset == "all" -%} +# max-complexity governs C901 (mccabe), active only under select=ALL; the curated +# ruleset omits the C90 prefix, so this block would be dead config there (like pydocstyle). [tool.ruff.lint.mccabe] max-complexity = 10 +{% endif -%} [tool.ruff.lint.pylint] max-args = 8 max-branches = 12 diff --git a/tests/test_generation.py b/tests/test_generation.py index d0f520b..0c85c9e 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -712,13 +712,18 @@ def test_curated_ruleset(render: RenderFn, tmp_path: Path) -> None: # The pydocstyle convention only takes effect when the D rules are selected (the # `all` ruleset); curated omits D, so the block must not render as dead config. assert "[tool.ruff.lint.pydocstyle]" not in pyproject + # Same for the mccabe block: max-complexity only governs C901 (C90 prefix), which curated + # does not select, so it must not render as dead config there either. + assert "[tool.ruff.lint.mccabe]" not in pyproject _ = run_in(project, "uv", "run", "ruff", "check", ".") # curated is the only MATRIX combo not otherwise run through a full `just ci` # (test_matrix runs only the fast subset); close that gap on the rendered project here. _ = run_in(project, "just", "ci") # ...and it IS present on the `all` path, where the D rules are active. allp = render(MINIMAL, tmp_path / "all") - assert "[tool.ruff.lint.pydocstyle]" in (allp / "pyproject.toml").read_text() + all_pyproject = (allp / "pyproject.toml").read_text() + assert "[tool.ruff.lint.pydocstyle]" in all_pyproject + assert "[tool.ruff.lint.mccabe]" in all_pyproject @pytest.mark.parametrize("version", ["3.11", "3.12", "3.13"]) From c1a12eb3b8aa1071eaf70a70a59fedc735014965 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:47 -0600 Subject: [PATCH 04/13] fix(template): scan committed history in the scan recipe `gitleaks dir .` scans only the working tree, so a secret committed and later deleted would pass the local recipe yet fail the CI gate (scan.yml runs `gitleaks git`). Switch the recipe to `gitleaks git .` so the local preview matches CI. The generation test asserts the `git` form renders and `dir` does not. --- template/justfile.jinja | 3 ++- tests/test_generation.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/template/justfile.jinja b/template/justfile.jinja index 205fbfd..14c3ab3 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -46,5 +46,6 @@ mutate: {% endif %}{% if enable_scanners %} scan: uvx semgrep@1.167.0 scan --config .semgrep.yml --metrics=off --error . - gitleaks dir . --redact --exit-code 1 + # `git` (not `dir`): scan committed history like CI, catching secrets committed then deleted. + gitleaks git . --redact --exit-code 1 {% endif -%} diff --git a/tests/test_generation.py b/tests/test_generation.py index 0c85c9e..fef3ce9 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -404,7 +404,11 @@ def test_scanner_layer(render: RenderFn, tmp_path: Path) -> None: on = render({**MINIMAL, "enable_scanners": True}, tmp_path / "on") assert (on / ".gitleaks.toml").is_file() assert (on / ".semgrep.yml").is_file() - assert "scan:" in (on / "justfile").read_text() + justfile = (on / "justfile").read_text() + assert "scan:" in justfile + # Local scan previews the CI gate: history scan (`git`), matching scan.yml — not `dir`. + assert "gitleaks git ." in justfile + assert "gitleaks dir" not in justfile off = render(MINIMAL, tmp_path / "off") assert not (off / ".gitleaks.toml").exists() assert not (off / ".semgrep.yml").exists() From d97bb57a3a8891afc848df27749514666ed39eaa Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:47 -0600 Subject: [PATCH 05/13] fix(template): gate the gitleaks mise pin on enable_scanners gitleaks is a scanner-only tool, but its mise.toml pin rendered unconditionally -- dead config (and recurring Renovate churn) in projects generated without the scanner layer. Gate the pin on enable_scanners. The generation test asserts it is present when scanners are on and absent when off. --- template/mise.toml.jinja | 3 +++ tests/test_generation.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/template/mise.toml.jinja b/template/mise.toml.jinja index 5dba406..b9d96b3 100644 --- a/template/mise.toml.jinja +++ b/template/mise.toml.jinja @@ -3,4 +3,7 @@ python = "{{ python_version }}" uv = "0.11.23" just = "1.50.0" copier = "9.15.2" +{%- if enable_scanners %} +# scanner-only tool; pinned here so Renovate's mise manager keeps it fresh. gitleaks = "8.30.1" +{%- endif %} diff --git a/tests/test_generation.py b/tests/test_generation.py index fef3ce9..8c49965 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -409,9 +409,12 @@ def test_scanner_layer(render: RenderFn, tmp_path: Path) -> None: # Local scan previews the CI gate: history scan (`git`), matching scan.yml — not `dir`. assert "gitleaks git ." in justfile assert "gitleaks dir" not in justfile + # gitleaks is pinned in mise.toml only when the scanner layer ships (else dead config). + assert 'gitleaks = "8.30.1"' in (on / "mise.toml").read_text() off = render(MINIMAL, tmp_path / "off") assert not (off / ".gitleaks.toml").exists() assert not (off / ".semgrep.yml").exists() + assert "gitleaks" not in (off / "mise.toml").read_text() def test_semgrep_runs_hermetically(render: RenderFn, tmp_path: Path) -> None: From d0cb5fdc4c97a755a0a312de5af0e5b7646e5b53 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 06/13] fix(template): checkout shallow unless secret scanning runs scan.yml checked out full history (fetch-depth: 0) unconditionally, but only gitleaks needs it. A project enabling scan.yml through the dependency-audit or sha-pin-policy layer alone paid an unbounded fetch for nothing. Gate fetch-depth on enable_scanners (0 with scanners, 1 without). A new generation test renders scan.yml via the dependency-audit layer with scanners off and asserts the semgrep/gitleaks steps and the deep fetch are all absent. --- ...sha_pin_policy %}scan.yml{% endif %}.jinja | 3 ++- tests/test_generation.py | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja b/template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja index d443af5..862e2d6 100644 --- a/template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if enable_scanners or enable_dependency_audit or enable_sha_pin_policy %}scan.yml{% endif %}.jinja @@ -19,7 +19,8 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - fetch-depth: 0 + # Full history is only needed for secret scanning; other steps read the tree/lockfile. + fetch-depth: {% if enable_scanners %}0{% else %}1{% endif %} persist-credentials: false - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: diff --git a/tests/test_generation.py b/tests/test_generation.py index 8c49965..a8afc28 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -417,6 +417,28 @@ def test_scanner_layer(render: RenderFn, tmp_path: Path) -> None: assert "gitleaks" not in (off / "mise.toml").read_text() +def test_scanner_steps_absent_when_scanners_off(render: RenderFn, tmp_path: Path) -> None: + """The semgrep/gitleaks scan.yml steps are gated solely on enable_scanners. + + Like the pip-audit and zizmor steps, the semgrep/gitleaks steps in scan.yml are gated + only by their toggle and absent from the local recipes' surface, so only a generation + assertion guards them — inverting the guard would emit a `--config .semgrep.yml` step + into a project shipping no .semgrep.yml. Render scan.yml via another layer, then prove + the scanner steps (and the scanners-only deep fetch) are gone when scanners are off. + """ + off = render( + {**MINIMAL, "enable_scanners": False, "enable_dependency_audit": True}, + tmp_path / "off", + ) + scan = (off / ".github" / "workflows" / "scan.yml").read_text() + assert "pip-audit" in scan # scan.yml really rendered (dependency-audit on), not empty + assert "semgrep" not in scan + assert "gitleaks" not in scan + # Full history is a scanners-only need; without gitleaks the checkout stays shallow. + assert "fetch-depth: 0" not in scan + assert "fetch-depth: 1" in scan + + def test_semgrep_runs_hermetically(render: RenderFn, tmp_path: Path) -> None: """Semgrep uses only the vendored config: telemetry off, no registry `auto`. From 19c3930d0f786e8fae11b395b4bfe8e1373db53e Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 07/13] fix(template): surface genuine mutation-workflow failures Job-level `continue-on-error: true` masked every failure of the mutation workflow, including real infrastructure breakage (a bad lockfile, a failed export), not just surviving mutants. Drop it: surviving mutants stay non-gating through the `|| true` on the `mutmut run` step, while genuine breakage now shows red. The generation test asserts the `|| true` step is present and no continue-on-error remains. --- ...able_mutation_tests %}mutation.yml{% endif %}.jinja | 3 ++- tests/test_generation.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja b/template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja index 28acc47..7b7a32d 100644 --- a/template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if enable_mutation_tests %}mutation.yml{% endif %}.jinja @@ -16,7 +16,8 @@ jobs: mutation: runs-on: ubuntu-latest timeout-minutes: 60 - continue-on-error: true + # Survivors stay non-gating via the `|| true` on the mutmut step below; the job is not set + # to ignore failures, so genuine infra breakage (bad lockfile, failed export) still shows red. steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/tests/test_generation.py b/tests/test_generation.py index a8afc28..d883846 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -525,11 +525,11 @@ def test_ci_workflows(render: RenderFn, tmp_path: Path) -> None: assert '"3.12"' not in ci assert (project / ".github" / "workflows" / "scan.yml").is_file() assert (project / ".github" / "workflows" / "mutation.yml").is_file() - # Mutation workflow is non-gating. - assert ( - "continue-on-error: true" - in (project / ".github" / "workflows" / "mutation.yml").read_text() - ) + # Mutation is non-gating on survivors (the run step swallows mutmut's nonzero exit) but + # carries no job-level continue-on-error, so genuine infra breakage still surfaces as red. + mutation = (project / ".github" / "workflows" / "mutation.yml").read_text() + assert "mutmut run || true" in mutation + assert "continue-on-error" not in mutation # Every job caps its runtime (else a hung step burns GitHub's 6h default). assert "timeout-minutes:" in ci assert "timeout-minutes:" in (project / ".github" / "workflows" / "scan.yml").read_text() From 2ab03cab1244abf8ebc801d7b65e80e1466957b5 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 08/13] fix(template): stop forcing 2-space indent on .toml The generated .editorconfig grouped .toml into the 2-space glob, but the rendered pyproject.toml ships 4-space arrays and no TOML formatter reconciles the two, so the rule only misled editors. Drop .toml from the glob; it falls back to the 4-space default. The generation test asserts `toml` no longer appears in .editorconfig. --- template/.editorconfig | 2 +- tests/test_generation.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/template/.editorconfig b/template/.editorconfig index d9fd974..08c80de 100644 --- a/template/.editorconfig +++ b/template/.editorconfig @@ -7,5 +7,5 @@ insert_final_newline = true indent_style = space indent_size = 4 -[*.{yml,yaml,json,toml}] +[*.{yml,yaml,json}] indent_size = 2 diff --git a/tests/test_generation.py b/tests/test_generation.py index d883846..2123740 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -90,7 +90,10 @@ def test_minimal_renders(render: RenderFn, tmp_path: Path) -> None: # Always-present support files render (AGENTS.md NEVER: no template file without an assertion). assert (project / ".editorconfig").is_file() - assert "indent_" in (project / ".editorconfig").read_text() + editorconfig = (project / ".editorconfig").read_text() + assert "indent_" in editorconfig + # .toml is not grouped into the 2-space glob; pyproject.toml arrays ship at 4-space. + assert "toml" not in editorconfig gitignore = (project / ".gitignore").read_text() assert "coverage.xml" in gitignore assert "requirements-audit.txt" in gitignore From 7d36a91fa0340cc3f9a76b1dfa75f1444c3737b6 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 09/13] docs(template): note scanners run in CI only in the ci recipe The `ci` recipe's comment claimed to mirror everything that blocks a PR, but the scanners run in CI only (scan.yml), never in the local gate. Reword the comment to say so and point to scan.yml when the scanner layer is enabled. No behavior change. --- template/justfile.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/justfile.jinja b/template/justfile.jinja index 14c3ab3..a09b9fc 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -3,7 +3,7 @@ set shell := ["bash", "-eu", "-o", "pipefail", "-c"] default: @just --list -# Complete local gate — mirrors what blocks a PR. +# The complete local gate — every PR-blocking check reproducible locally{% if enable_scanners %} (scanners run in CI only; see scan.yml){% endif %}. ci: fmt-check lint typecheck test{% if enable_property_tests %} fuzz{% endif %}{% if enable_policy_tests %} policy{% endif %}{% if enable_dependency_audit %} audit{% endif %} @echo "ci: all gates passed" From 50cda169d4c6da458e4a0d6712cbfe130042ebae Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 10/13] docs(agents): correct stale wiring and release guidance Two AGENTS.md statements had gone stale: - The guardrail-wiring step implied every layer adds a `ci` dependency, but out-of-band checks (scan/mutate) ship a recipe kept off `ci` and CI-only layers (renovate/sha-pin) add no recipe at all. Spell out the three cases. - The release note claimed the repo carries no tags; v0.1.0 exists. Reword to simply require tagging each released commit. --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de8796d..5cc90b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,12 +31,12 @@ The maintainer harness runs the same full `select=["ALL"]` ruleset the template 1. Add an `enable_*` toggle to `copier.yml`. 2. Add the conditional file(s) under `template/` (file: `{% if flag %}name{% endif %}.jinja`; dir: `{% if flag %}dir{% endif %}/`). -3. Wire it into `template/justfile.jinja` (recipe + `ci` dep). Then, where applicable: a dep in `template/pyproject.toml.jinja` (skip it for `uvx`-run tools like the scanners), a section in `template/AGENTS.md.jinja`, and a CI surface under `template/.github/workflows/` (a conditional step in `scan.yml`, or a dedicated conditional workflow file via the empty-name idiom). +3. Wire it into `template/justfile.jinja` (a recipe; add it as a `ci` dep only for a *gating* layer — out-of-band checks like `scan`/`mutate` ship a recipe but stay off `ci`, and CI-only layers like renovate/sha-pin add no recipe at all). Then, where applicable: a dep in `template/pyproject.toml.jinja` (skip it for `uvx`-run tools like the scanners), a section in `template/AGENTS.md.jinja`, and a CI surface under `template/.github/workflows/` (a conditional step in `scan.yml`, or a dedicated conditional workflow file via the empty-name idiom). 4. Extend `tests/test_generation.py`: assert present-when-on AND absent-when-off, and that the layer's gate passes. ## Release -`copier update` targets the **latest SemVer git tag, not HEAD** — an untagged template makes every downstream update silently pull in-progress commits. The repo carries no tags until the first release is cut, so tag the released commit (on `main`) before announcing it or letting any downstream consume the template: +`copier update` targets the **latest SemVer git tag, not HEAD** — an untagged template makes every downstream update silently pull in-progress commits. Always tag each released commit (on `main`) before announcing it or letting any downstream consume the template (v0.1.0 was the first release): ```bash git tag -a v0.1.0 -m "v0.1.0" # annotate the released commit From 31fae703e44af8a8d310d5b1ed4c30399136fec8 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 11/13] docs(tests): fix stale render-fixture skip comment The conftest render helper's comment referenced a "Task 7" that no longer exists and gave a wrong reason for skipping the pre-commit hook-install task. Describe the real reason (a network-bound copy-time _task, irrelevant to these assertions) and name the test that does cover the install path. --- tests/conftest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 80f0533..4760986 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,9 +91,10 @@ def render(template_root: Path) -> RenderFn: pytest.fail(f"required tools not on PATH: {missing}") def _render(data: Mapping[str, object], dst: Path) -> Path: - # Generation renders skip the slow pre-commit hook-install task (the config - # does not exist until that layer is added). A dedicated test in Task 7 - # exercises the install path with the flag left at its default. + # Generation renders skip the slow pre-commit hook-install task: it is a copy-time + # _task (`uv run pre-commit install --install-hooks`), network-bound and irrelevant to + # what these tests assert. `test_precommit_install_task_runs` covers the install path + # separately with the flag at its default. # The copy-time `uv lock`/`uv sync` _tasks must resolve against the generated # project's requires-python, not a leaked UV_PYTHON/VIRTUAL_ENV (see the pins note). with without_interpreter_pins(): From c426d9c0d1b6e6e9aa5cad6cbb83da0e2407c6dd Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:05:48 -0600 Subject: [PATCH 12/13] build(deps): declare plumbum as a direct dev dependency The maintainer harness imports `plumbum.local` directly to scrub interpreter pins from copier's _task channel, so it should declare plumbum rather than rely on copier's transitive pin. Add it to the dev group and refresh uv.lock. --- pyproject.toml | 3 +++ uv.lock | 2 ++ 2 files changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4f3fb98..9b377ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,9 @@ package = false [dependency-groups] dev = [ "copier>=9.6,<10", + # copier drives its _tasks through plumbum; the harness imports `plumbum.local` directly to + # scrub interpreter pins from that channel, so declare it rather than ride copier's transitive pin. + "plumbum>=1.8,<3", "pytest>=8,<10", "pyyaml>=6,<7", "basedpyright>=1.39,<1.40", diff --git a/uv.lock b/uv.lock index c556934..f285ab4 100644 --- a/uv.lock +++ b/uv.lock @@ -412,6 +412,7 @@ source = { virtual = "." } dev = [ { name = "basedpyright" }, { name = "copier" }, + { name = "plumbum" }, { name = "pytest" }, { name = "pyyaml" }, { name = "ruff" }, @@ -423,6 +424,7 @@ dev = [ dev = [ { name = "basedpyright", specifier = ">=1.39,<1.40" }, { name = "copier", specifier = ">=9.6,<10" }, + { name = "plumbum", specifier = ">=1.8,<3" }, { name = "pytest", specifier = ">=8,<10" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.15,<0.16" }, From 3ebd2796e61577e78d60fa6837796acde29184db Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 16:37:19 -0600 Subject: [PATCH 13/13] docs(changelog): record the Unreleased template fixes Add the config/CI corrections from this branch under [Unreleased], split into Changed (conditional-rendering cleanup) and Fixed (behavior corrections) per Keep a Changelog. Maintainer-only changes -- the test harness, docs, and dev deps -- are omitted; they do not affect generated projects. --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8880003..5551fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The gitleaks `mise` pin and `scan.yml`'s full-history checkout are now emitted + only when the scanner layer is enabled; projects that enable `scan.yml` through + another layer no longer carry the unused pin or an unbounded `fetch-depth`. +- The `[tool.ruff.lint.mccabe]` block is emitted only under the `all` ruleset, + where `max-complexity` governs a selected rule (C901); the curated ruleset no + longer renders it as dead config. + +### Fixed + +- `just fmt` runs `ruff check --fix` before `ruff format`, so its own output can + no longer be rejected by `just fmt-check`. +- `just scan` scans committed history (`gitleaks git`) to match the CI gate, + catching secrets that were committed and later deleted from the working tree. +- The mutation workflow no longer sets job-level `continue-on-error`, so genuine + infrastructure failures surface instead of being masked; surviving mutants stay + non-gating via `|| true` on the `mutmut` step. +- `.editorconfig` no longer forces 2-space indentation on `.toml`, which + conflicted with the 4-space arrays in the generated `pyproject.toml`. +- The `ci` recipe comment no longer claims the local gate mirrors everything that + blocks a PR; with scanners enabled it notes they run in CI only (`scan.yml`). + ## [0.1.0] - 2026-06-25 ### Added