Skip to content

cuda.bindings: support multiple CTK release lines on main #471

cuda.bindings: support multiple CTK release lines on main

cuda.bindings: support multiple CTK release lines on main #471

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
#
# SPDX-License-Identifier: Apache-2.0
# Validates the `pixi run test` developer workflow (source build + Cython
# codegen + test suite via the pixi-managed environment).
#
# WHY THIS EXISTS: the main CI (ci.yml / test-wheel-*.yml) tests prebuilt
# *wheels*; it never exercises the pixi *source build*. That path rots silently
# whenever the CUDA pin, generated bindings, conda-forge packages, or the
# cython-test build mechanics drift (see #2182, #2183). This workflow is the
# only thing that runs the pixi source build end to end.
#
# Three tiers, to spend GPU minutes deliberately (GPUs are scarce):
# - build-smoke (PRs): CPU-only. Source-builds bindings + core, imports them,
# builds the cython test extensions and checks placement. Catches the
# compile / ABI / .so-placement regressions WITHOUT a GPU.
# - build-identity-roundtrip (nightly + manual): CPU-only. The current ABI ->
# another configured ABI -> current ABI in one checkout, so build artifacts
# cannot be reused across CUDA majors. Kept off PRs to avoid adding another
# job to the org's shared concurrent-job quota; the fast unit tests in
# cuda_core/tests/test_build_hooks.py cover the same intent per-PR.
# - full-test (nightly + manual): GPU runner, full `pixi run test`.
name: "CI: pixi run test (source build)"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
on:
schedule:
# 2:37 AM UTC daily — offset from ci-nightly.yml (2:17) to avoid contending
# for the same scheduled-workflow window.
- cron: "37 2 * * *"
pull_request:
# Only the files that can break the source-build path (see #2182/#2183).
paths:
- "**/pixi.toml"
- "**/pixi.lock"
- "cuda_bindings*/build_hooks.py"
- "cuda_core/build_hooks.py"
- "cuda_bindings*/cuda/bindings/**" # generated bindings sources
- "cuda_bindings*/docs/**"
- "cuda_bindings*/tests/cython/**"
- "cuda_core/tests/cython/**"
- "ci/tools/bindings_config.py"
- "ci/tools/compute_ci_plan.py"
- "ci/pyproject.toml"
- "ci/versions.yml"
- ".github/workflows/ci-pixi-source-test.yml"
workflow_dispatch:
inputs:
cuda-env:
description: "Registered pixi CUDA ABI environment to test (empty selects current)."
type: string
default: ""
defaults:
run:
shell: bash --noprofile --norc -xeuo pipefail {0}
env:
# keep in sync with the version developers run locally. Must be >=0.71.0:
# older pixi re-ran the editable source build on every `pixi run`, recompiling
# all Cython extensions (#2138). The fix (content-addressed source-build cache,
# prefix-dev/pixi#6285 + #6123) also bumps the pixi.lock format to v7.
PIXI_VERSION: "v0.73.0"
jobs:
# ── PR guard: CPU-only build + import + placement smoke ──
build-smoke:
name: "build smoke (selected CUDA packages, linux-64, CPU)"
if: >-
github.repository_owner == 'nvidia' &&
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout ${{ github.event.repository.name }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history + tags so setuptools-scm derives the real
# package version; a shallow checkout yields 0.1.dev1, which trips
# cuda.core's supported cuda.bindings-version guard.
fetch-depth: 0
filter: blob:none
- name: Install CI tool dependencies
run: python3 -m pip install -e ./ci
- name: Setup pixi
# Pinned to a commit SHA; install logic lives in the action and is
# auditable/pinned (vs. a curl|bash of an unverified installer).
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
with:
pixi-version: ${{ env.PIXI_VERSION }}
run-install: false
- name: Select CUDA environments
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}
EVENT_NAME: ${{ github.event_name }}
REQUESTED_CUDA_ENV: ${{ inputs.cuda-env || '' }}
run: |
bindings_packages=$(python3 -m ci.tools.bindings_config --package-roots)
selected_packages='[]'
docs_packages='[]'
merge_packages() {
jq -cn --argjson left "$1" --argjson right "$2" \
'$left + $right | unique_by(.package_root)'
}
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ -n "${REQUESTED_CUDA_ENV}" ]]; then
selected_packages=$(jq -c --arg cuda_env "${REQUESTED_CUDA_ENV}" \
'[.[] | select(.cuda_variant == $cuda_env)]' <<< "${bindings_packages}")
if [[ "${selected_packages}" == '[]' ]]; then
registered_envs=$(jq -r '[.[].cuda_variant] | join(", ")' <<< "${bindings_packages}")
echo "::error::cuda-env must be a registered pixi environment: ${registered_envs}"
exit 1
fi
else
selected_packages=$(jq -c '[.[] | select(.release_status == "current")]' \
<<< "${bindings_packages}")
fi
else
merge_base=$(git merge-base HEAD "${BASE_SHA}")
workplan=$(python3 -m ci.tools.compute_ci_plan \
--merge-base "${merge_base}" \
--baseline-run-id pixi-source)
# Keep package-specific work package-specific. If only ABI-level
# work is selected, use the current package for that ABI when available.
selected_packages=$(jq -cn \
--argjson registry "${bindings_packages}" \
--argjson plan "${workplan}" '
$registry
| map(. as $package | select(
($plan.modules.bindings.package_roots[$package.package_root].needs_build // false)
or ($plan.modules.bindings.package_roots[$package.package_root].needs_test // false)
or ($plan.modules.python.package_roots[$package.package_root].needs_build // false)
or ($plan.modules.python.package_roots[$package.package_root].needs_test // false)
))
')
while IFS= read -r cuda_variant; do
if jq -e --arg cuda_variant "${cuda_variant}" \
'any(.[]; .cuda_variant == $cuda_variant)' <<< "${selected_packages}" > /dev/null; then
continue
fi
representative=$(jq -c --arg cuda_variant "${cuda_variant}" '
[.[] | select(.cuda_variant == $cuda_variant)] | first // empty
' <<< "${bindings_packages}")
if [[ -z "${representative}" ]]; then
echo "::error::workplan selected unregistered CUDA variant ${cuda_variant}"
exit 1
fi
selected_packages=$(merge_packages "${selected_packages}" "[${representative}]")
done < <(jq -r '.jobs.test_cuda_majors | to_entries[] | select(.value) | .key' <<< "${workplan}")
# Pixi manifests are deliberately ignored by the wheel planner.
# Add their package-local impact to its source/test decisions. The
# wheel planner also deliberately ignores documentation changes.
while IFS= read -r -d '' path; do
manifest_packages=$(jq -c --arg path "${path}" '
[.[] as $package
| select(
$path == ($package.package_root + "/pixi.toml")
or $path == ($package.package_root + "/pixi.lock")
)
| $package]
' <<< "${bindings_packages}")
if [[ "${manifest_packages}" != '[]' ]]; then
selected_packages=$(merge_packages "${selected_packages}" "${manifest_packages}")
else
case "${path}" in
*/pixi.toml|*/pixi.lock|pixi.toml|pixi.lock)
selected_packages=$(merge_packages "${selected_packages}" "${bindings_packages}")
;;
esac
fi
changed_docs_packages=$(jq -c --arg path "${path}" '
[.[] as $package
| select($path | startswith($package.package_root + "/docs/"))
| $package]
' <<< "${bindings_packages}")
if [[ "${changed_docs_packages}" != '[]' ]]; then
selected_packages=$(merge_packages "${selected_packages}" "${changed_docs_packages}")
docs_packages=$(merge_packages "${docs_packages}" "${changed_docs_packages}")
fi
done < <(git diff --no-renames --name-only -z "${merge_base}" HEAD)
fi
if [[ "${selected_packages}" == '[]' ]]; then
echo "::error::no bindings package selected for source-build smoke tests"
exit 1
fi
echo "BINDINGS_PACKAGES=${selected_packages}" | tee -a "$GITHUB_ENV"
echo "BINDINGS_DOC_PACKAGES=${docs_packages}" | tee -a "$GITHUB_ENV"
- name: Source-build + import + cython-placement smoke
run: |
while IFS= read -r bindings_package; do
BINDINGS_ROOT=$(jq -r '.package_root' <<< "${bindings_package}")
CUDA_ENV=$(jq -r '.cuda_variant' <<< "${bindings_package}")
echo "::group::Source-build ${BINDINGS_ROOT} (${CUDA_ENV})"
# Avoid carrying Cython outputs between bindings packages or CUDA ABIs.
rm -rf cuda_core/build/cython cuda_core/cython_debug
find cuda_core/cuda/core -type f -name '*.so' -delete
find "${BINDINGS_ROOT}/tests/cython" cuda_core/tests/cython \
-maxdepth 1 -type f \( -name 'test_*.cpp' -o -name 'test_*.so' \) -delete
# pathfinder: pure-Python, no GPU.
pixi run -e "${CUDA_ENV}" test-pathfinder
# Force both source builds and import them to catch compile/ABI
# regressions such as #2182. cuda-core environments may use
# published bindings by design; wheel CI covers local pairing.
pixi run --manifest-path "${BINDINGS_ROOT}" -e "${CUDA_ENV}" \
python -c "import cuda.bindings.driver, cuda.bindings.nvrtc, cuda.bindings.runtime; print('bindings import OK')"
pixi run --manifest-path cuda_core -e "${CUDA_ENV}" \
python -c "import cuda.core; print('core import OK')"
pixi run --manifest-path "${BINDINGS_ROOT}" -e "${CUDA_ENV}" build-cython-tests
pixi run --manifest-path cuda_core -e "${CUDA_ENV}" build-cython-tests
# Confirm each extension landed next to its .pyx (catches #2180).
for d in "${BINDINGS_ROOT}/tests/cython" cuda_core/tests/cython; do
if ! compgen -G "${d}/*.cpython-*.so" > /dev/null; then
echo "::error::no compiled cython test .so in ${d} (placement regression)"
exit 1
fi
done
echo "::endgroup::"
done < <(jq -c '.[]' <<< "${BINDINGS_PACKAGES}")
echo "cython test extensions placed correctly"
- name: Build changed bindings documentation
if: ${{ env.BINDINGS_DOC_PACKAGES != '[]' }}
run: |
while IFS= read -r bindings_package; do
bindings_root=$(jq -r '.package_root' <<< "${bindings_package}")
pixi run --manifest-path "${bindings_root}" -e docs build-docs
done < <(jq -c '.[]' <<< "${BINDINGS_DOC_PACKAGES}")
# ── Nightly guard: build artifacts must be CUDA-major aware ──
#
# Neither Cython nor setuptools tracks the CUDA major in its own up-to-date
# check: Cython does not hash `compile_time_env`, and an editable install's
# .so is named by the Python ABI tag alone. Before build_hooks keyed them,
# a build for one ABI followed by another in the same checkout failed while
# compiling generated C++ against headers from the second ABI.
#
# The round trip is what catches the second half: coming back to the current
# ABI must not silently reuse the other ABI's extension.
#
# cuda_core only: not every bindings package can be source-built in every ABI
# environment, for reasons unrelated to stale artifacts.
build-identity-roundtrip:
name: "current -> maintenance -> current round trip (linux-64, CPU)"
if: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.repository_owner == 'nvidia' }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout ${{ github.event.repository.name }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history + tags so setuptools-scm derives the real
# package version; a shallow checkout yields 0.1.dev1, which trips
# cuda.core's supported cuda.bindings-version guard.
fetch-depth: 0
- name: Install CI tool dependencies
run: python3 -m pip install -e ./ci
- name: Setup pixi
# Pinned to a commit SHA; install logic lives in the action and is
# auditable/pinned (vs. a curl|bash of an unverified installer).
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
with:
pixi-version: ${{ env.PIXI_VERSION }}
run-install: false
- name: Build current, another ABI, then current again in one checkout
run: |
current_package=$(python3 -m ci.tools.bindings_config --release-status current)
bindings_packages=$(python3 -m ci.tools.bindings_config --package-roots)
current_env=$(jq -r '.cuda_variant' <<< "${current_package}")
current_major=$(jq -r '.cuda_major' <<< "${current_package}")
other_env=$(jq -r --arg current_env "${current_env}" \
'[.[] | select(.cuda_variant != $current_env) | .cuda_variant] | first // empty' \
<<< "${bindings_packages}")
for cuda_env in "${current_env}" "${other_env}" "${current_env}"; do
echo "::group::${cuda_env}"
pixi run --manifest-path cuda_core -e "${cuda_env}" \
python -c "import cuda.core; print('core import OK')"
echo "::endgroup::"
done
# The last build was current, and each ABI must have kept its own
# generated sources rather than overwriting the other's.
stamp=$(cat cuda_core/build/.build-cuda-major)
if [ "${stamp}" != "${current_major}" ]; then
echo "::error::build stamp is '${stamp}', expected ${current_major}"
exit 1
fi
for cuda_env in "${other_env}" "${current_env}"; do
if [ ! -d "cuda_core/build/cython/${cuda_env}" ]; then
echo "::error::no ${cuda_env} generated-source directory"
exit 1
fi
done
# ── Nightly: full `pixi run test` on a GPU runner ──
full-test:
name: "pixi run test (${{ inputs.cuda-env || 'current' }}, linux-64, GPU)"
if: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.repository_owner == 'nvidia' }}
runs-on: "linux-amd64-gpu-l4-latest-1" # same label scheme as test-wheel-linux.yml
timeout-minutes: 90
container:
options: -u root --security-opt seccomp=unconfined --shm-size 16g
image: ubuntu:24.04
env:
NVIDIA_VISIBLE_DEVICES: ${{ env.NVIDIA_VISIBLE_DEVICES }}
steps:
- name: Ensure GPU is working
run: nvidia-smi
- name: Install system packages
run: |
apt-get update
# ca-certificates + git for checkout; libgl1/libegl1 for pyglet
# (pulled in by the test env). PyYAML and packaging drive the
# bindings registry resolver; pixi itself is installed by setup-pixi.
apt-get install -y --no-install-recommends \
ca-certificates git jq libgl1 libegl1 python3 python3-packaging python3-yaml
- name: Checkout ${{ github.event.repository.name }}
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Full history + tags so setuptools-scm derives the real
# package version; a shallow checkout yields 0.1.dev1, which trips
# cuda.core's supported cuda.bindings-version guard.
fetch-depth: 0
filter: blob:none
- name: Select CUDA environment
env:
REQUESTED_CUDA_ENV: ${{ inputs.cuda-env || '' }}
run: |
if [[ -n "${REQUESTED_CUDA_ENV}" ]]; then
bindings_packages=$(python3 -m ci.tools.bindings_config --package-roots)
if ! jq -e --arg cuda_env "${REQUESTED_CUDA_ENV}" \
'any(.[]; .cuda_variant == $cuda_env)' <<< "${bindings_packages}" > /dev/null; then
registered_envs=$(jq -r '[.[].cuda_variant] | join(", ")' <<< "${bindings_packages}")
echo "::error::cuda-env must be a registered pixi environment: ${registered_envs}"
exit 1
fi
cuda_env="${REQUESTED_CUDA_ENV}"
else
current_package=$(python3 -m ci.tools.bindings_config --release-status current)
cuda_env=$(jq -r '.cuda_variant' <<< "${current_package}")
fi
echo "CUDA_ENV=${cuda_env}" | tee -a "$GITHUB_ENV"
- name: Setup proxy cache
uses: nv-gha-runners/setup-proxy-cache@main
continue-on-error: true
- name: Setup pixi
# Pinned to a commit SHA; install logic lives in the action and is
# auditable/pinned (vs. a curl|bash of an unverified installer).
uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
with:
pixi-version: ${{ env.PIXI_VERSION }}
run-install: false
- name: pixi run test
run: pixi run -e "${CUDA_ENV}" test