Repository navigation
Dev tools are bounded only from below, so the rules a new project lints with depend on the day it is rendered #17
Description
Versions: python-library-template 8ebf45d, Copier 9.18.2, uv 0.13.1
template/pyproject.toml.jinja:41-46 gives the dev tools lower bounds only (ruff>=0.15.1, mypy>=1.19.1, pre-commit>=4.5.1), and the template ships no uv.lock. A new project therefore starts with whatever is newest on the day someone first runs uv sync, and the template's own CI (.github/workflows/ci.yml:22-39, which also runs copier and jinja2-time unpinned) tests whatever is newest on the day it runs. Ruff minor releases promote preview rules to stable, and the generated config selects whole families (RUF, PTH, PL* and others). A Ruff release can turn make check red on code that passed the day before, without any change to the template or the project. The floors also no longer match what the config was written for: mypy>=1.19.1 resolves to mypy 2.4.0, a major version above it.
Existing libraries are covered by their committed uv.lock and Dependabot's weekly grouped dev update, and that update is where a breaking Ruff release should show up. The gap is the template itself and a new project's first day.
Reproduction
In a render of 8ebf45d:
ls uv.lock
uv lock -q && grep -A1 -E '^name = "(ruff|mypy)"$' uv.lock
uv lock -q --exclude-newer 2026-09-25T00:00:00Z && grep -A1 -E '^name = "(ruff|mypy)"$' uv.lock
# a rule Ruff 0.17.0 promoted to stable, in a family the config selects (RUF)
cat > demo_lib/paths.py <<'PY'
import os.path
def shared_root(paths: list[str]) -> str:
return os.path.commonprefix(paths)
PY
uvx --from ruff==0.16.9 ruff check --output-format concise demo_lib/paths.py
uvx --from ruff==0.17.1 ruff check --output-format concise demo_lib/paths.pyActual
ls: cannot access 'uv.lock': No such file or directory
name = "mypy"
version = "2.4.0"
--
name = "ruff"
version = "0.17.1"
name = "mypy"
version = "2.3.1"
--
name = "ruff"
version = "0.16.9"
All checks passed!
demo_lib\paths.py:5:12: RUF071 `os.path.commonprefix()` compares strings character-by-character
Found 1 error.
1 unsafe fix available with `--fix --unsafe-fixes`.
I compared ruff rule --all --output-format json between 0.16.9 and 0.17.1. RUF071 is the only rule in the selected, non-ignored families that moved from preview to stable in that release. make check on the empty render passes with both versions, so nothing is broken today. The issue is that whether it stays green is decided outside the template.
Why
This config is curated rule by rule. Since #8 it says "Everything selected here runs." Which rules run should change in a template commit, where the render job checks it, and not when an upstream tool happens to release.
Proposal
- Bound the dev tools to the minor series the config was checked with, e.g.
ruff>=0.17.1,<0.18andmypy>=2.4,<2.5, and raise the bounds on purpose in a template PR, with the render job proving each raise. Dev dependency groups are not part of the published metadata, so upper bounds there cost consumers nothing. The catch is that Dependabot does not readpyproject.toml.jinja, so the raises are manual. A monthly reminder, or a scheduled run of the render job against unbounded versions, would show when a bound is stale. - Pin
copierandjinja2-timein the template's CI the same way, so a template CI result can be reproduced later.
Activity
AlexeyShalaev commentedon Oct 16, 2026
Plan:
ruff>=0.17.1,<0.18andmypy>=2.4.0,<2.5, the versions a render resolves today and passes its gate with. Those two decide whethermake checkis green.pre-commitkeeps its floor only, since it decides no rule. Dev groups are not in the published metadata, so the caps cost consumers nothing.copier==9.19.1andjinja2-time==0.2.0, so a CI result can be reproduced later.pyproject.toml.jinja, so a cap would go stale without anyone noticing. A weekly scheduled workflow renders the template, lifts the caps, and fails when a newer Ruff or mypy minor is out. It also runs the generated gate against that version, so the failure says both "raise the cap" and whether the raise is clean. It is a separate workflow because the template CI's own gate requires every job to succeed, and a schedule-only job there would show up as skipped on every PR.A raise then happens in a template PR, where the render job proves it.
AlexeyShalaev commentedon Oct 16, 2026
The fix is in #20: ruff
>=0.17.1,<0.18, mypy>=2.4.0,<2.5, copier and jinja2-time pinned in the template CI, and a weeklyTool boundsworkflow that fails when a newer series is out and says whether the gate passes on it.fix: cap ruff and mypy at the series the generated config was checked…