Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion template/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ insert_final_newline = true
indent_style = space
indent_size = 4

[*.{yml,yaml,json,toml}]
[*.{yml,yaml,json}]
indent_size = 2
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions template/justfile.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down Expand Up @@ -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 -%}
3 changes: 3 additions & 0 deletions template/mise.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
4 changes: 4 additions & 0 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,21 @@ 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():
_ = copier.run_copy(
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,
Expand Down
53 changes: 45 additions & 8 deletions tests/test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"])
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading