@@ -71,6 +71,159 @@ jobs:
7171 echo "skip=${skip}" >> "$GITHUB_OUTPUT"
7272 echo "doc_only=${doc_only}" >> "$GITHUB_OUTPUT"
7373
74+ # Detect which top-level modules were touched by the PR so downstream build
75+ # and test jobs can avoid rebuilding/retesting modules unaffected by the
76+ # change. See issue #299.
77+ #
78+ # Dependency graph (verified in pyproject.toml files):
79+ # cuda_pathfinder -> (no internal deps)
80+ # cuda_bindings -> cuda_pathfinder
81+ # cuda_core -> cuda_pathfinder, cuda_bindings
82+ # cuda_python -> cuda_bindings (meta package)
83+ #
84+ # A change to cuda_pathfinder (or shared infra) forces a rebuild of every
85+ # downstream module. A change to cuda_bindings forces rebuild of cuda_core.
86+ # A change to cuda_core alone skips rebuilding/retesting cuda_bindings.
87+ # On push to main, tag refs, schedule, or workflow_dispatch events we
88+ # unconditionally run everything because there is no meaningful "changed
89+ # paths" baseline for those events.
90+ detect-changes :
91+ runs-on : ubuntu-latest
92+ outputs :
93+ bindings : ${{ steps.compose.outputs.bindings }}
94+ core : ${{ steps.compose.outputs.core }}
95+ pathfinder : ${{ steps.compose.outputs.pathfinder }}
96+ python_meta : ${{ steps.compose.outputs.python_meta }}
97+ test_helpers : ${{ steps.compose.outputs.test_helpers }}
98+ shared : ${{ steps.compose.outputs.shared }}
99+ build_bindings : ${{ steps.compose.outputs.build_bindings }}
100+ build_core : ${{ steps.compose.outputs.build_core }}
101+ build_pathfinder : ${{ steps.compose.outputs.build_pathfinder }}
102+ test_bindings : ${{ steps.compose.outputs.test_bindings }}
103+ test_core : ${{ steps.compose.outputs.test_core }}
104+ test_pathfinder : ${{ steps.compose.outputs.test_pathfinder }}
105+ steps :
106+ - name : Checkout repository
107+ uses : actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
108+ with :
109+ fetch-depth : 0
110+
111+ # copy-pr-bot pushes every PR (whether it targets main or a backport
112+ # branch such as 12.9.x) to pull-request/<N>, so the base branch
113+ # cannot be inferred from github.ref_name. Look it up via the
114+ # upstream PR metadata so the diff below is rooted at the right place.
115+ - name : Resolve PR base branch
116+ id : pr-info
117+ if : ${{ startsWith(github.ref_name, 'pull-request/') }}
118+ uses : nv-gha-runners/get-pr-info@main
119+
120+ - name : Detect changed paths
121+ id : filter
122+ if : ${{ startsWith(github.ref_name, 'pull-request/') }}
123+ env :
124+ # GitHub Actions evaluates step-level `env:` expressions eagerly —
125+ # the step's `if:` gate does NOT short-circuit them. On non-PR
126+ # events (push/tag/schedule), `pr-info` is skipped and its outputs
127+ # are empty strings, so `fromJSON('')` would raise a template error
128+ # and fail the step despite `if:` being false. Guard the
129+ # `fromJSON` call with a short-circuit so the expression resolves
130+ # to an empty string on non-PR events; the step is still gated
131+ # off by `if:`, so `BASE_REF` is never consumed there.
132+ BASE_REF : ${{ steps.pr-info.outputs.pr-info && fromJSON(steps.pr-info.outputs.pr-info).base.ref || '' }}
133+ run : |
134+ # Diff against the merge base with the PR's actual target branch.
135+ # Uses merge-base so diverged branches only show files changed on
136+ # the PR side, not upstream commits.
137+ if [[ -z "${BASE_REF}" ]]; then
138+ echo "Could not resolve PR base branch from get-pr-info output" >&2
139+ exit 1
140+ fi
141+ base=$(git merge-base HEAD "origin/${BASE_REF}")
142+ changed=$(git diff --name-only "$base"...HEAD)
143+
144+ has_match() {
145+ grep -qE "$1" <<< "$changed" && echo true || echo false
146+ }
147+
148+ {
149+ echo "bindings=$(has_match '^cuda_bindings/')"
150+ echo "core=$(has_match '^cuda_core/')"
151+ echo "pathfinder=$(has_match '^cuda_pathfinder/')"
152+ echo "python_meta=$(has_match '^cuda_python/')"
153+ echo "test_helpers=$(has_match '^cuda_python_test_helpers/')"
154+ echo "shared=$(has_match '^(\.github/|ci/|scripts/|toolshed/|conftest\.py$|pyproject\.toml$|pixi\.(toml|lock)$|pytest\.ini$|ruff\.toml$)')"
155+ } >> "$GITHUB_OUTPUT"
156+
157+ - name : Compose gating outputs
158+ id : compose
159+ env :
160+ IS_PR : ${{ startsWith(github.ref_name, 'pull-request/') }}
161+ BINDINGS : ${{ steps.filter.outputs.bindings || 'false' }}
162+ CORE : ${{ steps.filter.outputs.core || 'false' }}
163+ PATHFINDER : ${{ steps.filter.outputs.pathfinder || 'false' }}
164+ PYTHON_META : ${{ steps.filter.outputs.python_meta || 'false' }}
165+ TEST_HELPERS : ${{ steps.filter.outputs.test_helpers || 'false' }}
166+ SHARED : ${{ steps.filter.outputs.shared || 'false' }}
167+ run : |
168+ set -euxo pipefail
169+ # Non-PR events (push to main, tag push, schedule, workflow_dispatch)
170+ # always exercise the full pipeline because there is no baseline for
171+ # a meaningful diff.
172+ if [[ "${IS_PR}" != "true" ]]; then
173+ bindings=true
174+ core=true
175+ pathfinder=true
176+ python_meta=true
177+ test_helpers=true
178+ shared=true
179+ else
180+ bindings="${BINDINGS}"
181+ core="${CORE}"
182+ pathfinder="${PATHFINDER}"
183+ python_meta="${PYTHON_META}"
184+ test_helpers="${TEST_HELPERS}"
185+ shared="${SHARED}"
186+ fi
187+
188+ or_flag() {
189+ for v in "$@"; do
190+ if [[ "${v}" == "true" ]]; then
191+ echo "true"
192+ return
193+ fi
194+ done
195+ echo "false"
196+ }
197+
198+ # Build gating: pathfinder change forces rebuild of bindings and
199+ # core; bindings change forces rebuild of core. shared changes force
200+ # a full rebuild.
201+ build_pathfinder="$(or_flag "${shared}" "${pathfinder}")"
202+ build_bindings="$(or_flag "${shared}" "${pathfinder}" "${bindings}")"
203+ build_core="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${core}")"
204+
205+ # Test gating: tests for a module must run whenever that module, any
206+ # of its runtime dependencies, the shared test helper package, or
207+ # shared infra changes. pathfinder tests are cheap and always run.
208+ test_pathfinder=true
209+ test_bindings="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${test_helpers}")"
210+ test_core="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${core}" "${test_helpers}")"
211+
212+ {
213+ echo "bindings=${bindings}"
214+ echo "core=${core}"
215+ echo "pathfinder=${pathfinder}"
216+ echo "python_meta=${python_meta}"
217+ echo "test_helpers=${test_helpers}"
218+ echo "shared=${shared}"
219+ echo "build_bindings=${build_bindings}"
220+ echo "build_core=${build_core}"
221+ echo "build_pathfinder=${build_pathfinder}"
222+ echo "test_bindings=${test_bindings}"
223+ echo "test_core=${test_core}"
224+ echo "test_pathfinder=${test_pathfinder}"
225+ } >> "$GITHUB_OUTPUT"
226+
74227 # NOTE: Build jobs are intentionally split by platform rather than using a single
75228 # matrix. This allows each test job to depend only on its corresponding build,
76229 # so faster platforms can proceed through build & test without waiting for slower
@@ -151,6 +304,7 @@ jobs:
151304 needs :
152305 - ci-vars
153306 - should-skip
307+ - detect-changes
154308 - build-linux-64
155309 secrets : inherit
156310 uses : ./.github/workflows/test-wheel-linux.yml
@@ -159,6 +313,7 @@ jobs:
159313 host-platform : ${{ matrix.host-platform }}
160314 build-ctk-ver : ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
161315 nruns : ${{ (github.event_name == 'schedule' && 100) || 1}}
316+ skip-bindings-test : ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
162317
163318 # See test-linux-64 for why test jobs are split by platform.
164319 test-linux-aarch64 :
@@ -174,6 +329,7 @@ jobs:
174329 needs :
175330 - ci-vars
176331 - should-skip
332+ - detect-changes
177333 - build-linux-aarch64
178334 secrets : inherit
179335 uses : ./.github/workflows/test-wheel-linux.yml
@@ -182,6 +338,7 @@ jobs:
182338 host-platform : ${{ matrix.host-platform }}
183339 build-ctk-ver : ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
184340 nruns : ${{ (github.event_name == 'schedule' && 100) || 1}}
341+ skip-bindings-test : ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
185342
186343 # See test-linux-64 for why test jobs are split by platform.
187344 test-windows :
@@ -197,6 +354,7 @@ jobs:
197354 needs :
198355 - ci-vars
199356 - should-skip
357+ - detect-changes
200358 - build-windows
201359 secrets : inherit
202360 uses : ./.github/workflows/test-wheel-windows.yml
@@ -205,6 +363,7 @@ jobs:
205363 host-platform : ${{ matrix.host-platform }}
206364 build-ctk-ver : ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
207365 nruns : ${{ (github.event_name == 'schedule' && 100) || 1}}
366+ skip-bindings-test : ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
208367
209368 doc :
210369 name : Docs
@@ -228,6 +387,7 @@ jobs:
228387 runs-on : ubuntu-latest
229388 needs :
230389 - should-skip
390+ - detect-changes
231391 - test-linux-64
232392 - test-linux-aarch64
233393 - test-windows
@@ -254,7 +414,16 @@ jobs:
254414 #
255415 # Note: When [doc-only] is in PR title, test jobs are intentionally
256416 # skipped and should not cause failure.
417+ #
418+ # detect-changes gates whether heavy test matrices run at all; if it
419+ # does not succeed, downstream test jobs are skipped rather than
420+ # failed, which would otherwise go unnoticed here. Require its
421+ # success explicitly so a broken gating step cannot masquerade as a
422+ # green CI run.
257423 doc_only=${{ needs.should-skip.outputs.doc-only }}
424+ if ${{ needs.detect-changes.result != 'success' }}; then
425+ exit 1
426+ fi
258427 if ${{ needs.doc.result == 'cancelled' || needs.doc.result == 'failure' }}; then
259428 exit 1
260429 fi
0 commit comments