mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
205 lines
6.9 KiB
Python
205 lines
6.9 KiB
Python
# Copyright © 2025-26 l5yth & contributors
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Keep CI testing the language runtimes the published images ship.
|
|
|
|
The web image ran ``ruby:3.3-alpine`` and the ingestor image Python 3.12
|
|
while CI tested only Ruby 3.4/4.0 and Python 3.13, so a change that broke the
|
|
shipped runtimes passed every check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
PYTHON_WORKFLOW = ".github/workflows/python.yml"
|
|
|
|
# (image Dockerfile, regex capturing each ``major.minor`` runtime it ships,
|
|
# workflow whose test matrix must list that version, matrix key).
|
|
RUNTIME_PINS = (
|
|
(
|
|
"web/Dockerfile",
|
|
r"^FROM\s+ruby:(\d+\.\d+)\b",
|
|
".github/workflows/ruby.yml",
|
|
"ruby-version",
|
|
),
|
|
(
|
|
"data/Dockerfile",
|
|
r"^ARG\s+PYTHON_VERSION=(\d+\.\d+)\b",
|
|
PYTHON_WORKFLOW,
|
|
"python-version",
|
|
),
|
|
)
|
|
|
|
# Files the guards in this module, in test_dependency_manifests.py and in
|
|
# test_configure_script.py read. A pull request touching any of them must run
|
|
# the Python workflow. Pushes to main run it unfiltered, which still covers what
|
|
# these guards read beyond this list (.gitignore, a manifest in a new
|
|
# directory).
|
|
GUARDED_INPUTS = (
|
|
".github/dependabot.yml",
|
|
".github/workflows/python.yml",
|
|
".github/workflows/ruby.yml",
|
|
"configure.sh",
|
|
"data/Dockerfile",
|
|
"data/requirements-dev.txt",
|
|
"data/requirements.txt",
|
|
"tests/test_ci_runtime_sync.py",
|
|
"tests/test_dependency_manifests.py",
|
|
"web/Dockerfile",
|
|
"web/Gemfile",
|
|
"web/Gemfile.lock",
|
|
)
|
|
|
|
|
|
def _read(relative: str) -> str:
|
|
"""Return a repository file's text.
|
|
|
|
Args:
|
|
relative: Path relative to the repository root.
|
|
|
|
Returns:
|
|
The file contents.
|
|
"""
|
|
|
|
return (REPO_ROOT / relative).read_text(encoding="utf-8")
|
|
|
|
|
|
def _matrix_values(workflow: str, key: str) -> set[str]:
|
|
"""Return the values of a flow-style ``key: [...]`` matrix list.
|
|
|
|
Args:
|
|
workflow: The workflow YAML.
|
|
key: The matrix key, such as ``ruby-version``.
|
|
|
|
Returns:
|
|
The listed values with quotes stripped; empty when ``key`` holds no
|
|
flow list (a scalar ``python-version: "3.13"`` is not a matrix).
|
|
"""
|
|
|
|
listing = re.search(
|
|
rf"^\s+{re.escape(key)}:\s*\[(?P<items>[^\]]*)\]", workflow, re.MULTILINE
|
|
)
|
|
if listing is None:
|
|
return set()
|
|
items = listing.group("items").split(",")
|
|
return {item.strip().strip("\"'") for item in items if item.strip()}
|
|
|
|
|
|
def _pull_request_paths(workflow: str) -> list[str]:
|
|
"""Return the path filters listed under ``on.pull_request.paths``.
|
|
|
|
Comment lines and blank lines inside the list are skipped, and so is a
|
|
trailing ``# comment`` on an item, so annotating the filters cannot hide
|
|
the ones below the annotation.
|
|
|
|
Args:
|
|
workflow: The workflow YAML (block style, two-space indents).
|
|
|
|
Returns:
|
|
The globs in order, with quotes stripped; empty when the workflow
|
|
has no such filter.
|
|
"""
|
|
|
|
section = re.search(r"^ pull_request:\n((?:(?: .*)?\n)*)", workflow, re.M)
|
|
body = section.group(1) if section else ""
|
|
listing = re.search(r"^ paths:\n((?:(?: [-#].*)?\n)*)", body, re.M)
|
|
lines = listing.group(1).splitlines() if listing else []
|
|
items = [re.sub(r"(^|\s)#.*$", "", line).strip() for line in lines]
|
|
return [item[2:].strip().strip("\"'") for item in items if item.startswith("- ")]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("dockerfile", "pattern", "workflow", "key"),
|
|
RUNTIME_PINS,
|
|
ids=("web-ruby", "ingestor-python"),
|
|
)
|
|
def test_ci_matrix_tests_the_shipped_runtime(
|
|
dockerfile: str, pattern: str, workflow: str, key: str
|
|
) -> None:
|
|
"""The workflow matrix lists every runtime minor the image ships."""
|
|
|
|
shipped = set(re.findall(pattern, _read(dockerfile), re.MULTILINE))
|
|
text = _read(workflow)
|
|
tested = _matrix_values(text, key)
|
|
assert shipped, f"no runtime version found in {dockerfile}"
|
|
assert shipped <= tested, (
|
|
f"{dockerfile} ships {sorted(shipped)} but the {key} matrix in "
|
|
f"{workflow} tests {sorted(tested)}"
|
|
)
|
|
# A matrix the setup step ignores tests one runtime N times.
|
|
assert "${{ matrix.%s }}" % key in text, f"{workflow} does not use matrix.{key}"
|
|
|
|
|
|
@pytest.mark.parametrize("workflow", sorted({pin[2] for pin in RUNTIME_PINS}))
|
|
def test_runtime_matrix_does_not_fail_fast(workflow: str) -> None:
|
|
"""One runtime's failure must not cancel, and so hide, the other legs."""
|
|
|
|
assert re.search(
|
|
r"^\s+fail-fast:\s*false\s*$", _read(workflow), re.MULTILINE
|
|
), f"{workflow}: set fail-fast: false on the runtime matrix"
|
|
|
|
|
|
def test_python_workflow_runs_the_guards_for_their_inputs() -> None:
|
|
"""A pull request touching a guarded input runs the Python workflow.
|
|
|
|
``fnmatch``'s ``*`` also crosses ``/``, which is looser than GitHub's
|
|
filter syntax; the filters here are exact names, ``name*`` suffix globs
|
|
and ``dir/**`` globs, on which both agree.
|
|
"""
|
|
|
|
filters = _pull_request_paths(_read(PYTHON_WORKFLOW))
|
|
unfiltered = [
|
|
path
|
|
for path in GUARDED_INPUTS
|
|
if not any(fnmatch.fnmatchcase(path, glob) for glob in filters)
|
|
]
|
|
assert filters, f"no pull_request.paths parsed from {PYTHON_WORKFLOW}"
|
|
assert not unfiltered, f"{PYTHON_WORKFLOW} pull_request.paths misses {unfiltered}"
|
|
|
|
|
|
def test_pull_request_paths_skip_comments_and_blank_lines() -> None:
|
|
"""Annotated filter lists parse whole; a workflow without a filter has none."""
|
|
|
|
workflow = (
|
|
"on:\n"
|
|
" push:\n"
|
|
' branches: [ "main" ]\n'
|
|
" pull_request:\n"
|
|
" paths:\n"
|
|
" - '.github/**'\n"
|
|
" # Read by a guard below.\n"
|
|
"\n"
|
|
' - "web/Gemfile*" # lockfile too\n'
|
|
" types: [opened]\n"
|
|
"\n"
|
|
"permissions:\n"
|
|
" contents: read\n"
|
|
)
|
|
assert _pull_request_paths(workflow) == [".github/**", "web/Gemfile*"]
|
|
assert _pull_request_paths("on:\n push:\n branches: [main]\n") == []
|
|
|
|
|
|
def test_matrix_values_ignore_a_scalar_setting() -> None:
|
|
"""A scalar version is no matrix; a flow list is read with quotes stripped."""
|
|
|
|
assert _matrix_values(' python-version: "3.13"\n', "python-version") == set()
|
|
flow = " ruby-version: ['3.3', \"4.0\"]\n"
|
|
assert _matrix_values(flow, "ruby-version") == {"3.3", "4.0"}
|