One Python per repo, always the newest, and the gate that enforces it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-28 20:58:19 +02:00
parent 5fc792d791
commit 3565b281ca
16 changed files with 194 additions and 68 deletions

View File

@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.x"
- name: Install dependencies
working-directory: crdt-sync
run: pip install -r requirements.txt

View File

@ -14,17 +14,13 @@ jobs:
defaults:
run:
working-directory: crdt-sync
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: "3.x"
- name: Install dependencies
run: pip install -r requirements.txt

View File

@ -28,7 +28,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.x"
- name: Every file is at most 250 lines
run: bash scripts/check_file_length.sh --all

View File

@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.x"
- name: Install dependencies
working-directory: gatelock
run: pip install -r requirements.txt

View File

@ -16,17 +16,13 @@ jobs:
defaults:
run:
working-directory: gatelock
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: "3.x"
- name: Install dependencies
run: pip install -r requirements.txt

View File

@ -20,7 +20,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.13'
python-version: "3.x"
# Compares tokens.md (prose freeze), web_ui/src/tokens.css,
# design_system tokens.dart and gatelock's LockConfig. Fails on a hex

View File

@ -24,7 +24,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
python-version: "3.x"
- name: Install test dependencies
run: python -m pip install --upgrade pytest pytest-cov pyyaml packaging

View File

@ -2,7 +2,7 @@
name = "crdt-sync"
version = "0.9.1"
description = "Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps"
requires-python = ">=3.10"
requires-python = ">=3.14"
dependencies = ["requests==2.34.2"]
# Ship only the library. `tool/` is a package too (it has an __init__.py so

View File

@ -18,6 +18,10 @@ CARGO: Final = "cargo"
GOMOD: Final = "gomod"
GITTAG: Final = "gittag"
TOOLCHAIN: Final = "toolchain"
# The constraint a workflow parser writes for a version MATRIX. Standing
# decision (kuhy, 2026-08-28): one toolchain version per repo, always newest,
# so a matrix is a finding to delete rather than a range to satisfy.
MATRIX: Final = "matrix"
ECOSYSTEMS: Final = (PUB, PYPI, NPM, CARGO, GOMOD, GITTAG, TOOLCHAIN)

View File

@ -15,7 +15,7 @@ purpose:
from __future__ import annotations
from dep_freshness._tables import TOOLCHAIN
from dep_freshness._tables import MATRIX, TOOLCHAIN
from dep_freshness.constraints import lower_bound, satisfies
from dep_freshness.models import Dep, Finding, Severity
from dep_freshness.resolve import Answer
@ -27,15 +27,32 @@ def _toolchain(dep: Dep, latest: str) -> Finding | None:
if behind(dep.pinned, latest):
return Finding(dep, Severity.STALE, latest)
return None
if dep.constraint == MATRIX:
# Standing decision: every repo runs exactly ONE toolchain version and
# it is the newest. A matrix is three answers to a one-answer question,
# and its older entries are stale by construction.
return Finding(
dep,
Severity.UNPINNED,
latest,
detail=f"a version MATRIX is not a version: run exactly one "
f"{dep.name}, the newest. Replace the whole matrix with a "
f'single `{dep.name}-version: "'
f'{latest.split(".")[0]}.x"`',
)
if not dep.constraint or dep.caret_ok is False:
return Finding(
dep, Severity.UNPINNED, latest,
dep,
Severity.UNPINNED,
latest,
detail="a channel name is not a version: two machines on the same "
"commit can build with different SDKs",
"commit can build with different SDKs",
)
if not satisfies(dep.constraint, latest):
return Finding(
dep, Severity.STALE, latest,
dep,
Severity.STALE,
latest,
detail=f"the constraint excludes the current toolchain {latest}",
)
return None
@ -45,19 +62,25 @@ def judge(dep: Dep, answer: Answer) -> Finding | None:
"""The gate's objection to `dep`, or None when it is fine."""
if dep.override:
return Finding(
dep, Severity.OVERRIDE, answer.version,
dep,
Severity.OVERRIDE,
answer.version,
detail="an override pins a transitive dependency nothing else "
"watches; allowlist it or remove it",
"watches; allowlist it or remove it",
)
if answer.unavailable:
return Finding(
dep, Severity.UNKNOWN, None,
dep,
Severity.UNKNOWN,
None,
detail="no network and no cached answer",
)
latest = answer.version
if latest is None:
return Finding(
dep, Severity.UNKNOWN, None,
dep,
Severity.UNKNOWN,
None,
detail="the registry has no stable release for this package",
)
@ -70,7 +93,9 @@ def judge(dep: Dep, answer: Answer) -> Finding | None:
# The meaningful question is whether the range still admits latest.
if not satisfies(dep.constraint, latest):
return Finding(
dep, Severity.STALE, latest,
dep,
Severity.STALE,
latest,
detail=f"the peer range excludes the current {latest}",
)
return None
@ -80,12 +105,16 @@ def judge(dep: Dep, answer: Answer) -> Finding | None:
floor = lower_bound(dep.constraint)
if floor and behind(floor, latest):
return Finding(
dep, Severity.STALE, latest,
dep,
Severity.STALE,
latest,
detail=f"range floor {floor} is behind latest stable",
)
return None
return Finding(
dep, Severity.UNPINNED, latest,
dep,
Severity.UNPINNED,
latest,
detail=f"exact-pin it ({latest}) so freshness is checkable",
)
@ -93,7 +122,9 @@ def judge(dep: Dep, answer: Answer) -> Finding | None:
return Finding(dep, Severity.STALE, latest)
if dep.locked and dep.locked != dep.pinned:
return Finding(
dep, Severity.LOCK_MISMATCH, latest,
dep,
Severity.LOCK_MISMATCH,
latest,
detail=f"lockfile resolved {dep.locked}, manifest pins {dep.pinned}",
)
return None

