Check the toolchain versions CI actually installs, not just the ones declared
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

`.fvmrc` is a declaration nothing in this fleet reads. `subosito/flutter-action`
takes its own `flutter-version:`, and that pin is what the runner installs, so
the two drift in silence and the failure mode is the worst kind: green
locally, red in CI, with no diff to explain it.

kuhylog is the live case. Its runner is on Flutter **3.44.9**, which predates
the `@awaitNotRequired` annotation, so `unawaited(Navigator.push(...))` is
`unnecessary_unawaited` on 3.47.2 here and `discarded_futures` there -- the two
SDKs demand opposite code and CI went red on a commit that is clean locally.
The fleet is spread across 3.44.6, 3.44.8, 3.47.0 and 3.47.1 with no one
tracking it.

So a workflow's `flutter-version:` / `node-version:` is now a dependency like
any other, judged against the same latest stable. That needs no separate
"these must agree" rule: two values both required to equal latest already
agree. A channel name and a `${{ }}` expression are skipped -- one is not a
version, the other resolves at run time and guessing is worse than silence.

Found real drift on its first run: both TypeScript workflows here pinned
`node-version: 24`, a floating major that lets two runs of the same commit
install different Node. Now 24.20.0.

321 tests, 100% branch coverage on every production module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK
This commit is contained in:
Krzysztof Rudnicki 2026-08-28 15:04:00 +02:00
parent 7a682a46f3
commit 98213cbc40
7 changed files with 169 additions and 6 deletions

View File

@ -24,7 +24,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '24'
node-version: "24.20.0"
- name: Install dependencies
run: pnpm install --frozen-lockfile

View File

@ -24,7 +24,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '24'
node-version: "24.20.0"
- name: Install dependencies
run: pnpm install --frozen-lockfile

View File

@ -56,4 +56,5 @@ repos:
(pubspec\.(yaml|lock)|pyproject\.toml|requirements.*\.txt|uv\.lock
|package\.json|package-lock\.json|pnpm-lock\.yaml
|Cargo\.(toml|lock)|go\.(mod|sum)
|\.fvmrc|\.nvmrc|\.python-version)$
|\.fvmrc|\.nvmrc|\.python-version
|\.github/workflows/.*\.ya?ml)$

View File

@ -14,7 +14,9 @@ import subprocess
from dep_freshness._tables import EXCLUDED_DIRS, MANIFEST_GLOBS, REQUIREMENTS_PATTERN
from dep_freshness.models import Dep
from dep_freshness.parsers import fvm, golang, javascript, pubspec, python, rust
from dep_freshness.parsers import (
fvm, golang, javascript, pubspec, python, rust, workflow,
)
_REQUIREMENTS = re.compile(REQUIREMENTS_PATTERN)
@ -31,7 +33,11 @@ _BY_NAME = {
def is_manifest(path: Path) -> bool:
return path.name in MANIFEST_GLOBS or bool(_REQUIREMENTS.match(path.name))
return (
path.name in MANIFEST_GLOBS
or bool(_REQUIREMENTS.match(path.name))
or workflow.is_workflow(path)
)
def parse_manifest(path: Path) -> list[Dep]:
@ -41,6 +47,8 @@ def parse_manifest(path: Path) -> list[Dep]:
return parser(path)
if _REQUIREMENTS.match(path.name):
return python.parse_requirements(path)
if workflow.is_workflow(path):
return workflow.parse(path)
return []

View File

@ -0,0 +1,70 @@
"""`.github/workflows/*.yml` -> the toolchain versions CI actually builds with.
`.fvmrc` declares the SDK, but nothing in this fleet reads it: `subosito/
flutter-action` takes its own `flutter-version:`, and that pin is what the
runner really installs. The two drift silently, and the failure mode is the
worst kind -- green locally, red in CI, with no diff to explain it. kuhylog
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.
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.
"""
from __future__ import annotations
import re
from pathlib import Path
from dep_freshness._tables import 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.
_PIN = re.compile(
r"""^\s*(?P<tool>flutter|node)-version:\s*["']?(?P<value>[^"'#\s]+)["']?"""
)
# A channel name is not a version, and `.fvmrc` already reports that case.
_NOT_A_VERSION = frozenset({"stable", "beta", "master", "main", "dev", "lts", "latest"})
def is_workflow(path: Path) -> bool:
"""True for a file inside a repo's `.github/workflows` directory."""
return (
path.suffix in (".yml", ".yaml")
and path.parent.name == "workflows"
and path.parent.parent.name == ".github"
)
def parse(path: Path) -> list[Dep]:
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
deps: list[Dep] = []
for number, line in enumerate(text.splitlines(), start=1):
match = _PIN.match(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),
)
)
return deps

View File

@ -17,4 +17,5 @@
(pubspec\.(yaml|lock)|pyproject\.toml|requirements.*\.txt|uv\.lock
|package\.json|package-lock\.json|pnpm-lock\.yaml
|Cargo\.(toml|lock)|go\.(mod|sum)
|\.fvmrc|\.nvmrc|\.python-version)$
|\.fvmrc|\.nvmrc|\.python-version
|\.github/workflows/.*\.ya?ml)$

View File

@ -0,0 +1,83 @@
"""The toolchain versions a CI runner actually installs.
`.fvmrc` is a declaration nothing in this fleet reads; `subosito/flutter-action`
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.
"""
from __future__ import annotations
from dep_freshness import discover
from dep_freshness.parsers import workflow
from dep_freshness.tests.conftest import write
CI = """\
name: ci
on:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.44.9
channel: stable
- uses: actions/setup-node@v4
with:
node-version: '24.18.0'
"""
def _path(repo):
return repo / ".github/workflows/ci.yml"
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))
}
assert deps["flutter"].pinned == "3.44.9"
assert deps["flutter"].line == 12
assert deps["node"].pinned == "24.18.0"
assert deps["node"].ecosystem == "toolchain"
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"
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
def test_an_unreadable_workflow_yields_nothing(repo):
assert workflow.parse(repo / ".github/workflows/gone.yml") == []
def test_only_files_under_github_workflows_count(repo):
assert workflow.is_workflow(_path(repo))
assert workflow.is_workflow(repo / ".github/workflows/ci.yaml")
assert not workflow.is_workflow(repo / ".github/ci.yml")
assert not workflow.is_workflow(repo / "workflows/ci.yml")
assert not workflow.is_workflow(repo / ".github/workflows/notes.md")
def test_discovery_routes_a_workflow_to_the_workflow_parser(repo):
path = write(repo, ".github/workflows/ci.yml", CI)
assert discover.is_manifest(path)
assert {d.name for d in discover.parse_manifest(path)} == {"flutter", "node"}
assert path in discover.find_manifests(repo)