--- key: ci.python-lint-ruff-config-committed title: 2026-08-21 — Python lint is a committed ruff.toml enforced in CI, not the operator machine's global config (#780) status: active since: '2026-08-21' supersedes: none superseded-by: none rule: The repo commits `ruff.toml`, and the `script-tests` job runs `ruff check` + `ruff format --check` under a PINNED ruff over an EXPLICIT population from `git ls-files`, never `ruff check .`. Never rely on `~/.config/ruff/ruff.toml`, and never add a lint rule to the config without making the tree clean against it in the same PR. signals: 'ruff · pyright · python lint · `ruff format --check` · lint passes on my machine but not yours · no repo lint config · S105 on a test stub credential · paths: `ruff.toml`, `.gitea/workflows/pr-checks.yml` · issues: #780, #773, #648, #512' mechanics: 'Config at repo root; `.gitea/workflows/pr-checks.yml` -> `script-tests` pins `ruff==0.12.11` via pip and runs both commands ahead of the jq preflight and pytest. Population is `git ls-files -z ''*.py'' ''*.pyi'' ''*.ipynb''` passed explicitly with `--no-force-exclude`, guarded by an empty-list arm; discovery-based invocation is defeated by an `exclude` in three config scopes, two of them per command. Bumping the pin is a deliberate PR because a new ruff release adds rules.' --- The global instructions tell every session to run `ruff check`, `ruff format --check` and `pyright` after touching Python. Before this, the repo enforced none of them and committed no config, so ruff fell back to whichever `~/.config/ruff/ruff.toml` the operator's machine happened to have — **a second machine lints this repo differently, or not at all.** That is the same shape as #643/#647/#648 (a shell gate whose behaviour was a function of an untested interpreter version) and #512 (a test that passed on a fast laptop and flaked on a starved CI VM): the verdict was a property of the environment rather than of the repo. The committed config is the operator's global one apart from `per-file-ignores`, which is narrowed to `scripts/tests/**`. That is what the tree was de-facto written against, so adopting it cost a mechanical reformat rather than a rewrite: 74 findings against `706674272`, of which 57 were fixed in code (mostly by the format pass) and 17 carry a per-site `# noqa` with its reason inline. `RUF100` is selected so those suppressions stay honest — a `# noqa` that suppresses nothing is otherwise invisible, and three were live the moment the rule was switched on: one whose rule had stopped firing, one for a rule this config never enables, and one added mid-branch on a site the same branch had already fixed in code. **One exemption is directory-wide, and it is the boring one.** `S101` for `scripts/tests/**`, because a test suite asserts. **`S105` is deliberately NOT directory-wide.** All eight of its hits among those 74 findings are stub credentials handed to the real hooks (`env["ETV_GITEA_TOKEN"] = "stub"`), with no true positive in the tree today (a ninth `# noqa: S105` predates this and sits on a commit-message marker in `decisions_validate.py`). A directory blanket would give up hardcoded-credential coverage over the largest Python surface in the repo, permanently, to suppress eight known lines — and this is the only Python lint the repo runs, so nothing else would catch a real token pasted into a fixture next year. Per-site `# noqa: S105` costs the same and keeps the rule live. **The population comes from `git ls-files`, not from ruff's discovery, and that is the load-bearing part.** `ruff check .` reports on what it *discovers*, and an `exclude` defeats discovery in three different config scopes — including `[format]`, which is where an appended line lands by TOML rules (two of the three defeat each command). Measured with ruff 0.12.11 and `exclude = ["scripts/**"]`, against a tracked file holding an unused import, a hardcoded credential and a formatting error. The pattern matters: `exclude` is matched per FILE, so a bare `["scripts"]` works at the top level but matches nothing under `[lint]`/`[format]`. GREEN means the gate was silently off: | `exclude` in | `ruff check .` | explicit `check` | `ruff format --check .` | explicit `format` | |---|---|---|---|---| | top level | GREEN | red | GREEN | red | | `[lint]` | GREEN | red | red | red | | `[format]` | red | red | GREEN | red | | top + `force-exclude` | GREEN | GREEN without `--no-force-exclude`, red with it | GREEN | same | Only the top-level scope empties both discovery commands; `[lint]` empties `check`, `[format]` empties `format --check`, so in those two the job would still redden on the other step. `[format]` is where a line appended to `ruff.toml` lands, by TOML rules. The last row is the whole reason for the flag. `include = []`, `extend-exclude` and a nested `scripts/ruff.toml` were tried too, and are equally inert against the explicit form. The empty-list arm is the anti-vacuity check — `ruff check .` over no files exits **0** with a stderr warning, so an emptied population is a green gate, not a red one. Enumerating from git also covers tracked-but-gitignored files, which discovery skips (`git add -f` under an ignored path is established practice here). **The rule set is not covered, and that is a stated limit rather than an oversight.** `select = []` silences every selected rule, so the `ruff check` step goes green over any lint violation (a syntax error still reds) while still printing a reassuring file count. `ruff format --check` is unaffected, because formatting is not rule-selected. So the population arm makes an emptied *file* set loud, nothing makes an emptied *rule* set loud, and half the gate is killable by a config edit only a reviewer catches. Both steps were witnessed red on the runner before merge, not argued to work — **on the body that shipped**: run 2173 job 9176 (`❌ Failure - Main Lint scripts (ruff check)` on an `F401`) and run 2170 job 9163 (`❌ Failure - Main Lint scripts (ruff format --check)`), printing `Linting 34 tracked Python files` and `Format-checking 34 tracked Python files` — the population arm executing (34 = the 33 tracked files plus the probe; the merged tree has 33). Each came from a temporary probe commit reverted before merge. Two probes are needed, not one: a check-dirty file stops the job before the format step ever runs. Earlier reds against the previous, discovery-based bodies were discarded rather than cited — a proof belongs to the code that ran, not to its predecessor. **Lint runs early in the job, ahead of the jq preflight.** `Preflight jq version` is a hard `--expect` tripwire; a lint step behind it stops running for as long as the jq contract is broken, under a red that names jq. Ordering is the difference between a gate that is skipped and one that is not. The `git` half of `Preflight external tools` stays *ahead* of the lint steps, because they consume `git`: without it, a missing git arrives as an empty population and both steps report a population problem instead of the missing tool. **`pyright` is deliberately NOT gated.** Its only findings here are `reportMissingImports` for `etv_client` in `scripts/scripted-schedules/entrypoint.py`, resolvable only inside that script's deploy environment. Gating it would put a node toolchain on the git-only `small` lane to find nothing. Revisit when this repo grows a typed Python surface — the reason is the cost/finding ratio today, not a judgement that type checking does not belong. **The pin is the second half of the fix.** An unpinned `pip install ruff` re-introduces exactly the divergence the config closes, one layer up: the verdict becomes a function of *when* the job ran. Same argument as the `jq` pin in the same job (`ci.jq-version-contract`), and the same consequence — a bump is a PR someone reads. `pytest` and `pyyaml` in the same job stay unpinned, and the asymmetry is the point rather than an oversight: a pytest release does not add assertions to your suite, a ruff release adds rules to your lint.