Skip to content

Dev tools are bounded only from below, so the rules a new project lints with depend on the day it is rendered #17

Closed

Description

@AlexeyShalaev

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.py

Actual

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.18 and mypy>=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 read pyproject.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 copier and jinja2-time in the template's CI the same way, so a template CI result can be reproduced later.

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Plan:

    • ruff>=0.17.1,<0.18 and mypy>=2.4.0,<2.5, the versions a render resolves today and passes its gate with. Those two decide whether make check is green. pre-commit keeps its floor only, since it decides no rule. Dev groups are not in the published metadata, so the caps cost consumers nothing.
    • The template CI pins copier==9.19.1 and jinja2-time==0.2.0, so a CI result can be reproduced later.
    • Dependabot can't read 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.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    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 weekly Tool bounds workflow that fails when a newer series is out and says whether the gate passes on it.

  3. added a commit that references this issue on Oct 16, 2026
    00ac431
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions