diff --git a/.github/workflows/ts-core-tests.yml b/.github/workflows/ts-core-tests.yml index d417977..9e33a83 100644 --- a/.github/workflows/ts-core-tests.yml +++ b/.github/workflows/ts-core-tests.yml @@ -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 diff --git a/.github/workflows/web-ui-tests.yml b/.github/workflows/web-ui-tests.yml index a22a5d4..ac42037 100644 --- a/.github/workflows/web-ui-tests.yml +++ b/.github/workflows/web-ui-tests.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7823e8d..f8ac810 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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)$ diff --git a/dep_freshness/discover.py b/dep_freshness/discover.py index c447edf..407239b 100644 --- a/dep_freshness/discover.py +++ b/dep_freshness/discover.py @@ -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 [] diff --git a/dep_freshness/parsers/workflow.py b/dep_freshness/parsers/workflow.py new file mode 100644 index 0000000..f0df2be --- /dev/null +++ b/dep_freshness/parsers/workflow.py @@ -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*(?Pflutter|node)-version:\s*["']?(?P[^"'#\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 diff --git a/dep_freshness/templates/precommit-hook.yaml b/dep_freshness/templates/precommit-hook.yaml index 3f557cb..dc8c3cf 100644 --- a/dep_freshness/templates/precommit-hook.yaml +++ b/dep_freshness/templates/precommit-hook.yaml @@ -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)$ diff --git a/dep_freshness/tests/test_workflow.py b/dep_freshness/tests/test_workflow.py new file mode 100644 index 0000000..36d9114 --- /dev/null +++ b/dep_freshness/tests/test_workflow.py @@ -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)