From 52190896e9a20c2c81f2dcd0e1ae3009d86ca284 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:17:23 -0400 Subject: [PATCH 1/7] ci: detect changed paths and gate cuda.bindings tests Add a detect-changes job to ci.yml that classifies which top-level modules were touched by a PR (cuda_bindings, cuda_core, cuda_pathfinder, cuda_python, cuda_python_test_helpers, shared infra) using dorny/paths-filter. The job emits composed gating outputs that account for the dependency graph (pathfinder -> bindings -> core). Thread a new skip-bindings-test input through the reusable test-wheel workflows so cuda.bindings tests (and their Cython counterparts) are skipped when the detect-changes output for test_bindings is false. For PRs that only touch cuda_core this skips the expensive bindings suite while still running cuda.core and cuda.pathfinder tests against the wheel built in the current CI run. Split the existing SKIP_CUDA_BINDINGS_TEST env var in ci/tools/env-vars into two orthogonal flags: USE_BACKPORT_BINDINGS drives the backport branch download path (CTK major mismatch), while SKIP_CUDA_BINDINGS_TEST remains the test-time gate. This lets path-filter-based skips reuse the existing SKIP_CUDA_BINDINGS_TEST plumbing without triggering a cross-branch artifact fetch. Non-PR events (push to main, tag, schedule, workflow_dispatch) still exercise the full pipeline. Refs #299 --- .github/workflows/ci.yml | 142 +++++++++++++++++++++++ .github/workflows/test-wheel-linux.yml | 14 ++- .github/workflows/test-wheel-windows.yml | 14 ++- ci/tools/env-vars | 25 +++- 4 files changed, 184 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7e0c65224..44b66dc894 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,142 @@ jobs: echo "skip=${skip}" >> "$GITHUB_OUTPUT" echo "doc_only=${doc_only}" >> "$GITHUB_OUTPUT" + # Detect which top-level modules were touched by the PR so downstream build + # and test jobs can avoid rebuilding/retesting modules unaffected by the + # change. See issue #299. + # + # Dependency graph (verified in pyproject.toml files): + # cuda_pathfinder -> (no internal deps) + # cuda_bindings -> cuda_pathfinder + # cuda_core -> cuda_pathfinder, cuda_bindings + # cuda_python -> cuda_bindings (meta package) + # + # A change to cuda_pathfinder (or shared infra) forces a rebuild of every + # downstream module. A change to cuda_bindings forces rebuild of cuda_core. + # A change to cuda_core alone skips rebuilding/retesting cuda_bindings. + # On push to main, tag refs, schedule, or workflow_dispatch events we + # unconditionally run everything because there is no meaningful "changed + # paths" baseline for those events. + detect-changes: + runs-on: ubuntu-latest + outputs: + bindings: ${{ steps.compose.outputs.bindings }} + core: ${{ steps.compose.outputs.core }} + pathfinder: ${{ steps.compose.outputs.pathfinder }} + python_meta: ${{ steps.compose.outputs.python_meta }} + test_helpers: ${{ steps.compose.outputs.test_helpers }} + shared: ${{ steps.compose.outputs.shared }} + build_bindings: ${{ steps.compose.outputs.build_bindings }} + build_core: ${{ steps.compose.outputs.build_core }} + build_pathfinder: ${{ steps.compose.outputs.build_pathfinder }} + test_bindings: ${{ steps.compose.outputs.test_bindings }} + test_core: ${{ steps.compose.outputs.test_core }} + test_pathfinder: ${{ steps.compose.outputs.test_pathfinder }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Filter changed paths + id: filter + if: ${{ startsWith(github.ref_name, 'pull-request/') }} + uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + with: + # Compare against the target branch of the PR. When a PR is pushed + # to the synthetic pull-request/ ref, dorny/paths-filter falls + # back to the repository default branch unless overridden. + filters: | + bindings: + - 'cuda_bindings/**' + core: + - 'cuda_core/**' + pathfinder: + - 'cuda_pathfinder/**' + python_meta: + - 'cuda_python/**' + test_helpers: + - 'cuda_python_test_helpers/**' + shared: + - '.github/**' + - 'ci/**' + - 'scripts/**' + - 'toolshed/**' + - 'conftest.py' + - 'pyproject.toml' + - 'pixi.toml' + - 'pixi.lock' + - 'pytest.ini' + - 'ruff.toml' + + - name: Compose gating outputs + id: compose + env: + IS_PR: ${{ startsWith(github.ref_name, 'pull-request/') }} + BINDINGS: ${{ steps.filter.outputs.bindings || 'false' }} + CORE: ${{ steps.filter.outputs.core || 'false' }} + PATHFINDER: ${{ steps.filter.outputs.pathfinder || 'false' }} + PYTHON_META: ${{ steps.filter.outputs.python_meta || 'false' }} + TEST_HELPERS: ${{ steps.filter.outputs.test_helpers || 'false' }} + SHARED: ${{ steps.filter.outputs.shared || 'false' }} + run: | + set -euxo pipefail + # Non-PR events (push to main, tag push, schedule, workflow_dispatch) + # always exercise the full pipeline because there is no baseline for + # a meaningful diff. + if [[ "${IS_PR}" != "true" ]]; then + bindings=true + core=true + pathfinder=true + python_meta=true + test_helpers=true + shared=true + else + bindings="${BINDINGS}" + core="${CORE}" + pathfinder="${PATHFINDER}" + python_meta="${PYTHON_META}" + test_helpers="${TEST_HELPERS}" + shared="${SHARED}" + fi + + or_flag() { + for v in "$@"; do + if [[ "${v}" == "true" ]]; then + echo "true" + return + fi + done + echo "false" + } + + # Build gating: pathfinder change forces rebuild of bindings and + # core; bindings change forces rebuild of core. shared changes force + # a full rebuild. + build_pathfinder="$(or_flag "${shared}" "${pathfinder}")" + build_bindings="$(or_flag "${shared}" "${pathfinder}" "${bindings}")" + build_core="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${core}")" + + # Test gating: tests for a module must run whenever that module, any + # of its runtime dependencies, the shared test helper package, or + # shared infra changes. pathfinder tests are cheap and always run. + test_pathfinder=true + test_bindings="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${test_helpers}")" + test_core="$(or_flag "${shared}" "${pathfinder}" "${bindings}" "${core}" "${test_helpers}")" + + { + echo "bindings=${bindings}" + echo "core=${core}" + echo "pathfinder=${pathfinder}" + echo "python_meta=${python_meta}" + echo "test_helpers=${test_helpers}" + echo "shared=${shared}" + echo "build_bindings=${build_bindings}" + echo "build_core=${build_core}" + echo "build_pathfinder=${build_pathfinder}" + echo "test_bindings=${test_bindings}" + echo "test_core=${test_core}" + echo "test_pathfinder=${test_pathfinder}" + } >> "$GITHUB_OUTPUT" + # NOTE: Build jobs are intentionally split by platform rather than using a single # matrix. This allows each test job to depend only on its corresponding build, # so faster platforms can proceed through build & test without waiting for slower @@ -151,6 +287,7 @@ jobs: needs: - ci-vars - should-skip + - detect-changes - build-linux-64 secrets: inherit uses: ./.github/workflows/test-wheel-linux.yml @@ -159,6 +296,7 @@ jobs: host-platform: ${{ matrix.host-platform }} build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} nruns: ${{ (github.event_name == 'schedule' && 100) || 1}} + skip-bindings-test: ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }} # See test-linux-64 for why test jobs are split by platform. test-linux-aarch64: @@ -174,6 +312,7 @@ jobs: needs: - ci-vars - should-skip + - detect-changes - build-linux-aarch64 secrets: inherit uses: ./.github/workflows/test-wheel-linux.yml @@ -182,6 +321,7 @@ jobs: host-platform: ${{ matrix.host-platform }} build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} nruns: ${{ (github.event_name == 'schedule' && 100) || 1}} + skip-bindings-test: ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }} # See test-linux-64 for why test jobs are split by platform. test-windows: @@ -197,6 +337,7 @@ jobs: needs: - ci-vars - should-skip + - detect-changes - build-windows secrets: inherit uses: ./.github/workflows/test-wheel-windows.yml @@ -205,6 +346,7 @@ jobs: host-platform: ${{ matrix.host-platform }} build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} nruns: ${{ (github.event_name == 'schedule' && 100) || 1}} + skip-bindings-test: ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }} doc: name: Docs diff --git a/.github/workflows/test-wheel-linux.yml b/.github/workflows/test-wheel-linux.yml index 270ed445a9..796040333b 100644 --- a/.github/workflows/test-wheel-linux.yml +++ b/.github/workflows/test-wheel-linux.yml @@ -22,6 +22,13 @@ on: nruns: type: number default: 1 + # When true, cuda.bindings tests (and the Cython tests that depend on + # them) are skipped even when CTK majors match. Callers set this based + # on the output of the detect-changes job in ci.yml so PRs that only + # touch unrelated modules avoid the expensive bindings test suite. + skip-bindings-test: + type: boolean + default: false defaults: run: @@ -113,6 +120,7 @@ jobs: LOCAL_CTK: ${{ matrix.LOCAL_CTK }} PY_VER: ${{ matrix.PY_VER }} SHA: ${{ github.sha }} + SKIP_BINDINGS_TEST_OVERRIDE: ${{ inputs.skip-bindings-test && '1' || '0' }} run: ./ci/tools/env-vars test - name: Download cuda-pathfinder build artifacts @@ -122,21 +130,21 @@ jobs: path: ./cuda_pathfinder - name: Download cuda-python build artifacts - if: ${{ env.SKIP_CUDA_BINDINGS_TEST == '0'}} + if: ${{ env.USE_BACKPORT_BINDINGS == '0' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cuda-python-wheel path: . - name: Download cuda.bindings build artifacts - if: ${{ env.SKIP_CUDA_BINDINGS_TEST == '0'}} + if: ${{ env.USE_BACKPORT_BINDINGS == '0' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ env.CUDA_BINDINGS_ARTIFACT_NAME }} path: ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} - name: Download cuda-python & cuda.bindings build artifacts from the prior branch - if: ${{ env.SKIP_CUDA_BINDINGS_TEST == '1'}} + if: ${{ env.USE_BACKPORT_BINDINGS == '1' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/.github/workflows/test-wheel-windows.yml b/.github/workflows/test-wheel-windows.yml index 91f098b0e9..765823c6bf 100644 --- a/.github/workflows/test-wheel-windows.yml +++ b/.github/workflows/test-wheel-windows.yml @@ -22,6 +22,13 @@ on: nruns: type: number default: 1 + # When true, cuda.bindings tests (and the Cython tests that depend on + # them) are skipped even when CTK majors match. Callers set this based + # on the output of the detect-changes job in ci.yml so PRs that only + # touch unrelated modules avoid the expensive bindings test suite. + skip-bindings-test: + type: boolean + default: false jobs: compute-matrix: @@ -107,6 +114,7 @@ jobs: LOCAL_CTK: ${{ matrix.LOCAL_CTK }} PY_VER: ${{ matrix.PY_VER }} SHA: ${{ github.sha }} + SKIP_BINDINGS_TEST_OVERRIDE: ${{ inputs.skip-bindings-test && '1' || '0' }} shell: bash --noprofile --norc -xeuo pipefail {0} run: ./ci/tools/env-vars test @@ -117,21 +125,21 @@ jobs: path: ./cuda_pathfinder - name: Download cuda-python build artifacts - if: ${{ env.SKIP_CUDA_BINDINGS_TEST == '0'}} + if: ${{ env.USE_BACKPORT_BINDINGS == '0' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: cuda-python-wheel path: . - name: Download cuda.bindings build artifacts - if: ${{ env.SKIP_CUDA_BINDINGS_TEST == '0'}} + if: ${{ env.USE_BACKPORT_BINDINGS == '0' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ env.CUDA_BINDINGS_ARTIFACT_NAME }} path: ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} - name: Download cuda-python & cuda.bindings build artifacts from the prior branch - if: ${{ env.SKIP_CUDA_BINDINGS_TEST == '1'}} + if: ${{ env.USE_BACKPORT_BINDINGS == '1' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/ci/tools/env-vars b/ci/tools/env-vars index bbc3ed66c5..c3e3212d24 100755 --- a/ci/tools/env-vars +++ b/ci/tools/env-vars @@ -52,17 +52,31 @@ elif [[ "${1}" == "test" ]]; then BUILD_CUDA_MAJOR="$(cut -d '.' -f 1 <<< ${BUILD_CUDA_VER})" TEST_CUDA_MAJOR="$(cut -d '.' -f 1 <<< ${CUDA_VER})" CUDA_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${BUILD_CUDA_VER}-${HOST_PLATFORM}" + # USE_BACKPORT_BINDINGS flags the CTK-major-mismatch case where the + # current-run bindings wheel was built for a different CTK major than the + # one under test, so we must pull the bindings wheel from the backport + # branch instead. This is independent of whether bindings tests run. + # SKIP_CUDA_BINDINGS_TEST is the test-time gate: it is set when the CTK + # majors differ OR when the caller tells us to skip for path-filter + # reasons via SKIP_BINDINGS_TEST_OVERRIDE. if [[ ${BUILD_CUDA_MAJOR} != ${TEST_CUDA_MAJOR} ]]; then + USE_BACKPORT_BINDINGS=1 SKIP_CUDA_BINDINGS_TEST=1 SKIP_CYTHON_TEST=1 else - SKIP_CUDA_BINDINGS_TEST=0 - BUILD_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_CUDA_VER})" - TEST_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${CUDA_VER})" - if [[ ${BUILD_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then + USE_BACKPORT_BINDINGS=0 + if [[ "${SKIP_BINDINGS_TEST_OVERRIDE:-0}" == "1" ]]; then + SKIP_CUDA_BINDINGS_TEST=1 SKIP_CYTHON_TEST=1 else - SKIP_CYTHON_TEST=0 + SKIP_CUDA_BINDINGS_TEST=0 + BUILD_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_CUDA_VER})" + TEST_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${CUDA_VER})" + if [[ ${BUILD_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then + SKIP_CYTHON_TEST=1 + else + SKIP_CYTHON_TEST=0 + fi fi fi # We don't test compute-sanitizer on CTK<12 because backporting fixes is too much effort @@ -80,6 +94,7 @@ elif [[ "${1}" == "test" ]]; then echo "SKIP_CUDA_BINDINGS_TEST=${SKIP_CUDA_BINDINGS_TEST}" echo "SKIP_CYTHON_TEST=${SKIP_CYTHON_TEST}" echo "TEST_CUDA_MAJOR=${TEST_CUDA_MAJOR}" + echo "USE_BACKPORT_BINDINGS=${USE_BACKPORT_BINDINGS}" } >> $GITHUB_ENV fi From 0a6e50c1c229a2280c9392ad3d37d40d9778cbcb Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:13:00 -0400 Subject: [PATCH 2/7] fix: set explicit dorny base and decouple cython-test skip Two fixes from code review: 1. Set `base: main` on dorny/paths-filter so backport PRs targeting non-default branches still diff against main for path detection. 2. Decouple SKIP_CYTHON_TEST from SKIP_BINDINGS_TEST_OVERRIDE. The path-filter override only skips bindings tests; cuda.core Cython tests should still run on core-only PRs. Cython skip is now driven solely by CTK minor-version mismatch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 8 +++++--- ci/tools/env-vars | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44b66dc894..25dfa81399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,9 +111,11 @@ jobs: if: ${{ startsWith(github.ref_name, 'pull-request/') }} uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 with: - # Compare against the target branch of the PR. When a PR is pushed - # to the synthetic pull-request/ ref, dorny/paths-filter falls - # back to the repository default branch unless overridden. + # Explicitly compare against the repository default branch. On push + # events dorny/paths-filter defaults to diffing against the prior + # commit, which is wrong for backport PRs targeting non-default + # branches. Pinning base to main ensures consistent detection. + base: main filters: | bindings: - 'cuda_bindings/**' diff --git a/ci/tools/env-vars b/ci/tools/env-vars index c3e3212d24..5d7382f500 100755 --- a/ci/tools/env-vars +++ b/ci/tools/env-vars @@ -65,18 +65,20 @@ elif [[ "${1}" == "test" ]]; then SKIP_CYTHON_TEST=1 else USE_BACKPORT_BINDINGS=0 + # Path-filter override only skips bindings tests, NOT cython tests + # for other modules (e.g. cuda.core). Cython skip is driven solely + # by the build/test CTK minor-version mismatch. if [[ "${SKIP_BINDINGS_TEST_OVERRIDE:-0}" == "1" ]]; then SKIP_CUDA_BINDINGS_TEST=1 - SKIP_CYTHON_TEST=1 else SKIP_CUDA_BINDINGS_TEST=0 - BUILD_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_CUDA_VER})" - TEST_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${CUDA_VER})" - if [[ ${BUILD_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then - SKIP_CYTHON_TEST=1 - else - SKIP_CYTHON_TEST=0 - fi + fi + BUILD_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_CUDA_VER})" + TEST_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${CUDA_VER})" + if [[ ${BUILD_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then + SKIP_CYTHON_TEST=1 + else + SKIP_CYTHON_TEST=0 fi fi # We don't test compute-sanitizer on CTK<12 because backporting fixes is too much effort From e49b100c8975e34ac8cc0bbba2f6ebc428a88a5d Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:48:19 -0400 Subject: [PATCH 3/7] fix: remove explicit dorny base, rely on v3 default dorny/paths-filter v3 already diffs against the repo default branch for non-default-branch pushes. Explicit base: main was redundant and would produce wrong baselines for backport PRs targeting release branches (all files seen as changed vs main). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25dfa81399..ef18833362 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,11 +111,10 @@ jobs: if: ${{ startsWith(github.ref_name, 'pull-request/') }} uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 with: - # Explicitly compare against the repository default branch. On push - # events dorny/paths-filter defaults to diffing against the prior - # commit, which is wrong for backport PRs targeting non-default - # branches. Pinning base to main ensures consistent detection. - base: main + # dorny/paths-filter v3 on push to a non-default branch defaults to + # diffing against the repository default branch. No explicit base is + # needed; omitting it lets backport PRs targeting release branches + # diff against their actual target rather than always against main. filters: | bindings: - 'cuda_bindings/**' From 7e34552ef65a0ff391c4a7c9a3dfcbc7d19638ad Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:31:21 -0400 Subject: [PATCH 4/7] refactor: replace dorny/paths-filter with native git diff Remove third-party dorny/paths-filter action dependency. Use git merge-base + git diff --name-only + grep instead. Same behavior, zero supply-chain risk, full control over base ref resolution. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 51 +++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef18833362..ec6d631be0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,38 +105,31 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - - name: Filter changed paths + - name: Detect changed paths id: filter if: ${{ startsWith(github.ref_name, 'pull-request/') }} - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 - with: - # dorny/paths-filter v3 on push to a non-default branch defaults to - # diffing against the repository default branch. No explicit base is - # needed; omitting it lets backport PRs targeting release branches - # diff against their actual target rather than always against main. - filters: | - bindings: - - 'cuda_bindings/**' - core: - - 'cuda_core/**' - pathfinder: - - 'cuda_pathfinder/**' - python_meta: - - 'cuda_python/**' - test_helpers: - - 'cuda_python_test_helpers/**' - shared: - - '.github/**' - - 'ci/**' - - 'scripts/**' - - 'toolshed/**' - - 'conftest.py' - - 'pyproject.toml' - - 'pixi.toml' - - 'pixi.lock' - - 'pytest.ini' - - 'ruff.toml' + run: | + # Diff against the merge base with the default branch. Uses + # merge-base so diverged branches only show files changed on + # the PR side, not upstream commits. + base=$(git merge-base HEAD origin/main) + changed=$(git diff --name-only "$base"...HEAD) + + has_match() { + echo "$changed" | grep -qE "$1" && echo true || echo false + } + + { + echo "bindings=$(has_match '^cuda_bindings/')" + echo "core=$(has_match '^cuda_core/')" + echo "pathfinder=$(has_match '^cuda_pathfinder/')" + echo "python_meta=$(has_match '^cuda_python/')" + echo "test_helpers=$(has_match '^cuda_python_test_helpers/')" + echo "shared=$(has_match '^(\.github/|ci/|scripts/|toolshed/|conftest\.py$|pyproject\.toml$|pixi\.(toml|lock)$|pytest\.ini$|ruff\.toml$)')" + } >> "$GITHUB_OUTPUT" - name: Compose gating outputs id: compose From d2059559818249696edaec702344a297ba1f67d6 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:31:55 -0400 Subject: [PATCH 5/7] style: use herestring instead of echo pipe in has_match Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec6d631be0..4981e55f0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: changed=$(git diff --name-only "$base"...HEAD) has_match() { - echo "$changed" | grep -qE "$1" && echo true || echo false + grep -qE "$1" <<< "$changed" && echo true || echo false } { From f410ba657fa9ba19e63875440abfd4c7fad13c97 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:49:47 -0400 Subject: [PATCH 6/7] ci: resolve PR base branch at runtime for detect-changes diff copy-pr-bot mirrors every PR to a pull-request/ branch regardless of whether the upstream PR targets main or a backport branch such as 12.9.x. Diffing against origin/main unconditionally therefore misclassifies changed paths on backport PRs and silently suppresses the cuda.bindings test matrix. Look up the real base ref via nv-gha-runners/get-pr-info (already used elsewhere in this repo) and pass it to git merge-base so the changed- paths classification matches the PR's actual target. --- .github/workflows/ci.yml | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4981e55f0c..613b994912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,14 +108,29 @@ jobs: with: fetch-depth: 0 + # copy-pr-bot pushes every PR (whether it targets main or a backport + # branch such as 12.9.x) to pull-request/, so the base branch + # cannot be inferred from github.ref_name. Look it up via the + # upstream PR metadata so the diff below is rooted at the right place. + - name: Resolve PR base branch + id: pr-info + if: ${{ startsWith(github.ref_name, 'pull-request/') }} + uses: nv-gha-runners/get-pr-info@main + - name: Detect changed paths id: filter if: ${{ startsWith(github.ref_name, 'pull-request/') }} + env: + BASE_REF: ${{ fromJSON(steps.pr-info.outputs.pr-info).base.ref }} run: | - # Diff against the merge base with the default branch. Uses - # merge-base so diverged branches only show files changed on + # Diff against the merge base with the PR's actual target branch. + # Uses merge-base so diverged branches only show files changed on # the PR side, not upstream commits. - base=$(git merge-base HEAD origin/main) + if [[ -z "${BASE_REF}" ]]; then + echo "Could not resolve PR base branch from get-pr-info output" >&2 + exit 1 + fi + base=$(git merge-base HEAD "origin/${BASE_REF}") changed=$(git diff --name-only "$base"...HEAD) has_match() { From c11f901a0c043302813070a6742772deb86dfc21 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:50:40 -0400 Subject: [PATCH 7/7] ci: require detect-changes success in final checks aggregation The checks job used if:always() plus shell-level result inspection of doc and the three test jobs, treating skipped as non-fatal to preserve intentional [doc-only] skips. detect-changes is a needs prerequisite of every test job but was absent from checks.needs, so a failure in the gating step silently cascaded into test-job skips and a green final status. Add detect-changes to checks.needs and require its result to be success. The legitimate doc-only skip path is unaffected because that leaves detect-changes itself successful. --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 613b994912..525c210889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -379,6 +379,7 @@ jobs: runs-on: ubuntu-latest needs: - should-skip + - detect-changes - test-linux-64 - test-linux-aarch64 - test-windows @@ -405,7 +406,16 @@ jobs: # # Note: When [doc-only] is in PR title, test jobs are intentionally # skipped and should not cause failure. + # + # detect-changes gates whether heavy test matrices run at all; if it + # does not succeed, downstream test jobs are skipped rather than + # failed, which would otherwise go unnoticed here. Require its + # success explicitly so a broken gating step cannot masquerade as a + # green CI run. doc_only=${{ needs.should-skip.outputs.doc-only }} + if ${{ needs.detect-changes.result != 'success' }}; then + exit 1 + fi if ${{ needs.doc.result == 'cancelled' || needs.doc.result == 'failure' }}; then exit 1 fi