| # 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})" |