Source code for tests.test_runner_catalog

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

"""The available-images table parses, and fails closed when it does not.

The fixture below is trimmed from the real `actions/runner-images` readme and
keeps every shape that has bitten: a `-latest` alias sitting *inside* a label
rather than at its end (`macos-latest-large`), a row whose plain label is the
x64 `-intel` variant rather than the bare name, badges with and without a link,
and the `<br>` endpoint markup that follows every display name.
"""

from __future__ import annotations

import pytest

from repomatic.release.binary import NUITKA_BUILD_TARGETS
from repomatic.runner_catalog import (
    CATALOG_ARCHITECTURES,
    RunnerImage,
    newer_version_than,
    parse_catalog,
    runner_architecture,
    successor_for,
)

README = """\
# Runner Images

## Available Images

| Image | Architecture | YAML Label | Included Software |
| ------|--------------|------------|-------------------|
| Ubuntu 26.04 ![preview](https://img.shields.io/badge/preview)<br>![Endpoint Badge](https://x) | x64 | `ubuntu-26.04` | [ubuntu-26.04] |
| Ubuntu 24.04<br>![Endpoint Badge](https://x) | x64 | `ubuntu-latest` or `ubuntu-24.04` | [ubuntu-24.04] |
| Ubuntu 22.04<br>![Endpoint Badge](https://x) | x64 | `ubuntu-22.04` | [ubuntu-22.04] |
| macOS 26<br>![Endpoint Badge](https://x) | x64 | `macos-latest-large`, `macos-26-intel`, `macos-26-large` | [macOS-26] |
| macOS 26 Arm64<br>![Endpoint Badge](https://x) | arm64 | `macos-latest`, `macos-26` or `macos-26-xlarge` | [macOS-26-arm64] |
| macOS 14 Arm64 [![deprecated](https://img.shields.io/badge/deprecated)](https://github.com/actions/runner-images/issues/13518)<br>![Endpoint Badge](https://x) | arm64 | `macos-14` or `macos-14-xlarge` | [macOS-14-arm64] |

### Label scheme

- Not a table row.
"""


