blob: 58f03ff8b68c0cd518fc58c948d919de4d788c91 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
#
---
name: tests
on: # yamllint disable-line rule:truthy
pull_request:
push:
branches: [main]
permissions: {}
jobs:
# Discover workspace members from the root pyproject.toml so the
# pytest matrix below is data-driven — adding a new tool is a
# one-line edit to `[tool.uv.workspace] members` and the matrix
# picks it up automatically.
members:
name: list-workspace-members
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
members: ${{ steps.list.outputs.members }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Full history so the PR path-filter below can diff the PR
# head against its merge base. Harmless on push events.
fetch-depth: 0
- id: list
# Emit the workspace members as a JSON array of {name,path}
# objects so the matrix below can both label jobs by name
# AND pass the path to `uv run --directory`. The `pytest`
# applicability filter mirrors the auto-discovery rule in
# `tools/dev/run-workspace-check.sh`: only members with a
# `[tool.pytest.ini_options]` section get pytest jobs, and
# an explicit `[tool.magpie.checks] skip = [..., "pytest"]`
# opts out.
#
# Path filter (PRs only): a member's pytest job is emitted
# only when that member's own Python source/test files changed
# in the PR. On push to `main` — and when a shared build input
# changes (root `pyproject.toml` / `uv.lock` / the
# workspace-check helper / this workflow) — the full matrix
# runs. If no member's tests are affected the matrix is empty
# and the `pytest` job is skipped; `tests-ok` treats that as a
# pass (see below).
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Write the changed-file list to a file rather than exporting it
# as an env var: a repo-wide PR (e.g. a licence-header sweep) can
# list thousands of paths, and putting that into the environment
# overflows the exec arg/env size limit — `python3` then dies with
# "Argument list too long" (exit 126). A file path stays small.
changed_file="${RUNNER_TEMP}/changed-files.txt"
if [ "${EVENT}" = "pull_request" ]; then
git diff --name-only "${BASE_SHA}...${HEAD_SHA}" > "${changed_file}"
else
: > "${changed_file}"
fi
members=$(CHANGED_FILE="${changed_file}" python3 <<'PY'
import json
import os
import tomllib
event = os.environ.get("EVENT", "")
changed_file = os.environ.get("CHANGED_FILE", "")
changed = []
if changed_file:
with open(changed_file, encoding="utf-8") as fh:
changed = [line.strip() for line in fh if line.strip()]
with open("pyproject.toml", "rb") as f:
root = tomllib.load(f)
paths = root["tool"]["uv"]["workspace"]["members"]
# Shared build inputs that force the full matrix even on a PR:
# a change to any of these can affect every member's tests.
GLOBAL = {
"pyproject.toml",
"uv.lock",
"tools/dev/run-workspace-check.sh",
".github/workflows/tests.yml",
}
run_all = event != "pull_request" or any(c in GLOBAL for c in changed)
def member_changed(path: str) -> bool:
# A member is affected when the PR touches a `.py` file
# anywhere under it, or any file under its `tests/` dir
# (fixtures / data a test reads).
prefix = path + "/"
tests_prefix = path + "/tests/"
for c in changed:
if c.startswith(prefix) and (
c.endswith(".py") or c.startswith(tests_prefix)
):
return True
return False
out = []
for path in paths:
with open(f"{path}/pyproject.toml", "rb") as f:
data = tomllib.load(f)
tool = data.get("tool", {})
if "ini_options" not in tool.get("pytest", {}):
continue
skip = tool.get("magpie", {}).get("checks", {}).get("skip", [])
if "pytest" in skip:
continue
if not run_all and not member_changed(path):
continue
out.append({"name": path.split("/")[-1], "path": path})
# Sort for deterministic CI ordering.
out.sort(key=lambda x: x["name"])
print(json.dumps(out))
PY
)
echo "members=${members}" >> "$GITHUB_OUTPUT"
echo "Selected workspace members for pytest matrix:"
echo "${members}" | python3 -m json.tool
# Per-member pytest matrix. Each Python project under tools/ that
# is a uv-workspace member with a `[tool.pytest.ini_options]`
# section gets its own job. Running them as separate matrix jobs
# surfaces per-project pass/fail in the CI checks list, which is
# easier to triage than the bundled `workspace-pytest` line inside
# the `prek` workflow's output. The `prek` workflow still
# exercises pytest as a hook, so this workflow is the visible
# signal — not the gate.
pytest:
name: "pytest (${{ matrix.project.name }})"
runs-on: ubuntu-latest
needs: members
# Guard against an empty selection. When the path filter selects no
# members (a PR that changed no project's Python), the members output
# is `[]`; feeding an empty array to `matrix` makes GitHub mark this
# job `failure` ("matrix vector does not contain any values"), not
# `skipped` — which would fail `tests-ok`. Skipping the job on an
# empty list yields the `skipped` result `tests-ok` treats as a pass.
if: ${{ needs.members.outputs.members != '[]' }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(needs.members.outputs.members) }}
# GitHub Actions log viewer renders ANSI colour escapes; without
# an attached TTY most tools default to monochrome. `FORCE_COLOR`
# is the de-facto signal honoured by uv, ruff, mypy, and pytest's
# own auto-detection. Belt-and-braces — the explicit
# `--color=yes` on the pytest invocation below covers tools that
# don't read `FORCE_COLOR`.
env:
FORCE_COLOR: "1"
PY_COLORS: "1"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# uv brings its own Python and reads each project's
# `pyproject.toml` + the workspace `uv.lock`. Minimum uv
# version is enforced by the root `pyproject.toml`'s
# `[tool.uv] required-version`.
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
- name: Sync workspace (installs ruff / mypy / pytest from root dev group)
# Dev-group deps live in the root pyproject (`[dependency-groups]
# dev`) and only land in the shared workspace `.venv` when an
# explicit `--group dev` sync runs. `--all-packages` ensures
# every workspace member is installed too (the root has
# `package = false`, so without it members and their
# `[project.scripts]` entry points would be skipped).
run: uv sync --all-packages --group dev
- name: Run pytest
# `--directory` (not `--project`) — both move uv's project
# context, but `--directory` also changes cwd, which pytest
# needs because each project's `pyproject.toml` declares
# `testpaths = ["tests"]` relative to its own root. No
# `--group` flag here because the sync above already
# installed the dev tools into the shared workspace venv.
run: uv run --directory ${{ matrix.project.path }} pytest --color=yes
# Umbrella status check. `.asf.yaml`'s `required_status_checks`
# cannot use patterns or wildcards (GitHub's branch-protection and
# rulesets APIs both require exact context names), so a matrix job
# with N entries would otherwise force N `.asf.yaml` lines that
# must be kept in lock-step with the matrix. This single
# `tests-ok` job is the only pytest-related context required by
# branch protection — it passes iff every entry in the `pytest`
# matrix above passed. Adding, renaming, or removing matrix
# entries no longer touches `.asf.yaml`.
tests-ok:
name: tests-ok
runs-on: ubuntu-latest
needs: pytest
# `always()` — without it, this job would be skipped (not
# failed) when an upstream matrix entry fails, and a skipped
# required-check is treated as missing by branch protection,
# which leaves the PR mergeable. Run unconditionally and assert
# on the result instead.
if: always()
steps:
- name: Assert all pytest matrix entries succeeded
# `success` — every selected member passed.
# `skipped` — the path filter selected no members (the PR
# changed no project's Python source/test files), so there
# was nothing to run; that is a pass, not a gap.
# Anything else (`failure` / `cancelled`) fails the gate.
run: |
result="${{ needs.pytest.result }}"
if [ "${result}" != "success" ] && [ "${result}" != "skipped" ]; then
echo "pytest matrix result: ${result}"
exit 1
fi
echo "pytest gate satisfied (matrix result: ${result})"