diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 764e0bbbf..6bbb309c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -400,7 +400,7 @@ jobs: # SHA pin resolved from `gh api repos/actions/setup-go/git/refs/tags/v6.4.0`. uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.24' + go-version: '1.24.13' # exact: bump deliberately (#1645) cache: false - name: Restore vexctl (macOS) @@ -617,7 +617,7 @@ jobs: # This job is the Linux leg of `test` (see there): same Go pin. uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.24' + go-version: '1.24.13' # exact: bump deliberately (#1645) cache: false - name: Install vexctl @@ -1419,13 +1419,13 @@ jobs: if: matrix.suite == 'e2e_redirect_pnpm_build' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.x' + node-version: '24.21.0' # exact: bump deliberately (#1645) - name: Setup Python if: matrix.suite == 'e2e_pypi' || matrix.uv != '' || matrix.poetry != '' || matrix.pdm != '' || matrix.hatch != '' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.12.x' + python-version: '3.12.10' # exact: last 3.12 with Windows+macOS builds; bump deliberately (#1645) - name: Setup uv (uv legs) if: matrix.uv != '' @@ -2238,7 +2238,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '24.x' + node-version: '24.21.0' # exact: bump deliberately (#1645) - name: Manifest-less VEX over every real yarn classic flow # The script exports SOCKET_PATCH_YARN_E2E_REQUIRED=1 and # SOCKET_PATCH_YARN_CLASSIC_E2E_VERSION itself. @@ -2632,9 +2632,9 @@ jobs: # `node:sqlite` for its store index, which does not exist before # Node 22 (and is only stable in 24) — on 20 every `pnpm install` # dies with ERR_UNKNOWN_BUILTIN_MODULE. This suite drives real, - # current package managers against production, so it tracks what - # users actually run rather than the pin the offline suites need. - node-version: '24.x' + # current package managers against production, so it runs the Node + # line users run rather than the 20.x pin the offline suites need. + node-version: '24.21.0' # exact: bump deliberately (#1645) - name: Setup npm-family package managers if: steps.gate.outputs.run == 'true' @@ -2679,7 +2679,7 @@ jobs: if: steps.gate.outputs.run == 'true' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.12.x' + python-version: '3.12.10' # exact: last 3.12 with Windows+macOS builds; bump deliberately (#1645) - name: Install uv if: steps.gate.outputs.run == 'true' @@ -2699,7 +2699,7 @@ jobs: if: steps.gate.outputs.run == 'true' uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.24' + go-version: '1.24.13' # exact: bump deliberately (#1645) cache: false - name: Run hosted-mode production e2e diff --git a/scripts/check-tool-pins.py b/scripts/check-tool-pins.py new file mode 100755 index 000000000..9992a51e5 --- /dev/null +++ b/scripts/check-tool-pins.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Fail when a workflow installs an oracle toolchain at a floating version. + +`pin-check.yml` makes every `uses:` reference an immutable SHA, but the +*tool* a setup action installs can still float: `node-version: '24.x'`, +`python-version: '3.12.x'`, `go-version: '1.24'`, or `astral-sh/setup-uv` +with no `version:` at all. Any upstream release then changes what CI runs +while main stays the same. On 2026-10-20 uv 0.13.2 did exactly that, failed +a Pipenv leg on every merge-queue entry and stalled the queue for 2.7 h +(#1645). + +Rules, per step: + * `astral-sh/setup-uv` must set `with.version`. + * `with.version` of setup-uv, and `with.node-version`, `python-version` + and `go-version` of any step, must be an exact `X.Y.Z` release. + * A `${{ ... }}` expression may read only `matrix.*`. Every quoted value + it can yield (comparison operands aside) must be exact, and so must + every value the job's matrix gives a key it reads. A matrix built with + `fromJSON` can't be resolved statically and is left to review. + +The workflow is scanned as text with the standard library only, the same +way scripts/tests reads ci.yml, so the lint runs on any python3. It relies +on the block style ci.yml uses: steps as `- ` items, `with:` inputs one +per line, matrix rows as `{key: value, ...}` or `key: [a, b]`. A step +shared through a YAML anchor is checked once, against the matrix of the +job that defines it. + +Repo gate: scripts/tests/test_check_tool_pins.py runs this on ci.yml in +the lean `lint-ecosystems` job. + +Usage: check-tool-pins.py WORKFLOW.yml [...] (exit 1 on any violation) +""" + +import re +import sys + +EXACT = re.compile(r"^\d+\.\d+\.\d+$") +VERSION_KEYS = ("node-version", "python-version", "go-version") +UV_ACTION = "astral-sh/setup-uv" +LITERAL = re.compile(r"'([^']*)'") +CONTEXT_REF = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z0-9_-]+)") +COMPARED = re.compile(r"(==|!=)\s*$") +COMPARES = re.compile(r"^\s*(==|!=)") +JOB = re.compile(r"^ ([A-Za-z0-9_-]+):\s*(#.*)?$") +KEY_VALUE = re.compile(r"^(\s*)(-\s+)?([A-Za-z0-9_-]+):\s*(.*)$") + + +def indent(line): + return len(line) - len(line.lstrip(" ")) + + +def strip_comment(value): + """Drop a trailing ` # ...` comment that sits outside quotes.""" + quote = None + for i, ch in enumerate(value): + if quote: + if ch == quote: + quote = None + elif ch in "'\"": + quote = ch + elif ch == "#" and (i == 0 or value[i - 1].isspace()): + return value[:i].rstrip() + return value.strip() + + +def unquote(value): + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": + return value[1:-1] + return value + + +def is_content(line): + stripped = line.strip() + return stripped != "" and not stripped.startswith("#") + + +def job_spans(lines): + """(name, start, end) for every job under the top-level `jobs:` key.""" + try: + top = next(i for i, l in enumerate(lines) if re.match(r"^jobs:\s*(#.*)?$", l)) + except StopIteration: + return [] + starts = [] + for i in range(top + 1, len(lines)): + line = lines[i] + if is_content(line) and indent(line) == 0: + break + m = JOB.match(line) + if m: + starts.append((m.group(1), i)) + end_of_jobs = next( + (i for i in range(top + 1, len(lines)) if is_content(lines[i]) and indent(lines[i]) == 0), + len(lines), + ) + return [ + (name, start, starts[n + 1][1] if n + 1 < len(starts) else end_of_jobs) + for n, (name, start) in enumerate(starts) + ] + + +def matrix_values(lines, start, end, key): + """Every value the job's matrix gives `key`, or None if unresolvable.""" + matrix_at = None + for i in range(start, end): + m = KEY_VALUE.match(lines[i]) + if m and m.group(3) == "matrix" and not m.group(2): + matrix_at = i + break + if matrix_at is None: + return [] + m = KEY_VALUE.match(lines[matrix_at]) + if "${{" in m.group(4): + return None + base = indent(lines[matrix_at]) + block_end = matrix_at + 1 + while block_end < end and (not is_content(lines[block_end]) or indent(lines[block_end]) > base): + block_end += 1 + values = [] + flow = re.compile(r"[{,]\s*" + re.escape(key) + r"""\s*:\s*('[^']*'|"[^"]*"|[^,}]+)""") + for i in range(matrix_at + 1, block_end): + line = strip_comment(lines[i]) + for v in flow.findall(line): + values.append(unquote(v)) + kv = KEY_VALUE.match(line) + if kv and kv.group(3) == key and not kv.group(2): + rest = kv.group(4).strip() + if rest.startswith("${{"): + return None + if rest.startswith("["): + values.extend(unquote(v) for v in rest.strip("[]").split(",") if v.strip()) + elif rest: + values.append(unquote(rest)) + else: + j = i + 1 + while j < block_end and (not is_content(lines[j]) or indent(lines[j]) > indent(lines[i])): + item = re.match(r"^\s*-\s+(.*)$", strip_comment(lines[j])) + if item: + values.append(unquote(item.group(1))) + j += 1 + return values + + +def yielded_literals(expr): + """Quoted literals an expression can evaluate to (not compared against).""" + out = [] + for m in LITERAL.finditer(expr): + before, after = expr[: m.start()], expr[m.end() :] + if COMPARED.search(before) or COMPARES.match(after): + continue + out.append(m.group(1)) + return out + + +def check_value(lines, span, value): + """Return reasons `value` is not an exact pin (empty if it is).""" + text = unquote(strip_comment(value)) + if "${{" not in text: + return [] if EXACT.match(text) else [f"{text!r} is not an exact X.Y.Z"] + reasons = [] + body = LITERAL.sub("''", text) + for ctx, key in CONTEXT_REF.findall(body): + if ctx != "matrix": + reasons.append(f"reads `{ctx}.{key}`, which can't be checked; use a literal or matrix value") + continue + values = matrix_values(lines, span[1], span[2], key) if span else None + for v in values or []: + if not EXACT.match(v): + reasons.append(f"matrix.{key} value {v!r} is not an exact X.Y.Z") + for literal in yielded_literals(text): + if not EXACT.match(literal): + reasons.append(f"value {literal!r} in the expression is not an exact X.Y.Z") + return reasons + + +def steps(lines): + """Yield (line_no, uses, {input: (line_no, value)}) for every `- ` item.""" + for i, line in enumerate(lines): + m = re.match(r"^(\s*)-\s+(.*)$", line) + if not m or m.group(2).startswith("{") or m.group(2).startswith("#"): + continue + dash = len(m.group(1)) + item = dash + 2 + uses, inputs, with_at = None, {}, None + j = i + while j < len(lines): + cur = lines[j] + if j > i and is_content(cur) and indent(cur) <= dash: + break + text = cur if j > i else " " * item + m.group(2) + kv = KEY_VALUE.match(text) + if kv and not kv.group(2): + level = len(kv.group(1)) + if level == item: + with_at = j if kv.group(3) == "with" else None + if kv.group(3) == "uses": + uses = unquote(strip_comment(kv.group(4))) + elif with_at is not None and level == item + 2: + inputs[kv.group(3)] = (j, kv.group(4)) + j += 1 + if uses is not None or inputs: + yield i, uses, inputs + + +def check_text(path, text): + lines = text.splitlines() + spans = job_spans(lines) + violations = [] + for line_no, uses, inputs in steps(lines): + span = next((s for s in spans if s[1] <= line_no < s[2]), None) + where = f"{path}:{line_no + 1}" + (f": job {span[0]!r}" if span else "") + if uses and uses.split("@")[0].lower() == UV_ACTION: + if "version" not in inputs: + violations.append(f"{where}: {UV_ACTION} without `version:`") + else: + at, value = inputs["version"] + for r in check_value(lines, span, value): + violations.append(f"{path}:{at + 1}: setup-uv version {r}") + for key in VERSION_KEYS: + if key in inputs: + at, value = inputs[key] + for r in check_value(lines, span, value): + violations.append(f"{path}:{at + 1}: {key} {r}") + return violations + + +def check_workflow(path): + with open(path, encoding="utf-8") as f: + return check_text(path, f.read()) + + +def main(argv): + if not argv: + print(__doc__.strip().splitlines()[-1], file=sys.stderr) + return 2 + violations = [v for path in argv for v in check_workflow(path)] + for v in violations: + print(f"::error::{v}") + if violations: + print( + f"{len(violations)} floating toolchain version(s). Pin each to an " + "exact release and bump it deliberately in its own PR." + ) + return 1 + print("All toolchain versions are pinned to exact releases.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/tests/test_check_tool_pins.py b/scripts/tests/test_check_tool_pins.py new file mode 100644 index 000000000..bc4d1c06a --- /dev/null +++ b/scripts/tests/test_check_tool_pins.py @@ -0,0 +1,228 @@ +"""Tests for scripts/check-tool-pins.py, the floating-toolchain lint (#1645). + +`Repository` is the gate: lint-ecosystems runs this module on every PR, so a +floating node/python/go/uv version in ci.yml fails the lean CI run. +""" + +import importlib.util +from pathlib import Path +import textwrap +import unittest + +ROOT = Path(__file__).parents[2] +SHA = "0" * 40 + + +def load_script(): + spec = importlib.util.spec_from_file_location( + "check_tool_pins", ROOT / "scripts" / "check-tool-pins.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +pins = load_script() + + +def workflow(steps, matrix=""): + """A one-job workflow; `steps` and `matrix` are dedented YAML blocks.""" + out = "on: push\njobs:\n e2e:\n runs-on: ubuntu-latest\n" + if matrix: + out += " strategy:\n" + textwrap.indent(textwrap.dedent(matrix).strip("\n"), " ") + "\n" + out += " steps:\n" + textwrap.indent(textwrap.dedent(steps).strip("\n"), " ") + "\n" + return out + + +def violations(steps, matrix=""): + return pins.check_text("ci.yml", workflow(steps, matrix)) + + +def setup(action, key, value): + return f""" + - name: Setup + uses: {action}@{SHA} # vX + with: + {key}: {value} + """ + + +class FloatingVersions(unittest.TestCase): + def test_floating_runtime_versions_are_rejected(self): + for key, value in [ + ("node-version", "'24.x'"), + ("python-version", "'3.12.x'"), + ("python-version", "'3.12'"), + ("python-version", "3.12"), + ("go-version", "'1.24'"), + ("node-version", "24"), + ("node-version", "latest"), + ("go-version", "'>=1.24.0'"), + ("node-version", "'24.x' # exact? no"), + ]: + with self.subTest(key=key, value=value): + found = violations(setup("actions/setup-x", key, value)) + self.assertEqual(len(found), 1, found) + self.assertIn(key, found[0]) + self.assertIn("ci.yml:9:", found[0]) + + def test_exact_versions_pass(self): + self.assertEqual(violations( + setup("actions/setup-node", "node-version", "'24.21.0' # bump deliberately") + + setup("actions/setup-python", "python-version", "3.12.10") + + setup("actions/setup-go", "go-version", '"1.24.13"') + ), []) + + def test_step_starting_with_uses_is_checked(self): + found = violations(f""" + - uses: actions/setup-node@{SHA} + with: + node-version: '24.x' + """) + self.assertEqual(len(found), 1, found) + + def test_other_inputs_and_run_scripts_are_not_checked(self): + self.assertEqual(violations(f""" + - uses: ruby/setup-ruby@{SHA} + with: + bundler: '2.6' + - name: Script + run: | + echo node-version: 24.x + - with: + version: latest + - name: Env + env: + go-version: '1.24' + run: true + """), []) + + +class SetupUv(unittest.TestCase): + def test_setup_uv_without_version_is_rejected(self): + # The 10-20 incident: no `version:`, so every run took the latest uv. + found = violations(f""" + - name: Setup uv + uses: astral-sh/setup-uv@{SHA} # v6.8.0 + with: + enable-cache: false + - name: Next + with: + version: '0.13.1' + """) + self.assertEqual(len(found), 1, found) + self.assertIn("without `version:`", found[0]) + + def test_setup_uv_with_no_inputs_is_rejected(self): + found = violations(f"- uses: astral-sh/setup-uv@{SHA}\n") + self.assertEqual(len(found), 1, found) + + def test_owner_and_repo_match_case_insensitively(self): + found = violations(f"- uses: Astral-sh/Setup-UV@{SHA}\n") + self.assertEqual(len(found), 1, found) + + def test_setup_uv_floating_version_is_rejected(self): + found = violations(setup("astral-sh/setup-uv", "version", "'0.13.x'")) + self.assertEqual(len(found), 1, found) + + def test_setup_uv_exact_version_with_comments_passes(self): + self.assertEqual(violations(f""" + - name: Setup uv + uses: astral-sh/setup-uv@{SHA} + with: + # Pin uv: bump deliberately. + version: "0.13.1" + """), []) + + +class Expressions(unittest.TestCase): + STEP = setup("actions/setup-node", "node-version", "${{ matrix.node || '24.21.0' }}") + + def test_exact_include_values_and_fallback_pass(self): + matrix = """ + matrix: + include: + - {os: ubuntu-latest, node: '22.22.1'} + - {os: x} + """ + self.assertEqual(violations(self.STEP, matrix), []) + + def test_floating_include_value_is_rejected(self): + matrix = """ + matrix: + include: + - {os: ubuntu-latest, node: '22.x', bun: '1'} + """ + found = violations(self.STEP, matrix) + self.assertEqual(len(found), 1, found) + self.assertIn("matrix.node value '22.x'", found[0]) + + def test_floating_inline_axis_value_is_rejected(self): + found = violations(self.STEP, "matrix:\n node: ['24.21.0', '22']\n") + self.assertEqual(len(found), 1, found) + self.assertIn("'22'", found[0]) + + def test_floating_block_axis_value_is_rejected(self): + found = violations(self.STEP, "matrix:\n node:\n - 24.21.0\n - 22.x\n") + self.assertEqual(len(found), 1, found) + self.assertIn("'22.x'", found[0]) + + def test_floating_fallback_literal_is_rejected(self): + found = violations(setup("actions/setup-node", "node-version", "${{ matrix.node || '24.x' }}")) + self.assertEqual(len(found), 1, found) + self.assertIn("'24.x'", found[0]) + + def test_comparison_operands_are_not_versions(self): + step = setup( + "actions/setup-node", + "node-version", + "${{ matrix.node != '' && matrix.node || runner_os == 'Windows' && '22.22.1' || '24.21.0' }}", + ) + found = violations(step) + # `runner_os` isn't a context reference (no dot); only the yielded + # literals are checked, and both are exact. + self.assertEqual(found, []) + + def test_non_matrix_context_is_rejected(self): + for expr in ("${{ vars.NODE_VERSION }}", "${{ env.NODE }}", "${{ inputs.node || '24.21.0' }}"): + with self.subTest(expr=expr): + found = violations(setup("actions/setup-node", "node-version", expr)) + self.assertEqual(len(found), 1, found) + self.assertIn("can't be checked", found[0]) + + def test_from_json_matrix_is_left_to_review(self): + found = violations(self.STEP, "matrix: ${{ fromJSON(vars.M) }}\n") + self.assertEqual(found, []) + + def test_matrix_of_another_job_is_not_used(self): + text = workflow(self.STEP) + " other:\n strategy:\n matrix:\n node: ['22.x']\n" + self.assertEqual(pins.check_text("ci.yml", text), []) + + +class Repository(unittest.TestCase): + CI = ROOT / ".github" / "workflows" / "ci.yml" + + def test_ci_yml_has_no_floating_toolchains(self): + self.assertEqual(pins.check_workflow(str(self.CI)), []) + + def test_ci_yml_setup_steps_are_seen(self): + # Guard against the text scan silently finding nothing: count the + # inputs it checks in ci.yml. + lines = self.CI.read_text().splitlines() + seen = [ + key + for _, _, inputs in pins.steps(lines) + for key in inputs + if key in pins.VERSION_KEYS + ] + self.assertGreaterEqual(seen.count("node-version"), 6, seen) + self.assertGreaterEqual(seen.count("python-version"), 2, seen) + self.assertGreaterEqual(seen.count("go-version"), 3, seen) + + def test_main_exit_codes(self): + self.assertEqual(pins.main([]), 2) + self.assertEqual(pins.main([str(self.CI)]), 0) + + +if __name__ == "__main__": + unittest.main()