Skip to content

Commit 189e99f

Browse files
committed
ci: select PR unit tests by changed component
Map a PR's changed files to the minimal tests/unit_tests paths in the torch job; fail-safe to the full suite on shared/unknown/CI changes. Other events and the CLI/E2E/model tests are unchanged. Unit-tested.
1 parent 4e018b8 commit 189e99f

3 files changed

Lines changed: 186 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,21 @@ jobs:
337337
# Note HSA_NO_SCRATCH_RECLAIM=1 must be set to avoid RCCL perf hit (TAS-8N Node), rocm ver:70125424
338338
export HSA_NO_SCRATCH_RECLAIM=1
339339
mkdir -p "${GITHUB_WORKSPACE}/test-reports"
340-
pytest --maxfail=1 -s ./tests/unit_tests/ \
340+
# Component-aware selection on PRs; full suite on push/release/dispatch.
341+
# Fail-safe: any failure to compute the diff falls back to the full suite.
342+
TARGETS="./tests/unit_tests/"
343+
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
344+
base_sha="${{ github.event.pull_request.base.sha }}"
345+
git fetch --no-tags --depth=200 origin "${{ github.base_ref }}" 2>/dev/null || true
346+
changed="$(git diff --name-only "${base_sha}" HEAD 2>/dev/null || true)"
347+
if [[ -n "${changed}" ]]; then
348+
sel="$(printf '%s\n' "${changed}" | python tools/ci/select_tests.py)"
349+
[[ -n "${sel}" ]] && TARGETS="${sel}"
350+
fi
351+
fi
352+
echo "Selected unit-test targets: ${TARGETS}"
353+
# shellcheck disable=SC2086 # intentional word-splitting of multiple paths
354+
pytest --maxfail=1 -s ${TARGETS} \
341355
--cov=primus --cov-report=term-missing:skip-covered \
342356
--junitxml="${GITHUB_WORKSPACE}/test-reports/core-unit.xml" \
343357
--deselect=tests/unit_tests/megatron/cco/test_tp_overlap.py::TPOverlapTestCase::test_fp8_te_linear \
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
###############################################################################
2+
# Copyright (c) 2025, Advanced Micro Devices, Inc. All rights reserved.
3+
#
4+
# See LICENSE for license information.
5+
###############################################################################
6+
7+
"""Unit tests for tools/ci/select_tests.py (component-aware PR test selection)."""
8+
9+
import importlib.util
10+
from pathlib import Path
11+
12+
_ROOT = Path(__file__).resolve().parents[3]
13+
_SPEC = importlib.util.spec_from_file_location("select_tests", _ROOT / "tools/ci/select_tests.py")
14+
_MOD = importlib.util.module_from_spec(_SPEC)
15+
_SPEC.loader.exec_module(_MOD)
16+
select = _MOD.select_targets
17+
18+
FULL = ["tests/unit_tests/"]
19+
20+
21+
def test_empty_runs_full():
22+
assert select([]) == FULL
23+
24+
25+
def test_ci_change_runs_full():
26+
assert select([".github/workflows/ci.yaml"]) == FULL
27+
28+
29+
def test_tools_change_runs_full():
30+
assert select(["tools/ci/select_tests.py"]) == FULL
31+
32+
33+
def test_root_requirements_runs_full():
34+
assert select(["requirements.txt"]) == FULL
35+
assert select(["requirements-jax.txt"]) == FULL
36+
37+
38+
def test_launcher_runs_full():
39+
assert select(["primus/core/launcher/initialize.py"]) == FULL
40+
41+
42+
def test_unknown_primus_path_runs_full():
43+
assert select(["primus/brand_new_area/x.py"]) == FULL
44+
45+
46+
def test_megatron_backend_maps_to_both_dirs():
47+
out = select(["primus/backends/megatron/training/global_vars.py"])
48+
assert "tests/unit_tests/backends/megatron/" in out
49+
assert "tests/unit_tests/megatron/" in out
50+
51+
52+
def test_projection_takes_precedence_over_core():
53+
assert select(["primus/core/projection/engine.py"]) == ["tests/unit_tests/core/projection/"]
54+
55+
56+
def test_generic_core_maps_to_core_tests():
57+
assert select(["primus/core/trainer/base.py"]) == ["tests/unit_tests/core/"]
58+
59+
60+
def test_cli_and_runner_map_to_cli_tests():
61+
assert select(["primus/cli/main.py"]) == ["tests/unit_tests/cli/"]
62+
assert select(["runner/helpers/foo.sh"]) == ["tests/unit_tests/cli/"]
63+
assert select(["primus_cli.py"]) == ["tests/unit_tests/cli/"]
64+
65+
66+
def test_changed_test_file_runs_its_own_dir():
67+
assert select(["tests/unit_tests/agents/test_tools.py"]) == ["tests/unit_tests/agents/"]
68+
69+
70+
def test_multiple_components_are_unioned():
71+
out = select(["primus/agents/a.py", "primus/cli/b.py"])
72+
assert set(out) == {"tests/unit_tests/agents/", "tests/unit_tests/cli/"}
73+
74+
75+
def test_docs_only_change_is_ignored_then_full():
76+
# No source/test matched -> fail-safe to full suite.
77+
assert select(["README.md", "docs/guide.md"]) == FULL

