diff --git a/AGENTS.md b/AGENTS.md index c7c6743..5cc90b8 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) ``` @@ -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 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 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/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/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/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/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/template/justfile.jinja b/template/justfile.jinja index f1f5f12..a09b9fc 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -3,15 +3,17 @@ 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" 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 . @@ -44,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/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/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/conftest.py b/tests/conftest.py index ce65a73..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(): @@ -101,6 +102,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, diff --git a/tests/test_generation.py b/tests/test_generation.py index d0f520b..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 @@ -404,10 +407,39 @@ 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 + # 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_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: @@ -496,11 +528,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() @@ -712,13 +744,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"]) 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" },