Coverage for .github / scripts / check_runner_images.py: 100.00%

168 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Compare the ``runs-on:`` labels in this repository against upstream runner images. 

2 

3Every other pinned surface in the project is watched by something. The runner 

4image is not: ``runs-on: ubuntu-latest`` is not a version pin, so Dependabot has 

5nothing to bump, and a floating label quietly changes underneath the workflows 

6whenever GitHub moves it. The two failure modes that matters are the opposite of 

7each other: 

8 

9* **Pinned too tightly.** ``macos-15`` keeps working long after a newer image is 

10 generally available, so CI silently runs on an ageing platform — and 

11 eventually on one upstream has marked deprecated, which is a removal notice 

12 with a date attached. 

13* **Chasing a preview.** A brand-new image (``ubuntu-26.04`` today) appears in 

14 the catalog months before it is GA. Moving to it early trades a stable CI 

15 platform for an unannounced one, so a preview must never be reported as 

16 something to act on. 

17 

18So this reports two different things. A newer *GA* image, or a label upstream 

19has deprecated, is **drift** — something to do. A newer image that is still 

20**preview** is a *note*: recorded so the next reader knows it exists and that 

21the current pin is deliberate, not stale. 

22 

23The catalog comes from the ``Available Images`` table in ``actions/runner-images``'s 

24README, which is the canonical published list — there is no API for it. Status 

25comes from the badges upstream puts in the image name cell (``preview``, 

26``beta``, ``deprecated``); an image with no badge is GA. Parsing is deliberately 

27strict: an unrecognised table shape yields *nothing* rather than a guess, and 

28the caller treats an empty catalog as "skip", never as "no drift". 

29 

30Usage:: 

31 

32 python3 .github/scripts/check_runner_images.py --format rows 

33 python3 .github/scripts/check_runner_images.py --format notes 

34 python3 .github/scripts/check_runner_images.py --format report 

35 python3 .github/scripts/check_runner_images.py --readme fixture.md --root . 

36 

37Exit codes:: 

38 

39 0 the comparison completed (with or without findings) 

40 2 the catalog could not be fetched or parsed, or no labels were found 

41 

42``main`` prints nothing on exit 2 except a diagnostic on stderr, so the shell 

43caller can treat empty output as "skip" exactly the way it treats an empty 

44``get_latest_*`` result. 

45""" 

46 

47from __future__ import annotations 

48 

49import argparse 

50import re 

51import sys 

52import urllib.error 

53import urllib.request 

54from dataclasses import dataclass 

55from pathlib import Path 

56 

57REPO_ROOT = Path(__file__).resolve().parent.parent.parent 

58README_URL = "https://raw.githubusercontent.com/actions/runner-images/main/README.md" 

59FETCH_TIMEOUT_SECONDS = 20 

60 

61# Badges upstream stamps into the image-name cell. Anything unbadged is GA. 

62STATUS_PREVIEW = "preview" 

63STATUS_DEPRECATED = "deprecated" 

64STATUS_GA = "ga" 

65_BADGE_PATTERN = re.compile(r"!\[(preview|beta|deprecated)\]", re.IGNORECASE) 

66 

67# `runs-on: X`, `runs-on: [X]`, and the matrix indirection `runner: X`. An 

68# expression (${{ ... }}) names no image, so it is skipped and the matrix values 

69# it points at are collected instead. 

70_RUNS_ON_PATTERN = re.compile(r"^\s*runs-on:\s*(?:\[\s*)?([A-Za-z0-9._-]+)", re.MULTILINE) 

71_MATRIX_RUNNER_PATTERN = re.compile(r"^\s*-?\s*runner:\s*([A-Za-z0-9._-]+)\s*$", re.MULTILINE) 

72 

73# An image family plus its comparable version, e.g. ("ubuntu", (24, 4)). Only 

74# labels within one family are ever compared: moving between families is not 

75# drift, it is a different platform. 

76_FAMILY_PATTERN = re.compile( 

77 r"^(?P<family>ubuntu|windows server|windows|macos|xcode)\s*(?P<version>\d+(?:\.\d+)?)", 

78 re.IGNORECASE, 

79) 

80 

81 

82class CatalogError(Exception): 

83 """The upstream catalog could not be fetched, or held no usable table.""" 

84 

85 

86@dataclass(frozen=True) 

87class RunnerImage: 

88 """One row of the upstream Available Images table.""" 

89 

90 name: str 

91 architecture: str 

92 labels: tuple[str, ...] 

93 status: str 

94 

95 @property 

96 def family(self) -> str | None: 

97 match = _FAMILY_PATTERN.match(self.name) 

98 return match.group("family").lower().replace("windows server", "windows") if match else None 

99 

100 @property 

101 def version(self) -> tuple[int, ...] | None: 

102 match = _FAMILY_PATTERN.match(self.name) 

103 if not match: 

104 return None 

105 return tuple(int(part) for part in match.group("version").split(".")) 

106 

107 @property 

108 def is_arm(self) -> bool: 

109 return self.architecture.strip().lower() == "arm64" 

110 

111 @property 

112 def preferred_label(self) -> str: 

113 """The label to recommend moving to. 