tools/ci/select_tests.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
###############################################################################
2+
# Copyright (c) 2025, Advanced Micro Devices, Inc. All rights reserved.
3+
#
4+
# See LICENSE for license information.
5+
###############################################################################
6+
7+
"""Map a PR's changed files (stdin, one per line) to the unit-test paths to run.
8+
9+
Fail-safe by design: shared / unknown / CI / packaging changes expand to the
10+
full suite, so it can only over-select, never under-select. Otherwise prints the
11+
union of the matched components' test dirs (a changed test file -> its own dir).
12+
13+
git diff --name-only "$BASE" HEAD | python tools/ci/select_tests.py
14+
"""
15+
16+
import sys
17+
18+
FULL = "tests/unit_tests/"
19+
20+
# Changed paths matching any of these -> run the whole suite.
21+
FULL_TRIGGERS = (
22+
".github/",
23+
"tools/",
24+
"pyproject.toml",
25+
"primus/__init__.py",
26+
"primus/core/launcher/",
27+
"primus/core/utils/",
28+
"primus/core/config/",
29+
"tests/utils.py",
30+
"tests/conftest.py",
31+
"tests/unit_tests/conftest.py",
32+
"tests/run_unit_tests.py",
33+
)
34+
35+
# Source-prefix -> test paths. Checked in order; first match wins, so more
36+
# specific prefixes (projection, megatron) must precede their generic parents.
37+
COMPONENT_MAP = (
38+
("primus/core/projection/", ("tests/unit_tests/core/projection/",)),
39+
("primus/backends/megatron/", ("tests/unit_tests/backends/megatron/", "tests/unit_tests/megatron/")),
40+
("primus/agents/", ("tests/unit_tests/agents/",)),
41+
("primus/cli/", ("tests/unit_tests/cli/",)),
42+
("primus_cli.py", ("tests/unit_tests/cli/",)),
43+
("runner/", ("tests/unit_tests/cli/",)),
44+
("primus/core/", ("tests/unit_tests/core/",)),
45+
("primus/backends/", ("tests/unit_tests/backends/",)),
46+
)
47+
48+
49+
def _is_full_trigger(path):
50+
# Root-level requirements*.txt (dependency change) -> full suite.
51+
if "/" not in path and path.startswith("requirements") and path.endswith(".txt"):
52+
return True
53+
return any(path == trig or path.startswith(trig) for trig in FULL_TRIGGERS)
54+
55+
56+
def select_targets(files):
57+
files = [f.strip() for f in files if f.strip()]
58+
if not files:
59+
return [FULL]
60+
61+
targets = []
62+
63+
def add(path):
64+
if path not in targets:
65+
targets.append(path)
66+
67+
for path in files:
68+
if _is_full_trigger(path):
69+
return [FULL]
70+
# A changed test file: run its own directory.
71+
if path.startswith("tests/unit_tests/") and path.endswith(".py"):
72+
add(path.rsplit("/", 1)[0] + "/")
73+
continue
74+
matched = False
75+
for prefix, tests in COMPONENT_MAP:
76+
if path.startswith(prefix):
77+
for test_path in tests:
78+
add(test_path)
79+
matched = True
80+
break
81+
if not matched and path.startswith("primus/"):
82+
return [FULL] # unknown source area -> be safe
83+
# else: non-source path (docs, README, ...) -> ignore
84+
85+
return targets or [FULL]
86+
87+
88+
def main():
89+
print(" ".join(select_targets(sys.stdin.read().splitlines())))
90+
return 0
91+
92+
93+
if __name__ == "__main__":
94+
raise SystemExit(main())

0 commit comments

Comments
 (0)