Skip to content

Commit 44a86cf

Browse files
CI: pin floating toolchain versions (#1645) (#1647)
* Start fix for #1645 Assisted-by: Claude Code:claude-opus-5-5 * CI: pin floating Node, Python and Go versions The e2e, yarn-classic, test, coverage and hosted-e2e jobs installed Node '24.x', Python '3.12.x' and Go '1.24'. Any upstream release then changed what CI ran with no commit here, which is how uv 0.13.2 turned main red and stalled the merge queue for 2.7 h on 10-20. Pin them to exact releases: Node 24.21.0 (already used by the vlt legs), Go 1.24.13, and Python 3.12.10, the newest 3.12 that actions/python-versions builds for Windows and macOS as well as Linux. Later 3.12 patches are source-only, so '3.12.x' already resolved to 3.12.10 on those runners. Refs #1645 Assisted-by: Claude Code:claude-opus-5-5 * CI: lint ci.yml for floating toolchain versions pin-check only made `uses:` references immutable. Nothing stopped a setup step from taking the latest tool, so a new upstream release could break the merge queue until someone landed a pin. Add scripts/check-tool-pins.py and run it from pin-check on ci.yml, the only workflow that gates PRs and the merge queue. It fails when setup-uv has no `version:`, or when a uv, node, python or go version is not an exact X.Y.Z, including literal fallbacks and the matrix values an expression reads. The pin-check job moves to the Depot runner mapping with the DISABLE_DEPOT_RUNNERS switch. Fixes #1645 Assisted-by: Claude Code:claude-opus-5-5 * CI: gate pins in lint-ecosystems, stdlib only Review follow-up. The pin lint imported PyYAML, which the runner's python3 may not have; on such a runner the new test module would break the required lint-ecosystems job. The lint now scans the workflow as text with the standard library, like the other ci.yml tests do. The pin-check step is dropped. lint-ecosystems already runs every scripts/tests module on each PR, so the repository test gates ci.yml with no new step or job, and pin-check is back to its main version. The lint also now rejects expressions that read vars/env/inputs, skips comparison operands such as `!= ''`, and matches setup-uv regardless of case. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 16242dd commit 44a86cf

3 files changed

Lines changed: 488 additions & 10 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ jobs:
400400
# SHA pin resolved from `gh api repos/actions/setup-go/git/refs/tags/v6.4.0`.
401401
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
402402
with:
403-
go-version: '1.24'
403+
go-version: '1.24.13' # exact: bump deliberately (#1645)
404404
cache: false
405405

406406
- name: Restore vexctl (macOS)
@@ -617,7 +617,7 @@ jobs:
617617
# This job is the Linux leg of `test` (see there): same Go pin.
618618
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
619619
with:
620-
go-version: '1.24'
620+
go-version: '1.24.13' # exact: bump deliberately (#1645)
621621
cache: false
622622

623623
- name: Install vexctl
@@ -1419,13 +1419,13 @@ jobs:
14191419
if: matrix.suite == 'e2e_redirect_pnpm_build'
14201420
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
14211421
with:
1422-
node-version: '24.x'
1422+
node-version: '24.21.0' # exact: bump deliberately (#1645)
14231423

14241424
- name: Setup Python
14251425
if: matrix.suite == 'e2e_pypi' || matrix.uv != '' || matrix.poetry != '' || matrix.pdm != '' || matrix.hatch != ''
14261426
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
14271427
with:
1428-
python-version: '3.12.x'
1428+
python-version: '3.12.10' # exact: last 3.12 with Windows+macOS builds; bump deliberately (#1645)
14291429

14301430
- name: Setup uv (uv legs)
14311431
if: matrix.uv != ''
@@ -2238,7 +2238,7 @@ jobs:
22382238
- name: Setup Node.js
22392239
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
22402240
with:
2241-
node-version: '24.x'
2241+
node-version: '24.21.0' # exact: bump deliberately (#1645)
22422242
- name: Manifest-less VEX over every real yarn classic flow
22432243
# The script exports SOCKET_PATCH_YARN_E2E_REQUIRED=1 and
22442244
# SOCKET_PATCH_YARN_CLASSIC_E2E_VERSION itself.
@@ -2632,9 +2632,9 @@ jobs:
26322632
# `node:sqlite` for its store index, which does not exist before
26332633
# Node 22 (and is only stable in 24) — on 20 every `pnpm install`
26342634
# dies with ERR_UNKNOWN_BUILTIN_MODULE. This suite drives real,
2635-
# current package managers against production, so it tracks what
2636-
# users actually run rather than the pin the offline suites need.
2637-
node-version: '24.x'
2635+
# current package managers against production, so it runs the Node
2636+
# line users run rather than the 20.x pin the offline suites need.
2637+
node-version: '24.21.0' # exact: bump deliberately (#1645)
26382638

26392639
- name: Setup npm-family package managers
26402640
if: steps.gate.outputs.run == 'true'
@@ -2679,7 +2679,7 @@ jobs:
26792679
if: steps.gate.outputs.run == 'true'
26802680
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
26812681
with:
2682-
python-version: '3.12.x'
2682+
python-version: '3.12.10' # exact: last 3.12 with Windows+macOS builds; bump deliberately (#1645)
26832683

26842684
- name: Install uv
26852685
if: steps.gate.outputs.run == 'true'
@@ -2699,7 +2699,7 @@ jobs:
26992699
if: steps.gate.outputs.run == 'true'
27002700
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
27012701
with:
2702-
go-version: '1.24'
2702+
go-version: '1.24.13' # exact: bump deliberately (#1645)
27032703
cache: false
27042704

27052705
- name: Run hosted-mode production e2e

‎scripts/check-tool-pins.py‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env python3
2+
"""Fail when a workflow installs an oracle toolchain at a floating version.
3+
4+
`pin-check.yml` makes every `uses:` reference an immutable SHA, but the
5+
*tool* a setup action installs can still float: `node-version: '24.x'`,
6+
`python-version: '3.12.x'`, `go-version: '1.24'`, or `astral-sh/setup-uv`
7+
with no `version:` at all. Any upstream release then changes what CI runs
8+
while main stays the same. On 2026-10-20 uv 0.13.2 did exactly that, failed
9+
a Pipenv leg on every merge-queue entry and stalled the queue for 2.7 h
10+
(#1645).
11+
12+
Rules, per step:
13+
* `astral-sh/setup-uv` must set `with.version`.
14+
* `with.version` of setup-uv, and `with.node-version`, `python-version`
15+
and `go-version` of any step, must be an exact `X.Y.Z` release.
16+
* A `${{ ... }}` expression may read only `matrix.*`. Every quoted value
17+
it can yield (comparison operands aside) must be exact, and so must
18+
every value the job's matrix gives a key it reads. A matrix built with
19+
`fromJSON` can't be resolved statically and is left to review.
20+
21+
The workflow is scanned as text with the standard library only, the same
22+
way scripts/tests reads ci.yml, so the lint runs on any python3. It relies
23+
on the block style ci.yml uses: steps as `- ` items, `with:` inputs one
24+
per line, matrix rows as `{key: value, ...}` or `key: [a, b]`. A step
25+
shared through a YAML anchor is checked once, against the matrix of the
26+
job that defines it.
27+
28+
Repo gate: scripts/tests/test_check_tool_pins.py runs this on ci.yml in
29+
the lean `lint-ecosystems` job.
30+
31+
Usage: check-tool-pins.py WORKFLOW.yml [...] (exit 1 on any violation)
32+
"""
33+
34+
import re
35+
import sys
36+
37+
EXACT = re.compile(r"^\d+\.\d+\.\d+$")
38+
VERSION_KEYS = ("node-version", "python-version", "go-version")
39+
UV_ACTION = "astral-sh/setup-uv"
40+
LITERAL = re.compile(r"'([^']*)'")
41+
CONTEXT_REF = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z0-9_-]+)")
42+
COMPARED = re.compile(r"(==|!=)\s*$")
43+
COMPARES = re.compile(r"^\s*(==|!=)")
44+
JOB = re.compile(r"^ ([A-Za-z0-9_-]+):\s*(#.*)?$")
45+
KEY_VALUE = re.compile(r"^(\s*)(-\s+)?([A-Za-z0-9_-]+):\s*(.*)$")
46+
47+
48+
def indent(line):
49+
return len(line) - len(line.lstrip(" "))
50+
51+
52+
def strip_comment(value):
53+
"""Drop a trailing ` # ...` comment that sits outside quotes."""
54+
quote = None
55+
for i, ch in enumerate(value):
56+
if quote:
57+
if ch == quote:
58+
quote = None
59+
elif ch in "'\"":
60+
quote = ch
61+
elif ch == "#" and (i == 0 or value[i - 1].isspace()):
62+
return value[:i].rstrip()
63+
return value.strip()
64+
65+
66+
def unquote(value):
67+
value = value.strip()
68+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
69+
return value[1:-1]
70+
return value
71+
72+
73+
def is_content(line):
74+
stripped = line.strip()
75+
return stripped != "" and not stripped.startswith("#")
76+
77+
78+
def job_spans(lines):
79+
"""(name, start, end) for every job under the top-level `jobs:` key."""
80+
try:
81+
top = next(i for i, l in enumerate(lines) if re.match(r"^jobs:\s*(#.*)?$", l))
82+
except StopIteration:
83+
return []
84+
starts = []
85+
for i in range(top + 1, len(lines)):
86+
line = lines[i]
87+
if is_content(line) and indent(line) == 0:
88+
break
89+
m = JOB.match(line)
90+
if m:
91+
starts.append((m.group(1), i))
92+
end_of_jobs = next(
93+
(i for i in range(top + 1, len(lines)) if is_content(lines[i]) and indent(lines[i]) == 0),
94+
len(lines),
95+
)
96+
return [
97+
(name, start, starts[n + 1][1] if n + 1 < len(starts) else end_of_jobs)
98+
for n, (name, start) in enumerate(starts)
99+
]
100+
101+
102+
def matrix_values(lines, start, end, key):
103+
"""Every value the job's matrix gives `key`, or None if unresolvable."""
104+
matrix_at = None
105+
for i in range(start, end):
106+
m = KEY_VALUE.match(lines[i])
107+
if m and m.group(3) == "matrix" and not m.group(2):
108+
matrix_at = i
109+
break
110+
if matrix_at is None:
111+
return []
112+
m = KEY_VALUE.match(lines[matrix_at])
113+
if "${{" in m.group(4):
114+
return None
115+
base = indent(lines[matrix_at])
116+
block_end = matrix_at + 1
117+
while block_end < end and (not is_content(lines[block_end]) or indent(lines[block_end]) > base):
118+
block_end += 1
119+
values = []
120+
flow = re.compile(r"[{,]\s*" + re.escape(key) + r"""\s*:\s*('[^']*'|"[^"]*"|[^,}]+)""")
121+
for i in range(matrix_at + 1, block_end):
122+
line = strip_comment(lines[i])
123+
for v in flow.findall(line):
124+
values.append(unquote(v))
125+
kv = KEY_VALUE.match(line)
126+
if kv and kv.group(3) == key and not kv.group(2):
127+
rest = kv.group(4).strip()
128+
if rest.startswith("${{"):
129+
return None
130+
if rest.startswith("["):
131+
values.extend(unquote(v) for v in rest.strip("[]").split(",") if v.strip())
132+
elif rest:
133+
values.append(unquote(rest))
134+
else:
135+
j = i + 1
136+
while j < block_end and (not is_content(lines[j]) or indent(lines[j]) > indent(lines[i])):
137+
item = re.match(r"^\s*-\s+(.*)$", strip_comment(lines[j]))
138+
if item:
139+
values.append(unquote(item.group(1)))
140+
j += 1
141+
return values
142+
143+
144+
def yielded_literals(expr):
145+
"""Quoted literals an expression can evaluate to (not compared against)."""
146+
out = []
147+
for m in LITERAL.finditer(expr):
148+
before, after = expr[: m.start()], expr[m.end() :]
149+
if COMPARED.search(before) or COMPARES.match(after):
150+
continue
151+
out.append(m.group(1))
152+
return out
153+
154+
155+
def check_value(lines, span, value):
156+
"""Return reasons `value` is not an exact pin (empty if it is)."""
157+
text = unquote(strip_comment(value))
158+
if "${{" not in text:
159+
return [] if EXACT.match(text) else [f"{text!r} is not an exact X.Y.Z"]
160+
reasons = []
161+
body = LITERAL.sub("''", text)
162+
for ctx, key in CONTEXT_REF.findall(body):
163+
if ctx != "matrix":
164+
reasons.append(f"reads `{ctx}.{key}`, which can't be checked; use a literal or matrix value")
165+
continue
166+
values = matrix_values(lines, span[1], span[2], key) if span else None
167+
for v in values or []:
168+
if not EXACT.match(v):
169+
reasons.append(f"matrix.{key} value {v!r} is not an exact X.Y.Z")
170+
for literal in yielded_literals(text):
171+
if not EXACT.match(literal):
172+
reasons.append(f"value {literal!r} in the expression is not an exact X.Y.Z")
173+
return reasons
174+
175+
176+
def steps(lines):
177+
"""Yield (line_no, uses, {input: (line_no, value)}) for every `- ` item."""
178+
for i, line in enumerate(lines):
179+
m = re.match(r"^(\s*)-\s+(.*)$", line)
180+
if not m or m.group(2).startswith("{") or m.group(2).startswith("#"):
181+
continue
182+
dash = len(m.group(1))
183+
item = dash + 2
184+
uses, inputs, with_at = None, {}, None
185+
j = i
186+
while j < len(lines):
187+
cur = lines[j]
188+
if j > i and is_content(cur) and indent(cur) <= dash:
189+
break
190+
text = cur if j > i else " " * item + m.group(2)
191+
kv = KEY_VALUE.match(text)
192+
if kv and not kv.group(2):
193+
level = len(kv.group(1))
194+
if level == item:
195+
with_at = j if kv.group(3) == "with" else None
196+
if kv.group(3) == "uses":
197+
uses = unquote(strip_comment(kv.group(4)))
198+
elif with_at is not None and level == item + 2:
199+
inputs[kv.group(3)] = (j, kv.group(4))
200+
j += 1
201+
if uses is not None or inputs:
202+
yield i, uses, inputs
203+
204+
205+
def check_text(path, text):
206+
lines = text.splitlines()
207+
spans = job_spans(lines)
208+
violations = []
209+
for line_no, uses, inputs in steps(lines):
210+
span = next((s for s in spans if s[1] <= line_no < s[2]), None)
211+
where = f"{path}:{line_no + 1}" + (f": job {span[0]!r}" if span else "")
212+
if uses and uses.split("@")[0].lower() == UV_ACTION:
213+
if "version" not in inputs:
214+
violations.append(f"{where}: {UV_ACTION} without `version:`")
215+
else:
216+
at, value = inputs["version"]
217+
for r in check_value(lines, span, value):
218+
violations.append(f"{path}:{at + 1}: setup-uv version {r}")
219+
for key in VERSION_KEYS:
220+
if key in inputs:
221+
at, value = inputs[key]
222+
for r in check_value(lines, span, value):
223+
violations.append(f"{path}:{at + 1}: {key} {r}")
224+
return violations
225+
226+
227+
def check_workflow(path):
228+
with open(path, encoding="utf-8") as f:
229+
return check_text(path, f.read())
230+
231+
232+
def main(argv):
233+
if not argv:
234+
print(__doc__.strip().splitlines()[-1], file=sys.stderr)
235+
return 2
236+
violations = [v for path in argv for v in check_workflow(path)]
237+
for v in violations:
238+
print(f"::error::{v}")
239+
if violations:
240+
print(
241+
f"{len(violations)} floating toolchain version(s). Pin each to an "
242+
"exact release and bump it deliberately in its own PR."
243+
)
244+
return 1
245+
print("All toolchain versions are pinned to exact releases.")
246+
return 0
247+
248+
249+
if __name__ == "__main__":
250+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)