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 base64
from unittest.mock import patch

import pytest
from extra_platforms import AARCH64, X86_64

from repomatic.matrix.runners import (
    CATALOG_REPO,
    RunnerCatalog,
    RunnerImage,
    fetch_catalog,
    parse_catalog,
    runner_architecture,
)
from repomatic.release.binary import NUITKA_BUILD_TARGETS

CATALOG_ARCHITECTURES = {"arm64": AARCH64, "x64": X86_64}
"""The *Architecture* column's own spelling, mapped to extra-platforms.

{attr}`~repomatic.matrix.runners.RunnerImage.architecture` keeps the table's
wording, so the cross-check below translates it before comparing.
"""

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.
"""  # noqa: E501


[docs] @pytest.fixture def catalog() -> RunnerCatalog: 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` warns about. """ 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. """ current = catalog.image(label) successor = catalog.successor_for(current) if current else None 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 {meth}`~repomatic.matrix.runners.RunnerCatalog.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 = RunnerCatalog((deprecated_arm, sibling)).successor_for(deprecated_arm) 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. """ current = catalog.image(label) assert current is not None newer = catalog.newer_version_than(current) 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" ), # The header closes the readme, so no row follows it, and the row above # it belongs to no table. pytest.param( "| Ubuntu 22.04 | x64 | `ubuntu-22.04` |\n" "| Image | Architecture | YAML Label |", id="header-closes-the-readme", ), ], ) 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 not parse_catalog(readme)
[docs] def test_rows_without_a_usable_label_are_skipped() -> None: """A row too short to hold a label, or listing only an alias, is no image.""" readme = ( "| Image | Architecture | YAML Label |\n" "| - | - | - |\n" "| Ubuntu 20.04 | x64 |\n" "| Ubuntu 24.04 | x64 | `ubuntu-latest` |\n" "| Ubuntu 22.04 | x64 | `ubuntu-22.04` |\n" ) assert [image.display_name for image in parse_catalog(readme)] == ["Ubuntu 22.04"]
[docs] def test_fetch_catalog_decodes_the_readme() -> None: """The readme arrives base64-encoded, in lines, and parses like the fixture.""" content = base64.encodebytes(README.encode("UTF-8")).decode("ascii") with patch( "repomatic.matrix.runners.gh_json", return_value={"content": content} ) as mock_gh: catalog = fetch_catalog() mock_gh.assert_called_once_with(["api", f"repos/{CATALOG_REPO}/readme"], soft=True) # Not empty, so the comparison below cannot match two empty catalogs. assert catalog assert catalog == parse_catalog(README)
[docs] @pytest.mark.parametrize( "content", [ pytest.param(base64.b64encode(b"\xff").decode("ascii"), id="not-utf-8"), pytest.param("papaya", id="not-base64"), ], ) def test_fetch_catalog_fails_closed_on_an_undecodable_readme(content: str) -> None: """A readme that does not decode is an unavailable catalog, never a crash.""" with patch("repomatic.matrix.runners.gh_json", return_value={"content": content}): assert not fetch_catalog()
[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.matrix.runners.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}" )