[docs] @pytest.fixture def catalog() -> list[RunnerImage]: return parse_catalog(README)
[docs] @pytest.fixture def by_name(catalog) -> dict[str, RunnerImage]: """The catalog indexed by display name, for assertion lookups.""" return {image.display_name: image for image in catalog}
[docs] def test_every_row_is_parsed(catalog) -> None: """The separator row and the trailing prose are not mistaken for images.""" assert [image.display_name for image in catalog] == [ "Ubuntu 26.04", "Ubuntu 24.04", "Ubuntu 22.04", "macOS 26", "macOS 26 Arm64", "macOS 14 Arm64", ]
[docs] def test_no_floating_alias_survives(catalog) -> None: """No `-latest` label reaches a caller, wherever it sat in the cell. `macos-latest-large` is the one that matters: a suffix test keeps it, and a caller writing it into a `runs-on:` would introduce the exact floating alias `lint-repo` rejects. """ leaked = [ label for image in catalog for label in image.labels if "latest" in label.split("-") ] assert not leaked, f"floating aliases survived parsing: {leaked}"
[docs] @pytest.mark.parametrize( ("display_name", "expected"), [ # The plain hosted label wins over the paid larger runner... ("macOS 26 Arm64", "macos-26"), # ...but `-intel` is a generation's x64 half, not a size variant, so it # is the right answer where no barer label exists. ("macOS 26", "macos-26-intel"), ("Ubuntu 24.04", "ubuntu-24.04"), ], ) def test_preferred_label(by_name, display_name: str, expected: str) -> None: """One label per row, picked without reaching for a larger runner.""" assert by_name[display_name].preferred_label == expected
[docs] @pytest.mark.parametrize( ("label", "expected"), [ # Released 24.04 wins over the newer-but-preview 26.04. ("ubuntu-22.04", "ubuntu-24.04"), # Stays inside the architecture: the Arm64 macOS row, not the x64 one. ("macos-14", "macos-26"), # A label the table does not carry resolves to nothing. ("ubuntu-18.04", None), ], ) def test_successor_prefers_released_over_preview( catalog, label: str, expected: str | None ) -> None: """A retirement lands on released ground whenever released ground exists. A forced move should not trade a known deadline for an unknown one, so a released successor wins however old it is. `newer_preview_than` is what surfaces the fresher option without taking it. """ successor = successor_for(label, catalog) assert (successor.preferred_label if successor else None) == expected
[docs] def test_successor_accepts_a_same_version_sibling(catalog) -> None: """A dying image with only a same-version sibling still has somewhere to go. Version is not filtered here, unlike {func}`newer_version_than`: staying on an image with an end date is worse than moving sideways to one without. """ deprecated_arm = RunnerImage( "Windows 11 Arm64", "arm64", ("windows-11-arm",), False, True ) sibling = RunnerImage( "Windows 11 Arm64 with Visual Studio 2026", "arm64", ("windows-11-vs2026-arm",), True, False, ) successor = successor_for("windows-11-arm", [deprecated_arm, sibling]) assert successor is not None assert successor.preferred_label == "windows-11-vs2026-arm"
[docs] @pytest.mark.parametrize( ("label", "expected"), [ # A genuine version bump, preview or not. ("ubuntu-24.04", "ubuntu-26.04"), # Already on the newest. ("ubuntu-26.04", None), ], ) def test_newer_version_is_strict(catalog, label: str, expected: str | None) -> None: """Only a strictly higher version counts as an upgrade. This is what keeps a same-version flavour, like a Visual Studio variant, from being proposed as a newer image when it is merely a different one. """ newer = newer_version_than(label, catalog) assert (newer.preferred_label if newer else None) == expected
[docs] @pytest.mark.parametrize( "readme", [ pytest.param("# Runner Images\n\nNo table here.\n", id="no-table"), pytest.param( "## Available Images\n\n| Name | Arch | Label |\n| - | - | - |\n", id="renamed-columns", ), pytest.param( "| Image | Architecture | YAML Label |\n| - | - | - |\n", id="no-rows" ), ], ) def test_parse_fails_closed(readme: str) -> None: """A restyled table yields nothing rather than something wrong. Every caller reads an empty catalog as "propose nothing". A wrong label would rewrite a `runs-on:` to an image GitHub does not host, failing every job in the repository; a missing one costs a cycle of not noticing. """ assert parse_catalog(readme) == []
[docs] @pytest.mark.parametrize( ("label", "expected"), [ # Bare images: x64 everywhere but macOS from its 14th generation on. ("ubuntu-26.04", "x86_64"), ("ubuntu-24.04", "x86_64"), ("ubuntu-slim", "x86_64"), ("windows-2025", "x86_64"), ("windows-2022", "x86_64"), ("macos-13", "x86_64"), ("macos-14", "aarch64"), ("macos-15", "aarch64"), ("macos-26", "aarch64"), # `-arm` reaches both Linux and Windows, and survives an infix. ("ubuntu-26.04-arm", "aarch64"), ("ubuntu-22.04-arm", "aarch64"), ("windows-11-arm", "aarch64"), ("windows-11-vs2026-arm", "aarch64"), # An infix that is not `-arm` leaves the image x64. ("windows-2025-vs2026", "x86_64"), # The macOS suffixes that invert what their names suggest. ("macos-15-large", "x86_64"), ("macos-15-xlarge", "aarch64"), ("macos-26-intel", "x86_64"), ("macos-latest-large", "x86_64"), # Floating aliases and the Xcode images, all Apple silicon today. ("macos-latest", "aarch64"), ("xcode-27", "aarch64"), ("xcode-27-xlarge", "aarch64"), ], ) def test_runner_architecture(label: str, expected: str) -> None: """Every label shape the published table lists resolves to its own column. Transcribed from the *Available Images* table rather than derived from the rule under test, so a rule that starts guessing fails here instead of agreeing with itself. """ assert runner_architecture(label).id == expected
[docs] def test_runner_architecture_matches_the_catalog(catalog) -> None: """The offline derivation agrees with the table it stands in for. {func}`~repomatic.runner_catalog.runner_architecture` exists because nothing running inside a job can wait on a network fetch, which makes it a second answer to a question the catalog already answers. This is what keeps the two from drifting: a label shape GitHub introduces that the rule reads wrong fails here, on the fixture, with no network involved. """ mismatches = { label: ( runner_architecture(label).id, CATALOG_ARCHITECTURES[image.architecture].id, ) for image in catalog for label in image.labels if runner_architecture(label) is not CATALOG_ARCHITECTURES[image.architecture] } assert not mismatches, ( f"derived architecture disagrees with the table: {mismatches}" )
[docs] def test_runner_architecture_matches_the_build_targets() -> None: """The derivation agrees with the hand-written Nuitka target table. {data}`~repomatic.release.binary.NUITKA_BUILD_TARGETS` pairs a runner image with an architecture by hand, for the six images release binaries are compiled on. Deriving the same pairing from the label is the cross-check: the two are written independently, so a repointed image breaks one of them loudly rather than shipping a binary built for the wrong architecture. """ mismatches = { target.runner: (runner_architecture(target.runner).id, target.arch.id) for target in NUITKA_BUILD_TARGETS.values() if runner_architecture(target.runner) is not target.arch } assert not mismatches, ( f"derived architecture disagrees with the targets: {mismatches}" )