@@ -71,6 +71,151 @@ 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+ BASE_REF : ${{ fromJSON(steps.pr-info.outputs.pr-info).base.ref }}
125+ run : |
126+ # Diff against the merge base with the PR's actual target branch.
127+ # Uses merge-base so diverged branches only show files changed on
128+ # the PR side, not upstream commits.
129+ if [[ -z "${BASE_REF}" ]]; then
130+ echo "Could not resolve PR base branch from get-pr-info output" >&2
131+ exit 1
132+ fi
133+ base=$(git merge-base HEAD "origin/${BASE_REF}")
134+ changed=$(git diff --name-only "$base"...HEAD)
135+
136+ has_match() {
137+ grep -qE "$1" <<< "$changed" && echo true || echo false
138+ }
139+
140+ {
141+ echo "bindings=$(has_match '^cuda_bindings/')"
142+ echo "core=$(has_match '^cuda_core/')"
143+ echo "pathfinder=$(has_match '^cuda_pathfinder/')"
144+ echo "python_meta=$(has_match '^cuda_python/')"
145+ echo "test_helpers=$(has_match '^cuda_python_test_helpers/')"
146+ echo "shared=$(has_match '^(\.github/|ci/|scripts/|toolshed/|conftest\.py$|pyproject\.toml$|pixi\.(toml|lock)$|pytest\.ini$|ruff\.toml$)')"
147+ } >> "$GITHUB_OUTPUT"
148+
149+ - name : Compose gating outputs
150+ id : compose
151+ env :
152+ IS_PR : ${{ startsWith(github.ref_name, 'pull-request/') }}
153+ BINDINGS : ${{ steps.filter.outputs.bindings || 'false' }}
154+ CORE : ${{ steps.filter.outputs.core || 'false' }}
155+ PATHFINDER : ${{ steps.filter.outputs.pathfinder || 'false' }}
156+ PYTHON_META : ${{ steps.filter.outputs.python_meta || 'false' }}
157+ TEST_HELPERS : ${{ steps.filter.outputs.test_helpers || 'false' }}
158+ SHARED : ${{ steps.filter.outputs.shared || 'false' }}
159+ run : |
160+ set -euxo pipefail
161+ # Non-PR events (push to main, tag push, schedule, workflow_dispatch)
162+ # always exercise the full pipeline because there is no baseline for
163+ # a meaningful diff.
164+ if [[ "${IS_PR}" != "true" ]]; then
165+ bindings=true
166+ core=true
167+ pathfinder=true
168+ python_meta=true
169+ test_helpers=true
170+ shared=true
171+ else
172+ bindings="${BINDINGS}"
173+ core="${CORE}"
174+ pathfinder="${PATHFINDER}"
175+ python_meta="${PYTHON_META}"
176+ test_helpers="${TEST_HELPERS}"
177+ shared="${SHARED}"
178+ fi
179+
180+ or_flag() {
181+ for v in "$@"; do
182+ if [[ "${v}" == "true" ]]; then
183+ echo "true"
184+ return
185+ fi
186+ done
187+ echo "false"
188+ }
189+
190+ # Build gating: pathfinder change forces rebuild of bindings and
191+ # core; bindings change forces rebuild of core. shared changes force
192+ # a full rebuild.
193+ build_pathfinder="$(or_flag "${shared}" "${pathfinder}")"
194+ build_bindings="$(or_flag "${shared}" "${pathfinder}" "${bindings}")"
195+ build_core="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${core}")"
196+
197+ # Test gating: tests for a module must run whenever that module, any
198+ # of its runtime dependencies, the shared test helper package, or
199+ # shared infra changes. pathfinder tests are cheap and always run.
200+ test_pathfinder=true
201+ test_bindings="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${test_helpers}")"
202+ test_core="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${core}" "${test_helpers}")"
203+
204+ {
205+ echo "bindings=${bindings}"
206+ echo "core=${core}"
207+ echo "pathfinder=${pathfinder}"
208+ echo "python_meta=${python_meta}"
209+ echo "test_helpers=${test_helpers}"
210+ echo "shared=${shared}"
211+ echo "build_bindings=${build_bindings}"
212+ echo "build_core=${build_core}"
213+ echo "build_pathfinder=${build_pathfinder}"
214+ echo "test_bindings=${test_bindings}"
215+ echo "test_core=${test_core}"
216+ echo "test_pathfinder=${test_pathfinder}"
217+ } >> "$GITHUB_OUTPUT"
218+
74219 # NOTE: Build jobs are intentionally split by platform rather than using a single
75220 # matrix. This allows each test job to depend only on its corresponding build,
76221 # so faster platforms can proceed through build & test without waiting for slower
@@ -151,6 +296,7 @@ jobs:
151296 needs :
152297 - ci-vars
153298 - should-skip
299+ - detect-changes
154300 - build-linux-64
155301 secrets : inherit
156302 uses : ./.github/workflows/test-wheel-linux.yml
@@ -159,6 +305,7 @@ jobs:
159305 host-platform : ${{ matrix.host-platform }}
160306 build-ctk-ver : ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
161307 nruns : ${{ (github.event_name == 'schedule' && 100) || 1}}
308+ skip-bindings-test : ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
162309
163310 # See test-linux-64 for why test jobs are split by platform.
164311 test-linux-aarch64 :
@@ -174,6 +321,7 @@ jobs:
174321 needs :
175322 - ci-vars
176323 - should-skip
324+ - detect-changes
177325 - build-linux-aarch64
178326 secrets : inherit
179327 uses : ./.github/workflows/test-wheel-linux.yml
@@ -182,6 +330,7 @@ jobs:
182330 host-platform : ${{ matrix.host-platform }}
183331 build-ctk-ver : ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
184332 nruns : ${{ (github.event_name == 'schedule' && 100) || 1}}
333+ skip-bindings-test : ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
185334
186335 # See test-linux-64 for why test jobs are split by platform.
187336 test-windows :
@@ -197,6 +346,7 @@ jobs:
197346 needs :
198347 - ci-vars
199348 - should-skip
349+ - detect-changes
200350 - build-windows
201351 secrets : inherit
202352 uses : ./.github/workflows/test-wheel-windows.yml
@@ -205,6 +355,7 @@ jobs:
205355 host-platform : ${{ matrix.host-platform }}
206356 build-ctk-ver : ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
207357 nruns : ${{ (github.event_name == 'schedule' && 100) || 1}}
358+ skip-bindings-test : ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
208359
209360 doc :
210361 name : Docs
@@ -228,6 +379,7 @@ jobs:
228379 runs-on : ubuntu-latest
229380 needs :
230381 - should-skip
382+ - detect-changes
231383 - test-linux-64
232384 - test-linux-aarch64
233385 - test-windows
@@ -254,7 +406,16 @@ jobs:
254406 #
255407 # Note: When [doc-only] is in PR title, test jobs are intentionally
256408 # skipped and should not cause failure.
409+ #
410+ # detect-changes gates whether heavy test matrices run at all; if it
411+ # does not succeed, downstream test jobs are skipped rather than
412+ # failed, which would otherwise go unnoticed here. Require its
413+ # success explicitly so a broken gating step cannot masquerade as a
414+ # green CI run.
257415 doc_only=${{ needs.should-skip.outputs.doc-only }}
416+ if ${{ needs.detect-changes.result != 'success' }}; then
417+ exit 1
418+ fi
258419 if ${{ needs.doc.result == 'cancelled' || needs.doc.result == 'failure' }}; then
259420 exit 1
260421 fi
0 commit comments