114 

115 Prefers an explicit version (``macos-26``) over a floating one 

116 (``macos-latest``): a floating label silently changes platform under the 

117 workflow later, which is the problem this check exists to surface. Also 

118 skips size variants (``-large`` / ``-xlarge``), which are a billing 

119 decision rather than a platform one. 

120 """ 

121 explicit = [ 

122 label 

123 for label in self.labels 

124 if not label.endswith("-latest") and not label.endswith(("-large", "-xlarge")) 

125 ] 

126 return (explicit or list(self.labels))[0] 

127 

128 

129@dataclass 

130class Finding: 

131 """A single actionable difference, or an informational note.""" 

132 

133 label: str 

134 current: str 

135 recommended: str 

136 reason: str 

137 

138 def as_row(self) -> str: 

139 """Render as the ``a|b|c`` shape the scan's Markdown tables consume.""" 

140 return f"{self.label}|{self.current}|{self.recommended}" 

141 

142 

143def fetch_readme(url: str = README_URL, timeout: int = FETCH_TIMEOUT_SECONDS) -> str: 

144 """Return the upstream README text, or raise :class:`CatalogError`. 

145 

146 The URL is a fixed literal, not built from any repository content, which is 

147 what makes the ``urlopen`` call here auditable. 

148 """ 

149 try: 

150 with urllib.request.urlopen( # nosec B310 # nosemgrep: dynamic-urllib-use-detected - the default is the fixed https://raw.githubusercontent.com literal in README_URL and no caller in this repository overrides it; the parameter exists only so the tests can inject a stub, so no scheme or host is ever derived from repository content # noqa: S310 

151 url, timeout=timeout 

152 ) as response: 

153 return str(response.read().decode("utf-8")) 

154 except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc: 

155 raise CatalogError(f"could not fetch {url}: {exc}") from exc 

156 

157 

158def _cell_labels(cell: str) -> tuple[str, ...]: 

159 """Pull the backtick-quoted YAML labels out of a table cell. 

160 

161 Upstream writes these as ``` `a`, `b`, or `c` ```, so the separators vary; 

162 reading only the code spans sidesteps that entirely. 

163 """ 

164 return tuple(match.group(1).strip() for match in re.finditer(r"`([^`]+)`", cell)) 

165 

166 

167def _cell_status(cell: str) -> str: 

168 """Classify an image-name cell by the badge upstream stamped on it.""" 

169 match = _BADGE_PATTERN.search(cell) 

170 if not match: 

171 return STATUS_GA 

172 badge = match.group(1).lower() 

173 return STATUS_DEPRECATED if badge == STATUS_DEPRECATED else STATUS_PREVIEW 

174 

175 

176def _cell_name(cell: str) -> str: 

177 """Strip badges, images, links and ``<br>`` noise down to the image name.""" 

178 text = re.sub(r"\[?!\[[^\]]*\]\([^)]*\)\]?(\([^)]*\))?", " ", cell) 

179 text = re.sub(r"<br\s*/?>", " ", text, flags=re.IGNORECASE) 

180 text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) 

