Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions tools/ci_changes.py
Original file line number Diff line number Diff line change
@@ -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()
57 changes: 57 additions & 0 deletions tools/test_ci_changes.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading