repomatic.matrix.runner_sync module

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 (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.

repomatic.matrix.runner_sync.AXES_SOURCE_PATH: Final = PosixPath('repomatic/matrix/axes.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.

repomatic.matrix.runner_sync.RUNS_ON_RE_TEMPLATE = '(?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.

repomatic.matrix.runner_sync.AXIS_LABEL_RE_TEMPLATE = '(?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.

class repomatic.matrix.runner_sync.ChangeKind(*values)[source]

Bases: 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 label: str

The kind as a pull request table cell shows it.

property rank: int

Sort position: a retirement carries a deadline, so it comes first.

class repomatic.matrix.runner_sync.RunnerChange(kind, label, successor, locations, reason, alternative, probe=False)[source]

Bases: object

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 label.

Rewritten onto 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 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 summary: str

One line naming the change, for a commit subject or a table row.

apply(workflow_dir, pyproject_path, axes_path=None)[source]

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.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow files.

  • pyproject_path (Path) – The project file an upgrade probe lands in.

  • axes_path (Path | None) – The curated axes module, set only inside a checkout of repomatic itself (see AXES_SOURCE_PATH).

Return type:

list[Path]

Returns:

The files actually rewritten.

rewrite_literals(workflow_dir)[source]

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.

Parameters:

workflow_dir (Path) – Directory holding the workflow files.

Return type:

list[Path]

Returns:

The files actually rewritten.

rewrite_axes(axes_path)[source]

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.

Parameters:

axes_path (Path) – Path to the curated axes module.

Return type:

bool

Returns:

Whether the file was modified.

add_probe(pyproject_path)[source]

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.

Parameters:

pyproject_path (Path) – The project file to edit.

Return type:

bool

Returns:

Whether the file was modified. A change with probe unset writes nothing: its successor is already in the fleet, so the unstable entry would mark cells that gate today continue-on-error.

repomatic.matrix.runner_sync.plan_runner_changes(literal, tracked, catalog, ignore=())[source]

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 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 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.

Parameters:
  • literal (Mapping[str, Sequence[str]]) – Labels named outright in workflows, mapped to their locations, as literal_runners() reports them.

  • tracked (Iterable[str]) – Every image this repository has a stake in.

  • catalog (RunnerCatalog) – Parsed available-images table.

  • ignore (Iterable[str]) – 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 type:

list[RunnerChange]

Returns:

The changes to propose, retirements first.

repomatic.matrix.runner_sync.render_change_table(changes)[source]

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.

Parameters:

changes (Sequence[RunnerChange]) – Changes from plan_runner_changes().

Return type:

str

Returns:

A GitHub-flavored Markdown table, newline-terminated.