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
« 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.
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:
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.
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.
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".
30Usage::
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 .
37Exit codes::
39 0 the comparison completed (with or without findings)
40 2 the catalog could not be fetched or parsed, or no labels were found
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"""
47from __future__ import annotations
49import argparse
50import re
51import sys
52import urllib.error
53import urllib.request
54from dataclasses import dataclass
55from pathlib import Path
57REPO_ROOT = Path(__file__).resolve().parent.parent.parent
58README_URL = "https://raw.githubusercontent.com/actions/runner-images/main/README.md"
59FETCH_TIMEOUT_SECONDS = 20
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)
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)
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)
82class CatalogError(Exception):
83 """The upstream catalog could not be fetched, or held no usable table."""
86@dataclass(frozen=True)
87class RunnerImage:
88 """One row of the upstream Available Images table."""
90 name: str
91 architecture: str
92 labels: tuple[str, ...]
93 status: str
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
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("."))
107 @property
108 def is_arm(self) -> bool:
109 return self.architecture.strip().lower() == "arm64"
111 @property
112 def preferred_label(self) -> str:
113 """The label to recommend moving to.
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]
129@dataclass
130class Finding:
131 """A single actionable difference, or an informational note."""
133 label: str
134 current: str
135 recommended: str
136 reason: str
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}"
143def fetch_readme(url: str = README_URL, timeout: int = FETCH_TIMEOUT_SECONDS) -> str:
144 """Return the upstream README text, or raise :class:`CatalogError`.
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
158def _cell_labels(cell: str) -> tuple[str, ...]:
159 """Pull the backtick-quoted YAML labels out of a table cell.
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))
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
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())
184def parse_catalog(readme: str) -> list[RunnerImage]:
185 """Parse the ``Available Images`` table into records.
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
220def collect_used_labels(root: Path) -> dict[str, int]:
221 """Return ``{runner label: occurrences}`` across every workflow.
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
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 ())
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.
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)
277 drift: list[Finding] = []
278 notes: list[Finding] = []
279 unknown: list[str] = []
281 for label in sorted(used):
282 image = by_label.get(label)
283 if image is None:
284 unknown.append(label)
285 continue
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
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
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
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)
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)
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
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
400if __name__ == "__main__":
401 sys.exit(main())