Source code for repomatic.matrix.runner_sync

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""Keep a repository's runner images current against what GitHub still offers.

A `runs-on:` value is the one dependency in a workflow that nothing bumps:
Dependabot rewrites `uses:` references, `sync-workflow-pins` rewrites version
literals, and neither touches a runner image. So an image retires on GitHub's
schedule, entirely outside this repository's view, and the first sign is a
failing build.

The source is the *Available Images* table
({mod}`repomatic.matrix.runners`), compared against the labels this repository
actually runs. Nothing else is read.

```{note} Why the table and not the announcement feed
The `Announcement`-labelled issues of `actions/runner-images` answer a
different question. The feed reports *what changed for anyone*; the table
reports *what is true for this repository*, and only the second decides
anything. A feed reader opens reports about images this repository either
already runs or never will.

Two things are given up, both deliberately. GitHub badges an image
`deprecated` when deprecation *begins* rather than when it is announced, so a
retirement surfaces here months later than the feed shows it: for Ubuntu
22.04, September rather than June. What remains is still ample, since the
badge lands well before the image stops working. And a change to the
*contents* of an image already in use, like a default toolchain moving, is
invisible in the table; the test suite is what catches those.
```

```{caution}
An unreadable or restyled table yields an empty catalog, and every caller here
reads that as "propose nothing" rather than "nothing exists". Failing closed
costs a cycle of not noticing; failing open would rewrite a `runs-on:` to an
image GitHub does not host, taking every job with it.
```
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path

import tomlrt

from ..pyproject import load_pyproject_doc
from ..tabular import render_markdown_table
from . import axes

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable, Mapping, Sequence
    from typing import Any, Final

    from .runners import RunnerCatalog

AXES_SOURCE_PATH: Final = Path(*axes.__name__.split(".")).with_suffix(".py")
"""Where the curated test axes live, relative to the root of a repomatic checkout.

Derived from the module name, so the path follows the module if it moves. Only a
checkout of `kdeldycke/repomatic` itself holds this file: a repository consuming
repomatic inherits the axes through its pin.
"""

RUNS_ON_RE_TEMPLATE = (
    r"(?P<prefix>^[ \t]*runs-on:[ \t]*)(?P<quote>['\"]?){label}(?P=quote)[ \t]*$"
)
"""A literal `runs-on:` naming one label, anchored to its own line.

Rewritten as raw text rather than through a YAML round-trip, for the reason
`repomatic.github.workflow_sync._extract_raw_job` gives: a round-trip
reformats the whole file, and a runner bump should read as a one-line diff.
The optional quote group is carried through so a quoted value stays quoted.
"""

AXIS_LABEL_RE_TEMPLATE = r'(?P<quote>["\']){label}(?P=quote)'
"""A runner label as a quoted string literal in the curated axes.

Rewritten as text for the same reason a `runs-on:` is: the axes are a hand-kept
tuple carrying comments and an ordering that says which runner is the fast one,
and rebuilding the module from an AST would discard both.
"""


