From 7d7e366a7c208304160f5aa8ece70696da44dc75 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sat, 24 Oct 2026 23:45:10 +0200 Subject: [PATCH] chore(deps): move the pre-commit ruff rev with the lint lock (#1206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock refresh in #1206 takes ruff 0.17.1 -> 0.17.2, but .pre-commit-config.yaml still pins the ruff-pre-commit rev at v0.17.1, so tests/unit/test_noxfile.py fails on every leg of the bump PR (15 jobs). The same hand bump was needed in #1143 and #1165. scripts/compile_constraints.py now rewrites the ruff-pre-commit `rev:` and the lint-imports hook's import-linter pin from the typecheck-and-lint lock it just compiled (and --check reports the file stale), and lock-refresh.yml commits .pre-commit-config.yaml with the lock. The hooks follow whatever ruff the lock holds, so develop keeps v0.17.1 with its 0.17.1 lock and the next refresh ships v0.17.2 alongside ruff==0.17.2. No ceiling (§8.4). Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/lock-refresh.yml | 11 +- .pre-commit-config.yaml | 3 +- CONTRIBUTING.md | 5 +- docs/ci.md | 5 +- python/scripts/compile_constraints.py | 68 ++++++++- .../tests/unit/test_pre_commit_lock_sync.py | 134 ++++++++++++++++++ 6 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 python/tests/unit/test_pre_commit_lock_sync.py diff --git a/.github/workflows/lock-refresh.yml b/.github/workflows/lock-refresh.yml index dedac33f..245eaec8 100644 --- a/.github/workflows/lock-refresh.yml +++ b/.github/workflows/lock-refresh.yml @@ -4,8 +4,9 @@ # rule 3), so tool and dependency upgrades must arrive as a PR that moves the # lock, never overnight on their own. This workflow is that PR's author: after # a green `Nightly matrix` run on `develop` (the fresh-resolve canary, ADR 0007 -# rule 4), it reruns scripts/compile_constraints.py and, when any file changed, -# force-pushes the result to the `bot/lock-refresh` branch and opens (or +# rule 4), it reruns scripts/compile_constraints.py (which also moves the +# pre-commit hooks' ruff and import-linter pins to the new lint lock) and, when +# any file changed, force-pushes the result to the `bot/lock-refresh` branch and opens (or # updates) ONE PR to `develop`. The bump PR's own CI is the gate: the nightly # proves the newest releases work, the PR proves the exact pins do. # @@ -114,8 +115,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -C "$BRANCH" - # Only a moved lock opens a PR; the regenerated docs ride along with it. - git add constraints README.md ../README.md ../ARCHITECTURE.md ../docs ../website/content ../website/public + # Only a moved lock opens a PR; the regenerated docs ride along with it, + # and so do the pre-commit hook pins compile_constraints.py moved to + # the new lint lock (tests/unit/test_noxfile.py holds them equal, #1206). + git add constraints README.md ../README.md ../ARCHITECTURE.md ../docs ../website/content ../website/public ../.pre-commit-config.yaml git commit -q -m "chore(deps): refresh the PR CI lock" \ -m "Recompiled by lock-refresh.yml after a green nightly: ${RUN_URL}" # The checkout persisted no credentials, so authenticate the push with diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab40d7e7..c1e43807 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,8 @@ # them: python/pyproject.toml's [tool.ruff.format] exclude covers ruff format, # and the `exclude:` below covers both ruff hooks. # tests/unit/test_noxfile.py keeps the ruff and import-linter versions below -# equal to the typecheck-and-lint job's lock. +# equal to the typecheck-and-lint job's lock, and python/scripts/ +# compile_constraints.py rewrites both whenever it refreshes that lock. repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.30.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f1bef56..6037f1f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -761,8 +761,11 @@ build plan has the rationale behind each rule: ```bash cd python python scripts/compile_constraints.py # needs `uv` on PATH (dev-time only; CI still uses plain pip) - git diff constraints/ # review before committing + git diff constraints/ ../.pre-commit-config.yaml # review before committing ``` + The script also moves the pre-commit hooks' ruff `rev:` and `import-linter` + pin to the new typecheck-and-lint lock, which `tests/unit/test_noxfile.py` + holds them equal to (#1206); commit that file with the lock. `python scripts/compile_constraints.py --check` recompiles every combo and exits 1 without writing if any file would change; because each combo is compiled on its own, a clean `--check` run also proves every committed file diff --git a/docs/ci.md b/docs/ci.md index 3381d6bd..d2ac0a39 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -116,7 +116,10 @@ issue once the nightly is green again. After every green nightly on `develop`, and on manual dispatch, this workflow reruns `python scripts/compile_constraints.py` on `develop` (#763, -[ADR 0011](adr/0011-nightly-lock-refresh-pr.md)). When any file under +[ADR 0011](adr/0011-nightly-lock-refresh-pr.md)). The script also moves the +ruff-pre-commit `rev:` and the lint-imports hook's `import-linter` pin in +`.pre-commit-config.yaml` to the new typecheck-and-lint lock, so a ruff release +doesn't fail `tests/unit/test_noxfile.py` on the bump PR (#1206). When any file under `python/constraints/` changed, it regenerates the docs that restate the lock (`python scripts/generate_docs.py`, which writes the [framework version pages](framework-support.md), #1068) and the site's llms diff --git a/python/scripts/compile_constraints.py b/python/scripts/compile_constraints.py index dd3acd66..87a07ab6 100644 --- a/python/scripts/compile_constraints.py +++ b/python/scripts/compile_constraints.py @@ -43,6 +43,12 @@ python scripts/compile_constraints.py # refresh constraints/*.txt python scripts/compile_constraints.py --check # exit 1 if refreshing would change a file +The repository's `.pre-commit-config.yaml` restates two pins of the +typecheck-and-lint lock: the ruff-pre-commit `rev:` and the lint-imports hook's +`import-linter==` dependency (`tests/unit/test_noxfile.py` holds them equal). +Every refresh rewrites those two from the lock it just compiled, so a ruff or +import-linter release reaches the hooks in the same bump PR (#1206). + `--check` recompiles every combo and diffs against the committed file. Because each combo is compiled on its own (never merged), a successful `--check` run also proves every committed file is still satisfiable for its own combo: an @@ -53,6 +59,7 @@ from __future__ import annotations import argparse +import re import subprocess import sys import tempfile @@ -61,6 +68,16 @@ _PYTHON_ROOT = Path(__file__).resolve().parents[1] _CONSTRAINTS_DIR = _PYTHON_ROOT / "constraints" +_PRE_COMMIT = _PYTHON_ROOT.parent / ".pre-commit-config.yaml" +#: The typecheck-and-lint job's lock: the ruff and import-linter the hooks run. +_LINT_LOCK = _CONSTRAINTS_DIR / "typecheck-and-lint-py3.11.txt" +#: The ruff-pre-commit repo's `rev:` line (its tags are `v`). +_RUFF_REV = re.compile( + r"^(\s*- repo: https://github\.com/astral-sh/ruff-pre-commit\n\s+rev: )v\S+$", + re.MULTILINE, +) +#: The lint-imports hook's pinned dependency. +_IMPORT_LINTER_DEP = re.compile(r"(? str: return header + compiled +class PreCommitSyncError(RuntimeError): + """The lint lock or `.pre-commit-config.yaml` lacks a pin the sync rewrites.""" + + +def _lock_pin(lock_text: str, package: str) -> str: + for line in lock_text.splitlines(): + if line.startswith(f"{package}=="): + return line.split("==", 1)[1].strip() + raise PreCommitSyncError(f"{package} is not pinned in {_LINT_LOCK.name}") + + +def sync_pre_commit_pins(config_text: str, lint_lock_text: str) -> str: + """Return ``config_text`` with its ruff and import-linter pins taken from the lock. + + Rewrites the ruff-pre-commit ``rev:`` to ``v`` and the + lint-imports hook's ``import-linter==`` dependency to the import-linter + pin, and leaves every other byte alone (a YAML round trip would drop the + comments). Raises :class:`PreCommitSyncError` when either pin or either + line is missing, so a renamed hook fails the refresh instead of leaving the + hooks behind the lock. + """ + ruff = _lock_pin(lint_lock_text, "ruff") + import_linter = _lock_pin(lint_lock_text, "import-linter") + synced, revs = _RUFF_REV.subn(lambda m: f"{m.group(1)}v{ruff}", config_text) + synced, deps = _IMPORT_LINTER_DEP.subn(lambda m: f"{m.group(1)}{import_linter}", synced) + if revs != 1: + raise PreCommitSyncError( + f"expected one astral-sh/ruff-pre-commit `rev:` in {_PRE_COMMIT.name}, found {revs}" + ) + if deps != 1: + raise PreCommitSyncError( + f"expected one import-linter== pin in {_PRE_COMMIT.name}, found {deps}" + ) + return synced + + def main(argv: list[str]) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( @@ -301,6 +354,7 @@ def main(argv: list[str]) -> int: _CONSTRAINTS_DIR.mkdir(exist_ok=True) changed: list[str] = [] + lint_lock_text = "" with tempfile.TemporaryDirectory() as tmp: tmp_dir = Path(tmp) for combo in _COMBOS: @@ -310,6 +364,8 @@ def main(argv: list[str]) -> int: # committed path — a --check run must not mutate it. scratch = tmp_dir / combo.file_name(python_version) content = _compile_one(combo, python_version, scratch) + if target == _LINT_LOCK: + lint_lock_text = content if args.check: existing = target.read_text(encoding="utf-8") if target.is_file() else "" if existing != content: @@ -323,9 +379,19 @@ def main(argv: list[str]) -> int: ) print(f"wrote {target.relative_to(_PYTHON_ROOT)} ({pins} pins)") + # The hooks follow the lint lock just compiled (written, or only diffed under --check). + config = _PRE_COMMIT.read_text(encoding="utf-8") + synced = sync_pre_commit_pins(config, lint_lock_text) + if synced != config: + if args.check: + changed.append(_PRE_COMMIT.name) + else: + _PRE_COMMIT.write_text(synced, encoding="utf-8") + print(f"wrote {_PRE_COMMIT.name} (ruff and import-linter pins from {_LINT_LOCK.name})") + if args.check and changed: names = ", ".join(changed) - print(f"::error::stale constraints files, run scripts/compile_constraints.py: {names}") + print(f"::error::stale lock files, run scripts/compile_constraints.py: {names}") return 1 return 0 diff --git a/python/tests/unit/test_pre_commit_lock_sync.py b/python/tests/unit/test_pre_commit_lock_sync.py new file mode 100644 index 00000000..f3b89ac8 --- /dev/null +++ b/python/tests/unit/test_pre_commit_lock_sync.py @@ -0,0 +1,134 @@ +"""The lock refresh moves the pre-commit tool pins with the lint lock (#1206). + +`tests/unit/test_noxfile.py` holds the ruff-pre-commit `rev:` and the +lint-imports hook's `import-linter==` pin equal to the typecheck-and-lint +job's lock. `lock-refresh.yml` moves that lock, so a ruff or import-linter +release used to turn every leg of the bump PR red until someone bumped +`.pre-commit-config.yaml` by hand (#1143, #1165, #1206). These checks hold the +refresh to rewriting those two pins itself, from the lock it just wrote, and +to committing the result with the lock. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Any + +import pytest +import yaml + +_PYTHON_ROOT = Path(__file__).resolve().parents[2] +_REPO = _PYTHON_ROOT.parent +_PRE_COMMIT = _REPO / ".pre-commit-config.yaml" +_LOCK_REFRESH = _REPO / ".github" / "workflows" / "lock-refresh.yml" + +sys.path.insert(0, str(_PYTHON_ROOT / "scripts")) +from compile_constraints import ( # noqa: E402 + _LINT_LOCK, + PreCommitSyncError, + sync_pre_commit_pins, +) + + +def _require(path: Path) -> Path: + if not path.is_file(): + pytest.skip(f"not a repo checkout (no {path.relative_to(_REPO)})") + return path + + +def _lock(**pins: str) -> str: + """A lint lock with the committed one's pins, ``pins`` replacing some.""" + lines = [] + for line in _LINT_LOCK.read_text(encoding="utf-8").splitlines(): + name = line.split("==", 1)[0] + lines.append(f"{name}=={pins[name]}" if name in pins else line) + return "\n".join(lines) + "\n" + + +def _hook_pins(config_text: str) -> tuple[str, str, list[str]]: + hooks: dict[str, dict[str, Any]] = { + hook["id"]: {**hook, "rev": repo.get("rev")} + for repo in yaml.safe_load(config_text)["repos"] + for hook in repo["hooks"] + } + return ( + hooks["ruff-check"]["rev"], + hooks["ruff-format"]["rev"], + hooks["lint-imports"]["additional_dependencies"], + ) + + +def test_a_ruff_release_in_the_lock_moves_the_ruff_pre_commit_rev() -> None: + # The #1206 case: the lock takes ruff 0.17.2 while the hook says v0.17.1. + config = _require(_PRE_COMMIT).read_text(encoding="utf-8") + stale = sync_pre_commit_pins(config, _lock(ruff="0.17.1")) + assert _hook_pins(stale)[0] == "v0.17.1" + synced = sync_pre_commit_pins(stale, _lock(ruff="0.17.2")) + check_rev, format_rev, linter = _hook_pins(synced) + assert check_rev == format_rev == "v0.17.2" + assert linter == [f"import-linter=={_pin(_LINT_LOCK.read_text(encoding='utf-8'))}"] + + +def test_an_import_linter_release_in_the_lock_moves_the_hook_dependency() -> None: + config = _require(_PRE_COMMIT).read_text(encoding="utf-8") + synced = sync_pre_commit_pins(config, _lock(**{"import-linter": "9.9.9"})) + assert _hook_pins(synced)[2] == ["import-linter==9.9.9"] + + +def test_the_sync_rewrites_only_the_two_pins() -> None: + # Comments, the gitleaks rev and every other line survive byte for byte. + config = _require(_PRE_COMMIT).read_text(encoding="utf-8") + synced = sync_pre_commit_pins(config, _lock(ruff="99.0.0", **{"import-linter": "9.9.9"})) + assert len(config.splitlines()) == len(synced.splitlines()) + changed = [ + new for old, new in zip(config.splitlines(), synced.splitlines(), strict=True) if old != new + ] + assert [new.strip() for new in changed] == [ + "rev: v99.0.0", + "additional_dependencies: [import-linter==9.9.9]", + ] + + +def test_the_committed_hooks_already_match_the_committed_lock() -> None: + # A refresh with no ruff or import-linter release leaves the file alone. + config = _require(_PRE_COMMIT).read_text(encoding="utf-8") + assert sync_pre_commit_pins(config, _LINT_LOCK.read_text(encoding="utf-8")) == config + + +def test_a_lock_without_the_pin_is_an_error_not_a_silent_skip() -> None: + config = _require(_PRE_COMMIT).read_text(encoding="utf-8") + lock = "".join( + line + "\n" + for line in _LINT_LOCK.read_text(encoding="utf-8").splitlines() + if not line.startswith("ruff==") + ) + with pytest.raises(PreCommitSyncError, match="ruff"): + sync_pre_commit_pins(config, lock) + + +def test_a_config_without_the_hook_is_an_error_not_a_silent_skip() -> None: + config = _require(_PRE_COMMIT).read_text(encoding="utf-8") + moved = config.replace("astral-sh/ruff-pre-commit", "example/elsewhere") + with pytest.raises(PreCommitSyncError, match="ruff-pre-commit"): + sync_pre_commit_pins(moved, _LINT_LOCK.read_text(encoding="utf-8")) + + +def test_the_lock_refresh_commits_the_synced_pre_commit_config() -> None: + # The bump PR carries the rewritten hooks, or test_noxfile.py fails on it. + workflow = yaml.safe_load(_require(_LOCK_REFRESH).read_text(encoding="utf-8")) + runs = [step.get("run", "") for step in workflow["jobs"]["refresh"]["steps"]] + adds = [ + line.strip() for run in runs for line in run.splitlines() if re.match(r"\s*git add ", line) + ] + assert len(adds) == 1, adds + assert "../.pre-commit-config.yaml" in adds[0].split() + + +def _pin(lock_text: str) -> str: + return next( + line.split("==", 1)[1].strip() + for line in lock_text.splitlines() + if line.startswith("import-linter==") + )