181 return " ".join(text.split()) 

182 

183 

184def parse_catalog(readme: str) -> list[RunnerImage]: 

185 """Parse the ``Available Images`` table into records. 

186 

187 Returns ``[]`` when the section or its table cannot be found, which the 

188 caller must treat as "skip" rather than "nothing to report". 

189 """ 

190 section = re.search( 

191 r"^##\s+Available Images\s*$(?P<body>.*?)^(?:##|###)\s+", readme, re.MULTILINE | re.DOTALL 

192 ) 

193 body = section.group("body") if section else "" 

194 images: list[RunnerImage] = [] 

195 for line in body.splitlines(): 

196 stripped = line.strip() 

197 if not stripped.startswith("|"): 

198 continue 

199 cells = [cell.strip() for cell in stripped.strip("|").split("|")] 

200 if len(cells) < 3: 

201 continue 

202 # Skip the header and its separator row. 

203 if cells[0].lower().startswith("image") or set(cells[0]) <= {"-", " ", ":"}: 

204 continue 

205 labels = _cell_labels(cells[2]) 

206 name = _cell_name(cells[0]) 

207 if not labels or not name: 

208 continue 

209 images.append( 

210 RunnerImage( 

211 name=name, 

212 architecture=cells[1], 

213 labels=labels, 

214 status=_cell_status(cells[0]), 

215 ) 

216 ) 

217 return images 

218 

219 

220def collect_used_labels(root: Path) -> dict[str, int]: 

221 """Return ``{runner label: occurrences}`` across every workflow. 

222 

223 Counts both direct ``runs-on:`` values and the ``runner:`` matrix values an 

224 expression-valued ``runs-on:`` resolves to, so a matrix-driven job is not 

225 invisible to the check. 

226 """ 

227 counts: dict[str, int] = {} 

228 workflows = sorted((root / ".github" / "workflows").glob("*.yml")) 

229 for workflow in workflows: 

230 text = workflow.read_text(encoding="utf-8") 

231 for pattern in (_RUNS_ON_PATTERN, _MATRIX_RUNNER_PATTERN): 

232 for match in pattern.finditer(text): 

233 # Expressions need no explicit guard: both patterns require a 

234 # bare `[A-Za-z0-9._-]` label, so `runs-on: ${{ matrix.runner }}` 

235 # simply does not match and the matrix values it resolves to are 

236 # picked up by _MATRIX_RUNNER_PATTERN instead. 

237 counts[match.group(1)] = counts.get(match.group(1), 0) + 1 

238 return counts 

239 

240 

241def _newest_in_family( 

242 images: list[RunnerImage], reference: RunnerImage, status: str 

243) -> RunnerImage | None: 

244 """Newest image sharing ``reference``'s family, architecture and ``status``.""" 

245 candidates = [ 

246 image 

247 for image in images 

248 if image.status == status 

249 and image.family is not None 

250 and image.family == reference.family 

251 and image.is_arm == reference.is_arm 

252 and image.version is not None 

253 and reference.version is not None 

254 and image.version > reference.version 

255 ] 

256 if not candidates: 

257 return None 

258 return max(candidates, key=lambda image: image.version or ()) 

259 

260 

261def evaluate( 

262 used: dict[str, int], images: list[RunnerImage] 

263) -> tuple[list[Finding], list[Finding], list[str]]: 

264 """Return ``(drift, notes, unknown_labels)`` for the labels in use. 

265 

266 Drift is a deprecated image or a newer GA one. A newer preview image is a 

267 note. Labels upstream does not list at all are returned separately: they are 

268 most likely self-hosted, so they are reported rather than guessed about. 

269 """ 

270 by_label: dict[str, RunnerImage] = {} 

271 for catalog_image in images: 

272 for catalog_label in catalog_image.labels: 

273 # `-latest` resolves to whichever image claims it; every other label 

274 # is unique, so first-wins is stable here. 

275 by_label.setdefault(catalog_label, catalog_image) 

276 

277 drift: list[Finding] = [] 

278 notes: list[Finding] = [] 

279 unknown: list[str] = [] 

280 

281 for label in sorted(used): 