[docs] class ChangeKind(StrEnum): """Why the table justifies moving a label.""" RETIREMENT = "retirement" """The image is badged deprecated: jobs on it carry an end date.""" UPGRADE = "upgrade" """A strictly newer version of the image exists.""" @property def label(self) -> str: """The kind as a pull request table cell shows it.""" return "🔴 retirement" if self is ChangeKind.RETIREMENT else "🆕 upgrade" @property def rank(self) -> int: """Sort position: a retirement carries a deadline, so it comes first.""" return 0 if self is ChangeKind.RETIREMENT else 1
[docs] @dataclass(frozen=True) class RunnerChange: """One runner-image edit the available-images table justifies.""" kind: ChangeKind """Whether the current image is going away, or has a newer version.""" label: str """Label this repository runs today.""" successor: str """Label to move onto, or to probe.""" locations: tuple[str, ...] """`file.yaml:job-id` entries whose literal `runs-on:` names {attr}`label`. Rewritten onto {attr}`successor` for either kind. A retirement moves them because the image is going away; an upgrade because nothing else ever will, a literal being the one `runs-on:` no probe can reach. """ reason: str """Why the table says this change is warranted.""" alternative: str """A newer preview passed over in favour of a released successor. Reported, never taken. Whether a fresher preview beats a released image is a capacity judgement, and the pull request exists to host exactly that. """ probe: bool = False """Whether the test matrix gains a `continue-on-error` cell on {attr}`successor`. An upgrade sets this only when the fleet does not run the successor yet. Probing one it already runs writes an `unstable` entry marking *every* cell on that image `continue-on-error`, so a single job left behind would stop the current image from gating the build. """ @property def summary(self) -> str: """One line naming the change, for a commit subject or a table row.""" if self.kind is ChangeKind.UPGRADE and not self.locations: return f"probe `{self.successor}` alongside `{self.label}`" return f"`{self.label}` → `{self.successor}`"
[docs] def apply( self, workflow_dir: Path, pyproject_path: Path, axes_path: Path | None = None ) -> list[Path]: """Make this change in the working tree. Both kinds move the jobs naming the image outright. A retirement also moves the curated axes, when *axes_path* names them. An upgrade also adds its matrix probe. :param workflow_dir: Directory holding the workflow files. :param pyproject_path: The project file an upgrade probe lands in. :param axes_path: The curated axes module, set only inside a checkout of repomatic itself (see {data}`AXES_SOURCE_PATH`). :return: The files actually rewritten. """ touched = self.rewrite_literals(workflow_dir) if self.kind is ChangeKind.RETIREMENT: if axes_path is not None and self.rewrite_axes(axes_path): touched.append(axes_path) elif self.add_probe(pyproject_path): touched.append(pyproject_path) return touched
[docs] def rewrite_literals(self, workflow_dir: Path) -> list[Path]: """Rewrite every literal `runs-on:` naming the label being moved off. Serves both kinds. A retirement rewrites because the image is going away; an upgrade rewrites because a literal is the one `runs-on:` a matrix probe cannot reach, so without this it sits on its image until a retirement forces the move, at the moment there is least runway to handle a break. ```{caution} An upgrade rewrite is a bet rather than an experiment: a job has one image, so there is no `continue-on-error` half to fall back on. The pull request is where that bet is taken or declined, and its own CI run is the evidence only for a job the pull request actually runs. A workflow triggered by `schedule` or `workflow_dispatch` alone shows nothing on the proposal, so read the diff for those rather than the checkmark. ``` Idempotent: a file already on the successor matches nothing and is left untouched, so a re-run after a merge is a no-op rather than a second edit. :param workflow_dir: Directory holding the workflow files. :return: The files actually rewritten. """ pattern = re.compile( RUNS_ON_RE_TEMPLATE.format(label=re.escape(self.label)), re.MULTILINE ) touched = [] for name in sorted({location.split(":")[0] for location in self.locations}): path = workflow_dir / name if not path.is_file(): continue before = path.read_text(encoding="UTF-8") after = pattern.sub( lambda match: ( f"{match.group('prefix')}{match.group('quote')}" f"{self.successor}{match.group('quote')}" ), before, ) if after != before: path.write_text(after, encoding="UTF-8") touched.append(path) return touched
[docs] def rewrite_axes(self, axes_path: Path) -> bool: """Move a retiring label forward in the curated test-matrix axes. Only meaningful inside `kdeldycke/repomatic`, where the axes live. A repository consuming repomatic inherits them through the pin, so its matrix moves when it adopts a release rather than when it edits anything. This is the highest-blast-radius edit the operation makes: every downstream repository picks these axes up at the next release. That is the argument for proposing it in a pull request whose own CI runs the full matrix on the new image, rather than for not proposing it. :param axes_path: Path to the curated axes module. :return: Whether the file was modified. """ if not axes_path.is_file(): return False pattern = re.compile(AXIS_LABEL_RE_TEMPLATE.format(label=re.escape(self.label))) before = axes_path.read_text(encoding="UTF-8") after = pattern.sub( lambda match: ( f"{match.group('quote')}{self.successor}{match.group('quote')}" ), before, ) if after == before: return False axes_path.write_text(after, encoding="UTF-8") return True
[docs] def add_probe(self, pyproject_path: Path) -> bool: """Add the successor to the full test matrix as a failing-allowed probe. Writes two keys under `[tool.repomatic.test-matrix]`: the label joins the `os` axis through `variations`, and an `unstable` entry marks every cell carrying it `continue-on-error`. Both are needed and neither alone is useful: the variation without the unstable entry gates the build on an image nobody has vetted, and the unstable entry without the variation matches nothing. Idempotent: an image already probed is detected in both keys and nothing is written. :param pyproject_path: The project file to edit. :return: Whether the file was modified. A change with {attr}`probe` unset writes nothing: its successor is already in the fleet, so the `unstable` entry would mark cells that gate today `continue-on-error`. """ if not self.probe or not pyproject_path.is_file(): return False doc = load_pyproject_doc(pyproject_path) # Seeded through `Table` rather than a bare dict, which `setdefault` # would turn into an inline table. # # Either way `format-pyproject` normalizes what lands here into dotted # keys under `[tool.repomatic]` (`test-matrix.variations.os = [ … ]`), # and tomlrt cannot emit that form: assigning a dotted string produces # a *quoted* key holding dots, which is a different key. So the # formatter gets the last word on layout, and the invariant that # matters is not the shape written but that re-reading the formatter's # shape finds the label already there. It does, so the two converge # instead of ping-ponging, and `tests/test_runner_sync.py` pins that. node: Any = doc for key in ("tool", "repomatic", "test-matrix"): if key not in node: node[key] = tomlrt.Table() node = node[key] matrix = node if "variations" not in matrix: matrix["variations"] = tomlrt.Table() variations = matrix["variations"] axis = variations.setdefault("os", []) unstable = matrix.setdefault("unstable", []) changed = False # The *successor* is what joins the matrix: `label` is the image already # in use, which the probe runs beside rather than replaces. probed = self.successor if probed not in list(axis): axis.append(probed) changed = True if not any(dict(entry).get("os") == probed for entry in unstable): unstable.append({"os": probed}) changed = True if changed: pyproject_path.write_text(tomlrt.dumps(doc), encoding="UTF-8") return changed
[docs] def plan_runner_changes( literal: Mapping[str, Sequence[str]], tracked: Iterable[str], catalog: RunnerCatalog, ignore: Iterable[str] = (), ) -> list[RunnerChange]: """Work out which runner-image edits the table justifies. Every label this repository runs is looked up in the table, and yields at most one change: - **Retirement.** The row is badged deprecated. Jobs naming the image outright move to the pick of {meth}`~repomatic.matrix.runners.RunnerCatalog.successor_for`. - **Upgrade.** A strictly newer *version* exists. Jobs naming the old image outright move onto it, and the matrix gains it as a `continue-on-error` probe, so nothing is bet on it there while the suite starts exercising it. The probe half is dropped when the fleet already runs the successor, per {attr}`RunnerChange.probe`; the rewrite half is what moves a job no probe can reach. A label absent from the table is skipped, with no change: the image is already gone, and `actionlint` fails the Lint workflow on an unknown label. Only literal `runs-on:` values are rewritten, for either kind: one built from an expression draws on a matrix axis, which is the axis owner's to move. Strictly newer by **version** is what separates an upgrade from a flavour. `Windows 11 Arm64 with Visual Studio 2026` sits at the same version as `Windows 11 Arm64`: a different toolchain, not a newer image, and proposing it as an upgrade would be wrong. :param literal: Labels named outright in workflows, mapped to their locations, as {func}`~repomatic.github.workflow_files.literal_runners` reports them. :param tracked: Every image this repository has a stake in. :param catalog: Parsed available-images table. :param ignore: Labels the repository has declined. A `sync-*` job regenerates on every run, so without this a closed pull request comes back and the proposal becomes a nuisance rather than a service. :return: The changes to propose, retirements first. """ if not catalog: # Fail closed: an unreadable table means no successor can be trusted. logging.info("Runner catalog unavailable: proposing no change.") return [] declined = frozenset(ignore) running = frozenset(tracked) changes: list[RunnerChange] = [] for label in sorted(running - declined): current = catalog.image(label) if current is None: # Absent from the table: withdrawn. Deliberately not reported here, # because `actionlint` already fails the Lint workflow on an unknown # label, and does it for a matrix axis as well as a literal # `runs-on:`. Saying it again would duplicate a stronger check with # a weaker one. continue if current.deprecated: successor = catalog.successor_for(current) if successor is None: # A dying image whose family offers nothing at all. Rare enough # to be worth a loud log rather than a silent skip. logging.warning(f"{label!r} is deprecated with no replacement.") continue preview_alt = catalog.newer_preview_than(successor, current) changes.append( RunnerChange( kind=ChangeKind.RETIREMENT, label=label, successor=successor.preferred_label, locations=tuple(literal.get(label, ())), reason=f"{current.display_name} is deprecated", alternative=preview_alt.preferred_label if preview_alt else "", ) ) continue newer = catalog.newer_version_than(current) if not newer or newer.preferred_label in declined: continue # Already in the fleet leaves nothing to start exercising, and probing # it anyway is worse than redundant: the `unstable` entry marks *every* # cell on that image `continue-on-error`, so one job left behind would # stop the image this repository runs today from gating the build. # The literal rewrite below still applies, which is how that job stops # being left behind. locations = tuple(literal.get(label, ())) probe = not running.intersection(newer.labels) if not locations and not probe: continue changes.append( RunnerChange( kind=ChangeKind.UPGRADE, label=label, successor=newer.preferred_label, locations=locations, reason=f"{newer.display_name} supersedes {current.display_name}", alternative="", probe=probe, ) ) changes.sort(key=lambda change: (change.kind.rank, change.label)) return changes
[docs] def render_change_table(changes: Sequence[RunnerChange]) -> str: """Render proposed changes as a Markdown table for a pull request body. Carries the reasoning rather than just the edit: the diff shows what moved, and what a reviewer cannot see there is why the table says it had to, which jobs are affected, and what was passed over. :param changes: Changes from {func}`plan_runner_changes`. :return: A GitHub-flavored Markdown table, newline-terminated. """ rows = [] for change in changes: halves = [] if change.locations: halves.append( f"`{change.label}` → `{change.successor}` in " + ", ".join(f"`{loc}`" for loc in change.locations) ) if change.probe: halves.append( f"`{change.successor}` joins the matrix as `continue-on-error`, " f"beside `{change.label}`" ) # A retirement with neither half is still worth a row: the image is # going away, and the label lives in a matrix axis this repository # inherits rather than in any `runs-on:` it could rewrite. where = ", and ".join(halves) or f"`{change.label}` → `{change.successor}`" alternative = f"`{change.alternative}` (preview)" if change.alternative else "—" rows.append((change.kind.label, where, change.reason, alternative)) table = render_markdown_table( ("Change", "What", "Why", "Passed over"), rows, align=("left",) * 4, ) return table + "\n"