diff --git a/.github/workflows/crdt-sync-precommit.yml b/.github/workflows/crdt-sync-precommit.yml index c698fc1..20dd47d 100644 --- a/.github/workflows/crdt-sync-precommit.yml +++ b/.github/workflows/crdt-sync-precommit.yml @@ -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 diff --git a/.github/workflows/crdt-sync-tests.yml b/.github/workflows/crdt-sync-tests.yml index 0a27b13..6275cb7 100644 --- a/.github/workflows/crdt-sync-tests.yml +++ b/.github/workflows/crdt-sync-tests.yml @@ -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 diff --git a/.github/workflows/file-length.yml b/.github/workflows/file-length.yml index f4c67e3..afd5bfb 100644 --- a/.github/workflows/file-length.yml +++ b/.github/workflows/file-length.yml @@ -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 diff --git a/.github/workflows/gatelock-precommit.yml b/.github/workflows/gatelock-precommit.yml index 487cdd3..51d904c 100644 --- a/.github/workflows/gatelock-precommit.yml +++ b/.github/workflows/gatelock-precommit.yml @@ -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 diff --git a/.github/workflows/gatelock-tests.yml b/.github/workflows/gatelock-tests.yml index c029165..5ec7b91 100644 --- a/.github/workflows/gatelock-tests.yml +++ b/.github/workflows/gatelock-tests.yml @@ -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 diff --git a/.github/workflows/palette-drift.yml b/.github/workflows/palette-drift.yml index b55845c..e736d1f 100644 --- a/.github/workflows/palette-drift.yml +++ b/.github/workflows/palette-drift.yml @@ -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 diff --git a/.github/workflows/shared-gates-tests.yml b/.github/workflows/shared-gates-tests.yml index 9ee597a..578eb8f 100644 --- a/.github/workflows/shared-gates-tests.yml +++ b/.github/workflows/shared-gates-tests.yml @@ -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 diff --git a/crdt-sync/pyproject.toml b/crdt-sync/pyproject.toml index 99ccbc6..84a0db9 100644 --- a/crdt-sync/pyproject.toml +++ b/crdt-sync/pyproject.toml @@ -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 diff --git a/dep_freshness/_tables.py b/dep_freshness/_tables.py index 1bd1cf3..22ce685 100644 --- a/dep_freshness/_tables.py +++ b/dep_freshness/_tables.py @@ -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) diff --git a/dep_freshness/evaluate.py b/dep_freshness/evaluate.py index ab9d715..8e91446 100644 --- a/dep_freshness/evaluate.py +++ b/dep_freshness/evaluate.py @@ -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 diff --git a/dep_freshness/parsers/workflow.py b/dep_freshness/parsers/workflow.py index f0df2be..d3b86b6 100644 --- a/dep_freshness/parsers/workflow.py +++ b/dep_freshness/parsers/workflow.py @@ -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*(?Pflutter|node)-version:\s*["']?(?P[^"'#\s]+)["']?""" + r"""(?:^|[{,])\s*(?Pflutter|node|python)-version:(?P.*)$""" ) # 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 `-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 diff --git a/dep_freshness/tests/test_evaluate.py b/dep_freshness/tests/test_evaluate.py index 1d2ef85..b1f74e7 100644 --- a/dep_freshness/tests/test_evaluate.py +++ b/dep_freshness/tests/test_evaluate.py @@ -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", diff --git a/dep_freshness/tests/test_workflow.py b/dep_freshness/tests/test_workflow.py index 36d9114..e6c5de2 100644 --- a/dep_freshness/tests/test_workflow.py +++ b/dep_freshness/tests/test_workflow.py @@ -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): diff --git a/gatelock/pyproject.toml b/gatelock/pyproject.toml index 3bbb524..5ea648b 100644 --- a/gatelock/pyproject.toml +++ b/gatelock/pyproject.toml @@ -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] diff --git a/web_ui/package.json b/web_ui/package.json index fb757d5..84e596a 100644 --- a/web_ui/package.json +++ b/web_ui/package.json @@ -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", diff --git a/web_ui/pnpm-lock.yaml b/web_ui/pnpm-lock.yaml index 146bb3f..7d60ade 100644 --- a/web_ui/pnpm-lock.yaml +++ b/web_ui/pnpm-lock.yaml @@ -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