282 image = by_label.get(label) 

283 if image is None: 

284 unknown.append(label) 

285 continue 

286 

287 if image.status == STATUS_DEPRECATED: 

288 replacement = _newest_in_family(images, image, STATUS_GA) 

289 drift.append( 

290 Finding( 

291 label=label, 

292 current=f"{image.name} (deprecated)", 

293 recommended=replacement.preferred_label if replacement else "see upstream", 

294 reason="upstream has deprecated this image; it will be removed", 

295 ) 

296 ) 

297 continue 

298 

299 newer_ga = _newest_in_family(images, image, STATUS_GA) 

300 if newer_ga is not None: 

301 drift.append( 

302 Finding( 

303 label=label, 

304 current=image.name, 

305 recommended=newer_ga.preferred_label, 

306 reason="a newer generally-available image exists", 

307 ) 

308 ) 

309 continue 

310 

311 newer_preview = _newest_in_family(images, image, STATUS_PREVIEW) 

312 if newer_preview is not None: 

313 notes.append( 

314 Finding( 

315 label=label, 

316 current=image.name, 

317 recommended=newer_preview.preferred_label, 

318 reason="newer image exists but is still in preview; staying put is correct", 

319 ) 

320 ) 

321 return drift, notes, unknown 

322 

323 

324def format_report( 

325 used: dict[str, int], 

326 images: list[RunnerImage], 

327 drift: list[Finding], 

328 notes: list[Finding], 

329 unknown: list[str], 

330) -> str: 

331 """Render the human-facing summary.""" 

332 lines = [ 

333 f"runner images: {len(used)} label(s) in use, {len(images)} upstream image(s) catalogued" 

334 ] 

335 for finding in drift: 

336 lines.append(f" - {finding.label}: {finding.current} -> {finding.recommended}") 

337 lines.append(f" {finding.reason}") 

338 for finding in notes: 

339 lines.append( 

340 f" note: {finding.label} pins {finding.current}; {finding.recommended} exists" 

341 ) 

342 lines.append(f" {finding.reason}") 

343 if unknown: 

344 lines.append( 

345 f" note: {len(unknown)} label(s) are not GitHub-hosted images " 

346 f"(self-hosted, or a typo): {', '.join(unknown)}" 

347 ) 

348 if not drift: 

349 lines.append(" every label in use resolves to the newest generally-available image.") 

350 return "\n".join(lines) 

351 

352 

353def main(argv: list[str] | None = None) -> int: 

354 parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) 

355 parser.add_argument( 

356 "--format", 

357 choices=("rows", "notes", "report"), 

358 default="report", 

359 help="rows: drift as name|current|latest. notes: preview/self-hosted context. " 

360 "report: human summary.", 

361 ) 

362 parser.add_argument( 

363 "--readme", 

364 type=Path, 

365 default=None, 

366 help="Read the catalog from a local file instead of fetching it (used by tests).", 

367 ) 

368 parser.add_argument( 

369 "--root", type=Path, default=REPO_ROOT, help="Repository root holding .github/workflows." 

370 ) 

371 args = parser.parse_args(argv) 

372 

373 try: 

374 if args.readme is not None: 

375 readme = args.readme.read_text(encoding="utf-8") 

376 else: 

377 readme = fetch_readme() 

378 images = parse_catalog(readme) 

379 if not images: 

380 raise CatalogError("the Available Images table could not be parsed") 

381 used = collect_used_labels(args.root) 

382 if not used: 

383 raise CatalogError(f"no runs-on labels found under {args.root}/.github/workflows") 

384 except (CatalogError, OSError) as exc: 

385 print(f"ERROR: {exc}", file=sys.stderr) 

386 return 2 

387 

388 drift, notes, unknown = evaluate(used, images) 

389 if args.format == "rows": 

390 for finding in drift: 

391 print(finding.as_row()) 

392 elif args.format == "notes": 

393 for finding in notes: 

394 print(finding.as_row()) 

395 else: 

396 print(format_report(used, images, drift, notes, unknown)) 

397 return 0 

398 

399 

400if __name__ == "__main__": 

401 sys.exit(main())