diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d550baa5..1e4d3ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ # CI for Tack. # -# Three jobs, all independent so they report in parallel: +# The jobs report in parallel. On a pull request, `changes` first says which +# its files need (tools/ci_changes.py): a change to the docs alone runs lint +# and docs; one to an example, lint and examples; anything in the packages, +# the build or the workflows, everything. A job left out reports "skipping". +# Pushes to main and runs by hand run everything. # # docs — builds the site strictly; publishes main's as `dev` # tests — the suite on Python 3.11/3.12/3.13 (Linux, CPU backend) @@ -39,6 +43,37 @@ concurrency: jobs: + # ── What changed ─────────────────────────────────────────────────── + # The groups a pull request's files need, as outputs the jobs below test: + # `code` (every test job, packaging and Metal), `docs`, `examples`. A path + # no rule of tools/ci_changes.py names is code. Lint needs no group. + changes: + name: what changed + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code: ${{ steps.classify.outputs.code }} + docs: ${{ steps.classify.outputs.docs }} + examples: ${{ steps.classify.outputs.examples }} + + steps: + - uses: actions/checkout@v7 + with: + # A pull request checks out its merge commit, whose first parent + # is the base it merges into. + fetch-depth: 2 + + - name: Classify the changed files + id: classify + shell: bash + run: | + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + git diff --name-only HEAD^1 HEAD | python3 tools/ci_changes.py >> "$GITHUB_OUTPUT" + else + printf 'code=true\ndocs=true\nexamples=true\n' >> "$GITHUB_OUTPUT" + fi + cat "$GITHUB_OUTPUT" + # ── Lint ─────────────────────────────────────────────────────────── # Cheapest job here and the one most likely to catch something before a # human looks. It found a real bug on its first run: an f-string with a @@ -91,6 +126,8 @@ jobs: docs: name: docs + needs: changes + if: needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 20 @@ -113,6 +150,14 @@ jobs: - name: Build the site run: uv run mkdocs build --strict + # Package tests that read the pages: each algorithm and export has its + # entry, and a VTK wrapper. A change to the docs alone runs no test job. + - name: The reference pages name everything + run: | + uv run --with pytest pytest -q -p no:cacheprovider \ + packages/tack-vis/tests/test_reference_pages.py \ + packages/tack-vis/tests/test_vtk_algorithms.py::test_every_algorithm_has_a_wrapper + # ── Publish ──────────────────────────────────────────────────────── # # Separate from the gate above because deploying and checking are @@ -165,6 +210,8 @@ jobs: # ── The main gate ────────────────────────────────────────────────── tests: name: tests (py${{ matrix.python-version }}, ${{ matrix.shard }}/4) + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest # The suite in four shares (conftest's --shard, by test-durations.json), each # serial: whole, it took 25-35+ min by 2026-10-29 (35 cancelled #212's py3.11 @@ -242,6 +289,8 @@ jobs: # repeats the whole dataset suite, which would crowd the main gate. ids-i64: name: dataset tests with 64-bit ids (${{ matrix.shard }}/4) + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest # In four shares (conftest's --shard): whole, 17.5-45 min by 2026-10-29 (30 # cancelled #212's run); in three shares, 17-25+ min a share by 2026-11-02 @@ -282,6 +331,8 @@ jobs: # (the vault's coefficient-layout proposal, D7). l2-cells: name: dataset tests with L2 cell-fastest (${{ matrix.shard }}/2) + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest # In two shares (conftest's --shard): whole, on four workers, it ran 13 min by # 2026-10-29, and #213's run (more compiled kernels a worker) lost its runner @@ -324,6 +375,8 @@ jobs: # warp-simdata's int32 ids rather than sharing them. warp: name: Warp backend and interop + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 20 @@ -362,6 +415,8 @@ jobs: # warns (an annotation on the run) rather than failing the job. vtk-master: name: VTK master + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 25 @@ -409,6 +464,8 @@ jobs: # job rather than skip, and oversubscription lets 3 ranks run on any runner. mpi: name: mpi + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -467,6 +524,8 @@ jobs: # — so it needs its own job with a deliberately minimal install. packaging: name: GPU install needs no llvmlite + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -512,6 +571,8 @@ jobs: # of tack-core's when it comes first) fails here. wheels: name: Built wheels install and import + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -564,6 +625,8 @@ jobs: # the log says what was really checked rather than just "green". examples: name: examples + needs: changes + if: needs.changes.outputs.examples == 'true' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 20 @@ -594,6 +657,8 @@ jobs: # quietly omit the hardware cases. metal: name: metal (${{ matrix.shard }}/4) + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: macos-latest # The suite in four shares (conftest's --shard), one process each: whole, it # took 42 min of a 45 min limit when macOS runners were short (2026-10-29), diff --git a/CLAUDE.md b/CLAUDE.md index 97ea3226..0f001326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,8 @@ validation round or a merge gate, once per machine, not for iteration. CI's long jobs run in shares across runners (`--shard K/N`, the root conftest's: test files dealt by their seconds in `test-durations.json`, longest first, to the least-loaded share, so every share collects the same suite and they are disjoint; by test count they came out 27 against 14 minutes; refresh the table with `uv run pytest -n auto -q --durations=0 | python tools/test_durations.py`), each share one process as before: the Metal job in four (Metal tests never run under xdist; whole, it reached 42 of its 45 minutes when macOS runners were short, and a third 25 of 30), the Linux `tests` per Python version and `ids-i64` in four (whole, 25-45 minutes by 2026-10-29; in three shares, 17-28 minutes a share once #259 and #260 merged, and `ids-i64`'s 25-minute limit cancelled a third). +On a pull request, ci.yml's `changes` job says which jobs its files need (`tools/ci_changes.py`, first match wins; a path no rule names is `code`): `code` gates every test job, packaging, wheels and Metal; `docs` (with `code`) the docs job, which also runs the package tests that read its pages (`test_reference_pages.py`, `test_every_algorithm_has_a_wrapper`); `examples` (with `code`) the examples job; lint always runs. Pushes to `main` and `workflow_dispatch` run everything. A new test that reads a file outside the packages (as `test_threading_benchmark.py` reads `benchmarks/`) needs that path classified as `code`. + A test module that compares in f64 (against VTK, say) takes conftest's `f64_backend` as an autouse fixture, so it runs on every backend with f64 (the CPU, CUDA, HIP, Level Zero) whatever the run's default device, never on Metal, which has none; pinning it to the CPU would lose the GPUs. Metal is covered by `test_dataset_devices`' device without f64 (`test_cell_quality.py`, `test_feature_edges.py`). Do not edit a `.py` file while a run that imported it is still going: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fec989a0..97a17b47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,6 +157,12 @@ its own backends, so GPU memory grows with the worker count. The `timing` tests skip themselves on a busy host, so a parallel run skips them; run `-m timing` alone when they matter. CI runs the suite serially (`uv run pytest`). +On a pull request CI runs the jobs its files need (`tools/ci_changes.py`): a +change to the docs alone runs lint and the docs build, one to an example lint +and the examples; a change to the packages, the build or the workflows runs +everything. The jobs left out report "skipping". Pushes to `main` run +everything. + ## The thing to know about the tests **A green run does not mean every backend was exercised.** Each backend's tests diff --git a/tools/ci_changes.py b/tools/ci_changes.py new file mode 100644 index 00000000..308cb8e2 --- /dev/null +++ b/tools/ci_changes.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) Kitware Inc. +# SPDX-License-Identifier: BSD-3-Clause + +"""Which of CI's jobs a pull request's changed files need. + +Reads the changed paths (one a line, ``git diff --name-only``) on standard +input and prints ``group=true`` or ``group=false`` for each group, which +ci.yml's ``changes`` job appends to ``$GITHUB_OUTPUT``: + +- ``code``: the packages, their tests, the build and the workflows. Every + test job, the packaging jobs and Metal. +- ``docs``: the site's pages and the prose around the code. The docs job. +- ``examples``: the shipped examples. The examples job. + +Lint runs on every change and is no group. A path no rule names is code, so +a new kind of file runs everything until a rule says otherwise. + + python3 tools/ci_changes.py < changed.txt +""" + +import sys + +GROUPS = ("code", "docs", "examples") + +# (test, groups), first match wins. A test is a prefix ("docs/"), a whole path +# ("mkdocs.yml") or, for the packages' examples, a function. + + +def _example(path): + parts = path.split("/") + return len(parts) >= 4 and parts[0] == "packages" and parts[2] == "examples" + + +def _prose(path): + """Markdown outside the packages, which the site or a reader reads, nothing runs.""" + return (path.endswith(".md") and not path.startswith("packages/")) or path.endswith("/CLAUDE.md") + + +RULES = ( + # Outside the workspace, ruff and the SPDX check; nothing runs it. + ("archive/", ()), + # Tested by lint alone (tools/ tests itself there). + ("tools/", ()), + (_example, ("examples",)), + ("docs/", ("docs",)), + ("overrides/", ("docs",)), + ("mkdocs.yml", ("docs",)), + ("changelog.d/", ()), # lint checks the entries + ("LICENSE", ()), + (_prose, ("docs",)), +) + + +def _matches(test, path): + if callable(test): + return test(path) + return path.startswith(test) if test.endswith("/") else path == test + + +def groups_of(path): + """The groups one changed path needs.""" + for test, groups in RULES: + if _matches(test, path): + return set(groups) + return {"code"} + + +def classify(paths): + """``{group: needed}`` for a change's paths.""" + needed = set() + for path in paths: + needed |= groups_of(path) + return {group: group in needed for group in GROUPS} + + +def main(): + paths = [line.strip() for line in sys.stdin if line.strip()] + for path in paths: + print(f"{path}: {', '.join(sorted(groups_of(path))) or 'lint'}", file=sys.stderr) + for group, needed in classify(paths).items(): + print(f"{group}={'true' if needed else 'false'}") + + +if __name__ == "__main__": + main() diff --git a/tools/test_ci_changes.py b/tools/test_ci_changes.py new file mode 100644 index 00000000..7af4c9a5 --- /dev/null +++ b/tools/test_ci_changes.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) Kitware Inc. +# SPDX-License-Identifier: BSD-3-Clause + +"""``tools/ci_changes.py``: a pull request's paths choose CI's jobs.""" + +import subprocess +import sys + +import ci_changes +import pytest + + +@pytest.mark.parametrize("path, groups", [ + ("packages/tack-core/src/tack/runtime/cuda_backend.py", {"code"}), + ("packages/tack-vis/tests/test_streamlines.py", {"code"}), + ("packages/tack-vis/tests/data/vtk/README.md", {"code"}), + ("packages/tack-core/README.md", {"code"}), # the package's description + ("packages/tack-vis/tools/make_tables.py", {"code"}), + ("pyproject.toml", {"code"}), + ("uv.lock", {"code"}), + ("conftest.py", {"code"}), + ("test-durations.json", {"code"}), + (".github/workflows/ci.yml", {"code"}), + ("benchmarks/threading_decisions.py", {"code"}), # test_threading_benchmark runs it + ("something/new.txt", {"code"}), + ("packages/tack-core/examples/01_hello_tack.py", {"examples"}), + ("packages/tack-vis/examples/data/README.md", {"examples"}), + ("docs/vis-guide/reference/algorithms.md", {"docs"}), + ("docs/examples/validate.py", {"docs"}), + ("docs/_ext/tack_namespace.py", {"docs"}), + ("mkdocs.yml", {"docs"}), + ("overrides/main.html", {"docs"}), + ("README.md", {"docs"}), + ("CONTRIBUTING.md", {"docs"}), + ("CLAUDE.md", {"docs"}), + ("packages/tack-vis/CLAUDE.md", {"docs"}), + ("changelog.d/ci-path-filter.md", set()), + ("tools/changelog.py", set()), + ("archive/tack-rendering/NOTES.md", set()), + ("LICENSE", set()), +]) +def test_each_path_has_its_groups(path, groups): + assert ci_changes.groups_of(path) == groups + + +def test_a_change_needs_the_union_of_its_paths(): + assert ci_changes.classify(["docs/install.md", "packages/tack-core/examples/02_saxpy.py"]) == { + "code": False, "docs": True, "examples": True} + assert ci_changes.classify(["changelog.d/x.md", "tools/spdx_headers.py"]) == { + "code": False, "docs": False, "examples": False} + assert ci_changes.classify(["docs/index.md", "uv.lock"])["code"] + + +def test_prints_one_output_a_group(): + out = subprocess.run([sys.executable, "ci_changes.py"], input="docs/index.md\n\n", + capture_output=True, text=True, check=True).stdout + assert out.splitlines() == ["code=false", "docs=true", "examples=false"]