View File

@ -8,12 +8,26 @@ went red on `discarded_futures` for exactly this reason: the runner was on
Flutter 3.44.9, which predates the `@awaitNotRequired` annotation that makes
the same `unawaited(...)` call a violation on 3.47.2.
`actions/setup-python` is the same story and cost testsAndMisc a red run on
2026-08-28: `python-tests.yml` still said `python-version: "3.11"` while the
repo's pins had moved to `numpy 2.5.2`, which requires >=3.12. Nothing local
could see it -- the gate judged the interpreter in the shell, not the one the
runner installs.
So the CI pin is a dependency like any other, judged against the same latest
stable. Keeping both honest that way needs no separate "these must agree"
rule: two values both required to equal latest already agree.
A `${{ ... }}` expression is skipped -- it resolves at run time, and guessing
is worse than saying nothing.
**A version MATRIX is a finding, not a support range.** Standing decision
(kuhy, 2026-08-28): every repo runs EXACTLY ONE toolchain version and it is
always the newest. `python-version: ["3.10", "3.11", "3.12"]` is three answers
to a question that has one, and the two oldest are guaranteed to be behind. It
is reported so it gets deleted, not bumped.
Two things are skipped rather than guessed: a `${{ ... }}` expression, which
resolves at run time, and a wildcard like `3.x`, which `setup-python` already
resolves to the newest stable and therefore cannot drift. `3.x` is what the
fleet standardised on, so the common case makes zero registry calls.
"""
from __future__ import annotations
@ -21,18 +35,21 @@ from __future__ import annotations
import re
from pathlib import Path
from dep_freshness._tables import TOOLCHAIN
from dep_freshness._tables import MATRIX, TOOLCHAIN
from dep_freshness.models import Dep
from dep_freshness.versions import exact_pin
WORKFLOW_DIR = ".github/workflows"
# `flutter-version: 3.47.2`, `node-version: '24.18.0'`, quoted or not.
# `flutter-version: 3.47.2`, `node-version: '24.18.0'`, quoted or not, and the
# inline-mapping form `with: {python-version: "3.12"}` that build_your_x uses.
_PIN = re.compile(
r"""^\s*(?P<tool>flutter|node)-version:\s*["']?(?P<value>[^"'#\s]+)["']?"""
r"""(?:^|[{,])\s*(?P<tool>flutter|node|python)-version:(?P<rest>.*)$"""
)
# A channel name is not a version, and `.fvmrc` already reports that case.
_NOT_A_VERSION = frozenset({"stable", "beta", "master", "main", "dev", "lts", "latest"})
# `3.x`, `24.x`, bare `x`: already "newest", so there is nothing to compare.
_WILDCARD = re.compile(r"^\d+(\.\d+)*\.x$|^x$")
def is_workflow(path: Path) -> bool:
@ -44,6 +61,33 @@ def is_workflow(path: Path) -> bool:
)
def _scalar(rest: str) -> str:
"""The bare version out of everything that can follow the colon.
Handles the quoting, a trailing `}` from an inline mapping, and a trailing
`# comment`. Returns "" when the line declares no value at all, which in
YAML means a block list follows -- i.e. a matrix written the long way.
"""
text = rest.split("#", 1)[0].strip().rstrip("}").strip()
return text.strip("\"'")
def _dep(tool: str, value: str, path: Path, number: int) -> Dep | None:
"""The `Dep` for one `<tool>-version:` line, or None when unjudgeable."""
if value.startswith("${{") or value in _NOT_A_VERSION or _WILDCARD.match(value):
return None
# An inline `[...]` list, or an empty value with a block list beneath it.
matrix = value.startswith("[") or value == ""
return Dep(
ecosystem=TOOLCHAIN,
name=tool,
constraint=MATRIX if matrix else value,
path=path,
line=number,
pinned=None if matrix else exact_pin(value),
)
def parse(path: Path) -> list[Dep]:
try:
text = path.read_text(encoding="utf-8")
@ -51,20 +95,10 @@ def parse(path: Path) -> list[Dep]:
return []
deps: list[Dep] = []
for number, line in enumerate(text.splitlines(), start=1):
match = _PIN.match(line)
match = _PIN.search(line)
if not match:
continue
value = match.group("value")
if value.startswith("${{") or value in _NOT_A_VERSION:
continue
deps.append(
Dep(
ecosystem=TOOLCHAIN,
name=match.group("tool"),
constraint=value,
path=path,
line=number,
pinned=exact_pin(value),
)
)
dep = _dep(match.group("tool"), _scalar(match.group("rest")), path, number)
if dep is not None:
deps.append(dep)
return deps

View File

@ -101,6 +101,22 @@ def test_a_channel_name_in_fvmrc_is_reported_unpinned():
assert finding.severity is Severity.UNPINNED
def test_a_version_matrix_is_reported_with_the_single_pin_to_use():
"""One toolchain version per repo, always the newest (kuhy, 2026-08-28).
A matrix has to be DELETED, not bumped, so the detail must not read like
"exact-pin it (3.14.7)" -- that would leave the other entries in place.
"""
finding = judge(
dep(ecosystem="toolchain", name="python", constraint="matrix",
pinned=None, path=Path(".github/workflows/ci.yml")),
Answer("3.14.7"),
)
assert finding.severity is Severity.UNPINNED
assert "MATRIX is not a version" in finding.detail
assert 'python-version: "3.x"' in finding.detail
def test_a_pinned_toolchain_behind_latest_is_stale():
finding = judge(
dep(ecosystem="toolchain", name="flutter", constraint="3.47.1",

View File

@ -5,12 +5,13 @@ takes its own `flutter-version:`, and that is what the runner gets. When the
two disagree the failure is green-locally / red-in-CI with no diff to explain
it -- kuhylog's runner was on Flutter 3.44.9, which predates the
`@awaitNotRequired` annotation, so the same code was a violation there and
clean here.
clean here. `actions/setup-python` did the same to testsAndMisc on 2026-08-28.
"""
from __future__ import annotations
from dep_freshness import discover
from dep_freshness._tables import MATRIX
from dep_freshness.parsers import workflow
from dep_freshness.tests.conftest import write
@ -38,10 +39,16 @@ def _path(repo):
return repo / ".github/workflows/ci.yml"
def _parse(repo, body):
return workflow.parse(write(repo, ".github/workflows/ci.yml", body))
def _names(repo, body):
return [d.name for d in _parse(repo, body)]
def test_a_workflow_pin_is_a_toolchain_dependency(repo):
deps = {
d.name: d for d in workflow.parse(write(repo, ".github/workflows/ci.yml", CI))
}
deps = {d.name: d for d in _parse(repo, CI)}
assert deps["flutter"].pinned == "3.44.9"
assert deps["flutter"].line == 12
assert deps["node"].pinned == "24.18.0"
@ -50,18 +57,60 @@ def test_a_workflow_pin_is_a_toolchain_dependency(repo):
def test_a_channel_name_is_not_a_version(repo):
body = CI.replace("flutter-version: 3.44.9", "flutter-version: stable")
names = [
d.name for d in workflow.parse(write(repo, ".github/workflows/ci.yml", body))
]
assert "flutter" not in names, "a channel tells the gate nothing to compare"
assert "flutter" not in _names(repo, body), "a channel gives nothing to compare"
def test_a_runtime_expression_is_skipped_rather_than_guessed(repo):
body = CI.replace("flutter-version: 3.44.9", "flutter-version: ${{ env.FLUTTER }}")
names = [
d.name for d in workflow.parse(write(repo, ".github/workflows/ci.yml", body))
]
assert "flutter" not in names
assert "flutter" not in _names(repo, body)
def test_a_setup_python_pin_is_a_toolchain_dependency(repo):
"""testsAndMisc went red because its runner was still on 3.11.
Its pins had moved to numpy 2.5.2, which declares >=3.12. Nothing local
could see it: the gate judged the interpreter in the shell, never the one
`actions/setup-python` installs.
"""
body = CI + ' python-version: "3.11"\n'
deps = {d.name: d for d in _parse(repo, body)}
assert deps["python"].pinned == "3.11"
assert deps["python"].ecosystem == "toolchain"
def test_a_wildcard_has_nothing_to_compare(repo):
"""`3.x` already resolves to newest stable, so it cannot drift behind."""
for value in ('"3.x"', '"24.x"', '"x"'):
body = CI + f" python-version: {value}\n"
assert "python" not in _names(repo, body), value
def test_an_inline_matrix_is_reported_rather_than_bumped(repo):
"""One version per repo, always newest: a matrix is deleted, not raised."""
body = CI + ' python-version: ["3.10", "3.11", "3.12"]\n'
deps = {d.name: d for d in _parse(repo, body)}
assert deps["python"].constraint == MATRIX
assert deps["python"].pinned is None
def test_a_block_list_matrix_is_caught_too(repo):
"""The long form declares no value on the line; the list is beneath it."""
body = CI + " python-version:\n - '3.10'\n - '3.11'\n"
deps = {d.name: d for d in _parse(repo, body)}
assert deps["python"].constraint == MATRIX
def test_an_inline_mapping_pin_is_found(repo):
"""build_your_x writes `with: {python-version: "3.12"}` on one line."""
body = CI + ' with: {python-version: "3.12"}\n'
deps = {d.name: d for d in _parse(repo, body)}
assert deps["python"].pinned == "3.12"
def test_a_trailing_comment_is_not_part_of_the_version(repo):
body = CI + ' python-version: "3.12" # oldest supported\n'
deps = {d.name: d for d in _parse(repo, body)}
assert deps["python"].pinned == "3.12"
def test_an_unreadable_workflow_yields_nothing(repo):

View File

@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "gatelock"
version = "0.7.2"
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
requires-python = ">=3.10"
requires-python = ">=3.14"
dependencies = [] # pure stdlib — tkinter is bundled with Python
[project.optional-dependencies]

View File

@ -35,7 +35,7 @@
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.2",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.6",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",

View File

@ -15,8 +15,8 @@ importers:
specifier: 7.0.1
version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.11)
'@testing-library/react':
specifier: 16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
specifier: 16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/user-event':
specifier: 14.6.6
version: 14.6.6(@testing-library/dom@10.4.1)
@ -333,8 +333,8 @@ packages:
vitest:
optional: true
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
'@testing-library/react@16.3.3':
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
@ -1402,7 +1402,7 @@ snapshots:
optionalDependencies:
vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.4.0))
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1