From 3ffe7cfeef77eef3e28084d0261bd72c7b786b47 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Tue, 20 Oct 2026 21:40:08 -0700 Subject: [PATCH 1/8] Build CUDA 12 and CUDA 13 artifacts from main Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/build-wheel.yml | 497 ++++++++++++----- .github/workflows/ci.yml | 260 +++++---- .github/workflows/test-sdist-linux.yml | 73 ++- .github/workflows/test-sdist-windows.yml | 74 ++- ci/ci-pipeline.mmd | 22 +- ci/tools/compute_ci_plan.py | 319 +++++++++-- ci/tools/download-wheels | 118 +++- ci/tools/env-vars | 141 +++-- ci/tools/merge_cuda_core_wheels.py | 32 +- ci/tools/tests/test_compute_ci_plan.py | 525 ++++++++++++++++-- ci/tools/tests/test_download_wheels.py | 209 +++++++ ci/tools/tests/test_merge_cuda_core_wheels.py | 57 ++ 12 files changed, 1816 insertions(+), 511 deletions(-) create mode 100644 ci/tools/tests/test_download_wheels.py create mode 100644 ci/tools/tests/test_merge_cuda_core_wheels.py diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml index 2f92940b1cd..063af3bbdfb 100644 --- a/.github/workflows/build-wheel.yml +++ b/.github/workflows/build-wheel.yml @@ -8,17 +8,14 @@ on: host-platform: required: true type: string - cuda-version: - required: true - type: string - prev-cuda-version: + bindings-config: + description: Normalized CUDA bindings package-root registry JSON. required: true type: string workplan: - description: JSON workplan. An empty value builds and tests everything. - required: false + description: JSON workplan. + required: true type: string - default: "" single-cuda-major: description: "Build wheels for only the current CUDA major; skip the prior-major build and wheel merge" required: false @@ -40,14 +37,8 @@ permissions: jobs: build: env: - BUILD_PATHFINDER: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.pathfinder.needs_build }} - BUILD_BINDINGS: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.bindings.needs_build }} - BUILD_CORE: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.core.needs_build }} - BUILD_PYTHON: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.python.needs_build }} - TEST_BINDINGS: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.bindings.needs_test }} - TEST_CORE: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.core.needs_test }} - BASELINE_RUN_ID: ${{ inputs.workplan != '' && fromJSON(inputs.workplan).baseline.run_id || '' }} - BASELINE_SHA: ${{ inputs.workplan != '' && fromJSON(inputs.workplan).baseline.sha || '' }} + BASELINE_RUN_ID: ${{ fromJSON(inputs.workplan).baseline.run_id }} + BASELINE_SHA: ${{ fromJSON(inputs.workplan).baseline.sha }} strategy: fail-fast: false matrix: @@ -79,6 +70,39 @@ jobs: # the job level instead of linking into the diff. run: echo "::add-matcher::.github/matchers/compiler.json" + - name: Resolve package-specific workplan gates + env: + BINDINGS_CONFIG: ${{ inputs.bindings-config }} + WORKPLAN: ${{ inputs.workplan }} + run: | + set -euo pipefail + jq -er --argjson config "$BINDINGS_CONFIG" ' + . as $plan + | ($config.package_roots | map(select(.release_status == "current")) + | if length == 1 then .[0] else error("expected one current bindings package root") end) as $current + | ($config.package_roots | map(select(.release_status == "maintenance")) + | if length == 1 then .[0] else error("expected one maintenance bindings package root") end) as $maintenance + | { + PATHFINDER_SOURCE: $plan.sources.pathfinder, + BUILD_PATHFINDER: $plan.modules.pathfinder.needs_build, + BUILD_BINDINGS: $plan.modules.bindings.needs_build, + BUILD_BINDINGS_CURRENT: $plan.modules.bindings.package_roots[$current.package_root].needs_build, + BUILD_BINDINGS_MAINTENANCE: $plan.modules.bindings.package_roots[$maintenance.package_root].needs_build, + BUILD_CORE: $plan.modules.core.needs_build, + BUILD_CUDA_PYTHON: $plan.modules.python.needs_build, + BUILD_CUDA_PYTHON_CURRENT: $plan.modules.python.package_roots[$current.package_root].needs_build, + BUILD_CUDA_PYTHON_MAINTENANCE: $plan.modules.python.package_roots[$maintenance.package_root].needs_build, + TEST_BINDINGS: $plan.modules.bindings.needs_test, + TEST_BINDINGS_CURRENT: $plan.modules.bindings.package_roots[$current.package_root].needs_test, + TEST_BINDINGS_MAINTENANCE: $plan.modules.bindings.package_roots[$maintenance.package_root].needs_test, + TEST_CORE: $plan.modules.core.needs_test, + TEST_CORE_CURRENT: $plan.modules.core.cuda_majors[$current.cuda_variant].needs_test, + TEST_CORE_MAINTENANCE: $plan.modules.core.cuda_majors[$maintenance.cuda_variant].needs_test + } + | to_entries[] + | if .value == null then error("missing workplan value for \(.key)") else "\(.key)=\(.value)" end + ' <<< "$WORKPLAN" >> "$GITHUB_ENV" + - name: Install latest rapidsai/sccache if: ${{ startsWith(inputs.host-platform, 'linux') && (env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true') }} run: | @@ -148,14 +172,22 @@ jobs: $env:Path += ";$((Get-Location).Path)\\$env:YQ_DIR" yq --version + - name: Install CI tool dependencies + run: python -m pip install -e ./ci + - name: Set environment variables env: - CUDA_VER: ${{ inputs.cuda-version }} HOST_PLATFORM: ${{ inputs.host-platform }} PY_VER: ${{ matrix.python-version }} SHA: ${{ github.sha }} run: ./ci/tools/env-vars build + - name: Prepare local maintenance SCM milestone + if: ${{ !inputs.single-cuda-major && (env.BUILD_BINDINGS_MAINTENANCE == 'true' || env.BUILD_CUDA_PYTHON_MAINTENANCE == 'true') }} + run: | + python -m ci.tools.bindings_scm_version \ + --package-root "${MAINTENANCE_BINDINGS_ROOT}" --sha "${{ github.sha }}" + - name: Dump environment run: | env @@ -173,7 +205,7 @@ jobs: popd - name: Download reusable cuda.pathfinder wheel - if: ${{ env.BUILD_PATHFINDER != 'true' }} + if: ${{ env.BUILD_PATHFINDER != 'true' && env.BASELINE_RUN_ID != '' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: cuda-pathfinder-wheel @@ -182,6 +214,7 @@ jobs: run-id: ${{ env.BASELINE_RUN_ID }} - name: List the cuda.pathfinder artifacts directory + if: ${{ env.BUILD_PATHFINDER == 'true' || env.BASELINE_RUN_ID != '' }} run: | if [[ "${{ inputs.host-platform }}" == win* ]]; then export CHOWN=chown @@ -202,38 +235,48 @@ jobs: if: ${{ env.BUILD_BINDINGS == 'true' }} run: | pathfinder_wheels=(cuda_pathfinder/cuda_pathfinder-*.whl) - test "${#pathfinder_wheels[@]}" -eq 1 - test -f "${pathfinder_wheels[0]}" mkdir -p wheel-constraints - if [[ "${{ inputs.host-platform }}" == win* ]]; then - pathfinder_uri="file:///$(cygpath -am "${pathfinder_wheels[0]}")" + if [[ "$PATHFINDER_SOURCE" == "artifact" ]]; then + if [[ "${#pathfinder_wheels[@]}" -ne 1 || ! -f "${pathfinder_wheels[0]}" ]]; then + echo "Expected exactly one cuda.pathfinder wheel" >&2 + exit 1 + fi + if [[ "${{ inputs.host-platform }}" == win* ]]; then + pathfinder_uri="file:///$(cygpath -am "${pathfinder_wheels[0]}")" + else + pathfinder_uri="file:///host$(realpath "${pathfinder_wheels[0]}")" + fi + printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" > wheel-constraints/cuda-bindings.txt + elif [[ "$PATHFINDER_SOURCE" == "published" ]]; then + : > wheel-constraints/cuda-bindings.txt else - pathfinder_uri="file:///host$(realpath "${pathfinder_wheels[0]}")" + echo "Unknown cuda.pathfinder source: $PATHFINDER_SOURCE" >&2 + exit 1 fi - printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" | tee wheel-constraints/cuda-bindings.txt + cat wheel-constraints/cuda-bindings.txt - name: Upload cuda.pathfinder build artifacts - if: ${{ strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + if: ${{ (env.BUILD_PATHFINDER == 'true' || env.BASELINE_RUN_ID != '') && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: cuda-pathfinder-wheel path: cuda_pathfinder/*.whl if-no-files-found: error - - name: Set up mini CTK - if: ${{ env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true' || env.TEST_BINDINGS == 'true' || env.TEST_CORE == 'true' }} + - name: Set up current mini CTK + if: ${{ env.BUILD_BINDINGS_CURRENT == 'true' || env.BUILD_CORE == 'true' || env.TEST_BINDINGS_CURRENT == 'true' || env.TEST_CORE_CURRENT == 'true' }} uses: ./.github/actions/fetch_ctk continue-on-error: false with: host-platform: ${{ inputs.host-platform }} - cuda-version: ${{ inputs.cuda-version }} + cuda-version: ${{ env.CURRENT_CUDA_VERSION }} - - name: Build cuda.bindings wheel - if: ${{ env.BUILD_BINDINGS == 'true' }} + - name: Build cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} wheel + if: ${{ env.BUILD_BINDINGS_CURRENT == 'true' }} uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 with: - package-dir: ./cuda_bindings_13/ - output-dir: ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} + package-dir: ./${{ env.CURRENT_BINDINGS_ROOT }}/ + output-dir: ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }} extras: uv env: CIBW_BUILD: ${{ env.CIBW_BUILD }} @@ -264,43 +307,44 @@ jobs: "/host/${{ env.SCCACHE_PATH }}" --show-stats --stats-format=json > /host/${{ github.workspace }}/sccache_bindings.json && auditwheel repair -w {dest_dir} {wheel} - - name: Report sccache stats (cuda.bindings) - if: ${{ env.BUILD_BINDINGS == 'true' && !startsWith(inputs.host-platform, 'win') }} + - name: Report sccache stats (cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }}) + if: ${{ env.BUILD_BINDINGS_CURRENT == 'true' && !startsWith(inputs.host-platform, 'win') }} uses: ./.github/actions/sccache-summary with: json-file: sccache_bindings.json - label: "cuda.bindings" - build-step: "Build cuda.bindings wheel" + label: "cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }}" + build-step: "Build cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} wheel" - - name: Download reusable cuda.bindings wheel - if: ${{ env.BUILD_BINDINGS != 'true' }} + - name: Download reusable cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} wheel + if: ${{ env.BUILD_BINDINGS_CURRENT != 'true' && env.BASELINE_RUN_ID != '' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: ${{ env.CUDA_BINDINGS_ARTIFACT_BASENAME }}-${{ env.BASELINE_SHA }} - path: ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} + name: ${{ env.CURRENT_BINDINGS_ARTIFACT_BASENAME }}-${{ env.BASELINE_SHA }} + path: ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }} github-token: ${{ github.token }} run-id: ${{ env.BASELINE_RUN_ID }} - - name: List the cuda.bindings artifacts directory + - name: List the cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} artifacts directory + if: ${{ env.BUILD_BINDINGS_CURRENT == 'true' || env.BASELINE_RUN_ID != '' }} run: | if [[ "${{ inputs.host-platform }}" == win* ]]; then export CHOWN=chown else export CHOWN="sudo chown" fi - $CHOWN -R $(whoami) ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} - ls -lahR ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }} + $CHOWN -R $(whoami) ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }} + ls -lahR ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }} - - name: Check cuda.bindings wheel - if: ${{ env.BUILD_BINDINGS == 'true' }} + - name: Check cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} wheel + if: ${{ env.BUILD_BINDINGS_CURRENT == 'true' }} run: | - twine check --strict ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}/*.whl + twine check --strict ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }}/*.whl - name: Constrain cuda.core to the local cuda.bindings wheel if: ${{ env.BUILD_CORE == 'true' }} run: | pathfinder_wheels=(cuda_pathfinder/cuda_pathfinder-*.whl) - bindings_wheels=("${CUDA_BINDINGS_ARTIFACTS_DIR}"/cuda_bindings-"${BUILD_CUDA_MAJOR}".*.whl) + bindings_wheels=("${CURRENT_BINDINGS_ARTIFACTS_DIR}"/cuda_bindings-"${CURRENT_CUDA_MAJOR}".*.whl) test "${#pathfinder_wheels[@]}" -eq 1 test "${#bindings_wheels[@]}" -eq 1 test -f "${pathfinder_wheels[0]}" @@ -318,14 +362,15 @@ jobs: printf 'cuda-bindings @ %s\n' "${bindings_uri}" } | tee wheel-constraints/cuda-core.txt - - name: Upload cuda.bindings build artifacts + - name: Upload cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} build artifacts + if: ${{ env.BUILD_BINDINGS_CURRENT == 'true' || env.BASELINE_RUN_ID != '' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: ${{ env.CUDA_BINDINGS_ARTIFACT_NAME }} - path: ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}/*.whl + name: ${{ env.CURRENT_BINDINGS_ARTIFACT_NAME }} + path: ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }}/*.whl if-no-files-found: error - - name: Build cuda.core wheel + - name: Build cuda.core ${{ env.CURRENT_CUDA_VARIANT }} wheel if: ${{ env.BUILD_CORE == 'true' }} uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 with: @@ -340,7 +385,7 @@ jobs: CIBW_ENVIRONMENT_LINUX: > CUDA_PATH=/host/${{ env.CUDA_PATH }} CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }} - CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_CUDA_MAJOR }} + CUDA_CORE_BUILD_MAJOR=${{ env.CURRENT_CUDA_MAJOR }} CUDA_PYTHON_WERROR=1 UV_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core.txt UV_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core.txt @@ -354,7 +399,7 @@ jobs: CIBW_ENVIRONMENT_WINDOWS: > CUDA_PATH="$(cygpath -w ${{ env.CUDA_PATH }})" CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }} - CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_CUDA_MAJOR }} + CUDA_CORE_BUILD_MAJOR=${{ env.CURRENT_CUDA_MAJOR }} CUDA_PYTHON_WERROR=1 UV_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core.txt)" UV_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core.txt)" @@ -370,7 +415,7 @@ jobs: with: json-file: sccache_core.json label: "cuda.core" - build-step: "Build cuda.core wheel" + build-step: "Build cuda.core ${{ env.CURRENT_CUDA_VARIANT }} wheel" - name: List the cuda.core artifacts directory and rename if: ${{ env.BUILD_CORE == 'true' }} @@ -383,12 +428,12 @@ jobs: $CHOWN -R $(whoami) ${{ env.CUDA_CORE_ARTIFACTS_DIR }} # Rename wheel to include CUDA version suffix - mkdir -p "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${BUILD_CUDA_MAJOR}" + mkdir -p "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${CURRENT_CUDA_MAJOR}" for wheel in ${{ env.CUDA_CORE_ARTIFACTS_DIR }}/*.whl; do if [[ -f "${wheel}" ]]; then base_name=$(basename "${wheel}" .whl) - new_name="${base_name}.cu${BUILD_CUDA_MAJOR}.whl" - mv "${wheel}" "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${BUILD_CUDA_MAJOR}/${new_name}" + new_name="${base_name}.cu${CURRENT_CUDA_MAJOR}.whl" + mv "${wheel}" "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${CURRENT_CUDA_MAJOR}/${new_name}" echo "Renamed wheel to: ${new_name}" fi done @@ -396,7 +441,7 @@ jobs: ls -lahR ${{ env.CUDA_CORE_ARTIFACTS_DIR }} - name: Download reusable cuda.core wheel - if: ${{ env.BUILD_CORE != 'true' }} + if: ${{ env.BUILD_CORE != 'true' && env.BASELINE_RUN_ID != '' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ env.CUDA_CORE_ARTIFACT_BASENAME }}-${{ env.BASELINE_SHA }} @@ -404,42 +449,71 @@ jobs: github-token: ${{ github.token }} run-id: ${{ env.BASELINE_RUN_ID }} - # We only need/want a single pure python wheel, pick linux-64 index 0. - - name: Build and check cuda-python wheel - if: ${{ env.BUILD_PYTHON == 'true' && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + # We only need/want one copy of each pure Python wheel, so use linux-64 index 0. + - name: Build and check cuda-python ${{ env.MAINTENANCE_CUDA_MAJOR }} wheel + if: ${{ !inputs.single-cuda-major && env.BUILD_CUDA_PYTHON_MAINTENANCE == 'true' && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} run: | + mkdir -p "cuda_python/dist/${MAINTENANCE_CUDA_VARIANT}" pushd cuda_python - pip wheel -v --no-deps . - twine check --strict *.whl + CUDA_PYTHON_BUILD_MAJOR="${MAINTENANCE_CUDA_MAJOR}" \ + pip wheel -v --no-deps --wheel-dir "dist/${MAINTENANCE_CUDA_VARIANT}" . + twine check --strict "dist/${MAINTENANCE_CUDA_VARIANT}"/*.whl + popd + + - name: Download reusable cuda-python ${{ env.MAINTENANCE_CUDA_MAJOR }} wheel + if: ${{ !inputs.single-cuda-major && env.BUILD_CUDA_PYTHON_MAINTENANCE != 'true' && env.BASELINE_RUN_ID != '' && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ env.MAINTENANCE_PYTHON_ARTIFACT_NAME }} + path: cuda_python/dist/${{ env.MAINTENANCE_CUDA_VARIANT }} + github-token: ${{ github.token }} + run-id: ${{ env.BASELINE_RUN_ID }} + + - name: Upload cuda-python ${{ env.MAINTENANCE_CUDA_MAJOR }} build artifacts + if: ${{ !inputs.single-cuda-major && (env.BUILD_CUDA_PYTHON_MAINTENANCE == 'true' || env.BASELINE_RUN_ID != '') && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ env.MAINTENANCE_PYTHON_ARTIFACT_NAME }} + path: cuda_python/dist/${{ env.MAINTENANCE_CUDA_VARIANT }}/*.whl + if-no-files-found: error + + - name: Build and check cuda-python ${{ env.CURRENT_CUDA_MAJOR }} wheel + if: ${{ env.BUILD_CUDA_PYTHON_CURRENT == 'true' && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + run: | + mkdir -p "cuda_python/dist/${CURRENT_CUDA_VARIANT}" + pushd cuda_python + CUDA_PYTHON_BUILD_MAJOR="${CURRENT_CUDA_MAJOR}" \ + pip wheel -v --no-deps --wheel-dir "dist/${CURRENT_CUDA_VARIANT}" . + twine check --strict "dist/${CURRENT_CUDA_VARIANT}"/*.whl popd - - name: Download reusable cuda-python wheel - if: ${{ env.BUILD_PYTHON != 'true' && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + - name: Download reusable cuda-python ${{ env.CURRENT_CUDA_MAJOR }} wheel + if: ${{ env.BUILD_CUDA_PYTHON_CURRENT != 'true' && env.BASELINE_RUN_ID != '' && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: cuda-python-wheel - path: cuda_python + name: ${{ env.CURRENT_PYTHON_ARTIFACT_NAME }} + path: cuda_python/dist/${{ env.CURRENT_CUDA_VARIANT }} github-token: ${{ github.token }} run-id: ${{ env.BASELINE_RUN_ID }} - - name: List the cuda-python artifacts directory - if: ${{ strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + - name: Upload cuda-python ${{ env.CURRENT_CUDA_MAJOR }} build artifacts + if: ${{ (env.BUILD_CUDA_PYTHON_CURRENT == 'true' || env.BASELINE_RUN_ID != '') && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ env.CURRENT_PYTHON_ARTIFACT_NAME }} + path: cuda_python/dist/${{ env.CURRENT_CUDA_VARIANT }}/*.whl + if-no-files-found: error + + - name: List the cuda-python artifacts directories + if: ${{ (env.BUILD_CUDA_PYTHON == 'true' || env.BASELINE_RUN_ID != '') && strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} run: | if [[ "${{ inputs.host-platform }}" == win* ]]; then export CHOWN=chown else export CHOWN="sudo chown" fi - $CHOWN -R $(whoami) cuda_python/*.whl - ls -lahR cuda_python - - - name: Upload cuda-python build artifacts - if: ${{ strategy.job-index == 0 && inputs.host-platform == 'linux-64' }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: cuda-python-wheel - path: cuda_python/*.whl - if-no-files-found: error + $CHOWN -R $(whoami) cuda_python/dist + ls -lahR cuda_python/dist - name: Set up Python id: setup-python2 @@ -471,7 +545,19 @@ jobs: - name: Install cuda.pathfinder (required for next step) if: ${{ env.TEST_BINDINGS == 'true' || env.TEST_CORE == 'true' }} run: | - pip install cuda_pathfinder/*.whl + pathfinder_wheels=(cuda_pathfinder/*.whl) + if [[ "$PATHFINDER_SOURCE" == "artifact" ]]; then + if [[ "${#pathfinder_wheels[@]}" -ne 1 || ! -f "${pathfinder_wheels[0]}" ]]; then + echo "Expected exactly one cuda.pathfinder wheel" >&2 + exit 1 + fi + pip install "${pathfinder_wheels[0]}" + elif [[ "$PATHFINDER_SOURCE" == "published" ]]; then + pip install cuda-pathfinder + else + echo "Unknown cuda.pathfinder source: $PATHFINDER_SOURCE" >&2 + exit 1 + fi - name: Hide GNU link.exe so Meson finds MSVC link.exe if: ${{ startsWith(inputs.host-platform, 'win') && (env.TEST_BINDINGS == 'true' || env.TEST_CORE == 'true') }} @@ -480,28 +566,36 @@ jobs: mv "/c/Program Files/Git/usr/bin/link.exe" "/c/Program Files/Git/usr/bin/link.exe.bak" fi - - name: Build cuda.bindings Cython tests - if: ${{ env.TEST_BINDINGS == 'true' }} + - name: Build cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} Cython tests + if: ${{ env.TEST_BINDINGS_CURRENT == 'true' }} run: | - pip install ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}/*.whl --group ./cuda_bindings_13/pyproject.toml:test - pushd ${{ env.CUDA_BINDINGS_CYTHON_TESTS_DIR }} + bindings_wheels=(${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }}/*.whl) + test "${#bindings_wheels[@]}" -eq 1 + test -f "${bindings_wheels[0]}" + bindings_pyproject="${CURRENT_BINDINGS_ROOT}/pyproject.toml" + if grep -q '^\[dependency-groups\][[:space:]]*$' "${bindings_pyproject}"; then + python -m pip install "${bindings_wheels[0]}" --group "${bindings_pyproject}:test" + else + python -m pip install "${bindings_wheels[0]}[test]" + fi + pushd ${{ env.CURRENT_BINDINGS_CYTHON_TESTS_DIR }} bash build_tests.sh popd - - name: Upload cuda.bindings Cython tests - if: ${{ env.TEST_BINDINGS == 'true' }} + - name: Upload cuda.bindings ${{ env.CURRENT_CUDA_MAJOR }} Cython tests + if: ${{ env.TEST_BINDINGS_CURRENT == 'true' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: ${{ env.CUDA_BINDINGS_ARTIFACT_NAME }}-tests - path: ${{ env.CUDA_BINDINGS_CYTHON_TESTS_DIR }}/test_*${{ env.PY_EXT_SUFFIX }} + name: ${{ env.CURRENT_BINDINGS_ARTIFACT_NAME }}-tests + path: ${{ env.CURRENT_BINDINGS_CYTHON_TESTS_DIR }}/test_*${{ env.PY_EXT_SUFFIX }} if-no-files-found: error - - name: Build cuda.core Cython tests - if: ${{ env.TEST_CORE == 'true' }} + - name: Build cuda.core ${{ env.CURRENT_CUDA_VARIANT }} Cython tests + if: ${{ env.TEST_CORE_CURRENT == 'true' }} run: | - pip install ${{ env.CUDA_BINDINGS_ARTIFACTS_DIR }}/*.whl + pip install ${{ env.CURRENT_BINDINGS_ARTIFACTS_DIR }}/*.whl if ${{ env.BUILD_CORE == 'true' }}; then - core_wheel=$(find "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${BUILD_CUDA_MAJOR}" -maxdepth 1 -type f -name '*.whl' -print -quit) + core_wheel=$(find "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${CURRENT_CUDA_MAJOR}" -maxdepth 1 -type f -name '*.whl' -print -quit) else core_wheel=$(find "${{ env.CUDA_CORE_ARTIFACTS_DIR }}" -maxdepth 1 -type f -name '*.whl' -print -quit) fi @@ -514,22 +608,22 @@ jobs: bash build_tests.sh popd - - name: Upload cuda.core Cython tests - if: ${{ env.TEST_CORE == 'true' }} + - name: Upload current cuda.core Cython tests + if: ${{ env.TEST_CORE_CURRENT == 'true' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: ${{ env.CUDA_CORE_ARTIFACT_NAME }}-tests + name: ${{ env.CUDA_CORE_ARTIFACT_NAME }}-${{ env.CURRENT_CUDA_VARIANT }}-tests path: ${{ env.CUDA_CORE_CYTHON_TESTS_DIR }}/test_*${{ env.PY_EXT_SUFFIX }} if-no-files-found: error - # Note: This overwrites CUDA_PATH etc - - name: Set up mini CTK - if: ${{ !inputs.single-cuda-major && (env.BUILD_CORE == 'true' || env.TEST_CORE == 'true') }} + # Note: This overwrites CUDA_PATH etc. + - name: Set up maintenance mini CTK + if: ${{ !inputs.single-cuda-major && (env.BUILD_BINDINGS_MAINTENANCE == 'true' || env.BUILD_CORE == 'true' || env.TEST_BINDINGS_MAINTENANCE == 'true' || env.TEST_CORE_MAINTENANCE == 'true') }} uses: ./.github/actions/fetch_ctk continue-on-error: false with: host-platform: ${{ inputs.host-platform }} - cuda-version: ${{ inputs.prev-cuda-version }} + cuda-version: ${{ env.MAINTENANCE_CUDA_VERSION }} cuda-path: "./cuda_toolkit_prev" - name: Build cuda.core test binaries @@ -549,47 +643,111 @@ jobs: ${{ env.CUDA_CORE_TEST_BINARIES_DIR }}/*.lib if-no-files-found: error - - name: Download cuda.bindings build artifacts from the prior branch - if: ${{ !inputs.single-cuda-major && env.BUILD_CORE == 'true' }} + - name: Build cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} wheel + if: ${{ !inputs.single-cuda-major && env.BUILD_BINDINGS_MAINTENANCE == 'true' }} + uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 + with: + package-dir: ./${{ env.MAINTENANCE_BINDINGS_ROOT }}/ + output-dir: ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }} + extras: uv env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CIBW_BUILD: ${{ env.CIBW_BUILD }} + # Use the same frontend and constraints as the current package. + CIBW_BUILD_FRONTEND: "build[uv]" + CIBW_ENVIRONMENT_LINUX: > + CUDA_PATH=/host/${{ env.CUDA_PATH }} + CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }} + UV_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-bindings.txt + UV_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-bindings.txt + CC="/host/${{ env.SCCACHE_PATH }} cc" + CXX="/host/${{ env.SCCACHE_PATH }} c++" + SCCACHE_GHA_ENABLED=true + ACTIONS_RUNTIME_TOKEN=${{ env.ACTIONS_RUNTIME_TOKEN }} + ACTIONS_RUNTIME_URL=${{ env.ACTIONS_RUNTIME_URL }} + ACTIONS_RESULTS_URL=${{ env.ACTIONS_RESULTS_URL }} + ACTIONS_CACHE_URL=${{ env.ACTIONS_CACHE_URL }} + ACTIONS_CACHE_SERVICE_V2=${{ env.ACTIONS_CACHE_SERVICE_V2 }} + CIBW_ENVIRONMENT_WINDOWS: > + CUDA_PATH="$(cygpath -w ${{ env.CUDA_PATH }})" + CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }} + UV_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-bindings.txt)" + UV_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-bindings.txt)" + # Dump sccache stats during repair; no dummy test stage. + CIBW_REPAIR_WHEEL_COMMAND_LINUX: > + "/host/${{ env.SCCACHE_PATH }}" --show-adv-stats && + "/host/${{ env.SCCACHE_PATH }}" --show-stats --stats-format=json > /host/${{ github.workspace }}/sccache_bindings_maintenance.json && + auditwheel repair -w {dest_dir} {wheel} + + - name: Report sccache stats (cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }}) + if: ${{ !inputs.single-cuda-major && env.BUILD_BINDINGS_MAINTENANCE == 'true' && !startsWith(inputs.host-platform, 'win') }} + uses: ./.github/actions/sccache-summary + with: + json-file: sccache_bindings_maintenance.json + label: "cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }}" + build-step: "Build cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} wheel" + + - name: Download reusable cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} wheel + if: ${{ !inputs.single-cuda-major && env.BUILD_BINDINGS_MAINTENANCE != 'true' && env.BASELINE_RUN_ID != '' }} + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ env.MAINTENANCE_BINDINGS_ARTIFACT_BASENAME }}-${{ env.BASELINE_SHA }} + path: ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }} + github-token: ${{ github.token }} + run-id: ${{ env.BASELINE_RUN_ID }} + + - name: List the cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} artifacts directory + if: ${{ !inputs.single-cuda-major && (env.BUILD_BINDINGS_MAINTENANCE == 'true' || env.BASELINE_RUN_ID != '') }} run: | - if ! (command -v gh 2>&1 >/dev/null); then - # See https://github.com/cli/cli/blob/trunk/docs/install_linux.md#debian-ubuntu-linux-raspberry-pi-os-apt. - # gh is needed for artifact fetching. - mkdir -p -m 755 /etc/apt/keyrings \ - && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - && cat $out | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ - && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && apt update \ - && apt install gh -y + if [[ "${{ inputs.host-platform }}" == win* ]]; then + export CHOWN=chown + else + export CHOWN="sudo chown" fi + $CHOWN -R $(whoami) ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }} + ls -lahR ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }} + + - name: Check cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} wheel + if: ${{ !inputs.single-cuda-major && env.BUILD_BINDINGS_MAINTENANCE == 'true' }} + run: twine check --strict ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }}/*.whl + + - name: Upload cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} build artifacts + if: ${{ !inputs.single-cuda-major && (env.BUILD_BINDINGS_MAINTENANCE == 'true' || env.BASELINE_RUN_ID != '') }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ env.MAINTENANCE_BINDINGS_ARTIFACT_NAME }} + path: ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }}/*.whl + if-no-files-found: error - OLD_BRANCH=$(yq '.backport_branch' ci/versions.yml) - OLD_ARTIFACT_PATTERN="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda*-${{ inputs.host-platform }}-*[0-9a-f]" - LATEST_PRIOR_RUN_ID=$(./ci/tools/lookup-run-id \ - --branch "${OLD_BRANCH}" \ - --artifact "${OLD_ARTIFACT_PATTERN}" \ - NVIDIA/cuda-python "CI") - PREV_BINDINGS_DIR="cuda_bindings_13/dist-prev" - - gh run download \ - "${LATEST_PRIOR_RUN_ID}" \ - -p "${OLD_ARTIFACT_PATTERN}" \ - -R NVIDIA/cuda-python - OLD_ARTIFACT_DIR=$(compgen -G "${OLD_ARTIFACT_PATTERN}") - test -d "${OLD_ARTIFACT_DIR}" - ls -al "${OLD_ARTIFACT_DIR}" - mkdir -p "${PREV_BINDINGS_DIR}" - mv "${OLD_ARTIFACT_DIR}"/*.whl "${PREV_BINDINGS_DIR}" - rmdir "${OLD_ARTIFACT_DIR}" - - - name: Constrain previous cuda.core to the downloaded cuda.bindings wheel + - name: Build cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} Cython tests + if: ${{ !inputs.single-cuda-major && env.TEST_BINDINGS_MAINTENANCE == 'true' }} + run: | + pip uninstall -y cuda-bindings + bindings_wheels=(${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }}/*.whl) + test "${#bindings_wheels[@]}" -eq 1 + test -f "${bindings_wheels[0]}" + bindings_pyproject="${MAINTENANCE_BINDINGS_ROOT}/pyproject.toml" + if grep -q '^\[dependency-groups\][[:space:]]*$' "${bindings_pyproject}"; then + python -m pip install "${bindings_wheels[0]}" --group "${bindings_pyproject}:test" + else + python -m pip install "${bindings_wheels[0]}[test]" + fi + pushd ${{ env.MAINTENANCE_BINDINGS_CYTHON_TESTS_DIR }} + bash build_tests.sh + popd + + - name: Upload cuda.bindings ${{ env.MAINTENANCE_CUDA_MAJOR }} Cython tests + if: ${{ !inputs.single-cuda-major && env.TEST_BINDINGS_MAINTENANCE == 'true' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ env.MAINTENANCE_BINDINGS_ARTIFACT_NAME }}-tests + path: ${{ env.MAINTENANCE_BINDINGS_CYTHON_TESTS_DIR }}/test_*${{ env.PY_EXT_SUFFIX }} + if-no-files-found: error + + - name: Constrain maintenance cuda.core to the local cuda.bindings wheel if: ${{ !inputs.single-cuda-major && env.BUILD_CORE == 'true' }} run: | pathfinder_wheels=(cuda_pathfinder/cuda_pathfinder-*.whl) - bindings_wheels=(cuda_bindings_13/dist-prev/cuda_bindings-"${BUILD_PREV_CUDA_MAJOR}".*.whl) + bindings_wheels=("${MAINTENANCE_BINDINGS_ARTIFACTS_DIR}"/cuda_bindings-"${MAINTENANCE_CUDA_MAJOR}".*.whl) test "${#pathfinder_wheels[@]}" -eq 1 test "${#bindings_wheels[@]}" -eq 1 test -f "${pathfinder_wheels[0]}" @@ -605,9 +763,9 @@ jobs: { printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" printf 'cuda-bindings @ %s\n' "${bindings_uri}" - } | tee wheel-constraints/cuda-core-prev.txt + } | tee wheel-constraints/cuda-core-maintenance.txt - - name: Build cuda.core wheel + - name: Build cuda.core ${{ env.MAINTENANCE_CUDA_VARIANT }} wheel if: ${{ !inputs.single-cuda-major && env.BUILD_CORE == 'true' }} uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 with: @@ -622,10 +780,10 @@ jobs: CIBW_ENVIRONMENT_LINUX: > CUDA_PATH=/host/${{ env.CUDA_PATH }} CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }} - CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_PREV_CUDA_MAJOR }} + CUDA_CORE_BUILD_MAJOR=${{ env.MAINTENANCE_CUDA_MAJOR }} CUDA_PYTHON_WERROR=1 - UV_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core-prev.txt - UV_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core-prev.txt + UV_BUILD_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core-maintenance.txt + UV_CONSTRAINT=/host/${{ github.workspace }}/wheel-constraints/cuda-core-maintenance.txt CC="/host/${{ env.SCCACHE_PATH }} cc" CXX="/host/${{ env.SCCACHE_PATH }} c++" SCCACHE_GHA_ENABLED=true @@ -636,23 +794,23 @@ jobs: CIBW_ENVIRONMENT_WINDOWS: > CUDA_PATH="$(cygpath -w ${{ env.CUDA_PATH }})" CUDA_PYTHON_PARALLEL_LEVEL=${{ env.CUDA_PYTHON_PARALLEL_LEVEL }} - CUDA_CORE_BUILD_MAJOR=${{ env.BUILD_PREV_CUDA_MAJOR }} + CUDA_CORE_BUILD_MAJOR=${{ env.MAINTENANCE_CUDA_MAJOR }} CUDA_PYTHON_WERROR=1 - UV_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core-prev.txt)" - UV_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core-prev.txt)" + UV_BUILD_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core-maintenance.txt)" + UV_CONSTRAINT="$(cygpath -w ./wheel-constraints/cuda-core-maintenance.txt)" # Dump sccache stats during repair; no dummy test stage. CIBW_REPAIR_WHEEL_COMMAND_LINUX: > "/host/${{ env.SCCACHE_PATH }}" --show-adv-stats && - "/host/${{ env.SCCACHE_PATH }}" --show-stats --stats-format=json > /host/${{ github.workspace }}/sccache_core_prev.json && + "/host/${{ env.SCCACHE_PATH }}" --show-stats --stats-format=json > /host/${{ github.workspace }}/sccache_core_maintenance.json && auditwheel repair -w {dest_dir} {wheel} - - name: Report sccache stats (cuda.core prev) + - name: Report sccache stats (maintenance cuda.core) if: ${{ !inputs.single-cuda-major && env.BUILD_CORE == 'true' && !startsWith(inputs.host-platform, 'win') }} uses: ./.github/actions/sccache-summary with: - json-file: sccache_core_prev.json - label: "cuda.core (prev CTK)" - build-step: "Build cuda.core wheel" + json-file: sccache_core_maintenance.json + label: "cuda.core (maintenance CTK)" + build-step: "Build cuda.core ${{ env.MAINTENANCE_CUDA_VARIANT }} wheel" - name: List the cuda.core artifacts directory and rename if: ${{ !inputs.single-cuda-major && env.BUILD_CORE == 'true' }} @@ -666,32 +824,66 @@ jobs: ls -lahR ${{ env.CUDA_CORE_ARTIFACTS_DIR }} # Rename wheel to include CUDA version suffix - mkdir -p "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${BUILD_PREV_CUDA_MAJOR}" + mkdir -p "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${MAINTENANCE_CUDA_MAJOR}" for wheel in ${{ env.CUDA_CORE_ARTIFACTS_DIR }}/*.whl; do if [[ -f "${wheel}" ]]; then base_name=$(basename "${wheel}" .whl) - new_name="${base_name}.cu${BUILD_PREV_CUDA_MAJOR}.whl" - mv "${wheel}" "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${BUILD_PREV_CUDA_MAJOR}/${new_name}" + new_name="${base_name}.cu${MAINTENANCE_CUDA_MAJOR}.whl" + mv "${wheel}" "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${MAINTENANCE_CUDA_MAJOR}/${new_name}" echo "Renamed wheel to: ${new_name}" fi done ls -lahR ${{ env.CUDA_CORE_ARTIFACTS_DIR }} + - name: Build cuda.core ${{ env.MAINTENANCE_CUDA_VARIANT }} Cython tests + if: ${{ !inputs.single-cuda-major && env.TEST_CORE_MAINTENANCE == 'true' }} + run: | + # The current package's test extensions were built in this same source tree. + # Remove generated outputs so Cython and the compiler cannot reuse them. + find "${{ env.CUDA_CORE_CYTHON_TESTS_DIR }}" -maxdepth 1 -type f \ + \( -name 'test_*.cpp' -o -name 'test_*.so' -o -name 'test_*.pyd' \) -delete + if [[ -d "${{ env.CUDA_CORE_CYTHON_TESTS_DIR }}/build" ]]; then + find "${{ env.CUDA_CORE_CYTHON_TESTS_DIR }}/build" -type f -delete + fi + pip uninstall -y cuda-bindings cuda-core + pip install ${{ env.MAINTENANCE_BINDINGS_ARTIFACTS_DIR }}/*.whl + if ${{ env.BUILD_CORE == 'true' }}; then + core_wheel=$(find "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/cu${MAINTENANCE_CUDA_MAJOR}" -maxdepth 1 -type f -name '*.whl' -print -quit) + else + core_wheel=$(find "${{ env.CUDA_CORE_ARTIFACTS_DIR }}" -maxdepth 1 -type f -name '*.whl' -print -quit) + fi + if [[ -z "${core_wheel}" ]]; then + echo "No cuda.core wheel found" >&2 + exit 1 + fi + pip install "${core_wheel}" --group ./cuda_core/pyproject.toml:test + pushd ${{ env.CUDA_CORE_CYTHON_TESTS_DIR }} + bash build_tests.sh + popd + + - name: Upload maintenance cuda.core Cython tests + if: ${{ !inputs.single-cuda-major && env.TEST_CORE_MAINTENANCE == 'true' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ env.CUDA_CORE_ARTIFACT_NAME }}-${{ env.MAINTENANCE_CUDA_VARIANT }}-tests + path: ${{ env.CUDA_CORE_CYTHON_TESTS_DIR }}/test_*${{ env.PY_EXT_SUFFIX }} + if-no-files-found: error + - name: Merge cuda.core wheels if: ${{ !inputs.single-cuda-major && env.BUILD_CORE == 'true' }} run: | pip install wheel python ci/tools/merge_cuda_core_wheels.py \ - "${{ env.CUDA_CORE_ARTIFACTS_DIR }}"/cu"${BUILD_CUDA_MAJOR}"/cuda_core*.whl \ - "${{ env.CUDA_CORE_ARTIFACTS_DIR }}"/cu"${BUILD_PREV_CUDA_MAJOR}"/cuda_core*.whl \ + "${{ env.CUDA_CORE_ARTIFACTS_DIR }}"/cu"${CURRENT_CUDA_MAJOR}"/cuda_core*.whl \ + "${{ env.CUDA_CORE_ARTIFACTS_DIR }}"/cu"${MAINTENANCE_CUDA_MAJOR}"/cuda_core*.whl \ --output-dir "${{ env.CUDA_CORE_ARTIFACTS_DIR }}" - name: Finalize single-major cuda.core wheel if: ${{ inputs.single-cuda-major && env.BUILD_CORE == 'true' }} run: | - for wheel in "${{ env.CUDA_CORE_ARTIFACTS_DIR }}"/cu"${BUILD_CUDA_MAJOR}"/*.cu"${BUILD_CUDA_MAJOR}".whl; do - base_name=$(basename "${wheel}" ".cu${BUILD_CUDA_MAJOR}.whl") + for wheel in "${{ env.CUDA_CORE_ARTIFACTS_DIR }}"/cu"${CURRENT_CUDA_MAJOR}"/*.cu"${CURRENT_CUDA_MAJOR}".whl; do + base_name=$(basename "${wheel}" ".cu${CURRENT_CUDA_MAJOR}.whl") mv "${wheel}" "${{ env.CUDA_CORE_ARTIFACTS_DIR }}/${base_name}.whl" done ls -lahR "${{ env.CUDA_CORE_ARTIFACTS_DIR }}" @@ -702,6 +894,7 @@ jobs: twine check --strict ${{ env.CUDA_CORE_ARTIFACTS_DIR }}/*.whl - name: Upload cuda.core build artifacts + if: ${{ env.BUILD_CORE == 'true' || env.BASELINE_RUN_ID != '' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ env.CUDA_CORE_ARTIFACT_NAME }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 174f560e245..28d4b987f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ # SPDX-License-Identifier: Apache-2.0 # Note: This name is referred to in the test job, so make sure any changes are sync'd up! -# Further this is referencing a run in the backport branch to fetch old bindings. name: "CI" concurrency: @@ -27,61 +26,6 @@ on: workflow_dispatch: {} jobs: - ci-vars: - if: ${{ github.repository_owner == 'nvidia' }} - runs-on: ubuntu-latest - outputs: - CUDA_BUILD_VER: ${{ steps.get-vars.outputs.cuda_build_ver }} - CUDA_PREV_BUILD_VER: ${{ steps.get-vars.outputs.cuda_prev_build_ver }} - WINDOWS_ARM64_SUPPORTED: ${{ steps.get-vars.outputs.windows_arm64_supported }} - WINDOWS_ARM64_SINGLE_CUDA_MAJOR: ${{ steps.get-vars.outputs.windows_arm64_single_cuda_major }} - PYTHON_SUPPORTED: ${{ steps.get-vars.outputs.python_supported }} - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 1 - - name: Get CUDA build versions - id: get-vars - run: | - cuda_build_ver=$(yq '.cuda.build.version' ci/versions.yml) - echo "cuda_build_ver=$cuda_build_ver" >> $GITHUB_OUTPUT - - cuda_prev_build_ver=$(yq '.cuda.prev_build.version' ci/versions.yml) - echo "cuda_prev_build_ver=$cuda_prev_build_ver" >> $GITHUB_OUTPUT - - # Flattened one-line JSON for pass-through to build-wheel.yml via - # fromJSON on the receiving side. - python_supported=$(yq -o json -I 0 '.python.supported' ci/versions.yml) - echo "python_supported=$python_supported" >> $GITHUB_OUTPUT - - # Windows ARM64 is available starting with CUDA 13.4. - if [[ "$cuda_build_ver" =~ ^([0-9]+)\.([0-9]+)(\.|$) ]]; then - cuda_build_major="${BASH_REMATCH[1]}" - cuda_build_minor="${BASH_REMATCH[2]}" - else - echo "Invalid CUDA build version: $cuda_build_ver" >&2 - exit 1 - fi - if (( cuda_build_major > 13 || (cuda_build_major == 13 && cuda_build_minor >= 4) )); then - windows_arm64_supported=true - else - windows_arm64_supported=false - fi - echo "windows_arm64_supported=$windows_arm64_supported" >> $GITHUB_OUTPUT - - # No CUDA 13 windows-arm64 toolkit exists for a major other than the - # current one (windows-arm64 support started mid-way through the 13.x - # series), so cuda.core can only be built against a single CUDA major - # while the build major is still 13. Once the build major advances to - # 14, a CUDA 13 windows-arm64 toolkit will exist as the prior major. - if [[ "$cuda_build_major" == "13" ]]; then - windows_arm64_single_cuda_major=true - else - windows_arm64_single_cuda_major=false - fi - echo "windows_arm64_single_cuda_major=$windows_arm64_single_cuda_major" >> $GITHUB_OUTPUT - should-skip: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest @@ -122,19 +66,21 @@ jobs: # # Dependency graph (verified in pyproject.toml files): # cuda_pathfinder -> (no internal deps) - # cuda_bindings -> cuda_pathfinder + # each registered cuda.bindings package -> cuda_pathfinder # cuda_core -> cuda_pathfinder, cuda_bindings # cuda_python -> cuda_pathfinder, cuda_bindings, cuda_core (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. + # downstream module. A change to either bindings package rebuilds cuda_core + # against both majors, but rebuilds only the matching bindings package. # A change to cuda_core alone skips rebuilding/retesting cuda_bindings and # cuda_pathfinder, but still retests the downstream cuda-python metapackage. # Shared build/orchestration changes run the full pipeline; test-only CI # infrastructure runs every test suite without rebuilding package wheels. - # 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. + # On pushes to main, schedule, workflow_dispatch, or non-bindings release + # tags we unconditionally run everything because there is no meaningful + # "changed paths" baseline for those events. Bare bindings tags select the + # corresponding registered cuda.bindings package root and cuda-python build. detect-changes: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest @@ -143,6 +89,11 @@ jobs: actions: read contents: read outputs: + bindings-config: ${{ steps.registry.outputs.bindings-config }} + current-cuda-version: ${{ steps.registry.outputs.current-cuda-version }} + python-supported: ${{ steps.registry.outputs.python-supported }} + windows-arm64-supported: ${{ steps.registry.outputs.windows-arm64-supported }} + windows-arm64-single-cuda-major: ${{ steps.registry.outputs.windows-arm64-single-cuda-major }} workplan: ${{ steps.workplan.outputs.workplan }} steps: - name: Checkout repository @@ -152,6 +103,55 @@ jobs: fetch-depth: ${{ startsWith(github.ref_name, 'pull-request/') && '0' || '1' }} filter: blob:none + - name: Harden pip + uses: ./.github/actions/harden-pip + with: + python: python3 + + - name: Install CI tool test dependencies + run: python3 -m pip install -e './ci[test]' + + - name: Resolve registered build platforms + id: registry + run: | + set -euo pipefail + bindings_config=$(python3 -m ci.tools.bindings_config) + current_cuda_version=$(jq -er \ + '.package_roots[] | select(.release_status == "current") | .toolkit_version' \ + <<< "$bindings_config") + + if [[ "$current_cuda_version" =~ ^([0-9]+)\.([0-9]+)(\.|$) ]]; then + current_cuda_major="${BASH_REMATCH[1]}" + current_cuda_minor="${BASH_REMATCH[2]}" + else + echo "Invalid current CUDA version: $current_cuda_version" >&2 + exit 1 + fi + + # Windows ARM64 is available starting with CUDA 13.4. + if (( current_cuda_major > 13 || (current_cuda_major == 13 && current_cuda_minor >= 4) )); then + windows_arm64_supported=true + else + windows_arm64_supported=false + fi + + # CUDA 13 introduced Windows ARM64 mid-release-line, so there is no + # CUDA 12 Windows ARM64 toolkit. Once current advances to CUDA 14, + # CUDA 13 can be built as the maintenance major on this platform. + if [[ "$current_cuda_major" == "13" ]]; then + windows_arm64_single_cuda_major=true + else + windows_arm64_single_cuda_major=false + fi + + { + echo "python-supported=$(yq -o json -I 0 '.python.supported' ci/versions.yml)" + echo "bindings-config=$bindings_config" + echo "current-cuda-version=$current_cuda_version" + echo "windows-arm64-supported=$windows_arm64_supported" + echo "windows-arm64-single-cuda-major=$windows_arm64_single_cuda_major" + } >> "$GITHUB_OUTPUT" + - name: Resolve PR merge base id: merge-base if: ${{ startsWith(github.ref_name, 'pull-request/') }} @@ -172,8 +172,12 @@ jobs: if: ${{ startsWith(github.ref_name, 'pull-request/') }} env: BASE_REF: ${{ needs.should-skip.outputs.base-ref }} + BINDINGS_CONFIG: ${{ steps.registry.outputs.bindings-config }} + CURRENT_CUDA_VERSION: ${{ steps.registry.outputs.current-cuda-version }} MERGE_BASE: ${{ steps.merge-base.outputs.sha }} GH_TOKEN: ${{ github.token }} + WINDOWS_ARM64_SUPPORTED: ${{ steps.registry.outputs.windows-arm64-supported }} + WINDOWS_ARM64_SINGLE_CUDA_MAJOR: ${{ steps.registry.outputs.windows-arm64-single-cuda-major }} run: | set -uo pipefail @@ -223,26 +227,46 @@ jobs: } missing=() - for name in cuda-pathfinder-wheel cuda-python-wheel; do + has_artifact cuda-pathfinder-wheel || missing+=(cuda-pathfinder-wheel) + + if ! cuda_versions_json=$(jq -ce '[.package_roots[].toolkit_version]' <<< "$BINDINGS_CONFIG"); then + unavailable + fi + mapfile -t cuda_versions < <(jq -r '.[]' <<< "$cuda_versions_json") + for version in "${cuda_versions[@]}"; do + name="cuda-python-wheel-cuda${version}" has_artifact "$name" || missing+=("$name") done - cuda_version=$(yq '.cuda.build.version' ci/versions.yml) if ! python_versions=$(yq -r '.python.supported[]' ci/versions.yml); then unavailable fi if ! platforms=$(yq -r '.platforms[]' ci/test-matrix.yml); then unavailable fi + if [[ "$WINDOWS_ARM64_SUPPORTED" == "true" ]]; then + platforms+=$'\nwin-arm64' + fi if [[ -z "${python_versions}" || -z "${platforms}" ]]; then unavailable fi while IFS= read -r python_version; do python=${python_version//./} while IFS= read -r platform; do - binding="cuda-bindings-python${python}-cuda${cuda_version}-${platform}-${merge_base}" + # CPython 3.10 has no official Windows ARM64 build. + if [[ "$platform" == "win-arm64" && "$python_version" == "3.10" ]]; then + continue + fi + for version in "${cuda_versions[@]}"; do + if [[ "$platform" == "win-arm64" && + "$WINDOWS_ARM64_SINGLE_CUDA_MAJOR" == "true" && + "$version" != "$CURRENT_CUDA_VERSION" ]]; then + continue + fi + binding="cuda-bindings-python${python}-cuda${version}-${platform}-${merge_base}" + has_artifact "$binding" || missing+=("$binding") + done core="cuda-core-python${python}-${platform}-${merge_base}" - has_artifact "$binding" || missing+=("$binding") has_artifact "$core" || missing+=("$core") done <<< "${platforms}" done <<< "${python_versions}" @@ -258,27 +282,29 @@ jobs: echo "Reusable artifacts: run \`${run_id}\` at \`${merge_base}\` on \`${BASE_REF}\`." } >> "$GITHUB_STEP_SUMMARY" - - name: Test CI workplan planner - run: python3 -m unittest ci/tools/tests/test_compute_ci_plan.py + - name: Validate bindings package metadata and test CI tools + run: | + python3 -m ci.tools.bindings_config --check-package-metadata + python3 -m pytest -q --noconftest ci/tools/tests - name: Compute CI workplan id: workplan env: MERGE_BASE: ${{ steps.merge-base.outputs.sha }} BASELINE_RUN_ID: ${{ steps.baseline.outputs.run_id }} + RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | set -euo pipefail - workplan=$(python3 ci/tools/compute_ci_plan.py \ - --merge-base "$MERGE_BASE" \ - --baseline-run-id "$BASELINE_RUN_ID") - echo "workplan=$workplan" >> "$GITHUB_OUTPUT" - { - echo - echo "### CI workplan" - echo '```json' - jq . <<< "$workplan" - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + planner_args=( + --merge-base "$MERGE_BASE" + --baseline-run-id "$BASELINE_RUN_ID" + ) + if [[ -n "$RELEASE_TAG" ]]; then + planner_args+=(--release-tag "$RELEASE_TAG") + fi + python3 -m ci.tools.compute_ci_plan "${planner_args[@]}" \ + --github-output "$GITHUB_OUTPUT" \ + --github-step-summary "$GITHUB_STEP_SUMMARY" api-check-core-vs-release: name: API check (cuda_core vs. latest release) @@ -372,7 +398,6 @@ jobs: # - if: condition (build-linux-64 omits doc-only check since it's needed for docs) build-linux-64: needs: - - ci-vars - should-skip - detect-changes strategy: @@ -380,7 +405,7 @@ jobs: matrix: host-platform: - linux-64 - name: Build ${{ matrix.host-platform }}, CUDA ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + name: Build ${{ fromJSON(needs.detect-changes.outputs.workplan).labels.build }} (${{ matrix.host-platform }}) if: ${{ github.repository_owner == 'nvidia' && !fromJSON(needs.should-skip.outputs.skip) }} permissions: actions: read @@ -389,15 +414,13 @@ jobs: uses: ./.github/workflows/build-wheel.yml with: host-platform: ${{ matrix.host-platform }} - cuda-version: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} - prev-cuda-version: ${{ needs.ci-vars.outputs.CUDA_PREV_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} workplan: ${{ needs.detect-changes.outputs.workplan }} - python-versions: ${{ needs.ci-vars.outputs.PYTHON_SUPPORTED }} + python-versions: ${{ needs.detect-changes.outputs.python-supported }} # See build-linux-64 for why build jobs are split by platform. build-linux-aarch64: needs: - - ci-vars - should-skip - detect-changes strategy: @@ -405,7 +428,7 @@ jobs: matrix: host-platform: - linux-aarch64 - name: Build ${{ matrix.host-platform }}, CUDA ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + name: Build ${{ fromJSON(needs.detect-changes.outputs.workplan).labels.build_platform }} (${{ matrix.host-platform }}) if: ${{ github.repository_owner == 'nvidia' && !fromJSON(needs.should-skip.outputs.skip) && !fromJSON(needs.should-skip.outputs.doc-only) && @@ -417,15 +440,13 @@ jobs: uses: ./.github/workflows/build-wheel.yml with: host-platform: ${{ matrix.host-platform }} - cuda-version: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} - prev-cuda-version: ${{ needs.ci-vars.outputs.CUDA_PREV_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} workplan: ${{ needs.detect-changes.outputs.workplan }} - python-versions: ${{ needs.ci-vars.outputs.PYTHON_SUPPORTED }} + python-versions: ${{ needs.detect-changes.outputs.python-supported }} # See build-linux-64 for why build jobs are split by platform. build-windows: needs: - - ci-vars - should-skip - detect-changes strategy: @@ -433,7 +454,7 @@ jobs: matrix: host-platform: - win-64 - name: Build ${{ matrix.host-platform }}, CUDA ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + name: Build ${{ fromJSON(needs.detect-changes.outputs.workplan).labels.build_platform }} (${{ matrix.host-platform }}) if: ${{ github.repository_owner == 'nvidia' && !fromJSON(needs.should-skip.outputs.skip) && !fromJSON(needs.should-skip.outputs.doc-only) && @@ -445,23 +466,22 @@ jobs: uses: ./.github/workflows/build-wheel.yml with: host-platform: ${{ matrix.host-platform }} - cuda-version: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} - prev-cuda-version: ${{ needs.ci-vars.outputs.CUDA_PREV_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} workplan: ${{ needs.detect-changes.outputs.workplan }} - python-versions: ${{ needs.ci-vars.outputs.PYTHON_SUPPORTED }} + python-versions: ${{ needs.detect-changes.outputs.python-supported }} # Windows ARM64 is available starting with CUDA 13.4. Build only the current # CUDA major because no prior-major toolkit or wheel artifacts exist. build-windows-arm64: needs: - - ci-vars - should-skip - detect-changes - name: Build win-arm64, CUDA ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + name: Build ${{ fromJSON(needs.detect-changes.outputs.workplan).labels.build_current }} (win-arm64) if: ${{ github.repository_owner == 'nvidia' && !fromJSON(needs.should-skip.outputs.skip) && !fromJSON(needs.should-skip.outputs.doc-only) && - needs.ci-vars.outputs.WINDOWS_ARM64_SUPPORTED == 'true' && + needs.detect-changes.outputs.windows-arm64-supported == 'true' && + fromJSON(needs.detect-changes.outputs.workplan).jobs.windows_arm64_build && fromJSON(needs.detect-changes.outputs.workplan).jobs.platforms.windows }} permissions: actions: read @@ -470,11 +490,10 @@ jobs: uses: ./.github/workflows/build-wheel.yml with: host-platform: win-arm64 - cuda-version: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} - prev-cuda-version: ${{ needs.ci-vars.outputs.CUDA_PREV_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} workplan: ${{ needs.detect-changes.outputs.workplan }} - single-cuda-major: ${{ needs.ci-vars.outputs.WINDOWS_ARM64_SINGLE_CUDA_MAJOR == 'true' }} - python-versions: ${{ needs.ci-vars.outputs.PYTHON_SUPPORTED }} + single-cuda-major: ${{ needs.detect-changes.outputs.windows-arm64-single-cuda-major == 'true' }} + python-versions: ${{ needs.detect-changes.outputs.python-supported }} # NOTE: test-sdist jobs are split by platform (mirroring build-* and test-wheel-*) # so platform-specific sources (e.g. cuda_bindings_13/*_windows.pyx selected by @@ -483,12 +502,15 @@ jobs: # - host-platform value # - uses: (test-sdist-linux.yml vs test-sdist-windows.yml) test-sdist-linux: + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.detect-changes.outputs.bindings-config).package_roots }} needs: - - ci-vars - should-skip - detect-changes - build-linux-64 - name: Test sdist linux-64 + name: Sdist ${{ fromJSON(needs.detect-changes.outputs.workplan).labels.sdist[matrix.package.package_root] }} (linux-64) if: ${{ github.repository_owner == 'nvidia' && !fromJSON(needs.should-skip.outputs.skip) && !fromJSON(needs.should-skip.outputs.doc-only) && @@ -500,18 +522,21 @@ jobs: uses: ./.github/workflows/test-sdist-linux.yml with: host-platform: linux-64 - cuda-version: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + bindings-package: ${{ toJSON(matrix.package) }} workplan: ${{ needs.detect-changes.outputs.workplan }} # See test-sdist-linux for why sdist test jobs are split by platform. test-sdist-windows: + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.detect-changes.outputs.bindings-config).package_roots }} needs: - - ci-vars - should-skip - detect-changes - build-linux-64 - build-windows - name: Test sdist win-64 + name: Sdist ${{ fromJSON(needs.detect-changes.outputs.workplan).labels.sdist[matrix.package.package_root] }} (win-64) if: ${{ github.repository_owner == 'nvidia' && !fromJSON(needs.should-skip.outputs.skip) && !fromJSON(needs.should-skip.outputs.doc-only) && @@ -523,7 +548,7 @@ jobs: uses: ./.github/workflows/test-sdist-windows.yml with: host-platform: win-64 - cuda-version: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + bindings-package: ${{ toJSON(matrix.package) }} workplan: ${{ needs.detect-changes.outputs.workplan }} # NOTE: Test jobs are split by platform for the same reason as build jobs (see @@ -545,7 +570,6 @@ jobs: actions: read contents: read # This is required for actions/checkout needs: - - ci-vars - should-skip - detect-changes - build-linux-64 @@ -554,7 +578,7 @@ jobs: with: build-type: pull-request host-platform: ${{ matrix.host-platform }} - build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} nruns: ${{ (github.event_name == 'schedule' && 5) || 1}} workplan: ${{ needs.detect-changes.outputs.workplan }} @@ -573,7 +597,6 @@ jobs: actions: read contents: read # This is required for actions/checkout needs: - - ci-vars - should-skip - detect-changes - build-linux-64 @@ -583,7 +606,7 @@ jobs: with: build-type: pull-request host-platform: ${{ matrix.host-platform }} - build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} nruns: ${{ (github.event_name == 'schedule' && 5) || 1}} workplan: ${{ needs.detect-changes.outputs.workplan }} @@ -602,7 +625,6 @@ jobs: actions: read contents: read # This is required for actions/checkout needs: - - ci-vars - should-skip - detect-changes - build-linux-64 @@ -612,7 +634,7 @@ jobs: with: build-type: pull-request host-platform: ${{ matrix.host-platform }} - build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }} + bindings-config: ${{ needs.detect-changes.outputs.bindings-config }} nruns: ${{ (github.event_name == 'schedule' && 5) || 1}} workplan: ${{ needs.detect-changes.outputs.workplan }} @@ -625,11 +647,16 @@ jobs: contents: write pull-requests: write needs: - - ci-vars - build-linux-64 secrets: inherit uses: ./.github/workflows/build-docs.yml with: + # Bare cuda.bindings tags intentionally produce only their matching + # cuda.bindings and cuda-python artifacts. Keep tag docs on that focused + # artifact set; component-specific release runs build the other docs. + component: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && 'cuda-bindings' || 'all' }} + git-tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + run-id: ${{ github.run_id }} is-release: ${{ github.ref_type == 'tag' }} checks: @@ -637,7 +664,6 @@ jobs: if: ${{ always() && github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest needs: - - ci-vars - should-skip - detect-changes - build-linux-64 @@ -674,7 +700,8 @@ jobs: doc_only="${{ needs.should-skip.outputs.doc-only }}" linux_selected="${{ needs.detect-changes.outputs.workplan && fromJSON(needs.detect-changes.outputs.workplan).jobs.platforms.linux || false }}" windows_selected="${{ needs.detect-changes.outputs.workplan && fromJSON(needs.detect-changes.outputs.workplan).jobs.platforms.windows || false }}" - windows_arm64_supported="${{ needs.ci-vars.outputs.WINDOWS_ARM64_SUPPORTED }}" + windows_arm64_supported="${{ needs.detect-changes.outputs.windows-arm64-supported }}" + windows_arm64_selected="${{ needs.detect-changes.outputs.workplan && fromJSON(needs.detect-changes.outputs.workplan).jobs.windows_arm64_build || false }}" build_selected="${{ needs.detect-changes.outputs.workplan && fromJSON(needs.detect-changes.outputs.workplan).jobs.sdist_tests || false }}" run_core_api_check="${{ needs.detect-changes.outputs.workplan && fromJSON(needs.detect-changes.outputs.workplan).jobs.core_api_checks || false }}" is_pr="${{ startsWith(github.ref_name, 'pull-request/') }}" @@ -690,7 +717,6 @@ jobs: } # Control jobs, the universal Linux build, and docs always run. - check_result "ci-vars" "success" check_result "should-skip" "success" check_result "detect-changes" "success" check_result "build-linux-64" "success" @@ -709,7 +735,7 @@ jobs: check_result "build-windows" "$windows_expected" windows_arm64_expected="skipped" - if [[ "$windows_arm64_supported" == "true" ]]; then + if [[ "$windows_arm64_supported" == "true" && "$windows_arm64_selected" == "true" ]]; then windows_arm64_expected="$windows_expected" fi check_result "build-windows-arm64" "$windows_arm64_expected" diff --git a/.github/workflows/test-sdist-linux.yml b/.github/workflows/test-sdist-linux.yml index 6e38032edd2..59a3a7a165d 100644 --- a/.github/workflows/test-sdist-linux.yml +++ b/.github/workflows/test-sdist-linux.yml @@ -8,13 +8,13 @@ on: host-platform: required: true type: string - cuda-version: + bindings-package: + description: Normalized CUDA bindings package JSON. required: true type: string workplan: - description: JSON workplan. An empty value builds everything. - required: false - default: "" + description: JSON workplan. + required: true type: string defaults: @@ -27,13 +27,17 @@ permissions: jobs: test-sdist: - name: Test sdist builds - if: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).jobs.sdist_tests }} + name: Sdist ${{ fromJSON(inputs.workplan).labels.sdist[fromJSON(inputs.bindings-package).package_root] }} + if: ${{ fromJSON(inputs.workplan).jobs.sdist_package_roots[fromJSON(inputs.bindings-package).package_root] }} env: - BUILD_PATHFINDER: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.pathfinder.needs_build }} - BUILD_BINDINGS: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.bindings.needs_build }} - BUILD_CORE: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.core.needs_build }} - BUILD_PYTHON: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.python.needs_build }} + BINDINGS_ROOT: ${{ fromJSON(inputs.bindings-package).package_root }} + CUDA_BUILD_VERSION: ${{ fromJSON(inputs.bindings-package).toolkit_version }} + CUDA_PYTHON_BUILD_MAJOR: ${{ fromJSON(inputs.bindings-package).cuda_major }} + BUILD_PATHFINDER: ${{ fromJSON(inputs.workplan).modules.pathfinder.needs_build }} + BUILD_BINDINGS: ${{ fromJSON(inputs.workplan).modules.bindings.package_roots[fromJSON(inputs.bindings-package).package_root].needs_build }} + BUILD_CORE: ${{ fromJSON(inputs.workplan).modules.core.cuda_majors[fromJSON(inputs.bindings-package).cuda_variant].needs_build }} + BUILD_CUDA_PYTHON: ${{ fromJSON(inputs.workplan).modules.python.package_roots[fromJSON(inputs.bindings-package).package_root].needs_build }} + PATHFINDER_SOURCE: ${{ fromJSON(inputs.workplan).sources.pathfinder }} timeout-minutes: 60 runs-on: linux-amd64-cpu8 steps: @@ -54,8 +58,14 @@ jobs: - name: Harden pip uses: ./.github/actions/harden-pip - - name: Install build tools - run: python -m pip install build + - name: Install build and CI tools + run: python -m pip install build -e ./ci + + - name: Prepare local maintenance SCM milestone + if: ${{ fromJSON(inputs.bindings-package).release_status == 'maintenance' }} + run: | + python -m ci.tools.bindings_scm_version \ + --package-root "${BINDINGS_ROOT}" --sha "${{ github.sha }}" # Pure Python packages -- no CTK needed. - name: Build cuda.pathfinder sdist and wheel-from-sdist @@ -65,13 +75,14 @@ jobs: pip wheel --no-deps --wheel-dir cuda_pathfinder/dist cuda_pathfinder/dist/*.tar.gz - name: Build cuda-python sdist and wheel-from-sdist - if: ${{ env.BUILD_PYTHON == 'true' }} + if: ${{ env.BUILD_CUDA_PYTHON == 'true' }} run: | python -m build --sdist cuda_python/ pip wheel --no-deps --wheel-dir cuda_python/dist cuda_python/dist/*.tar.gz - name: Download cuda.pathfinder wheel - if: ${{ env.BUILD_PATHFINDER != 'true' && (env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true') }} + if: ${{ env.BUILD_PATHFINDER != 'true' && (env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true') && + env.PATHFINDER_SOURCE == 'artifact' }} uses: ./.github/actions/download-artifact-retry with: name: cuda-pathfinder-wheel @@ -81,11 +92,21 @@ jobs: if: ${{ env.BUILD_BINDINGS == 'true' }} run: | pathfinder_wheels=(cuda_pathfinder/dist/cuda_pathfinder-*.whl) - test "${#pathfinder_wheels[@]}" -eq 1 - test -f "${pathfinder_wheels[0]}" mkdir -p wheel-constraints - pathfinder_uri="file://$(realpath "${pathfinder_wheels[0]}")" - printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" | tee wheel-constraints/cuda-bindings.txt + if [[ "${PATHFINDER_SOURCE}" == "artifact" ]]; then + if [[ "${#pathfinder_wheels[@]}" -ne 1 || ! -f "${pathfinder_wheels[0]}" ]]; then + echo "Expected exactly one cuda-pathfinder wheel in cuda_pathfinder/dist" >&2 + exit 1 + fi + pathfinder_uri="file://$(realpath "${pathfinder_wheels[0]}")" + printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" > wheel-constraints/cuda-bindings.txt + elif [[ "${PATHFINDER_SOURCE}" == "published" ]]; then + : > wheel-constraints/cuda-bindings.txt + else + echo "Error: PATHFINDER_SOURCE must be artifact or published, got ${PATHFINDER_SOURCE}" >&2 + exit 1 + fi + cat wheel-constraints/cuda-bindings.txt # Cython packages need CTK + sccache. # The env vars ACTIONS_CACHE_SERVICE_V2, ACTIONS_RESULTS_URL, and ACTIONS_RUNTIME_TOKEN @@ -110,7 +131,7 @@ jobs: continue-on-error: false with: host-platform: ${{ inputs.host-platform }} - cuda-version: ${{ inputs.cuda-version }} + cuda-version: ${{ fromJSON(inputs.bindings-package).toolkit_version }} # cuda_bindings_13/setup.py parses CUDA headers at import time, so CUDA_PATH # (set by fetch_ctk) must be available for both sdist and wheel builds. @@ -122,22 +143,22 @@ jobs: export CXX="sccache c++" export PIP_BUILD_CONSTRAINT="$(pwd)/wheel-constraints/cuda-bindings.txt" export PIP_CONSTRAINT="${PIP_BUILD_CONSTRAINT}" - python -m build --sdist cuda_bindings_13/ - pip wheel --no-deps --wheel-dir cuda_bindings_13/dist cuda_bindings_13/dist/*.tar.gz + python -m build --sdist "${BINDINGS_ROOT}/" + pip wheel --no-deps --wheel-dir "${BINDINGS_ROOT}/dist" "${BINDINGS_ROOT}"/dist/*.tar.gz - name: Download cuda.bindings wheel if: ${{ env.BUILD_BINDINGS != 'true' && env.BUILD_CORE == 'true' }} uses: ./.github/actions/download-artifact-retry with: - name: cuda-bindings-python312-cuda${{ inputs.cuda-version }}-${{ inputs.host-platform }}-${{ github.sha }} - path: cuda_bindings_13/dist + name: cuda-bindings-python312-cuda${{ fromJSON(inputs.bindings-package).toolkit_version }}-${{ inputs.host-platform }}-${{ github.sha }} + path: ${{ env.BINDINGS_ROOT }}/dist - name: Constrain cuda.core to the local cuda.bindings wheel if: ${{ env.BUILD_CORE == 'true' }} run: | - CUDA_MAJOR="$(echo '${{ inputs.cuda-version }}' | cut -d. -f1)" + CUDA_MAJOR='${{ fromJSON(inputs.bindings-package).cuda_major }}' pathfinder_wheels=(cuda_pathfinder/dist/cuda_pathfinder-*.whl) - bindings_wheels=(cuda_bindings_13/dist/cuda_bindings-"${CUDA_MAJOR}".*.whl) + bindings_wheels=("${BINDINGS_ROOT}"/dist/cuda_bindings-"${CUDA_MAJOR}".*.whl) test "${#pathfinder_wheels[@]}" -eq 1 test "${#bindings_wheels[@]}" -eq 1 test -f "${pathfinder_wheels[0]}" @@ -157,7 +178,7 @@ jobs: if: ${{ env.BUILD_CORE == 'true' }} run: | export CUDA_PYTHON_PARALLEL_LEVEL=$(nproc) - export CUDA_CORE_BUILD_MAJOR="$(echo '${{ inputs.cuda-version }}' | cut -d. -f1)" + export CUDA_CORE_BUILD_MAJOR='${{ fromJSON(inputs.bindings-package).cuda_major }}' export CC="sccache cc" export CXX="sccache c++" export PIP_BUILD_CONSTRAINT="$(pwd)/wheel-constraints/cuda-core.txt" diff --git a/.github/workflows/test-sdist-windows.yml b/.github/workflows/test-sdist-windows.yml index 00a2c07041a..63291b221bc 100644 --- a/.github/workflows/test-sdist-windows.yml +++ b/.github/workflows/test-sdist-windows.yml @@ -14,13 +14,13 @@ on: host-platform: required: true type: string - cuda-version: + bindings-package: + description: Normalized CUDA bindings package JSON. required: true type: string workplan: - description: JSON workplan. An empty value builds everything. - required: false - default: "" + description: JSON workplan. + required: true type: string defaults: @@ -33,13 +33,17 @@ permissions: jobs: test-sdist: - name: Test sdist builds - if: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).jobs.sdist_tests }} + name: Sdist ${{ fromJSON(inputs.workplan).labels.sdist[fromJSON(inputs.bindings-package).package_root] }} + if: ${{ fromJSON(inputs.workplan).jobs.sdist_package_roots[fromJSON(inputs.bindings-package).package_root] }} env: - BUILD_PATHFINDER: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.pathfinder.needs_build }} - BUILD_BINDINGS: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.bindings.needs_build }} - BUILD_CORE: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.core.needs_build }} - BUILD_PYTHON: ${{ inputs.workplan == '' || fromJSON(inputs.workplan).modules.python.needs_build }} + BINDINGS_ROOT: ${{ fromJSON(inputs.bindings-package).package_root }} + CUDA_BUILD_VERSION: ${{ fromJSON(inputs.bindings-package).toolkit_version }} + CUDA_PYTHON_BUILD_MAJOR: ${{ fromJSON(inputs.bindings-package).cuda_major }} + BUILD_PATHFINDER: ${{ fromJSON(inputs.workplan).modules.pathfinder.needs_build }} + BUILD_BINDINGS: ${{ fromJSON(inputs.workplan).modules.bindings.package_roots[fromJSON(inputs.bindings-package).package_root].needs_build }} + BUILD_CORE: ${{ fromJSON(inputs.workplan).modules.core.cuda_majors[fromJSON(inputs.bindings-package).cuda_variant].needs_build }} + BUILD_CUDA_PYTHON: ${{ fromJSON(inputs.workplan).modules.python.package_roots[fromJSON(inputs.bindings-package).package_root].needs_build }} + PATHFINDER_SOURCE: ${{ fromJSON(inputs.workplan).sources.pathfinder }} timeout-minutes: 60 runs-on: windows-2022 steps: @@ -67,8 +71,15 @@ jobs: if: ${{ env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true' }} uses: step-security/msvc-dev-cmd@22c98154b708dbd743e6f27a933cf6ceba3305c4 # v1.13.1 - - name: Install build tools - run: python -m pip install build + - name: Install build and CI tools + run: python -m pip install build -e ./ci + + - name: Prepare local maintenance SCM milestone + if: ${{ fromJSON(inputs.bindings-package).release_status == 'maintenance' }} + shell: bash --noprofile --norc -xeuo pipefail {0} + run: | + python -m ci.tools.bindings_scm_version \ + --package-root "${BINDINGS_ROOT}" --sha "${{ github.sha }}" # Pure Python packages -- no CTK needed. - name: Build cuda.pathfinder sdist and wheel-from-sdist @@ -78,13 +89,14 @@ jobs: pip wheel --no-deps --wheel-dir cuda_pathfinder/dist cuda_pathfinder/dist/*.tar.gz - name: Build cuda-python sdist and wheel-from-sdist - if: ${{ env.BUILD_PYTHON == 'true' }} + if: ${{ env.BUILD_CUDA_PYTHON == 'true' }} run: | python -m build --sdist cuda_python/ pip wheel --no-deps --wheel-dir cuda_python/dist cuda_python/dist/*.tar.gz - name: Download cuda.pathfinder wheel - if: ${{ env.BUILD_PATHFINDER != 'true' && (env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true') }} + if: ${{ env.BUILD_PATHFINDER != 'true' && (env.BUILD_BINDINGS == 'true' || env.BUILD_CORE == 'true') && + env.PATHFINDER_SOURCE == 'artifact' }} uses: ./.github/actions/download-artifact-retry with: name: cuda-pathfinder-wheel @@ -94,11 +106,21 @@ jobs: if: ${{ env.BUILD_BINDINGS == 'true' }} run: | pathfinder_wheels=(cuda_pathfinder/dist/cuda_pathfinder-*.whl) - test "${#pathfinder_wheels[@]}" -eq 1 - test -f "${pathfinder_wheels[0]}" mkdir -p wheel-constraints - pathfinder_uri="file:///$(cygpath -am "${pathfinder_wheels[0]}")" - printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" | tee wheel-constraints/cuda-bindings.txt + if [[ "${PATHFINDER_SOURCE}" == "artifact" ]]; then + if [[ "${#pathfinder_wheels[@]}" -ne 1 || ! -f "${pathfinder_wheels[0]}" ]]; then + echo "Expected exactly one cuda-pathfinder wheel in cuda_pathfinder/dist" >&2 + exit 1 + fi + pathfinder_uri="file:///$(cygpath -am "${pathfinder_wheels[0]}")" + printf 'cuda-pathfinder @ %s\n' "${pathfinder_uri}" > wheel-constraints/cuda-bindings.txt + elif [[ "${PATHFINDER_SOURCE}" == "published" ]]; then + : > wheel-constraints/cuda-bindings.txt + else + echo "Error: PATHFINDER_SOURCE must be artifact or published, got ${PATHFINDER_SOURCE}" >&2 + exit 1 + fi + cat wheel-constraints/cuda-bindings.txt # Cython packages need CTK. No sccache on Windows (this is a correctness # smoke test, not a production build; see build-wheel.yml which also @@ -109,7 +131,7 @@ jobs: continue-on-error: false with: host-platform: ${{ inputs.host-platform }} - cuda-version: ${{ inputs.cuda-version }} + cuda-version: ${{ fromJSON(inputs.bindings-package).toolkit_version }} # cuda_bindings_13/setup.py parses CUDA headers at import time, so CUDA_PATH # (set by fetch_ctk) must be available for both sdist and wheel builds. @@ -121,22 +143,22 @@ jobs: export CUDA_PYTHON_PARALLEL_LEVEL=$(nproc) export PIP_BUILD_CONSTRAINT="$(cygpath -w "$(pwd)/wheel-constraints/cuda-bindings.txt")" export PIP_CONSTRAINT="${PIP_BUILD_CONSTRAINT}" - python -m build --sdist cuda_bindings_13/ - pip wheel --no-deps --wheel-dir cuda_bindings_13/dist cuda_bindings_13/dist/*.tar.gz + python -m build --sdist "${BINDINGS_ROOT}/" + pip wheel --no-deps --wheel-dir "${BINDINGS_ROOT}/dist" "${BINDINGS_ROOT}"/dist/*.tar.gz - name: Download cuda.bindings wheel if: ${{ env.BUILD_BINDINGS != 'true' && env.BUILD_CORE == 'true' }} uses: ./.github/actions/download-artifact-retry with: - name: cuda-bindings-python312-cuda${{ inputs.cuda-version }}-${{ inputs.host-platform }}-${{ github.sha }} - path: cuda_bindings_13/dist + name: cuda-bindings-python312-cuda${{ fromJSON(inputs.bindings-package).toolkit_version }}-${{ inputs.host-platform }}-${{ github.sha }} + path: ${{ env.BINDINGS_ROOT }}/dist - name: Constrain cuda.core to the local cuda.bindings wheel if: ${{ env.BUILD_CORE == 'true' }} run: | - CUDA_MAJOR="$(echo '${{ inputs.cuda-version }}' | cut -d. -f1)" + CUDA_MAJOR='${{ fromJSON(inputs.bindings-package).cuda_major }}' pathfinder_wheels=(cuda_pathfinder/dist/cuda_pathfinder-*.whl) - bindings_wheels=(cuda_bindings_13/dist/cuda_bindings-"${CUDA_MAJOR}".*.whl) + bindings_wheels=("${BINDINGS_ROOT}"/dist/cuda_bindings-"${CUDA_MAJOR}".*.whl) test "${#pathfinder_wheels[@]}" -eq 1 test "${#bindings_wheels[@]}" -eq 1 test -f "${pathfinder_wheels[0]}" @@ -156,7 +178,7 @@ jobs: if: ${{ env.BUILD_CORE == 'true' }} run: | export CUDA_PYTHON_PARALLEL_LEVEL=$(nproc) - export CUDA_CORE_BUILD_MAJOR="$(echo '${{ inputs.cuda-version }}' | cut -d. -f1)" + export CUDA_CORE_BUILD_MAJOR='${{ fromJSON(inputs.bindings-package).cuda_major }}' export PIP_BUILD_CONSTRAINT="$(cygpath -w "$(pwd)/wheel-constraints/cuda-core.txt")" export PIP_CONSTRAINT="${PIP_BUILD_CONSTRAINT}" python -m build --sdist cuda_core/ diff --git a/ci/ci-pipeline.mmd b/ci/ci-pipeline.mmd index b085fa869bc..6dcce24e4f9 100644 --- a/ci/ci-pipeline.mmd +++ b/ci/ci-pipeline.mmd @@ -25,16 +25,18 @@ config: flowchart TB subgraph TRIGGER["TRIGGER EVENTS"] direction LR - T1["Push to main branch"] ~~~ T2["Pull request"] ~~~ T3["Manual workflow dispatch"] + T1["Main or approved pull request"] ~~~ T2["Immutable package tag"] ~~~ T3["Manual workflow dispatch"] end + PLAN["Select affected packages and dependencies
A package tag selects its release line
Registry: ci/versions.yml"] + subgraph BUILD["BUILD STAGE"] direction TB subgraph BUILD_PLATFORMS["Parallel Platform Builds"] direction LR - B1["linux-64
(Self-hosted)"] ~~~ B2["linux-aarch64
(Self-hosted)"] ~~~ B3["win-64
(GitHub-hosted)"] + B1["Linux x86_64
(Self-hosted)"] ~~~ B2["Linux ARM64
(Self-hosted)"] ~~~ B3["Windows x86_64 / ARM64
(GitHub-hosted)"] end - BUILD_DETAILS["Python versions: 3.11, 3.12, 3.13, 3.14, 3.15
CUDA version: 13.0.0 (build-time)
Components: cuda-core, cuda-bindings,
cuda-pathfinder, cuda-python"] + BUILD_DETAILS["Python and platform matrices: ci.yml
Bindings toolkits and roots: registry
Build selected bindings, Core,
Pathfinder and metapackage artifacts"] end subgraph ARTIFACTS["ARTIFACT STORAGE"] @@ -51,22 +53,22 @@ flowchart TB direction TB subgraph TEST_PLATFORMS["Parallel Platform Tests"] direction LR - TS1["linux-64
(Self-hosted)"] ~~~ TS2["linux-aarch64
(Self-hosted)"] ~~~ TS3["win-64
(GitHub-hosted)"] + TS1["linux-64
(Self-hosted)"] ~~~ TS2["linux-aarch64
(Self-hosted)"] ~~~ TS3["win-64
(Self-hosted GPU)"] end - TEST_DETAILS["Download wheels from artifacts
Test against multiple CUDA runtime versions
Run Python unit tests, Cython tests, and examples"] - ARTIFACT_FLOWS["Artifact Flows:
cuda-pathfinder: main to backport
cuda-bindings: backport to main"] + TEST_DETAILS["Test sources match wheel build revision
Test against multiple CUDA runtime versions
Run Python unit tests, Cython tests, and examples"] + ARTIFACT_FLOWS["Both bindings roots build from this source tree
Reuse validated baseline for unaffected packages
Bindings tag: selected line and published Pathfinder
Core older-toolkit tests: published bindings floor"] end subgraph RELEASE["RELEASE PIPELINE"] direction TB subgraph RELEASE_STAGES["Sequential Release Steps"] direction LR - R1["Validation
Artifact integrity
Git tag verification"] --> R2["Publishing
PyPI/TestPyPI
Component or all releases"] --> R3["Documentation
GitHub Pages
Release notes"] + R1["Validation
Artifact integrity
Git tag verification"] --> R2["Publishing
PyPI/TestPyPI
Selected component"] --> R3["Documentation
GitHub Pages
Release notes"] end - RELEASE_DETAILS["Manual workflow dispatch with run ID
Supports individual component or full releases"] + RELEASE_DETAILS["Manual dry run for exact tag and successful CI run
Explicit publication for selected component
Bindings release does not require a Core release"] end - TRIGGER --> BUILD + TRIGGER --> PLAN --> BUILD BUILD -. "wheel upload" .-> ARTIFACTS ARTIFACTS --> TEST TEST --> RELEASE @@ -90,7 +92,7 @@ flowchart TB class TRIGGER triggerStyle class T1,T2,T3 triggerNodeStyle class BUILD,BUILD_PLATFORMS buildStyle - class B1,B2,B3,BUILD_DETAILS buildNodeStyle + class PLAN,B1,B2,B3,BUILD_DETAILS buildNodeStyle class ARTIFACTS,GITHUB_ARTIFACTS,GITHUB_CACHE artifactStyle class GA1,GC1 artifactNodeStyle class TEST,TEST_PLATFORMS testStyle diff --git a/ci/tools/compute_ci_plan.py b/ci/tools/compute_ci_plan.py index ea03abc31a9..5f432797821 100644 --- a/ci/tools/compute_ci_plan.py +++ b/ci/tools/compute_ci_plan.py @@ -12,25 +12,19 @@ import json import subprocess from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING, Any + +from .bindings_config import BindingsConfigError, load_config + +if TYPE_CHECKING: + from .bindings_config import BindingsConfig REPO_ROOT = Path(__file__).resolve().parents[2] -MODULES = ("pathfinder", "bindings", "core", "python") PLATFORMS = ("linux", "windows") -PACKAGE_MODULES = { - "cuda_pathfinder": "pathfinder", - "cuda_bindings_13": "bindings", - "cuda_core": "core", - "cuda_python": "python", -} - -# Source changes have different build and test consumers. In particular, -# cuda-python source needs a same-version bindings wheel, while a core-only -# change can reuse the baseline cuda-python wheel. -SOURCE_IMPACT = { - "pathfinder": (set(MODULES), set(MODULES)), - "bindings": ({"bindings", "core", "python"}, {"bindings", "core", "python"}), - "core": ({"core"}, {"core", "python"}), - "python": ({"bindings", "python"}, {"python"}), +PACKAGE_TARGETS: dict[str, tuple[str, None]] = { + "cuda_pathfinder": ("pathfinder", None), + "cuda_core": ("core", None), + "cuda_python": ("python", None), } IGNORED_BASENAMES = {"AGENTS.md", "CLAUDE.md", "pixi.lock", "pixi.toml"} @@ -58,22 +52,72 @@ "ci/tools/setup-sanitizer": "linux", } +Target = tuple[str, str | None] + def compute_workplan( - paths: list[str], *, + bindings_config: BindingsConfig, + paths: list[str], + linked_paths: set[str], merge_base: str, baseline_run_id: str, - linked_paths: set[str] | None = None, + release_tag: str, ) -> dict[str, object]: """Return the final CI decisions for the supplied changed paths.""" - linked_paths = linked_paths or set() - source_changes: set[str] = set() - test_changes: set[str] = set() + packages = bindings_config.package_roots + package_roots = tuple(package.package_root for package in packages) + cuda_variants = tuple(package.cuda_variant for package in packages) + + bindings_targets = frozenset(("bindings", package_root) for package_root in package_roots) + core_targets = frozenset(("core", variant) for variant in cuda_variants) + python_targets = frozenset(("python", package_root) for package_root in package_roots) + all_targets = frozenset({("pathfinder", None), *bindings_targets, *core_targets, *python_targets}) + + package_targets = {PurePosixPath(package.package_root): ("bindings", package.package_root) for package in packages} + package_targets.update((PurePosixPath(source_dir), target) for source_dir, target in PACKAGE_TARGETS.items()) + + # Source changes have different build/test consumers. Core-only changes can + # reuse baseline cuda-python wheels; cuda-python changes cannot reuse bindings. + source_impacts: dict[Target, tuple[frozenset[Target], frozenset[Target], frozenset[str]]] = { + ("pathfinder", None): (all_targets, all_targets, frozenset(package_roots)), + ("core", None): (core_targets, frozenset({*core_targets, *python_targets}), frozenset(package_roots)), + ("python", None): ( + frozenset({*bindings_targets, *python_targets}), + python_targets, + frozenset(package_roots), + ), + **{ + ("bindings", package.package_root): ( + frozenset( + { + ("bindings", package.package_root), + ("python", package.package_root), + *core_targets, + } + ), + frozenset( + { + ("bindings", package.package_root), + ("core", package.cuda_variant), + ("python", package.package_root), + } + ), + frozenset({package.package_root}), + ) + for package in packages + }, + } + + source_changes: set[tuple[str, str | None]] = set() + test_changes: set[tuple[str, str | None]] = set() test_platforms: set[str] = set() - force_all = not merge_base or not baseline_run_id + release_package = bindings_config.match_tag(release_tag) if release_tag else None + if release_tag.startswith("v") and release_package is None: + raise BindingsConfigError(f"no configured CUDA bindings package root matches release tag: {release_tag!r}") + force_all = release_package is None and (bool(release_tag) or not merge_base or not baseline_run_id) - if not force_all: + if release_package is None and not force_all: for path in paths: path_parts = PurePosixPath(path).parts if not path_parts: @@ -92,9 +136,20 @@ def compute_workplan( if path_parts[0] == ".github" or path_parts[-1] in IGNORED_BASENAMES: continue - module = PACKAGE_MODULES.get(path_parts[0]) - if module is not None and len(path_parts) > 1: - relative = path_parts[1:] + target_match = next( + ( + (root, target) + for root, target in sorted( + package_targets.items(), key=lambda item: len(item[0].parts), reverse=True + ) + if len(path_parts) > len(root.parts) and path_parts[: len(root.parts)] == root.parts + ), + None, + ) + if target_match is not None: + root, target = target_match + module, _ = target + relative = path_parts[len(root.parts) :] if relative[0] == "docs": continue if ( @@ -102,16 +157,21 @@ def compute_workplan( or relative[0] == "examples" or (module == "core" and relative == ("pytest.ini",)) ): - test_changes.add(module) + if module == "core": + test_changes.update(core_targets) + elif module == "python": + test_changes.update(python_targets) + else: + test_changes.add(target) elif PurePosixPath(path).suffix in IGNORED_SUFFIXES and path not in linked_paths: continue else: - source_changes.add(module) + source_changes.add(target) continue is_test_path = any(part in {"test", "tests"} for part in path_parts[:-1]) if is_test_path: - test_changes.update(MODULES) + test_changes.update(all_targets) elif ( path in IGNORED_PATHS or PurePosixPath(path).suffix in IGNORED_SUFFIXES @@ -119,48 +179,158 @@ def compute_workplan( ): continue elif path_parts[0] in {"benchmarks", "cuda_python_test_helpers"}: - test_changes.update(MODULES) + test_changes.update(all_targets) else: force_all = True break - if force_all: - builds = set(MODULES) - tests = set(MODULES) + if release_package is not None: + builds = { + ("bindings", release_package.package_root), + ("python", release_package.package_root), + } + tests = set(builds) + test_platforms = set(PLATFORMS) + sdist_package_roots = {release_package.package_root} + elif force_all: + builds = set(all_targets) + tests = set(all_targets) test_platforms = set(PLATFORMS) + sdist_package_roots = set(package_roots) else: - builds: set[str] = set() - tests = set(MODULES) if test_platforms else set(test_changes) - for module in source_changes: - build_impact, test_impact = SOURCE_IMPACT[module] + builds: set[tuple[str, str | None]] = set() + tests = set(all_targets) if test_platforms else set(test_changes) + sdist_package_roots: set[str] = set() + for target in source_changes: + build_impact, test_impact, sdist_impact = source_impacts[target] builds.update(build_impact) tests.update(test_impact) + sdist_package_roots.update(sdist_impact) if source_changes or test_changes: test_platforms.update(PLATFORMS) - modules = { - module: { - "needs_build": module in builds, - "needs_test": module in tests, + def flags(module: str, selectors: tuple[str | None, ...]) -> dict[str, bool]: + return { + "needs_build": any((module, selector) in builds for selector in selectors), + "needs_test": any((module, selector) in tests for selector in selectors), + } + + modules: dict[str, dict[str, object]] = {"pathfinder": flags("pathfinder", (None,))} + for module in ("bindings", "python"): + package_decisions = {package.package_root: flags(module, (package.package_root,)) for package in packages} + modules[module] = { + **flags(module, package_roots), + "package_roots": package_decisions, } - for module in MODULES + + cuda_major_decisions = {variant: flags("core", (variant,)) for variant in cuda_variants} + modules["core"] = { + **flags("core", cuda_variants), + "cuda_majors": cuda_major_decisions, + } + + pathfinder_test = ("pathfinder", None) in tests + test_cuda_variants = { + package.cuda_variant + for package in packages + if pathfinder_test + or ("core", package.cuda_variant) in tests + or any((module, package.package_root) in tests for module in ("bindings", "python")) } + + def build_label(*, universal: bool, current_only: bool = False) -> str: + labels = [] + if universal and ("pathfinder", None) in builds: + labels.append("pathfinder") + for module in ("bindings", "core", "python"): + if module == "python" and not universal: + continue + for package in packages: + if current_only and package.release_status != "current": + continue + selector = package.cuda_variant if module == "core" else package.package_root + if (module, selector) in builds: + labels.append(f"{module}{package.cuda_major}") + return "+".join(labels) or "baseline wheels and test extensions" + return { + "labels": { + "build": build_label(universal=True), + "build_platform": build_label(universal=False), + "build_current": build_label(universal=False, current_only=True), + "sdist": { + package.package_root: "+".join( + [ + name + for selected, name in ( + (("pathfinder", None) in builds, "pathfinder"), + (("bindings", package.package_root) in builds, f"bindings{package.cuda_major}"), + (("core", package.cuda_variant) in builds, f"core{package.cuda_major}"), + (("python", package.package_root) in builds, f"python{package.cuda_major}"), + ) + if selected + ] + ) + or "excluded" + for package in packages + }, + }, "modules": modules, + "sources": { + # Focused bindings releases intentionally omit unrelated artifacts. + "pathfinder": "published" if release_package is not None else "artifact", + }, "jobs": { # These gates cover both optional artifact builds and wheel tests. "platforms": {platform: platform in test_platforms for platform in PLATFORMS}, - "sdist_tests": bool(builds), - "core_api_checks": force_all or "core" in source_changes, + "sdist_tests": bool(sdist_package_roots), + "windows_arm64_build": (bool(baseline_run_id) and release_package is None) + or any( + int(package.cuda_major) >= 13 + and (("bindings", package.package_root) in builds or ("core", package.cuda_variant) in builds) + for package in packages + ), + "core_api_checks": force_all or ("core", None) in source_changes, + "test_cuda_majors": {variant: variant in test_cuda_variants for variant in cuda_variants}, + "sdist_package_roots": { + package_root: package_root in sdist_package_roots for package_root in package_roots + }, }, "merge_base": merge_base, "baseline": { - "run_id": baseline_run_id if not force_all else "", - "sha": merge_base if not force_all else "", + "run_id": baseline_run_id if release_package is None and not force_all else "", + "sha": merge_base if release_package is None and not force_all else "", }, } +def format_workplan_summary(plan: dict[str, Any], config: BindingsConfig) -> str: + """Describe selected work and exact artifact reuse without hiding exclusions.""" + modules = plan["modules"] + baseline = plan["baseline"] + rows = [("cuda-pathfinder", "shared", modules["pathfinder"])] + for package in config.package_roots: + for module, name in (("bindings", "cuda-bindings"), ("python", "cuda-python")): + rows.append((name, package.cuda_major, modules[module]["package_roots"][package.package_root])) + rows.append(("cuda-core", package.cuda_major, modules["core"]["cuda_majors"][package.cuda_variant])) + lines = [ + "### CI workplan", + "", + "| Component | CUDA major | Wheel | Tests |", + "| --- | --- | --- | --- |", + ] + for component, major, flags in rows: + wheel = "build" if flags["needs_build"] else "reuse exact baseline" if baseline["run_id"] else "excluded" + tests = "required" if flags["needs_test"] else "excluded by plan" + lines.append(f"| {component} | {major} | {wheel} | {tests} |") + if baseline["run_id"]: + lines.extend(("", f"Reusable artifacts: run `{baseline['run_id']}` at `{baseline['sha']}`.")) + if plan["sources"]["pathfinder"] == "published": + lines.extend(("", "Focused bindings release: tests install published cuda-pathfinder; cuda-core is excluded.")) + lines.extend(("", "Windows ARM64 builds only supported toolkit majors; CUDA 12 has no Windows ARM64 toolkit.", "")) + return "\n".join(lines) + + def _git_output(*args: str) -> bytes: return subprocess.check_output( # noqa: S603 - argv is passed directly without a shell. ["git", *args], # noqa: S607 @@ -197,21 +367,58 @@ def _expand_linked_paths(paths: list[str], symlink_paths: list[str], *, root: Pa return expanded -def main() -> None: +def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser() parser.add_argument("--merge-base", default="") parser.add_argument("--baseline-run-id", default="") - args = parser.parse_args() + parser.add_argument("--release-tag", default="") + parser.add_argument( + "--github-output", + type=Path, + help="append normalized CI records to this GitHub output file", + ) + parser.add_argument( + "--github-step-summary", + type=Path, + help="append a formatted workplan to this GitHub step summary file", + ) + args = parser.parse_args(argv) + if args.github_step_summary is not None and args.github_output is None: + parser.error("--github-step-summary requires --github-output") - reusable_baseline = bool(args.merge_base and args.baseline_run_id) + config = load_config() + reusable_baseline = bool(args.merge_base and args.baseline_run_id and not args.release_tag) paths, linked_paths = _changed_paths(args.merge_base) if reusable_baseline else ([], set()) - plan = compute_workplan( - paths, - merge_base=args.merge_base, - baseline_run_id=args.baseline_run_id, - linked_paths=linked_paths, - ) - print(json.dumps(plan, separators=(",", ":"), sort_keys=True)) + try: + plan = compute_workplan( + bindings_config=config, + paths=paths, + linked_paths=linked_paths, + merge_base=args.merge_base, + baseline_run_id=args.baseline_run_id, + release_tag=args.release_tag, + ) + except BindingsConfigError as error: + parser.error(str(error)) + workplan = json.dumps(plan, separators=(",", ":"), sort_keys=True) + if args.github_output is None: + print(workplan) + return + + records = { + "bindings-config": config.to_json(), + "workplan": workplan, + } + with args.github_output.open("a", encoding="utf-8") as output: + for name, value in records.items(): + output.write(f"{name}={value}\n") + if args.github_step_summary is not None: + formatted = json.dumps(plan, indent=2, sort_keys=True) + with args.github_step_summary.open("a", encoding="utf-8") as summary: + summary.write(format_workplan_summary(plan, config)) + summary.write( + f"\n
Full workplan JSON\n\n```json\n{formatted}\n```\n
\n" + ) if __name__ == "__main__": diff --git a/ci/tools/download-wheels b/ci/tools/download-wheels index 58e8a213226..c945f0dd6b8 100755 --- a/ci/tools/download-wheels +++ b/ci/tools/download-wheels @@ -11,11 +11,13 @@ set -euo pipefail # Check required arguments if [[ $# -lt 3 ]]; then - echo "Usage: $0 [output-dir]" >&2 + echo "Usage: $0 [output-dir] [git-tag] [bindings-package-json]" >&2 echo " run-id: The GitHub Actions run ID containing the artifacts" >&2 echo " component: The component name pattern to download (e.g., cuda-core, cuda-bindings)" >&2 echo " repository: The GitHub repository (e.g., NVIDIA/cuda-python)" >&2 echo " output-dir: Optional output directory (default: ./dist)" >&2 + echo " git-tag: Optional release tag used to select its configured bindings package" >&2 + echo " bindings-package-json: Resolved package JSON; required for bindings/python release tags" >&2 exit 1 fi @@ -23,6 +25,8 @@ RUN_ID="$1" COMPONENT="$2" REPOSITORY="$3" OUTPUT_DIR="${4:-./dist}" +GIT_TAG="${5:-}" +RESOLVED_BINDINGS_PACKAGE_JSON="${6:-}" # Ensure we have a GitHub token if [[ -z "${GH_TOKEN:-}" ]]; then @@ -32,44 +36,106 @@ fi echo "Downloading wheels for component: $COMPONENT from run: $RUN_ID" -# Download component wheels using the same logic as release.yml +ARTIFACTS_DIR="$(mktemp -d)" +cleanup() { + rm -rf -- "$ARTIFACTS_DIR" +} +trap cleanup EXIT + +# Select artifacts before downloading so non-wheel test artifacts cannot enter +# the release staging directory. if [[ "$COMPONENT" == "all" ]]; then - # Download all component patterns - gh run download "$RUN_ID" -p "cuda-*" -R "$REPOSITORY" + ARTIFACT_PATTERN="cuda-*" else - gh run download "$RUN_ID" -p "${COMPONENT}*" -R "$REPOSITORY" + ARTIFACT_PATTERN="${COMPONENT}*" fi -# Create output directory -mkdir -p "$OUTPUT_DIR" +IS_BINDINGS_RELEASE=false +if [[ "$COMPONENT" != "all" \ + && "$COMPONENT" =~ ^cuda-(bindings|python)$ \ + && "$GIT_TAG" == v* ]]; then + IS_BINDINGS_RELEASE=true +fi -# Process downloaded artifacts -for p in cuda-* -do - if [[ ! -d "$p" ]]; then - continue +if [[ "$IS_BINDINGS_RELEASE" == true ]]; then + if [[ -z "$RESOLVED_BINDINGS_PACKAGE_JSON" ]]; then + echo "Error: resolved bindings-package JSON is required for $COMPONENT release tag $GIT_TAG" >&2 + exit 1 fi - - # exclude cython test artifacts - if [[ "${p}" == *-tests ]]; then - echo "Skipping test artifact: $p" - continue + if ! command -v jq >/dev/null 2>&1; then + echo "Error: jq is required for bindings release artifact routing" >&2 + exit 1 + fi + if ! TOOLKIT_VERSION="$(jq -er '.toolkit_version | select(type == "string")' \ + <<< "$RESOLVED_BINDINGS_PACKAGE_JSON")"; then + echo "Error: resolved bindings-package JSON has no string toolkit_version" >&2 + exit 1 fi + if [[ ! "$TOOLKIT_VERSION" =~ ^[1-9][0-9]*\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$ ]]; then + echo "Error: invalid toolkit_version in resolved bindings-package JSON: $TOOLKIT_VERSION" >&2 + exit 1 + fi +fi + +if ! ARTIFACT_NAMES="$(gh api --paginate \ + "repos/${REPOSITORY}/actions/runs/${RUN_ID}/artifacts?per_page=100" \ + --jq '.artifacts[] | select(.expired == false) | .name')"; then + echo "Error: failed to inspect artifacts for run $RUN_ID" >&2 + exit 1 +fi - # If we're not downloading "all", only process matching component - if [[ "$COMPONENT" != "all" && "$p" != ${COMPONENT}* ]]; then - continue +MATCHING_ARTIFACTS=() +if [[ "$IS_BINDINGS_RELEASE" == true && "$COMPONENT" == "cuda-python" ]]; then + ARTIFACT_NAME="cuda-python-wheel-cuda${TOOLKIT_VERSION}" + if grep -Fxq "$ARTIFACT_NAME" <<< "$ARTIFACT_NAMES"; then + MATCHING_ARTIFACTS+=("$ARTIFACT_NAME") fi +else + if [[ "$IS_BINDINGS_RELEASE" == true ]]; then + ARTIFACT_PATTERN="cuda-bindings-python*-cuda${TOOLKIT_VERSION}-*" + fi + while IFS= read -r artifact_name; do + # Release patterns contain only the validated toolkit version. Other + # patterns preserve the component matching accepted by this script. + # shellcheck disable=SC2053 + if [[ "$artifact_name" == $ARTIFACT_PATTERN && "$artifact_name" != *-tests ]]; then + MATCHING_ARTIFACTS+=("$artifact_name") + fi + done <<< "$ARTIFACT_NAMES" +fi - echo "Processing artifact: $p" - # Move wheel files to output directory - if [[ -d "$p" ]]; then - find "$p" -name "*.whl" -exec mv {} "$OUTPUT_DIR/" \; +if [[ ${#MATCHING_ARTIFACTS[@]} == 0 ]]; then + if [[ "$IS_BINDINGS_RELEASE" == true ]]; then + echo "Error: run $RUN_ID has no unexpired release artifact for CUDA $TOOLKIT_VERSION" >&2 + else + echo "Error: run $RUN_ID has no unexpired releasable artifact matching $ARTIFACT_PATTERN" >&2 fi + exit 1 +fi + +for artifact_name in "${MATCHING_ARTIFACTS[@]}"; do + gh run download "$RUN_ID" --name "$artifact_name" \ + -R "$REPOSITORY" --dir "$ARTIFACTS_DIR" done -# Clean up artifact directories -rm -rf cuda-* +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# `gh run download` extracts a sole selected artifact directly into --dir, +# while multiple artifacts get per-artifact subdirectories. Find wheels +# recursively so both layouts are handled identically. +MOVED_WHEELS=0 +while IFS= read -r -d '' wheel; do + relative_path="${wheel#"$ARTIFACTS_DIR"/}" + echo "Processing wheel: $relative_path" + mv "$wheel" "$OUTPUT_DIR/" + MOVED_WHEELS=$((MOVED_WHEELS + 1)) +done < <(find "$ARTIFACTS_DIR" -type f -name "*.whl" -print0) + +if [[ "$MOVED_WHEELS" == 0 ]]; then + echo "Error: selected artifacts contain no releasable wheels" >&2 + exit 1 +fi echo "Downloaded wheels to: $OUTPUT_DIR" ls -la "$OUTPUT_DIR" diff --git a/ci/tools/env-vars b/ci/tools/env-vars index 219c9db04f7..48da8700669 100755 --- a/ci/tools/env-vars +++ b/ci/tools/env-vars @@ -11,7 +11,13 @@ set -euo pipefail # Check if the script was called with exactly 1 argument if [[ ${#} -ne 1 ]]; then echo "Error: This script requires exactly 1 argument (the build mode). You provided ${#}" - echo "Usage: ${0} build_mode[build or test]" + echo "Usage: ${0} {build|test}" + exit 1 +fi +BUILD_MODE=${1} +if [[ "${BUILD_MODE}" != "build" && "${BUILD_MODE}" != "test" ]]; then + echo "Error: build mode must be build or test, got ${BUILD_MODE}" >&2 + echo "Usage: ${0} {build|test}" >&2 exit 1 fi @@ -29,16 +35,17 @@ fi echo "${TOOLS_PATH}" >> $GITHUB_PATH echo "CUDA_PYTHON_PARALLEL_LEVEL=$(nproc)" >> $GITHUB_ENV CUDA_CORE_ARTIFACT_BASENAME="cuda-core-python${PYTHON_VERSION_FORMATTED}-${HOST_PLATFORM}" +CUDA_CORE_ARTIFACT_NAME="${CUDA_CORE_ARTIFACT_BASENAME}-${SHA}" { echo "CUDA_CORE_ARTIFACT_BASENAME=${CUDA_CORE_ARTIFACT_BASENAME}" - echo "CUDA_CORE_ARTIFACT_NAME=${CUDA_CORE_ARTIFACT_BASENAME}-${SHA}" + echo "CUDA_CORE_ARTIFACT_NAME=${CUDA_CORE_ARTIFACT_NAME}" echo "CUDA_CORE_ARTIFACTS_DIR=$(realpath "${REPO_DIR}/cuda_core/dist")" echo "CUDA_CORE_CYTHON_TESTS_DIR=$(realpath "${REPO_DIR}/cuda_core/tests/cython")" echo "CUDA_CORE_TEST_BINARIES_DIR=$(realpath "${REPO_DIR}/cuda_core/tests/test_binaries")" echo "PYTHON_VERSION_FORMATTED=${PYTHON_VERSION_FORMATTED}" } >> $GITHUB_ENV -if [[ "${1}" == "build" ]]; then +if [[ "${BUILD_MODE}" == "build" ]]; then if [[ "${HOST_PLATFORM}" == "linux-64" ]]; then CIBW_PLATFORM_TAG="manylinux_x86_64" elif [[ "${HOST_PLATFORM}" == "linux-aarch64" ]]; then @@ -52,68 +59,93 @@ if [[ "${1}" == "build" ]]; then exit 1 fi echo "CIBW_BUILD=cp${PYTHON_VERSION_FORMATTED}-${CIBW_PLATFORM_TAG}" >> "$GITHUB_ENV" - BUILD_CUDA_MAJOR="$(cut -d '.' -f 1 <<< ${CUDA_VER})" - echo "BUILD_CUDA_MAJOR=${BUILD_CUDA_MAJOR}" >> $GITHUB_ENV - echo "BUILD_PREV_CUDA_MAJOR=$((${BUILD_CUDA_MAJOR} - 1))" >> $GITHUB_ENV - CUDA_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${CUDA_VER}-${HOST_PLATFORM}" -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}" + # Native jq on Windows emits CRLF, so treat CR as an input delimiter too. + IFS=$'\t\r' read -r \ + CURRENT_BINDINGS_ROOT CURRENT_CUDA_VERSION CURRENT_CUDA_MAJOR CURRENT_CUDA_VARIANT \ + MAINTENANCE_BINDINGS_ROOT MAINTENANCE_CUDA_VERSION MAINTENANCE_CUDA_MAJOR MAINTENANCE_CUDA_VARIANT < <( + python -m ci.tools.bindings_config --package-roots \ + | jq -er ' + (.[] | select(.release_status == "current")) as $current + | (.[] | select(.release_status == "maintenance")) as $maintenance + | [$current.package_root, $current.toolkit_version, $current.cuda_major, $current.cuda_variant, + $maintenance.package_root, $maintenance.toolkit_version, $maintenance.cuda_major, + $maintenance.cuda_variant] + | @tsv + ' + ) + CURRENT_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${CURRENT_CUDA_VERSION}-${HOST_PLATFORM}" + MAINTENANCE_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${MAINTENANCE_CUDA_VERSION}-${HOST_PLATFORM}" + + { + echo "CURRENT_BINDINGS_ROOT=${CURRENT_BINDINGS_ROOT}" + echo "CURRENT_CUDA_VERSION=${CURRENT_CUDA_VERSION}" + echo "CURRENT_CUDA_MAJOR=${CURRENT_CUDA_MAJOR}" + echo "CURRENT_CUDA_VARIANT=${CURRENT_CUDA_VARIANT}" + echo "MAINTENANCE_BINDINGS_ROOT=${MAINTENANCE_BINDINGS_ROOT}" + echo "MAINTENANCE_CUDA_VERSION=${MAINTENANCE_CUDA_VERSION}" + echo "MAINTENANCE_CUDA_MAJOR=${MAINTENANCE_CUDA_MAJOR}" + echo "MAINTENANCE_CUDA_VARIANT=${MAINTENANCE_CUDA_VARIANT}" + echo "CURRENT_BINDINGS_ARTIFACT_BASENAME=${CURRENT_BINDINGS_ARTIFACT_BASENAME}" + echo "CURRENT_BINDINGS_ARTIFACT_NAME=${CURRENT_BINDINGS_ARTIFACT_BASENAME}-${SHA}" + echo "CURRENT_BINDINGS_ARTIFACTS_DIR=$(realpath "${REPO_DIR}/${CURRENT_BINDINGS_ROOT}/dist")" + echo "CURRENT_BINDINGS_CYTHON_TESTS_DIR=$(realpath "${REPO_DIR}/${CURRENT_BINDINGS_ROOT}/tests/cython")" + echo "MAINTENANCE_BINDINGS_ARTIFACT_BASENAME=${MAINTENANCE_BINDINGS_ARTIFACT_BASENAME}" + echo "MAINTENANCE_BINDINGS_ARTIFACT_NAME=${MAINTENANCE_BINDINGS_ARTIFACT_BASENAME}-${SHA}" + echo "MAINTENANCE_BINDINGS_ARTIFACTS_DIR=$(realpath "${REPO_DIR}/${MAINTENANCE_BINDINGS_ROOT}/dist")" + echo "MAINTENANCE_BINDINGS_CYTHON_TESTS_DIR=$(realpath "${REPO_DIR}/${MAINTENANCE_BINDINGS_ROOT}/tests/cython")" + echo "CURRENT_PYTHON_ARTIFACT_NAME=cuda-python-wheel-cuda${CURRENT_CUDA_VERSION}" + echo "MAINTENANCE_PYTHON_ARTIFACT_NAME=cuda-python-wheel-cuda${MAINTENANCE_CUDA_VERSION}" + } >> $GITHUB_ENV +else + TEST_CUDA_MAJOR="$(cut -d '.' -f 1 <<< "${CUDA_VER}")" # BINDINGS_SOURCE controls which cuda-bindings to install at test time: - # main — use the just-built bindings wheel from this CI run - # backport — fetch bindings from the prior (N-1) branch - # floor — install from PyPI the oldest cuda-bindings that the cuda-core - # wheel under test supports, its per-major floor (see - # cuda_core/cuda/core/_bindings_floor.py and ci/tools/run-tests). - # Selected when the test CTK minor differs from the minor the - # wheel was built against. Those rows exercise a new cuda-core - # with the floor cuda-bindings and older CTK libraries, the skew - # that cuda-core supports. cuda-bindings older than the floor is - # unsupported and fails at import - # (https://github.com/NVIDIA/cuda-python/issues/2783). + # local — use the matching configured-package wheel from this CI run + # floor — install the oldest bindings supported by the Core wheel, + # read from that wheel's METADATA, with older toolkit libraries # # SKIP_CUDA_BINDINGS_TEST / SKIP_CYTHON_TEST control which *tests* to run # (they do NOT affect installation — that's BINDINGS_SOURCE's job). - BUILD_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_CUDA_VER})" TEST_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${CUDA_VER})" - # CI builds the prior-major half of the cuda-core wheel against the prev_build - # toolkit in ci/versions.yml and the backport branch's bindings. - if ! command -v yq >/dev/null 2>&1; then - echo "Error: yq is required to read ci/versions.yml" >&2 + if [[ "${PATHFINDER_SOURCE:-}" != "artifact" && "${PATHFINDER_SOURCE:-}" != "published" ]]; then + echo "Error: PATHFINDER_SOURCE must be artifact or published, got ${PATHFINDER_SOURCE:-}" >&2 exit 1 fi - BUILD_PREV_CUDA_VER="$(yq -r '.cuda.prev_build.version' ci/versions.yml)" - BUILD_PREV_CUDA_MINOR="$(cut -d '.' -f 2 <<< ${BUILD_PREV_CUDA_VER})" - if [[ ${BUILD_CUDA_MAJOR} != ${TEST_CUDA_MAJOR} ]]; then - SKIP_CUDA_BINDINGS_TEST=1 - SKIP_CYTHON_TEST=1 - if [[ ${BUILD_PREV_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then - # Prior major, minor mismatch (e.g. built against 12.9, test=12.6): floor - # bindings from PyPI with the older CTK libraries. - BINDINGS_SOURCE=floor - else - # Prior major, same minor (e.g. build=13.x, test=12.9): the backport branch. - BINDINGS_SOURCE=backport - fi - elif [[ ${BUILD_CUDA_MINOR} != ${TEST_CUDA_MINOR} ]]; then - # Same major, minor mismatch (e.g. build=13.4, test=13.0): floor bindings - # from PyPI with the older CTK libraries. - BINDINGS_SOURCE=floor + if [[ "${BINDINGS_SOURCE}" == "local" ]]; then + : "${BINDINGS_SOURCE_DIR:?BINDINGS_SOURCE_DIR is required for local bindings}" + BINDINGS_BUILD_CUDA_VER="${CUDA_VER}" + CUDA_BINDINGS_ROOT="${BINDINGS_SOURCE_DIR}" + elif [[ "${BINDINGS_SOURCE}" == "floor" ]]; then + # The matrix selects floor bindings for toolkit rows without an exact + # registry match. Core declares the supported minimum for each major. + : "${DEFAULT_BINDINGS_SOURCE_DIR:?DEFAULT_BINDINGS_SOURCE_DIR is required for floor bindings}" + CUDA_BINDINGS_ROOT="${DEFAULT_BINDINGS_SOURCE_DIR}" SKIP_CUDA_BINDINGS_TEST=1 SKIP_CYTHON_TEST=1 else - # Exact match: use the just-built bindings wheel. - BINDINGS_SOURCE=main + echo "Error: BINDINGS_SOURCE must be local or floor, got ${BINDINGS_SOURCE}" >&2 + exit 1 + fi + + if [[ "${BINDINGS_SOURCE}" == "local" ]]; then if [[ "${SKIP_BINDINGS_TEST_OVERRIDE:-0}" == "1" ]]; then SKIP_CUDA_BINDINGS_TEST=1 else SKIP_CUDA_BINDINGS_TEST=0 fi SKIP_CYTHON_TEST=0 + CUDA_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${BINDINGS_BUILD_CUDA_VER}-${HOST_PLATFORM}" + CUDA_BINDINGS_ARTIFACTS_DIR="$(realpath "${REPO_DIR}/${CUDA_BINDINGS_ROOT}/dist")" + CUDA_BINDINGS_CYTHON_TESTS_DIR="$(realpath "${REPO_DIR}/${CUDA_BINDINGS_ROOT}/tests/cython")" + CUDA_PYTHON_ARTIFACT_NAME="cuda-python-wheel-cuda${BINDINGS_BUILD_CUDA_VER}" + else + # These are unused for floor bindings, but deterministic paths make + # environment dumps and ad-hoc invocations easier to understand. + CUDA_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${CUDA_VER}-${HOST_PLATFORM}" + CUDA_BINDINGS_ARTIFACTS_DIR="$(realpath "${REPO_DIR}/${CUDA_BINDINGS_ROOT}/dist")" + CUDA_BINDINGS_CYTHON_TESTS_DIR="$(realpath "${REPO_DIR}/${CUDA_BINDINGS_ROOT}/tests/cython")" fi # We don't test compute-sanitizer on CTK<12 because backporting fixes is too much effort # We only test compute-sanitizer on python 3.12 arbitrarily; we don't need to use sanitizer on the entire matrix @@ -128,16 +160,19 @@ elif [[ "${1}" == "test" ]]; then { echo "SETUP_SANITIZER=${SETUP_SANITIZER}" echo "BINDINGS_SOURCE=${BINDINGS_SOURCE}" + echo "CUDA_BINDINGS_ROOT=${CUDA_BINDINGS_ROOT}" + echo "PATHFINDER_SOURCE=${PATHFINDER_SOURCE}" + if [[ "${BINDINGS_SOURCE}" == "local" ]]; then + echo "CUDA_PYTHON_ARTIFACT_NAME=${CUDA_PYTHON_ARTIFACT_NAME}" + fi echo "SKIP_CUDA_BINDINGS_TEST=${SKIP_CUDA_BINDINGS_TEST}" echo "SKIP_CYTHON_TEST=${SKIP_CYTHON_TEST}" echo "TEST_CUDA_MAJOR=${TEST_CUDA_MAJOR}" echo "TEST_CUDA_MINOR=${TEST_CUDA_MINOR}" + echo "CUDA_BINDINGS_ARTIFACT_BASENAME=${CUDA_BINDINGS_ARTIFACT_BASENAME}" + echo "CUDA_BINDINGS_ARTIFACT_NAME=${CUDA_BINDINGS_ARTIFACT_BASENAME}-${SHA}" + echo "CUDA_BINDINGS_ARTIFACTS_DIR=${CUDA_BINDINGS_ARTIFACTS_DIR}" + echo "CUDA_BINDINGS_CYTHON_TESTS_DIR=${CUDA_BINDINGS_CYTHON_TESTS_DIR}" + echo "CUDA_CORE_CYTHON_TEST_ARTIFACT_NAME=${CUDA_CORE_ARTIFACT_NAME}-cu${TEST_CUDA_MAJOR}-tests" } >> $GITHUB_ENV fi - -{ - echo "CUDA_BINDINGS_ARTIFACT_BASENAME=${CUDA_BINDINGS_ARTIFACT_BASENAME}" - echo "CUDA_BINDINGS_ARTIFACT_NAME=${CUDA_BINDINGS_ARTIFACT_BASENAME}-${SHA}" - echo "CUDA_BINDINGS_ARTIFACTS_DIR=$(realpath "${REPO_DIR}/cuda_bindings_13/dist")" - echo "CUDA_BINDINGS_CYTHON_TESTS_DIR=$(realpath "${REPO_DIR}/cuda_bindings_13/tests/cython")" -} >> $GITHUB_ENV diff --git a/ci/tools/merge_cuda_core_wheels.py b/ci/tools/merge_cuda_core_wheels.py index 374ea1f53ea..5670913bada 100644 --- a/ci/tools/merge_cuda_core_wheels.py +++ b/ci/tools/merge_cuda_core_wheels.py @@ -7,20 +7,20 @@ """ Script to merge CUDA-specific wheels into a single multi-CUDA wheel. -This script takes wheels built for different CUDA versions (cu12, cu13) and merges them -into a single wheel that supports both CUDA versions. +This script takes wheels built for different CUDA ABI majors and merges them into a +single wheel that supports every supplied major. In particular, each wheel contains a CUDA-specific build of the `cuda.core` library and the associated bindings. This script merges these directories into a single wheel -that supports both CUDA versions, i.e., containing both `cuda/core/cu12` -and `cuda/core/cu13`. At runtime, the code in `cuda/core/__init__.py` -is used to import the appropriate CUDA-specific bindings. +with one `cuda/core/cuN` directory per input. At runtime, the code in +`cuda/core/__init__.py` imports the appropriate CUDA-specific bindings. This script is based on the one in NVIDIA/CCCL. """ import argparse import os +import re import shutil import subprocess import sys @@ -28,6 +28,16 @@ import zipfile from pathlib import Path +CUDA_WHEEL_SUFFIX = re.compile(r"\.cu(?P[1-9][0-9]*)\.whl$") + + +def cuda_variant_from_wheel(wheel: Path) -> str: + """Return the ``cuN`` variant encoded in an intermediate wheel name.""" + match = CUDA_WHEEL_SUFFIX.search(wheel.name) + if match is None: + raise ValueError(f"wheel name does not contain a .cuN suffix: {wheel.name}") + return f"cu{match.group('major')}" + def run_command(cmd: list[str], cwd: Path | None = None, env: dict = os.environ) -> subprocess.CompletedProcess: """Run a command with error handling.""" @@ -84,6 +94,9 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool if len(wheels) == 1: raise RuntimeError("only one wheel is provided, nothing to merge") + cuda_variants = [cuda_variant_from_wheel(wheel) for wheel in wheels] + if len(set(cuda_variants)) != len(cuda_variants): + raise RuntimeError(f"input wheels must have unique CUDA variants: {cuda_variants}") # Extract all wheels to temporary directories with tempfile.TemporaryDirectory() as temp_dir: @@ -133,11 +146,8 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool # Copy version-specific directories from each wheel into versioned subdirectories base_dir = Path("cuda") / "core" - versioned_dirs = set() - for i, wheel_dir in enumerate(extracted_wheels): - cuda_version = wheels[i].name.split(".cu")[1].split(".")[0] - versioned_dir = base_wheel / base_dir / f"cu{cuda_version}" - versioned_dirs.add(versioned_dir.name) + for wheel_dir, cuda_variant in zip(extracted_wheels, cuda_variants, strict=True): + versioned_dir = base_wheel / base_dir / cuda_variant # Copy entire directory tree from source wheel to versioned directory print(f" Copying {wheel_dir / base_dir} to {versioned_dir}", file=sys.stderr) @@ -152,7 +162,7 @@ def merge_wheels(wheels: list[Path], output_dir: Path, show_wheel_contents: bool # build-independent floor logic in _bindings_floor, then redirects every # later import into the versioned tree. Anything else left at top level # is a dead copy that nothing imports. - items_to_keep = {"__init__.py", "_version.py", "_bindings_floor.py", *versioned_dirs} + items_to_keep = {"__init__.py", "_version.py", "_bindings_floor.py", *cuda_variants} all_items = os.scandir(base_wheel / base_dir) removed_count = 0 for f in all_items: diff --git a/ci/tools/tests/test_compute_ci_plan.py b/ci/tools/tests/test_compute_ci_plan.py index e1844384d54..130c7aa5c3e 100644 --- a/ci/tools/tests/test_compute_ci_plan.py +++ b/ci/tools/tests/test_compute_ci_plan.py @@ -4,58 +4,119 @@ from __future__ import annotations +import json +import os +import re +import subprocess import tempfile import unittest from pathlib import Path -from ci.tools.compute_ci_plan import _expand_linked_paths, compute_workplan +import pytest +import yaml + +from ci.tools.bindings_config import BindingsConfig, BindingsPackage, load_config +from ci.tools.compute_ci_plan import _expand_linked_paths, compute_workplan, format_workplan_summary, main ALL_MODULES = {"pathfinder", "bindings", "core", "python"} ALL_PLATFORMS = {"linux", "windows"} +VARIANT_MODULES = {"bindings", "core", "python"} +DEFAULT_BINDINGS_CONFIG = load_config() +CUDA_VARIANTS = {package.cuda_variant for package in DEFAULT_BINDINGS_CONFIG.package_roots} +PACKAGE_VARIANTS = {package.package_root: package.cuda_variant for package in DEFAULT_BINDINGS_CONFIG.package_roots} def plan_for( *paths: str, baseline: bool = True, linked_paths: set[str] | None = None, + release_tag: str = "", + bindings_config: BindingsConfig | None = None, ) -> dict[str, object]: return compute_workplan( - list(paths), + bindings_config=bindings_config if bindings_config is not None else DEFAULT_BINDINGS_CONFIG, + paths=list(paths), + linked_paths=linked_paths if linked_paths is not None else set(), merge_base="base", baseline_run_id="123" if baseline else "", - linked_paths=linked_paths, + release_tag=release_tag, ) +def as_dict(value: object) -> dict[str, object]: + assert isinstance(value, dict) + return value + + +def enabled(decisions: dict[str, object], key: str | None = None) -> set[str]: + if key is None: + return {name for name, value in decisions.items() if value} + return {name for name, value in decisions.items() if as_dict(value)[key]} + + def selected(plan: dict[str, object], key: str) -> set[str]: - modules = plan["modules"] - assert isinstance(modules, dict) - return {name for name, decision in modules.items() if decision[key]} + return enabled(as_dict(plan["modules"]), key) def selected_platforms(plan: dict[str, object]) -> set[str]: - jobs = plan["jobs"] - assert isinstance(jobs, dict) - platforms = jobs["platforms"] - assert isinstance(platforms, dict) + platforms = as_dict(as_dict(plan["jobs"])["platforms"]) assert set(platforms) == ALL_PLATFORMS - return {name for name, enabled in platforms.items() if enabled} + return enabled(platforms) + + +def selected_variants(plan: dict[str, object], module: str, key: str) -> set[str]: + modules = as_dict(plan["modules"]) + if module == "core": + return enabled(as_dict(as_dict(modules[module])["cuda_majors"]), key) + packages = as_dict(as_dict(modules[module])["package_roots"]) + return {PACKAGE_VARIANTS[root] for root in enabled(packages, key)} + + +def selected_package_roots(plan: dict[str, object], module: str, key: str) -> set[str]: + module_plan = as_dict(as_dict(plan["modules"])[module]) + return enabled(as_dict(module_plan["package_roots"]), key) + + +def selected_core_majors(plan: dict[str, object], key: str) -> set[str]: + core = as_dict(as_dict(plan["modules"])["core"]) + return enabled(as_dict(core["cuda_majors"]), key) + + +def selected_cuda_majors(plan: dict[str, object], key: str) -> set[str]: + majors = as_dict(as_dict(plan["jobs"])[key]) + cuda_majors = as_dict(as_dict(as_dict(plan["modules"])["core"])["cuda_majors"]) + assert set(majors) == set(cuda_majors) + return enabled(majors) + + +def selected_sdist_variants(plan: dict[str, object]) -> set[str]: + jobs = as_dict(plan["jobs"]) + selected_roots = enabled(as_dict(jobs["sdist_package_roots"])) + return {PACKAGE_VARIANTS[root] for root in selected_roots} + + +def synthetic_package(package_root: str, ctk_target: str, release_status: str) -> BindingsPackage: + return BindingsPackage( + package_root=package_root, + toolkit_version=f"{ctk_target}.0", + release_status=release_status, + ) + + +def synthetic_config( + *packages: BindingsPackage, +) -> BindingsConfig: + return BindingsConfig( + schema_version=2, + package_roots=packages, + ) class ComputeWorkplanTest(unittest.TestCase): def test_path_impacts(self) -> None: cases = { - "cuda_pathfinder/cuda/pathfinder/_loader.py": (ALL_MODULES, ALL_MODULES, False), - "cuda_bindings_13/cuda/bindings/driver.pyx": ( - {"bindings", "core", "python"}, - {"bindings", "core", "python"}, - False, - ), - "cuda_core/cuda/core/_device.py": ({"core"}, {"core", "python"}, True), "cuda_core/cuda/core/examples/demo.py": ({"core"}, {"core", "python"}, True), - "cuda_python/pyproject.toml": ({"bindings", "python"}, {"python"}, False), "cuda_pathfinder/tests/test_loader.py": (set(), {"pathfinder"}, False), - "cuda_bindings_13/examples/0_Introduction/vectorAddDrv.py": (set(), {"bindings"}, False), "cuda_bindings_13/tests/README.md": (set(), {"bindings"}, False), "cuda_core/pytest.ini": (set(), {"core"}, False), "cuda_python/tests/test_import.py": (set(), {"python"}, False), @@ -65,10 +126,6 @@ def test_path_impacts(self) -> None: False, ), "benchmarks/cuda_bindings/run_pyperf.py": (set(), ALL_MODULES, False), - "benchmarks/cuda_core/runner.py": (set(), ALL_MODULES, False), - "ci/tools/run-tests": (ALL_MODULES, ALL_MODULES, True), - "ci/versions.yml": (ALL_MODULES, ALL_MODULES, True), - "pytest.ini": (ALL_MODULES, ALL_MODULES, True), } for path, (builds, tests, core_api) in cases.items(): @@ -80,6 +137,119 @@ def test_path_impacts(self) -> None: assert plan["jobs"]["sdist_tests"] == bool(builds) assert plan["jobs"]["core_api_checks"] == core_api + self._check_variant_path_impacts() + + def _check_variant_path_impacts(self) -> None: + all_variant_modules = dict.fromkeys(VARIANT_MODULES, CUDA_VARIANTS) + cases = ( + ( + ("cuda_bindings_12/cuda/bindings/driver.pyx",), + {"bindings": {"cu12"}, "core": CUDA_VARIANTS, "python": {"cu12"}}, + {"bindings": {"cu12"}, "core": {"cu12"}, "python": {"cu12"}}, + {"cu12"}, + {"cu12"}, + ), + ( + ("cuda_bindings_13/cuda/bindings/driver.pyx",), + {"bindings": {"cu13"}, "core": CUDA_VARIANTS, "python": {"cu13"}}, + {"bindings": {"cu13"}, "core": {"cu13"}, "python": {"cu13"}}, + {"cu13"}, + {"cu13"}, + ), + ( + ("cuda_bindings_12/examples/0_Introduction/vectorAddDrv.py",), + {}, + {"bindings": {"cu12"}}, + {"cu12"}, + set(), + ), + ( + ("cuda_bindings_13/tests/test_driver.py",), + {}, + {"bindings": {"cu13"}}, + {"cu13"}, + set(), + ), + ( + ("cuda_core/cuda/core/_device.py",), + {"core": CUDA_VARIANTS}, + {"core": CUDA_VARIANTS, "python": CUDA_VARIANTS}, + CUDA_VARIANTS, + CUDA_VARIANTS, + ), + ( + ("cuda_python/pyproject.toml",), + {"bindings": CUDA_VARIANTS, "python": CUDA_VARIANTS}, + {"python": CUDA_VARIANTS}, + CUDA_VARIANTS, + CUDA_VARIANTS, + ), + ( + ("cuda_pathfinder/cuda/pathfinder/_loader.py",), + all_variant_modules, + all_variant_modules, + CUDA_VARIANTS, + CUDA_VARIANTS, + ), + ( + ( + "cuda_bindings_12/cuda/bindings/driver.pyx", + "cuda_bindings_13/cuda/bindings/runtime.pyx", + ), + all_variant_modules, + all_variant_modules, + CUDA_VARIANTS, + CUDA_VARIANTS, + ), + ) + + for paths, variant_builds, variant_tests, test_majors, sdist_majors in cases: + with self.subTest(paths=paths): + plan = plan_for(*paths) + for module in VARIANT_MODULES: + builds = selected_variants(plan, module, "needs_build") + tests = selected_variants(plan, module, "needs_test") + assert builds == variant_builds.get(module, set()) + assert tests == variant_tests.get(module, set()) + + modules = plan["modules"] + assert isinstance(modules, dict) + decision = modules[module] + assert decision["needs_build"] == bool(builds) + assert decision["needs_test"] == bool(tests) + + assert selected_cuda_majors(plan, "test_cuda_majors") == test_majors + assert selected_sdist_variants(plan) == sdist_majors + + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_release_tags_select_only_the_matching_package(self) -> None: + for release_tag, package_root, variant in ( + ("v12.9.9", "cuda_bindings_12", "cu12"), + ("v13.4.1", "cuda_bindings_13", "cu13"), + ("v13.4.1b1", "cuda_bindings_13", "cu13"), + ("v13.4.1rc1", "cuda_bindings_13", "cu13"), + ("v13.4.1.dev1", "cuda_bindings_13", "cu13"), + ("v12.9.9.post1", "cuda_bindings_12", "cu12"), + ("v13.4.1.post1", "cuda_bindings_13", "cu13"), + ): + with self.subTest(release_tag=release_tag): + plan = plan_for(baseline=False, release_tag=release_tag) + assert selected(plan, "needs_build") == {"bindings", "python"} + assert selected(plan, "needs_test") == {"bindings", "python"} + for module in ("bindings", "python"): + assert selected_package_roots(plan, module, "needs_build") == {package_root} + assert selected_package_roots(plan, module, "needs_test") == {package_root} + assert selected_variants(plan, module, "needs_build") == {variant} + assert selected_variants(plan, module, "needs_test") == {variant} + assert not selected_variants(plan, "core", "needs_build") + assert not selected_variants(plan, "core", "needs_test") + assert selected_cuda_majors(plan, "test_cuda_majors") == {variant} + assert selected_sdist_variants(plan) == {variant} + assert selected_platforms(plan) == ALL_PLATFORMS + assert plan["jobs"]["sdist_tests"] + assert not plan["jobs"]["core_api_checks"] + assert plan["baseline"] == {"run_id": "", "sha": ""} + def test_test_infrastructure_platforms(self) -> None: cases = { ".github/workflows/test-wheel-linux.yml": {"linux"}, @@ -96,9 +266,13 @@ def test_test_infrastructure_platforms(self) -> None: plan = plan_for(path) assert not selected(plan, "needs_build") assert selected(plan, "needs_test") == ALL_MODULES + for module in VARIANT_MODULES: + assert selected_variants(plan, module, "needs_test") == CUDA_VARIANTS assert selected_platforms(plan) == platforms assert not plan["jobs"]["sdist_tests"] assert not plan["jobs"]["core_api_checks"] + assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS + assert not selected_sdist_variants(plan) mixed_plan = plan_for("ci/tools/install_gpu_driver.sh", "ci/tools/install_gpu_driver.ps1") assert selected_platforms(mixed_plan) == ALL_PLATFORMS @@ -107,6 +281,7 @@ def test_test_infrastructure_platforms(self) -> None: assert selected_platforms(source_plan) == ALL_PLATFORMS def test_ignored_paths_select_no_work(self) -> None: + empty_plan = plan_for() for path in ( "cuda_core/docs/index.rst", "cuda_core/pixi.toml", @@ -115,6 +290,8 @@ def test_ignored_paths_select_no_work(self) -> None: "benchmarks/cuda_bindings/AGENTS.md", "cuda_core/cuda/core/_cpp/rt/DESIGN.md", "cuda_bindings_13/README.md", + "cuda_bindings_12/README.md", + "cuda_bindings_12/docs/index.rst", "cuda_core/README.md", "new-area/pixi.toml", "notes.md", @@ -123,10 +300,7 @@ def test_ignored_paths_select_no_work(self) -> None: ".github/ISSUE_TEMPLATE/bug.yml", ): with self.subTest(path=path): - plan = plan_for(path) - assert not selected(plan, "needs_build") - assert not selected(plan, "needs_test") - assert not selected_platforms(plan) + assert plan_for(path) == empty_plan def test_unknown_path_and_missing_baseline_force_all(self) -> None: for plan in ( @@ -135,22 +309,86 @@ def test_unknown_path_and_missing_baseline_force_all(self) -> None: plan_for(".github/workflows/new-main-ci-workflow.yml"), plan_for(".github/actions/doc_preview/action.yml"), plan_for("cuda_core/docs/index.rst", baseline=False), - compute_workplan([], merge_base="", baseline_run_id="123"), + compute_workplan( + bindings_config=DEFAULT_BINDINGS_CONFIG, + paths=[], + linked_paths=set(), + merge_base="", + baseline_run_id="123", + release_tag="", + ), + plan_for(baseline=False, release_tag="cuda-core-v1.3.0"), ): assert selected(plan, "needs_build") == ALL_MODULES assert selected(plan, "needs_test") == ALL_MODULES + for module in VARIANT_MODULES: + assert selected_variants(plan, module, "needs_build") == CUDA_VARIANTS + assert selected_variants(plan, module, "needs_test") == CUDA_VARIANTS assert selected_platforms(plan) == ALL_PLATFORMS assert plan["jobs"]["core_api_checks"] + assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS + assert selected_sdist_variants(plan) == CUDA_VARIANTS assert plan["baseline"] == {"run_id": "", "sha": ""} + for release_tag in ("v12.8.1", "v13.5.0", "v14.0.0"): + with ( + self.subTest(release_tag=release_tag), + pytest.raises(ValueError, match="no configured CUDA bindings package root"), + ): + plan_for(baseline=False, release_tag=release_tag) + def test_mixed_changes_are_combined(self) -> None: plan = plan_for("cuda_core/tests/test_device.py", "cuda_python/pyproject.toml") assert selected(plan, "needs_build") == {"bindings", "python"} assert selected(plan, "needs_test") == {"core", "python"} + assert selected_variants(plan, "bindings", "needs_build") == CUDA_VARIANTS + assert selected_variants(plan, "python", "needs_build") == CUDA_VARIANTS + assert selected_variants(plan, "core", "needs_test") == CUDA_VARIANTS + assert selected_variants(plan, "python", "needs_test") == CUDA_VARIANTS assert selected_platforms(plan) == ALL_PLATFORMS assert plan["jobs"]["sdist_tests"] + assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS + assert selected_sdist_variants(plan) == CUDA_VARIANTS assert plan["baseline"] == {"run_id": "123", "sha": "base"} + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_mixed_bindings_source_and_core_test_keep_line_specific_tests(self) -> None: + plan = plan_for( + "cuda_bindings_12/cuda/bindings/driver.pyx", + "cuda_core/tests/test_device.py", + ) + + assert selected(plan, "needs_test") == {"bindings", "core", "python"} + assert selected_package_roots(plan, "bindings", "needs_test") == {"cuda_bindings_12"} + assert selected_core_majors(plan, "needs_test") == CUDA_VARIANTS + assert selected_package_roots(plan, "python", "needs_test") == {"cuda_bindings_12"} + assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS + + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_wheel_workflows_consume_line_specific_test_gates(self) -> None: + expected_fragments = { + "TEST_BINDINGS": ( + "matrix.BINDINGS_SOURCE == 'local'", + ".modules.bindings.package_roots[matrix.BINDINGS_SOURCE_DIR].needs_test", + ), + "TEST_CORE": (".modules.core.cuda_majors[matrix.CUDA_VARIANT].needs_test",), + "TEST_PYTHON": ( + "matrix.BINDINGS_SOURCE == 'local'", + ".modules.python.package_roots[matrix.BINDINGS_SOURCE_DIR].needs_test", + ), + } + + repo_root = Path(__file__).resolve().parents[3] + for platform in ("linux", "windows"): + with self.subTest(platform=platform): + workflow = yaml.safe_load( + (repo_root / ".github" / "workflows" / f"test-wheel-{platform}.yml").read_text(encoding="utf-8") + ) + gates = workflow["jobs"]["test"]["env"] + for name, fragments in expected_fragments.items(): + with self.subTest(platform=platform, gate=name): + assert all(fragment in gates[name] for fragment in fragments) + def test_changed_symlink_targets_include_their_consumers(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -168,13 +406,232 @@ def test_changed_symlink_targets_include_their_consumers(self) -> None: assert paths == ["README.md", "cuda_python/README.md"] plan = plan_for(*paths, linked_paths={"cuda_python/README.md"}) - assert selected(plan, "needs_build") == {"bindings", "python"} - assert selected(plan, "needs_test") == {"python"} - removed_link = plan_for("cuda_python/README.md", linked_paths={"cuda_python/README.md"}) - assert selected(removed_link, "needs_build") == {"bindings", "python"} - assert selected(removed_link, "needs_test") == {"python"} + for linked_plan in (plan, removed_link): + assert selected(linked_plan, "needs_build") == {"bindings", "python"} + assert selected(linked_plan, "needs_test") == {"python"} + for module in ("bindings", "python"): + assert selected_variants(linked_plan, module, "needs_build") == CUDA_VARIANTS + assert selected_variants(linked_plan, "python", "needs_test") == CUDA_VARIANTS + + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_current_package_can_move_to_a_new_cuda_major(self) -> None: + package_11 = synthetic_package("cuda_bindings_11", "11.8", "maintenance") + package_12 = synthetic_package("cuda_bindings_12", "12.0", "current") + config = synthetic_config( + package_11, + package_12, + ) + + plan = plan_for( + "cuda_bindings_12/cuda/bindings/runtime.pyx", + bindings_config=config, + ) + + assert selected_package_roots(plan, "bindings", "needs_build") == {package_12.package_root} + assert selected_package_roots(plan, "python", "needs_build") == {package_12.package_root} + assert selected_core_majors(plan, "needs_build") == {"cu11", "cu12"} + assert selected_core_majors(plan, "needs_test") == {"cu12"} + assert selected_cuda_majors(plan, "test_cuda_majors") == {"cu12"} + assert selected_sdist_variants(plan) == {"cu12"} + + core_plan = plan_for("cuda_core/cuda/core/_device.py", bindings_config=config) + assert selected_core_majors(core_plan, "needs_build") == {"cu11", "cu12"} + assert selected_package_roots(core_plan, "python", "needs_test") == { + package_11.package_root, + package_12.package_root, + } + + +@pytest.mark.agent_authored(model="gpt-5.6") +def test_github_outputs_are_emitted_without_shell_json_transforms(tmp_path: Path) -> None: + output = tmp_path / "github-output" + summary = tmp_path / "github-summary" + + main( + [ + "--release-tag", + "v13.4.1", + "--github-output", + str(output), + "--github-step-summary", + str(summary), + ] + ) + + records = dict(line.split("=", maxsplit=1) for line in output.read_text(encoding="utf-8").splitlines()) + assert set(records) == {"bindings-config", "workplan"} + registry = json.loads(records["bindings-config"]) + assert [package["package_root"] for package in registry["package_roots"]] == [ + "cuda_bindings_12", + "cuda_bindings_13", + ] + assert json.loads(records["workplan"])["jobs"]["sdist_package_roots"] == { + "cuda_bindings_12": False, + "cuda_bindings_13": True, + } + assert "### CI workplan" in summary.read_text(encoding="utf-8") if __name__ == "__main__": unittest.main() + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_workplan_labels_and_summary_show_component_majors_and_exclusions() -> None: + plan = plan_for(release_tag="v12.9.11") + assert plan["labels"] == { + "build": "bindings12+python12", + "build_platform": "bindings12", + "build_current": "baseline wheels and test extensions", + "sdist": {"cuda_bindings_12": "bindings12+python12", "cuda_bindings_13": "excluded"}, + } + assert plan["jobs"]["windows_arm64_build"] is False + summary = format_workplan_summary(plan, DEFAULT_BINDINGS_CONFIG) + assert "| cuda-bindings | 12 | build | required |" in summary + assert "| cuda-bindings | 13 | excluded | excluded by plan |" in summary + assert "published cuda-pathfinder" in summary + assert "CUDA 12 has no Windows ARM64 toolkit" in summary + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_workplan_labels_keep_both_core_majors_for_a_bindings_change() -> None: + plan = plan_for("cuda_bindings_12/cuda/bindings/driver.pyx") + assert plan["labels"]["build_platform"] == "bindings12+core12+core13" + assert plan["labels"]["build_current"] == "core13" + assert plan["jobs"]["windows_arm64_build"] is True + summary = format_workplan_summary(plan, DEFAULT_BINDINGS_CONFIG) + assert "| cuda-bindings | 13 | reuse exact baseline | excluded by plan |" in summary + assert "Reusable artifacts: run `123` at `base`." in summary + + +@pytest.mark.parametrize("platform", ["linux", "windows"]) +@pytest.mark.parametrize("scenario", ["full", "drop-local", "maintenance-only", "nightly-floor", "nightly-standard"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_workflow_matrix_executes_component_selection_and_coverage_guard( + tmp_path: Path, platform: str, scenario: str +) -> None: + workflow = Path(__file__).resolve().parents[3] / ".github" / "workflows" / f"test-wheel-{platform}.yml" + document = yaml.safe_load(workflow.read_text()) + step = next(step for step in document["jobs"]["compute-matrix"]["steps"] if step.get("id") == "compute-matrix") + script = re.sub(r"^TEST_MATRIX=\$\(yq.*$", 'TEST_MATRIX="$CI_TEST_MATRIX"', step["run"], flags=re.MULTILINE) + matrix_filter = 'map(select(.CUDA_VER != "12.9.1"))' if scenario == "drop-local" else "." + script = script.replace("${{ inputs.matrix_filter }}", matrix_filter) + plan = ( + plan_for("cuda_bindings_12/cuda/bindings/driver.pyx") + if scenario == "maintenance-only" + else plan_for(baseline=False) + ) + versions = ["12.9.1", "13.4.2", "12.6.3", "13.0.2"] + if scenario == "nightly-floor": + versions = ["12.6.3"] + elif scenario == "nightly-standard": + versions = ["13.4.2"] + matrix = [ + dict(CUDA_VER=version, PY_VER="3.12", LOCAL_CTK="0", GPU="l4", GPU_COUNT="1", DRIVER="latest") + for version in versions + ] + output = tmp_path / "output" + summary = tmp_path / "summary" + env = { + **os.environ, + "BINDINGS_CONFIG": DEFAULT_BINDINGS_CONFIG.to_json(), + "BUILD_TYPE": "nightly" if scenario.startswith("nightly-") else "pull-request", + "TEST_MODE": "nightly-pytorch" if scenario == "nightly-floor" else "standard", + "WORKPLAN": "" if scenario.startswith("nightly-") else json.dumps(plan), + "ARCH": "amd64", + "GITHUB_OUTPUT": str(output), + "GITHUB_STEP_SUMMARY": str(summary), + "CI_TEST_MATRIX": json.dumps(matrix), + } + result = subprocess.run( # noqa: S603 - executes the checked-in workflow script + ["/bin/bash", "-euo", "pipefail", "-c", script], env=env, text=True, capture_output=True, check=False + ) + if scenario == "drop-local": + assert result.returncode != 0 + assert "omits required local-wheel coverage: cuda_bindings_12" in result.stderr + return + assert result.returncode == 0, result.stderr + rows = json.loads(output.read_text().strip().removeprefix("MATRIX="))["include"] + if scenario == "maintenance-only": + assert [row["SUITES"] for row in rows] == ["bindings12+core12+python12", "core12"] + elif scenario == "nightly-floor": + assert rows[0]["BINDINGS_SOURCE"] == "floor" + assert rows[0]["SUITES"] == "nightly-pytorch" + elif scenario == "full": + assert rows[0]["SUITES"] == "pathfinder+bindings12+core12+python12" + assert rows[1]["SUITES"] == "pathfinder+bindings13+core13+python13" + assert rows[2]["SUITES"] == "pathfinder+core12" + assert "Selected wheel test coverage" in summary.read_text() + + +@pytest.mark.parametrize("platform", ["linux", "windows"]) +@pytest.mark.parametrize( + ("major", "bindings", "core", "pathfinder_source", "resolve_dependencies"), + [ + ("13", True, False, "published", True), + ("13", True, False, "artifact", True), + ("13", False, False, "artifact", True), + ("13", True, True, "artifact", False), + ("12", True, False, "published", False), + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_metapackage_install_resolves_published_core_for_bindings_only_cuda13( + tmp_path: Path, + platform: str, + major: str, + bindings: bool, + core: bool, + pathfinder_source: str, + resolve_dependencies: bool, +) -> None: + workflow = Path(__file__).resolve().parents[3] / ".github" / "workflows" / f"test-wheel-{platform}.yml" + document = yaml.safe_load(workflow.read_text()) + step = next( + step for step in document["jobs"]["test"]["steps"] if step.get("name") == "Ensure cuda-python installable" + ) + commands = tmp_path / "bin" + commands.mkdir() + pip = commands / "pip" + pip.write_text('#!/bin/bash\nprintf "%s\\n" "$@" > "$PIP_ARGUMENTS"\n') + pip.chmod(0o755) + python = commands / "python" + python.write_text('#!/bin/bash\nprintf "cuda-pathfinder==1.4.0\\n"\n') + python.chmod(0o755) + pathfinder = tmp_path / "cuda_pathfinder" + pathfinder.mkdir() + (pathfinder / "cuda_pathfinder-1.4.0-py3-none-any.whl").touch() + bindings_dist = tmp_path / "bindings" + bindings_dist.mkdir() + bindings_wheel = bindings_dist / f"cuda_bindings-{major}.4.0-cp312-cp312-linux_x86_64.whl" + bindings_wheel.touch() + (tmp_path / f"cuda_python-{major}.4.0-py3-none-any.whl").touch() + arguments = tmp_path / "pip-arguments" + result = subprocess.run( # noqa: S603 - executes the checked-in workflow script with stub installers + ["/bin/bash", "-euo", "pipefail", "-c", step["run"]], + cwd=tmp_path, + env={ + **os.environ, + "PATH": f"{commands}{os.pathsep}{os.environ['PATH']}", + "PIP_ARGUMENTS": str(arguments), + "CUDA_BINDINGS_ARTIFACTS_DIR": str(bindings_dist), + "TEST_CUDA_MAJOR": major, + "TEST_BINDINGS": str(bindings).lower(), + "TEST_CORE": str(core).lower(), + "PATHFINDER_SOURCE": pathfinder_source, + "LOCAL_CTK": "0", + }, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + captured = arguments.read_text().splitlines() + assert ("--no-deps" not in captured) is resolve_dependencies + if resolve_dependencies: + assert str(bindings_wheel) in captured + if pathfinder_source == "published": + assert "cuda-pathfinder==1.4.0" in captured + else: + assert "./cuda_pathfinder/cuda_pathfinder-1.4.0-py3-none-any.whl" in captured diff --git a/ci/tools/tests/test_download_wheels.py b/ci/tools/tests/test_download_wheels.py new file mode 100644 index 00000000000..323974b1b17 --- /dev/null +++ b/ci/tools/tests/test_download_wheels.py @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +DOWNLOAD_WHEELS = Path(__file__).parent.parent / "download-wheels" + +FAKE_GH = r"""#!/usr/bin/env python3 +import fnmatch +import json +import os +import sys +from pathlib import Path + +args = sys.argv[1:] +with Path(os.environ["FAKE_GH_LOG"]).open("a", encoding="utf-8") as stream: + print(json.dumps(args), file=stream) +artifacts = json.loads(os.environ["FAKE_ARTIFACTS"]) + +if args[:1] == ["api"]: + print(*artifacts, sep="\n") + raise SystemExit(0) + +if args[:2] == ["run", "download"]: + if "--name" in args: + name = args[args.index("--name") + 1] + selected = [artifact for artifact in artifacts if artifact == name] + else: + pattern = args[args.index("-p") + 1] + selected = [artifact for artifact in artifacts if fnmatch.fnmatchcase(artifact, pattern)] + if not selected: + raise SystemExit(1) + destination = Path(args[args.index("--dir") + 1]) + for artifact in selected: + artifact_dir = destination / artifact + artifact_dir.mkdir(parents=True) + suffix = ".so" if artifact.endswith("-tests") else ".whl" + (artifact_dir / f"{artifact}{suffix}").touch() + raise SystemExit(0) + +raise SystemExit(2) +""" + + +@pytest.fixture +def fake_gh(tmp_path): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + gh = fake_bin / "gh" + gh.write_text(FAKE_GH, encoding="utf-8") + gh.chmod(0o755) + return fake_bin + + +def resolved_package(*, package_root="cuda_bindings", toolkit_version="13.3.0", release_version="13.3.1"): + return json.dumps( + {"package_root": package_root, "toolkit_version": toolkit_version, "release_version": release_version} + ) + + +def run_download(tmp_path, fake_gh, artifacts, component, *, tag="", package=None): + log = tmp_path / "gh.log" + env = os.environ.copy() + env.update( + { + "FAKE_ARTIFACTS": json.dumps(artifacts), + "FAKE_GH_LOG": str(log), + "GH_TOKEN": "test-token", + "PATH": f"{fake_gh}{os.pathsep}{env['PATH']}", + } + ) + args = [str(DOWNLOAD_WHEELS), "123", component, "NVIDIA/cuda-python", str(tmp_path / "dist")] + if tag or package is not None: + args.append(tag) + if package is not None: + args.append(package) + result = subprocess.run( # noqa: S603 + args, + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + ) + commands = [json.loads(item) for item in log.read_text(encoding="utf-8").splitlines()] if log.exists() else [] + return result, commands + + +class TestDownloadWheels: + @pytest.mark.parametrize( + ("package_root", "toolkit_version", "release_version"), + ( + ("cuda_bindings", "13.3.0", "13.3.1"), + ("cuda_bindings_12", "12.9.1", "12.9.9"), + ), + ) + @pytest.mark.agent_authored(model="gpt-6") + def test_python_selects_exact_toolkit_artifact( + self, tmp_path, fake_gh, package_root, toolkit_version, release_version + ): + expected = f"cuda-python-wheel-cuda{toolkit_version}" + result, commands = run_download( + tmp_path, + fake_gh, + ["cuda-python-wheel", "cuda-python-wheel-cuda13.2.0", expected], + "cuda-python", + tag=f"v{release_version}", + package=resolved_package( + package_root=package_root, toolkit_version=toolkit_version, release_version=release_version + ), + ) + + assert result.returncode == 0, result.stderr + downloads = [command[command.index("--name") + 1] for command in commands if "--name" in command] + assert downloads == [expected] + assert [path.name for path in (tmp_path / "dist").glob("*.whl")] == [f"{expected}.whl"] + + @pytest.mark.parametrize("artifact", ("cuda-python-wheel", "cuda-python-wheel-cuda13.2.0")) + @pytest.mark.agent_authored(model="gpt-6") + def test_python_requires_exact_toolkit_artifact(self, tmp_path, fake_gh, artifact): + result, commands = run_download( + tmp_path, + fake_gh, + [artifact], + "cuda-python", + tag="v13.3.1", + package=resolved_package(), + ) + + assert result.returncode == 1 + assert "no unexpired release artifact for CUDA 13.3.0" in result.stderr + assert not any(command[:2] == ["run", "download"] for command in commands) + + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_bindings_pattern_contains_exact_toolkit_pin(self, tmp_path, fake_gh): + exact = "cuda-bindings-python312-cuda13.3.0-linux-64-sha" + result, commands = run_download( + tmp_path, + fake_gh, + ["cuda-bindings-python312-cuda13.2.0-linux-64-sha", exact, f"{exact}-tests"], + "cuda-bindings", + tag="v13.3.1", + package=resolved_package(), + ) + + assert result.returncode == 0, result.stderr + downloads = [command[command.index("--name") + 1] for command in commands if "--name" in command] + assert downloads == [exact] + assert [path.name for path in (tmp_path / "dist").glob("*.whl")] == [f"{exact}.whl"] + + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_release_routing_requires_resolved_package(self, tmp_path, fake_gh): + result, commands = run_download( + tmp_path, + fake_gh, + ["cuda-python-wheel-cuda13.3.0"], + "cuda-python", + tag="v13.3.1", + ) + + assert result.returncode == 1 + assert commands == [] + + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_bindings_test_artifact_is_not_releasable(self, tmp_path, fake_gh): + artifact = "cuda-bindings-python312-cuda13.3.0-linux-64-sha-tests" + result, _ = run_download( + tmp_path, + fake_gh, + [artifact], + "cuda-bindings", + tag="v13.3.1", + package=resolved_package(), + ) + + assert result.returncode == 1 + assert "no unexpired release artifact" in result.stderr + + +@pytest.mark.parametrize( + ("component", "artifacts", "expected"), + ( + ( + "all", + ["cuda-core-wheel", "cuda-core-wheel-tests", "cuda-python-wheel-cuda13.3.0", "build-metadata"], + ["cuda-core-wheel", "cuda-python-wheel-cuda13.3.0"], + ), + ( + "cuda-core", + ["cuda-core-wheel", "cuda-core-wheel-tests", "cuda-python-wheel-cuda13.3.0"], + ["cuda-core-wheel"], + ), + ), +) +@pytest.mark.agent_authored(model="gpt-5.6") +def test_non_release_selection_excludes_test_artifacts(tmp_path, fake_gh, component, artifacts, expected): + result, commands = run_download(tmp_path, fake_gh, artifacts, component) + + assert result.returncode == 0, result.stderr + downloads = [command[command.index("--name") + 1] for command in commands if "--name" in command] + assert downloads == expected + assert all("-p" not in command for command in commands) diff --git a/ci/tools/tests/test_merge_cuda_core_wheels.py b/ci/tools/tests/test_merge_cuda_core_wheels.py new file mode 100644 index 00000000000..bdabb168819 --- /dev/null +++ b/ci/tools/tests/test_merge_cuda_core_wheels.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path +from zipfile import ZipFile + +import pytest +from wheel.wheelfile import WheelFile + +from ci.tools.merge_cuda_core_wheels import cuda_variant_from_wheel, merge_wheels + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("cuda_core-1.2.3-cp312-cp312-manylinux_x86_64.cu12.whl", "cu12"), + ("cuda_core-2.0.0-cp315-cp315-win_amd64.cu14.whl", "cu14"), + ], +) +def test_cuda_variant_from_wheel(name, expected): + assert cuda_variant_from_wheel(Path(name)) == expected + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_cuda_variant_from_wheel_rejects_missing_suffix(): + with pytest.raises(ValueError, match=r"does not contain a \.cuN suffix"): + cuda_variant_from_wheel(Path("cuda_core-1.2.3-py3-none-any.whl")) + + +@pytest.mark.agent_authored(model="gpt-6-astra") +def test_merged_wheel_preserves_floor_helper_and_all_abi_variants(tmp_path): + wheels = [] + floor_helper = "SUPPORTED_BINDINGS = {12: '12.9.8', 13: '13.4.1'}\n" + for major in (12, 13): + path = tmp_path / f"cuda_core-1.2.3-py3-none-any.cu{major}.whl" + with WheelFile(path, "w") as wheel: + wheel.writestr("cuda/core/__init__.py", "from . import _bindings_floor\n") + wheel.writestr("cuda/core/_version.py", "__version__ = '1.2.3'\n") + wheel.writestr("cuda/core/_bindings_floor.py", floor_helper) + wheel.writestr("cuda/core/_abi.py", f"CUDA_MAJOR = {major}\n") + wheel.writestr( + "cuda_core-1.2.3.dist-info/METADATA", "Metadata-Version: 2.1\nName: cuda-core\nVersion: 1.2.3\n" + ) + wheel.writestr( + "cuda_core-1.2.3.dist-info/WHEEL", + "Wheel-Version: 1.0\nGenerator: test\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ) + wheels.append(path) + + merged = merge_wheels(wheels, tmp_path / "merged", show_wheel_contents=False) + + with ZipFile(merged) as wheel: + assert wheel.read("cuda/core/_bindings_floor.py").decode() == floor_helper + for major in (12, 13): + assert wheel.read(f"cuda/core/cu{major}/_abi.py").decode() == f"CUDA_MAJOR = {major}\n" + assert "cuda/core/_abi.py" not in wheel.namelist() From ea4cc978cab273e33c486378865f43c0f3c75886 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Tue, 20 Oct 2026 22:13:46 -0700 Subject: [PATCH 2/8] CI: label metapackage build jobs as meta Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/compute_ci_plan.py | 5 +++-- ci/tools/tests/test_compute_ci_plan.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ci/tools/compute_ci_plan.py b/ci/tools/compute_ci_plan.py index 5f432797821..4fc5b9057de 100644 --- a/ci/tools/compute_ci_plan.py +++ b/ci/tools/compute_ci_plan.py @@ -243,6 +243,7 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: if universal and ("pathfinder", None) in builds: labels.append("pathfinder") for module in ("bindings", "core", "python"): + component_label = "meta" if module == "python" else module if module == "python" and not universal: continue for package in packages: @@ -250,7 +251,7 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: continue selector = package.cuda_variant if module == "core" else package.package_root if (module, selector) in builds: - labels.append(f"{module}{package.cuda_major}") + labels.append(f"{component_label}{package.cuda_major}") return "+".join(labels) or "baseline wheels and test extensions" return { @@ -266,7 +267,7 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: (("pathfinder", None) in builds, "pathfinder"), (("bindings", package.package_root) in builds, f"bindings{package.cuda_major}"), (("core", package.cuda_variant) in builds, f"core{package.cuda_major}"), - (("python", package.package_root) in builds, f"python{package.cuda_major}"), + (("python", package.package_root) in builds, f"meta{package.cuda_major}"), ) if selected ] diff --git a/ci/tools/tests/test_compute_ci_plan.py b/ci/tools/tests/test_compute_ci_plan.py index 130c7aa5c3e..0dfd4227468 100644 --- a/ci/tools/tests/test_compute_ci_plan.py +++ b/ci/tools/tests/test_compute_ci_plan.py @@ -481,10 +481,10 @@ def test_github_outputs_are_emitted_without_shell_json_transforms(tmp_path: Path def test_workplan_labels_and_summary_show_component_majors_and_exclusions() -> None: plan = plan_for(release_tag="v12.9.11") assert plan["labels"] == { - "build": "bindings12+python12", + "build": "bindings12+meta12", "build_platform": "bindings12", "build_current": "baseline wheels and test extensions", - "sdist": {"cuda_bindings_12": "bindings12+python12", "cuda_bindings_13": "excluded"}, + "sdist": {"cuda_bindings_12": "bindings12+meta12", "cuda_bindings_13": "excluded"}, } assert plan["jobs"]["windows_arm64_build"] is False summary = format_workplan_summary(plan, DEFAULT_BINDINGS_CONFIG) From 401441a16f7b4e9672be2c377b69af19348c8cfe Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 26 Oct 2026 12:39:44 -0700 Subject: [PATCH 3/8] Reuse unchanged Core wheels for bindings-only CI changes Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/ci.yml | 5 +++-- ci/tools/compute_ci_plan.py | 3 ++- ci/tools/tests/test_compute_ci_plan.py | 29 +++++++++++++++++--------- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28d4b987f40..6758da1388a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,8 +71,9 @@ jobs: # cuda_python -> cuda_pathfinder, cuda_bindings, cuda_core (meta package) # # A change to cuda_pathfinder (or shared infra) forces a rebuild of every - # downstream module. A change to either bindings package rebuilds cuda_core - # against both majors, but rebuilds only the matching bindings package. + # downstream module. A bindings-only change rebuilds its bindings and + # metapackage, then tests them with the unchanged Core wheel from the baseline. + # Core source changes rebuild both CUDA variants. # A change to cuda_core alone skips rebuilding/retesting cuda_bindings and # cuda_pathfinder, but still retests the downstream cuda-python metapackage. # Shared build/orchestration changes run the full pipeline; test-only CI diff --git a/ci/tools/compute_ci_plan.py b/ci/tools/compute_ci_plan.py index 4fc5b9057de..cb702c5c516 100644 --- a/ci/tools/compute_ci_plan.py +++ b/ci/tools/compute_ci_plan.py @@ -79,6 +79,8 @@ def compute_workplan( # Source changes have different build/test consumers. Core-only changes can # reuse baseline cuda-python wheels; cuda-python changes cannot reuse bindings. + # Bindings-only changes reuse the unchanged Core wheel and test it against + # the changed bindings major; Core source or shared changes rebuild both halves. source_impacts: dict[Target, tuple[frozenset[Target], frozenset[Target], frozenset[str]]] = { ("pathfinder", None): (all_targets, all_targets, frozenset(package_roots)), ("core", None): (core_targets, frozenset({*core_targets, *python_targets}), frozenset(package_roots)), @@ -93,7 +95,6 @@ def compute_workplan( { ("bindings", package.package_root), ("python", package.package_root), - *core_targets, } ), frozenset( diff --git a/ci/tools/tests/test_compute_ci_plan.py b/ci/tools/tests/test_compute_ci_plan.py index 0dfd4227468..00698146653 100644 --- a/ci/tools/tests/test_compute_ci_plan.py +++ b/ci/tools/tests/test_compute_ci_plan.py @@ -144,14 +144,14 @@ def _check_variant_path_impacts(self) -> None: cases = ( ( ("cuda_bindings_12/cuda/bindings/driver.pyx",), - {"bindings": {"cu12"}, "core": CUDA_VARIANTS, "python": {"cu12"}}, + {"bindings": {"cu12"}, "python": {"cu12"}}, {"bindings": {"cu12"}, "core": {"cu12"}, "python": {"cu12"}}, {"cu12"}, {"cu12"}, ), ( ("cuda_bindings_13/cuda/bindings/driver.pyx",), - {"bindings": {"cu13"}, "core": CUDA_VARIANTS, "python": {"cu13"}}, + {"bindings": {"cu13"}, "python": {"cu13"}}, {"bindings": {"cu13"}, "core": {"cu13"}, "python": {"cu13"}}, {"cu13"}, {"cu13"}, @@ -196,7 +196,7 @@ def _check_variant_path_impacts(self) -> None: "cuda_bindings_12/cuda/bindings/driver.pyx", "cuda_bindings_13/cuda/bindings/runtime.pyx", ), - all_variant_modules, + {"bindings": CUDA_VARIANTS, "python": CUDA_VARIANTS}, all_variant_modules, CUDA_VARIANTS, CUDA_VARIANTS, @@ -430,7 +430,7 @@ def test_current_package_can_move_to_a_new_cuda_major(self) -> None: assert selected_package_roots(plan, "bindings", "needs_build") == {package_12.package_root} assert selected_package_roots(plan, "python", "needs_build") == {package_12.package_root} - assert selected_core_majors(plan, "needs_build") == {"cu11", "cu12"} + assert selected_core_majors(plan, "needs_build") == set() assert selected_core_majors(plan, "needs_test") == {"cu12"} assert selected_cuda_majors(plan, "test_cuda_majors") == {"cu12"} assert selected_sdist_variants(plan) == {"cu12"} @@ -494,16 +494,25 @@ def test_workplan_labels_and_summary_show_component_majors_and_exclusions() -> N assert "CUDA 12 has no Windows ARM64 toolkit" in summary +@pytest.mark.parametrize("major", ["12", "13"]) @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_workplan_labels_keep_both_core_majors_for_a_bindings_change() -> None: - plan = plan_for("cuda_bindings_12/cuda/bindings/driver.pyx") - assert plan["labels"]["build_platform"] == "bindings12+core12+core13" - assert plan["labels"]["build_current"] == "core13" - assert plan["jobs"]["windows_arm64_build"] is True +def test_bindings_changes_reuse_core_and_test_the_matching_major(major: str) -> None: + plan = plan_for(f"cuda_bindings_{major}/cuda/bindings/driver.pyx") + assert plan["labels"]["build_platform"] == f"bindings{major}" + assert plan["labels"]["build_current"] == ("bindings13" if major == "13" else "baseline wheels and test extensions") + assert not selected_core_majors(plan, "needs_build") + assert selected_core_majors(plan, "needs_test") == {f"cu{major}"} + assert plan["baseline"] == {"run_id": "123", "sha": "base"} summary = format_workplan_summary(plan, DEFAULT_BINDINGS_CONFIG) - assert "| cuda-bindings | 13 | reuse exact baseline | excluded by plan |" in summary + assert f"| cuda-core | {major} | reuse exact baseline | required |" in summary + other_major = "13" if major == "12" else "12" + assert f"| cuda-core | {other_major} | reuse exact baseline | excluded by plan |" in summary assert "Reusable artifacts: run `123` at `base`." in summary + # A missing exact baseline still requires a complete rebuild. + cold_plan = plan_for(f"cuda_bindings_{major}/cuda/bindings/driver.pyx", baseline=False) + assert selected_core_majors(cold_plan, "needs_build") == CUDA_VARIANTS + @pytest.mark.parametrize("platform", ["linux", "windows"]) @pytest.mark.parametrize("scenario", ["full", "drop-local", "maintenance-only", "nightly-floor", "nightly-standard"]) From 4ecdff483cffa36baea14ed1f52a4713a6dfa76c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 26 Oct 2026 12:41:38 -0700 Subject: [PATCH 4/8] Clarify bindings CI planning and build platform selection Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/ci.yml | 4 +-- ci/tools/compute_ci_plan.py | 58 +++++++++++++++++++------------------ 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6758da1388a..71db8377359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: # # Dependency graph (verified in pyproject.toml files): # cuda_pathfinder -> (no internal deps) - # each registered cuda.bindings package -> cuda_pathfinder + # cuda.bindings (both versions) -> cuda_pathfinder # cuda_core -> cuda_pathfinder, cuda_bindings # cuda_python -> cuda_pathfinder, cuda_bindings, cuda_core (meta package) # @@ -112,7 +112,7 @@ jobs: - name: Install CI tool test dependencies run: python3 -m pip install -e './ci[test]' - - name: Resolve registered build platforms + - name: Read bindings configuration and select supported build platforms id: registry run: | set -euo pipefail diff --git a/ci/tools/compute_ci_plan.py b/ci/tools/compute_ci_plan.py index cb702c5c516..44f3e9373ad 100644 --- a/ci/tools/compute_ci_plan.py +++ b/ci/tools/compute_ci_plan.py @@ -14,7 +14,7 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any -from .bindings_config import BindingsConfigError, load_config +from .bindings_config import load_config if TYPE_CHECKING: from .bindings_config import BindingsConfig @@ -65,16 +65,18 @@ def compute_workplan( release_tag: str, ) -> dict[str, object]: """Return the final CI decisions for the supplied changed paths.""" - packages = bindings_config.package_roots - package_roots = tuple(package.package_root for package in packages) - cuda_variants = tuple(package.cuda_variant for package in packages) + bindings_packages = bindings_config.package_roots + package_roots = tuple(package.package_root for package in bindings_packages) + cuda_variants = tuple(package.cuda_variant for package in bindings_packages) bindings_targets = frozenset(("bindings", package_root) for package_root in package_roots) core_targets = frozenset(("core", variant) for variant in cuda_variants) python_targets = frozenset(("python", package_root) for package_root in package_roots) all_targets = frozenset({("pathfinder", None), *bindings_targets, *core_targets, *python_targets}) - package_targets = {PurePosixPath(package.package_root): ("bindings", package.package_root) for package in packages} + package_targets = { + PurePosixPath(package.package_root): ("bindings", package.package_root) for package in bindings_packages + } package_targets.update((PurePosixPath(source_dir), target) for source_dir, target in PACKAGE_TARGETS.items()) # Source changes have different build/test consumers. Core-only changes can @@ -106,7 +108,7 @@ def compute_workplan( ), frozenset({package.package_root}), ) - for package in packages + for package in bindings_packages }, } @@ -115,7 +117,7 @@ def compute_workplan( test_platforms: set[str] = set() release_package = bindings_config.match_tag(release_tag) if release_tag else None if release_tag.startswith("v") and release_package is None: - raise BindingsConfigError(f"no configured CUDA bindings package root matches release tag: {release_tag!r}") + raise ValueError(f"no configured CUDA bindings package root matches release tag: {release_tag!r}") force_all = release_package is None and (bool(release_tag) or not merge_base or not baseline_run_id) if release_package is None and not force_all: @@ -218,7 +220,9 @@ def flags(module: str, selectors: tuple[str | None, ...]) -> dict[str, bool]: modules: dict[str, dict[str, object]] = {"pathfinder": flags("pathfinder", (None,))} for module in ("bindings", "python"): - package_decisions = {package.package_root: flags(module, (package.package_root,)) for package in packages} + package_decisions = { + package.package_root: flags(module, (package.package_root,)) for package in bindings_packages + } modules[module] = { **flags(module, package_roots), "package_roots": package_decisions, @@ -233,7 +237,7 @@ def flags(module: str, selectors: tuple[str | None, ...]) -> dict[str, bool]: pathfinder_test = ("pathfinder", None) in tests test_cuda_variants = { package.cuda_variant - for package in packages + for package in bindings_packages if pathfinder_test or ("core", package.cuda_variant) in tests or any((module, package.package_root) in tests for module in ("bindings", "python")) @@ -247,7 +251,7 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: component_label = "meta" if module == "python" else module if module == "python" and not universal: continue - for package in packages: + for package in bindings_packages: if current_only and package.release_status != "current": continue selector = package.cuda_variant if module == "core" else package.package_root @@ -255,27 +259,25 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: labels.append(f"{component_label}{package.cuda_major}") return "+".join(labels) or "baseline wheels and test extensions" + sdist_labels = {} + for package in bindings_packages: + labels = [] + if ("pathfinder", None) in builds: + labels.append("pathfinder") + if ("bindings", package.package_root) in builds: + labels.append(f"bindings{package.cuda_major}") + if ("core", package.cuda_variant) in builds: + labels.append(f"core{package.cuda_major}") + if ("python", package.package_root) in builds: + labels.append(f"meta{package.cuda_major}") + sdist_labels[package.package_root] = "+".join(labels) or "excluded" + return { "labels": { "build": build_label(universal=True), "build_platform": build_label(universal=False), "build_current": build_label(universal=False, current_only=True), - "sdist": { - package.package_root: "+".join( - [ - name - for selected, name in ( - (("pathfinder", None) in builds, "pathfinder"), - (("bindings", package.package_root) in builds, f"bindings{package.cuda_major}"), - (("core", package.cuda_variant) in builds, f"core{package.cuda_major}"), - (("python", package.package_root) in builds, f"meta{package.cuda_major}"), - ) - if selected - ] - ) - or "excluded" - for package in packages - }, + "sdist": sdist_labels, }, "modules": modules, "sources": { @@ -290,7 +292,7 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: or any( int(package.cuda_major) >= 13 and (("bindings", package.package_root) in builds or ("core", package.cuda_variant) in builds) - for package in packages + for package in bindings_packages ), "core_api_checks": force_all or ("core", None) in source_changes, "test_cuda_majors": {variant: variant in test_cuda_variants for variant in cuda_variants}, @@ -400,7 +402,7 @@ def main(argv: list[str] | None = None) -> None: baseline_run_id=args.baseline_run_id, release_tag=args.release_tag, ) - except BindingsConfigError as error: + except ValueError as error: parser.error(str(error)) workplan = json.dumps(plan, separators=(",", ":"), sort_keys=True) if args.github_output is None: From 8385fcd6d5e2f7bf92229140ec2d1359ddba58df Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 26 Oct 2026 14:10:15 -0700 Subject: [PATCH 5/8] Write bindings build environment variables in Python Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/tools/bindings_config.py | 55 +++++++++++++++++++++++++++++++++++++ ci/tools/env-vars | 39 ++------------------------ 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/ci/tools/bindings_config.py b/ci/tools/bindings_config.py index 5c617cdefdd..c333eedb1ed 100644 --- a/ci/tools/bindings_config.py +++ b/ci/tools/bindings_config.py @@ -280,6 +280,42 @@ def write_github_env(data: Mapping[str, object], path: Path) -> None: stream.write(f"BINDINGS_PACKAGE_ROOT={package_root}\n") +def write_build_github_env( + config: BindingsConfig, + path: Path, + *, + repo_root: Path, + python_version: str, + host_platform: str, + sha: str, +) -> None: + """Append both bindings lines' build paths and artifact names for Actions.""" + python_tag = python_version.replace(".", "") + values = {} + for role in ("current", "maintenance"): + package = config.package_for_release_status(role) + prefix = role.upper() + source = repo_root / package.package_root + artifact = f"cuda-bindings-python{python_tag}-cuda{package.toolkit_version}-{host_platform}" + values.update( + { + f"{prefix}_BINDINGS_ROOT": package.package_root, + f"{prefix}_CUDA_VERSION": package.toolkit_version, + f"{prefix}_CUDA_MAJOR": package.cuda_major, + f"{prefix}_CUDA_VARIANT": package.cuda_variant, + f"{prefix}_BINDINGS_ARTIFACT_BASENAME": artifact, + f"{prefix}_BINDINGS_ARTIFACT_NAME": f"{artifact}-{sha}", + # Forward slashes work in native Python and Git Bash on Windows. + f"{prefix}_BINDINGS_ARTIFACTS_DIR": (source / "dist").resolve().as_posix(), + f"{prefix}_BINDINGS_CYTHON_TESTS_DIR": (source / "tests" / "cython").resolve().as_posix(), + f"{prefix}_PYTHON_ARTIFACT_NAME": f"cuda-python-wheel-cuda{package.toolkit_version}", + } + ) + with path.open("a", encoding="utf-8") as stream: + for name, value in values.items(): + stream.write(f"{name}={value}\n") + + def main(argv: list[str] | None = None) -> int: """Emit normalized registry JSON or export one package for GitHub Actions.""" parser = ArgumentParser(description=__doc__) @@ -304,9 +340,28 @@ def main(argv: list[str] | None = None) -> int: help="append bindings build variables from package JSON on stdin", ) write_env.add_argument("github_env", type=Path, metavar="GITHUB_ENV") + write_build_env = commands.add_parser( + "write-build-env", + help="append both bindings lines' build variables from the registry", + ) + write_build_env.add_argument("github_env", type=Path, metavar="GITHUB_ENV") + write_build_env.add_argument("--repo-root", type=Path, default=REPO_ROOT) + write_build_env.add_argument("--python-version", required=True) + write_build_env.add_argument("--host-platform", required=True) + write_build_env.add_argument("--sha", required=True) args = parser.parse_args(argv) try: + if args.command == "write-build-env": + write_build_github_env( + load_config(args.config), + args.github_env, + repo_root=args.repo_root, + python_version=args.python_version, + host_platform=args.host_platform, + sha=args.sha, + ) + return 0 if args.command == "write-github-env": if ( args.package_roots diff --git a/ci/tools/env-vars b/ci/tools/env-vars index 48da8700669..2d01dd12a39 100755 --- a/ci/tools/env-vars +++ b/ci/tools/env-vars @@ -59,43 +59,10 @@ if [[ "${BUILD_MODE}" == "build" ]]; then exit 1 fi echo "CIBW_BUILD=cp${PYTHON_VERSION_FORMATTED}-${CIBW_PLATFORM_TAG}" >> "$GITHUB_ENV" - # Native jq on Windows emits CRLF, so treat CR as an input delimiter too. - IFS=$'\t\r' read -r \ - CURRENT_BINDINGS_ROOT CURRENT_CUDA_VERSION CURRENT_CUDA_MAJOR CURRENT_CUDA_VARIANT \ - MAINTENANCE_BINDINGS_ROOT MAINTENANCE_CUDA_VERSION MAINTENANCE_CUDA_MAJOR MAINTENANCE_CUDA_VARIANT < <( - python -m ci.tools.bindings_config --package-roots \ - | jq -er ' - (.[] | select(.release_status == "current")) as $current - | (.[] | select(.release_status == "maintenance")) as $maintenance - | [$current.package_root, $current.toolkit_version, $current.cuda_major, $current.cuda_variant, - $maintenance.package_root, $maintenance.toolkit_version, $maintenance.cuda_major, - $maintenance.cuda_variant] - | @tsv - ' - ) - CURRENT_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${CURRENT_CUDA_VERSION}-${HOST_PLATFORM}" - MAINTENANCE_BINDINGS_ARTIFACT_BASENAME="cuda-bindings-python${PYTHON_VERSION_FORMATTED}-cuda${MAINTENANCE_CUDA_VERSION}-${HOST_PLATFORM}" + python -m ci.tools.bindings_config write-build-env "$GITHUB_ENV" \ + --repo-root "$REPO_DIR" --python-version "$PY_VER" \ + --host-platform "$HOST_PLATFORM" --sha "$SHA" - { - echo "CURRENT_BINDINGS_ROOT=${CURRENT_BINDINGS_ROOT}" - echo "CURRENT_CUDA_VERSION=${CURRENT_CUDA_VERSION}" - echo "CURRENT_CUDA_MAJOR=${CURRENT_CUDA_MAJOR}" - echo "CURRENT_CUDA_VARIANT=${CURRENT_CUDA_VARIANT}" - echo "MAINTENANCE_BINDINGS_ROOT=${MAINTENANCE_BINDINGS_ROOT}" - echo "MAINTENANCE_CUDA_VERSION=${MAINTENANCE_CUDA_VERSION}" - echo "MAINTENANCE_CUDA_MAJOR=${MAINTENANCE_CUDA_MAJOR}" - echo "MAINTENANCE_CUDA_VARIANT=${MAINTENANCE_CUDA_VARIANT}" - echo "CURRENT_BINDINGS_ARTIFACT_BASENAME=${CURRENT_BINDINGS_ARTIFACT_BASENAME}" - echo "CURRENT_BINDINGS_ARTIFACT_NAME=${CURRENT_BINDINGS_ARTIFACT_BASENAME}-${SHA}" - echo "CURRENT_BINDINGS_ARTIFACTS_DIR=$(realpath "${REPO_DIR}/${CURRENT_BINDINGS_ROOT}/dist")" - echo "CURRENT_BINDINGS_CYTHON_TESTS_DIR=$(realpath "${REPO_DIR}/${CURRENT_BINDINGS_ROOT}/tests/cython")" - echo "MAINTENANCE_BINDINGS_ARTIFACT_BASENAME=${MAINTENANCE_BINDINGS_ARTIFACT_BASENAME}" - echo "MAINTENANCE_BINDINGS_ARTIFACT_NAME=${MAINTENANCE_BINDINGS_ARTIFACT_BASENAME}-${SHA}" - echo "MAINTENANCE_BINDINGS_ARTIFACTS_DIR=$(realpath "${REPO_DIR}/${MAINTENANCE_BINDINGS_ROOT}/dist")" - echo "MAINTENANCE_BINDINGS_CYTHON_TESTS_DIR=$(realpath "${REPO_DIR}/${MAINTENANCE_BINDINGS_ROOT}/tests/cython")" - echo "CURRENT_PYTHON_ARTIFACT_NAME=cuda-python-wheel-cuda${CURRENT_CUDA_VERSION}" - echo "MAINTENANCE_PYTHON_ARTIFACT_NAME=cuda-python-wheel-cuda${MAINTENANCE_CUDA_VERSION}" - } >> $GITHUB_ENV else TEST_CUDA_MAJOR="$(cut -d '.' -f 1 <<< "${CUDA_VER}")" From cb0e4efd26da9ab7293b1865e49eea5fce441b46 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 26 Oct 2026 14:10:30 -0700 Subject: [PATCH 6/8] Keep CI tests focused on selection and release outcomes Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/ci.yml | 3 +- ci/tools/compute_ci_plan.py | 34 +- ci/tools/tests/test_compute_ci_plan.py | 723 +++++-------------------- ci/tools/tests/test_download_wheels.py | 229 +++----- 4 files changed, 195 insertions(+), 794 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71db8377359..b105625a942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,8 @@ jobs: # CUDA 13 introduced Windows ARM64 mid-release-line, so there is no # CUDA 12 Windows ARM64 toolkit. Once current advances to CUDA 14, - # CUDA 13 can be built as the maintenance major on this platform. + # CUDA 13 can be built as the maintenance major on this platform; + # keep this equality so CUDA 14 does not exclude that supported line. if [[ "$current_cuda_major" == "13" ]]; then windows_arm64_single_cuda_major=true else diff --git a/ci/tools/compute_ci_plan.py b/ci/tools/compute_ci_plan.py index 44f3e9373ad..231e67ec5f3 100644 --- a/ci/tools/compute_ci_plan.py +++ b/ci/tools/compute_ci_plan.py @@ -12,7 +12,7 @@ import json import subprocess from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from .bindings_config import load_config @@ -308,33 +308,6 @@ def build_label(*, universal: bool, current_only: bool = False) -> str: } -def format_workplan_summary(plan: dict[str, Any], config: BindingsConfig) -> str: - """Describe selected work and exact artifact reuse without hiding exclusions.""" - modules = plan["modules"] - baseline = plan["baseline"] - rows = [("cuda-pathfinder", "shared", modules["pathfinder"])] - for package in config.package_roots: - for module, name in (("bindings", "cuda-bindings"), ("python", "cuda-python")): - rows.append((name, package.cuda_major, modules[module]["package_roots"][package.package_root])) - rows.append(("cuda-core", package.cuda_major, modules["core"]["cuda_majors"][package.cuda_variant])) - lines = [ - "### CI workplan", - "", - "| Component | CUDA major | Wheel | Tests |", - "| --- | --- | --- | --- |", - ] - for component, major, flags in rows: - wheel = "build" if flags["needs_build"] else "reuse exact baseline" if baseline["run_id"] else "excluded" - tests = "required" if flags["needs_test"] else "excluded by plan" - lines.append(f"| {component} | {major} | {wheel} | {tests} |") - if baseline["run_id"]: - lines.extend(("", f"Reusable artifacts: run `{baseline['run_id']}` at `{baseline['sha']}`.")) - if plan["sources"]["pathfinder"] == "published": - lines.extend(("", "Focused bindings release: tests install published cuda-pathfinder; cuda-core is excluded.")) - lines.extend(("", "Windows ARM64 builds only supported toolkit majors; CUDA 12 has no Windows ARM64 toolkit.", "")) - return "\n".join(lines) - - def _git_output(*args: str) -> bytes: return subprocess.check_output( # noqa: S603 - argv is passed directly without a shell. ["git", *args], # noqa: S607 @@ -384,7 +357,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument( "--github-step-summary", type=Path, - help="append a formatted workplan to this GitHub step summary file", + help="append the workplan JSON to this GitHub step summary file", ) args = parser.parse_args(argv) if args.github_step_summary is not None and args.github_output is None: @@ -419,9 +392,8 @@ def main(argv: list[str] | None = None) -> None: if args.github_step_summary is not None: formatted = json.dumps(plan, indent=2, sort_keys=True) with args.github_step_summary.open("a", encoding="utf-8") as summary: - summary.write(format_workplan_summary(plan, config)) summary.write( - f"\n
Full workplan JSON\n\n```json\n{formatted}\n```\n
\n" + f"### CI workplan\n\n
Workplan JSON\n\n```json\n{formatted}\n```\n
\n" ) diff --git a/ci/tools/tests/test_compute_ci_plan.py b/ci/tools/tests/test_compute_ci_plan.py index 00698146653..b55ef578a13 100644 --- a/ci/tools/tests/test_compute_ci_plan.py +++ b/ci/tools/tests/test_compute_ci_plan.py @@ -1,646 +1,167 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations +"""Protect CI selection outcomes that could otherwise silently omit coverage.""" -import json -import os -import re -import subprocess -import tempfile -import unittest +from dataclasses import replace from pathlib import Path import pytest -import yaml -from ci.tools.bindings_config import BindingsConfig, BindingsPackage, load_config -from ci.tools.compute_ci_plan import _expand_linked_paths, compute_workplan, format_workplan_summary, main +from ci.tools.bindings_config import load_config +from ci.tools.compute_ci_plan import _expand_linked_paths, compute_workplan -ALL_MODULES = {"pathfinder", "bindings", "core", "python"} -ALL_PLATFORMS = {"linux", "windows"} -VARIANT_MODULES = {"bindings", "core", "python"} -DEFAULT_BINDINGS_CONFIG = load_config() -CUDA_VARIANTS = {package.cuda_variant for package in DEFAULT_BINDINGS_CONFIG.package_roots} -PACKAGE_VARIANTS = {package.package_root: package.cuda_variant for package in DEFAULT_BINDINGS_CONFIG.package_roots} +CONFIG = load_config() +PACKAGES = CONFIG.package_roots +ROOTS = {package.package_root for package in PACKAGES} +VARIANTS = {package.cuda_variant for package in PACKAGES} +BINDINGS = {("bindings", root) for root in ROOTS} +CORE = {("core", variant) for variant in VARIANTS} +META = {("python", root) for root in ROOTS} +ALL = {("pathfinder", None), *BINDINGS, *CORE, *META} -def plan_for( - *paths: str, - baseline: bool = True, - linked_paths: set[str] | None = None, - release_tag: str = "", - bindings_config: BindingsConfig | None = None, -) -> dict[str, object]: +def plan_for(*paths, baseline=True, release_tag="", config=CONFIG, linked_paths=()): return compute_workplan( - bindings_config=bindings_config if bindings_config is not None else DEFAULT_BINDINGS_CONFIG, + bindings_config=config, paths=list(paths), - linked_paths=linked_paths if linked_paths is not None else set(), + linked_paths=set(linked_paths), merge_base="base", baseline_run_id="123" if baseline else "", release_tag=release_tag, ) -def as_dict(value: object) -> dict[str, object]: - assert isinstance(value, dict) - return value +def selected(plan, decision): + targets = set() + for component, details in plan["modules"].items(): + if component == "pathfinder": + if details[decision]: + targets.add((component, None)) + continue + selectors = details["cuda_majors" if component == "core" else "package_roots"] + targets.update((component, selector) for selector, flags in selectors.items() if flags[decision]) + return targets -def enabled(decisions: dict[str, object], key: str | None = None) -> set[str]: - if key is None: - return {name for name, value in decisions.items() if value} - return {name for name, value in decisions.items() if as_dict(value)[key]} - - -def selected(plan: dict[str, object], key: str) -> set[str]: - return enabled(as_dict(plan["modules"]), key) - - -def selected_platforms(plan: dict[str, object]) -> set[str]: - platforms = as_dict(as_dict(plan["jobs"])["platforms"]) - assert set(platforms) == ALL_PLATFORMS - return enabled(platforms) - - -def selected_variants(plan: dict[str, object], module: str, key: str) -> set[str]: - modules = as_dict(plan["modules"]) - if module == "core": - return enabled(as_dict(as_dict(modules[module])["cuda_majors"]), key) - packages = as_dict(as_dict(modules[module])["package_roots"]) - return {PACKAGE_VARIANTS[root] for root in enabled(packages, key)} - - -def selected_package_roots(plan: dict[str, object], module: str, key: str) -> set[str]: - module_plan = as_dict(as_dict(plan["modules"])[module]) - return enabled(as_dict(module_plan["package_roots"]), key) - - -def selected_core_majors(plan: dict[str, object], key: str) -> set[str]: - core = as_dict(as_dict(plan["modules"])["core"]) - return enabled(as_dict(core["cuda_majors"]), key) - - -def selected_cuda_majors(plan: dict[str, object], key: str) -> set[str]: - majors = as_dict(as_dict(plan["jobs"])[key]) - cuda_majors = as_dict(as_dict(as_dict(plan["modules"])["core"])["cuda_majors"]) - assert set(majors) == set(cuda_majors) - return enabled(majors) - - -def selected_sdist_variants(plan: dict[str, object]) -> set[str]: - jobs = as_dict(plan["jobs"]) - selected_roots = enabled(as_dict(jobs["sdist_package_roots"])) - return {PACKAGE_VARIANTS[root] for root in selected_roots} - - -def synthetic_package(package_root: str, ctk_target: str, release_status: str) -> BindingsPackage: - return BindingsPackage( - package_root=package_root, - toolkit_version=f"{ctk_target}.0", - release_status=release_status, - ) - - -def synthetic_config( - *packages: BindingsPackage, -) -> BindingsConfig: - return BindingsConfig( - schema_version=2, - package_roots=packages, - ) - - -class ComputeWorkplanTest(unittest.TestCase): - def test_path_impacts(self) -> None: - cases = { - "cuda_core/cuda/core/examples/demo.py": ({"core"}, {"core", "python"}, True), - "cuda_pathfinder/tests/test_loader.py": (set(), {"pathfinder"}, False), - "cuda_bindings_13/tests/README.md": (set(), {"bindings"}, False), - "cuda_core/pytest.ini": (set(), {"core"}, False), - "cuda_python/tests/test_import.py": (set(), {"python"}, False), - "cuda_python_test_helpers/cuda_python_test_helpers/cuda_utils.py": ( - set(), - ALL_MODULES, - False, - ), - "benchmarks/cuda_bindings/run_pyperf.py": (set(), ALL_MODULES, False), - } - - for path, (builds, tests, core_api) in cases.items(): - with self.subTest(path=path): - plan = plan_for(path) - assert selected(plan, "needs_build") == builds - assert selected(plan, "needs_test") == tests - assert selected_platforms(plan) == ALL_PLATFORMS - assert plan["jobs"]["sdist_tests"] == bool(builds) - assert plan["jobs"]["core_api_checks"] == core_api - - self._check_variant_path_impacts() - - def _check_variant_path_impacts(self) -> None: - all_variant_modules = dict.fromkeys(VARIANT_MODULES, CUDA_VARIANTS) - cases = ( - ( - ("cuda_bindings_12/cuda/bindings/driver.pyx",), - {"bindings": {"cu12"}, "python": {"cu12"}}, - {"bindings": {"cu12"}, "core": {"cu12"}, "python": {"cu12"}}, - {"cu12"}, - {"cu12"}, - ), - ( - ("cuda_bindings_13/cuda/bindings/driver.pyx",), - {"bindings": {"cu13"}, "python": {"cu13"}}, - {"bindings": {"cu13"}, "core": {"cu13"}, "python": {"cu13"}}, - {"cu13"}, - {"cu13"}, - ), - ( - ("cuda_bindings_12/examples/0_Introduction/vectorAddDrv.py",), - {}, - {"bindings": {"cu12"}}, - {"cu12"}, - set(), - ), - ( - ("cuda_bindings_13/tests/test_driver.py",), - {}, - {"bindings": {"cu13"}}, - {"cu13"}, - set(), - ), - ( - ("cuda_core/cuda/core/_device.py",), - {"core": CUDA_VARIANTS}, - {"core": CUDA_VARIANTS, "python": CUDA_VARIANTS}, - CUDA_VARIANTS, - CUDA_VARIANTS, - ), - ( - ("cuda_python/pyproject.toml",), - {"bindings": CUDA_VARIANTS, "python": CUDA_VARIANTS}, - {"python": CUDA_VARIANTS}, - CUDA_VARIANTS, - CUDA_VARIANTS, - ), - ( - ("cuda_pathfinder/cuda/pathfinder/_loader.py",), - all_variant_modules, - all_variant_modules, - CUDA_VARIANTS, - CUDA_VARIANTS, - ), - ( - ( - "cuda_bindings_12/cuda/bindings/driver.pyx", - "cuda_bindings_13/cuda/bindings/runtime.pyx", - ), - {"bindings": CUDA_VARIANTS, "python": CUDA_VARIANTS}, - all_variant_modules, - CUDA_VARIANTS, - CUDA_VARIANTS, - ), - ) - - for paths, variant_builds, variant_tests, test_majors, sdist_majors in cases: - with self.subTest(paths=paths): - plan = plan_for(*paths) - for module in VARIANT_MODULES: - builds = selected_variants(plan, module, "needs_build") - tests = selected_variants(plan, module, "needs_test") - assert builds == variant_builds.get(module, set()) - assert tests == variant_tests.get(module, set()) - - modules = plan["modules"] - assert isinstance(modules, dict) - decision = modules[module] - assert decision["needs_build"] == bool(builds) - assert decision["needs_test"] == bool(tests) - - assert selected_cuda_majors(plan, "test_cuda_majors") == test_majors - assert selected_sdist_variants(plan) == sdist_majors - - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_release_tags_select_only_the_matching_package(self) -> None: - for release_tag, package_root, variant in ( - ("v12.9.9", "cuda_bindings_12", "cu12"), - ("v13.4.1", "cuda_bindings_13", "cu13"), - ("v13.4.1b1", "cuda_bindings_13", "cu13"), - ("v13.4.1rc1", "cuda_bindings_13", "cu13"), - ("v13.4.1.dev1", "cuda_bindings_13", "cu13"), - ("v12.9.9.post1", "cuda_bindings_12", "cu12"), - ("v13.4.1.post1", "cuda_bindings_13", "cu13"), - ): - with self.subTest(release_tag=release_tag): - plan = plan_for(baseline=False, release_tag=release_tag) - assert selected(plan, "needs_build") == {"bindings", "python"} - assert selected(plan, "needs_test") == {"bindings", "python"} - for module in ("bindings", "python"): - assert selected_package_roots(plan, module, "needs_build") == {package_root} - assert selected_package_roots(plan, module, "needs_test") == {package_root} - assert selected_variants(plan, module, "needs_build") == {variant} - assert selected_variants(plan, module, "needs_test") == {variant} - assert not selected_variants(plan, "core", "needs_build") - assert not selected_variants(plan, "core", "needs_test") - assert selected_cuda_majors(plan, "test_cuda_majors") == {variant} - assert selected_sdist_variants(plan) == {variant} - assert selected_platforms(plan) == ALL_PLATFORMS - assert plan["jobs"]["sdist_tests"] - assert not plan["jobs"]["core_api_checks"] - assert plan["baseline"] == {"run_id": "", "sha": ""} - - def test_test_infrastructure_platforms(self) -> None: - cases = { - ".github/workflows/test-wheel-linux.yml": {"linux"}, - ".github/workflows/test-wheel-windows.yml": {"windows"}, - "ci/tools/configure_driver_mode.ps1": {"windows"}, - "ci/tools/guess_latest.sh": {"linux"}, - "ci/tools/install_gpu_driver.ps1": {"windows"}, - "ci/tools/install_gpu_driver.sh": {"linux"}, - "ci/tools/setup-sanitizer": {"linux"}, - } - - for path, platforms in cases.items(): - with self.subTest(path=path): - plan = plan_for(path) - assert not selected(plan, "needs_build") - assert selected(plan, "needs_test") == ALL_MODULES - for module in VARIANT_MODULES: - assert selected_variants(plan, module, "needs_test") == CUDA_VARIANTS - assert selected_platforms(plan) == platforms - assert not plan["jobs"]["sdist_tests"] - assert not plan["jobs"]["core_api_checks"] - assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS - assert not selected_sdist_variants(plan) - - mixed_plan = plan_for("ci/tools/install_gpu_driver.sh", "ci/tools/install_gpu_driver.ps1") - assert selected_platforms(mixed_plan) == ALL_PLATFORMS - - source_plan = plan_for("ci/tools/install_gpu_driver.sh", "cuda_python/pyproject.toml") - assert selected_platforms(source_plan) == ALL_PLATFORMS - - def test_ignored_paths_select_no_work(self) -> None: - empty_plan = plan_for() - for path in ( - "cuda_core/docs/index.rst", - "cuda_core/pixi.toml", - "cuda_core/tests/fixtures/pixi.toml", - "benchmarks/cuda_bindings/pixi.toml", - "benchmarks/cuda_bindings/AGENTS.md", - "cuda_core/cuda/core/_cpp/rt/DESIGN.md", - "cuda_bindings_13/README.md", - "cuda_bindings_12/README.md", - "cuda_bindings_12/docs/index.rst", - "cuda_core/README.md", - "new-area/pixi.toml", - "notes.md", - "diagram.svg", - ".github/labeler.yml", - ".github/ISSUE_TEMPLATE/bug.yml", - ): - with self.subTest(path=path): - assert plan_for(path) == empty_plan - - def test_unknown_path_and_missing_baseline_force_all(self) -> None: - for plan in ( - plan_for("new-top-level-file"), - plan_for("new-area/config.toml"), - plan_for(".github/workflows/new-main-ci-workflow.yml"), - plan_for(".github/actions/doc_preview/action.yml"), - plan_for("cuda_core/docs/index.rst", baseline=False), - compute_workplan( - bindings_config=DEFAULT_BINDINGS_CONFIG, - paths=[], - linked_paths=set(), - merge_base="", - baseline_run_id="123", - release_tag="", - ), - plan_for(baseline=False, release_tag="cuda-core-v1.3.0"), - ): - assert selected(plan, "needs_build") == ALL_MODULES - assert selected(plan, "needs_test") == ALL_MODULES - for module in VARIANT_MODULES: - assert selected_variants(plan, module, "needs_build") == CUDA_VARIANTS - assert selected_variants(plan, module, "needs_test") == CUDA_VARIANTS - assert selected_platforms(plan) == ALL_PLATFORMS - assert plan["jobs"]["core_api_checks"] - assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS - assert selected_sdist_variants(plan) == CUDA_VARIANTS - assert plan["baseline"] == {"run_id": "", "sha": ""} - - for release_tag in ("v12.8.1", "v13.5.0", "v14.0.0"): - with ( - self.subTest(release_tag=release_tag), - pytest.raises(ValueError, match="no configured CUDA bindings package root"), - ): - plan_for(baseline=False, release_tag=release_tag) - - def test_mixed_changes_are_combined(self) -> None: - plan = plan_for("cuda_core/tests/test_device.py", "cuda_python/pyproject.toml") - assert selected(plan, "needs_build") == {"bindings", "python"} - assert selected(plan, "needs_test") == {"core", "python"} - assert selected_variants(plan, "bindings", "needs_build") == CUDA_VARIANTS - assert selected_variants(plan, "python", "needs_build") == CUDA_VARIANTS - assert selected_variants(plan, "core", "needs_test") == CUDA_VARIANTS - assert selected_variants(plan, "python", "needs_test") == CUDA_VARIANTS - assert selected_platforms(plan) == ALL_PLATFORMS - assert plan["jobs"]["sdist_tests"] - assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS - assert selected_sdist_variants(plan) == CUDA_VARIANTS - assert plan["baseline"] == {"run_id": "123", "sha": "base"} - - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_mixed_bindings_source_and_core_test_keep_line_specific_tests(self) -> None: - plan = plan_for( - "cuda_bindings_12/cuda/bindings/driver.pyx", - "cuda_core/tests/test_device.py", - ) - - assert selected(plan, "needs_test") == {"bindings", "core", "python"} - assert selected_package_roots(plan, "bindings", "needs_test") == {"cuda_bindings_12"} - assert selected_core_majors(plan, "needs_test") == CUDA_VARIANTS - assert selected_package_roots(plan, "python", "needs_test") == {"cuda_bindings_12"} - assert selected_cuda_majors(plan, "test_cuda_majors") == CUDA_VARIANTS - - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_wheel_workflows_consume_line_specific_test_gates(self) -> None: - expected_fragments = { - "TEST_BINDINGS": ( - "matrix.BINDINGS_SOURCE == 'local'", - ".modules.bindings.package_roots[matrix.BINDINGS_SOURCE_DIR].needs_test", - ), - "TEST_CORE": (".modules.core.cuda_majors[matrix.CUDA_VARIANT].needs_test",), - "TEST_PYTHON": ( - "matrix.BINDINGS_SOURCE == 'local'", - ".modules.python.package_roots[matrix.BINDINGS_SOURCE_DIR].needs_test", - ), - } - - repo_root = Path(__file__).resolve().parents[3] - for platform in ("linux", "windows"): - with self.subTest(platform=platform): - workflow = yaml.safe_load( - (repo_root / ".github" / "workflows" / f"test-wheel-{platform}.yml").read_text(encoding="utf-8") - ) - gates = workflow["jobs"]["test"]["env"] - for name, fragments in expected_fragments.items(): - with self.subTest(platform=platform, gate=name): - assert all(fragment in gates[name] for fragment in fragments) - - def test_changed_symlink_targets_include_their_consumers(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "cuda_python").mkdir() - (root / "cuda_core").mkdir() - (root / "README.md").write_text("readme", encoding="utf-8") - (root / "cuda_python" / "README.md").symlink_to("../README.md") - (root / "cuda_core" / "README.md").symlink_to("../README.md") - - paths = _expand_linked_paths( - ["README.md"], - ["cuda_python/README.md"], - root=root, - ) +@pytest.mark.parametrize( + ("path", "builds", "tests"), + [ + ("cuda_pathfinder/cuda/pathfinder/_loader.py", ALL, ALL), + ("cuda_core/cuda/core/_device.py", CORE, CORE | META), + ("cuda_python/setup.py", BINDINGS | META, META), + ("cuda_core/tests/test_device.py", set(), CORE), + ("cuda_pathfinder/tests/test_loader.py", set(), {("pathfinder", None)}), + ("cuda_python/tests/test_import.py", set(), META), + ("cuda_python_test_helpers/cuda_python_test_helpers/cuda_utils.py", set(), ALL), + ("benchmarks/cuda_bindings/run_pyperf.py", set(), ALL), + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_source_and_test_changes_select_their_consumers(path, builds, tests): + plan = plan_for(path) + assert selected(plan, "needs_build") == builds + assert selected(plan, "needs_test") == tests + assert {platform for platform, enabled in plan["jobs"]["platforms"].items() if enabled} == {"linux", "windows"} - assert paths == ["README.md", "cuda_python/README.md"] - plan = plan_for(*paths, linked_paths={"cuda_python/README.md"}) - removed_link = plan_for("cuda_python/README.md", linked_paths={"cuda_python/README.md"}) - for linked_plan in (plan, removed_link): - assert selected(linked_plan, "needs_build") == {"bindings", "python"} - assert selected(linked_plan, "needs_test") == {"python"} - for module in ("bindings", "python"): - assert selected_variants(linked_plan, module, "needs_build") == CUDA_VARIANTS - assert selected_variants(linked_plan, "python", "needs_test") == CUDA_VARIANTS - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_current_package_can_move_to_a_new_cuda_major(self) -> None: - package_11 = synthetic_package("cuda_bindings_11", "11.8", "maintenance") - package_12 = synthetic_package("cuda_bindings_12", "12.0", "current") - config = synthetic_config( - package_11, - package_12, - ) +@pytest.mark.parametrize("package", PACKAGES, ids=lambda package: package.package_root) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_bindings_changes_reuse_core_and_test_the_matching_major(package): + own = {("bindings", package.package_root), ("python", package.package_root)} + plan = plan_for(f"{package.package_root}/cuda/bindings/driver.pyx") + assert selected(plan, "needs_build") == own + assert selected(plan, "needs_test") == own | {("core", package.cuda_variant)} + assert plan["baseline"] == {"run_id": "123", "sha": "base"} + assert {root for root, enabled in plan["jobs"]["sdist_package_roots"].items() if enabled} == {package.package_root} - plan = plan_for( - "cuda_bindings_12/cuda/bindings/runtime.pyx", - bindings_config=config, - ) + tests_only = plan_for(f"{package.package_root}/tests/test_driver.py") + assert selected(tests_only, "needs_build") == set() + assert selected(tests_only, "needs_test") == {("bindings", package.package_root)} - assert selected_package_roots(plan, "bindings", "needs_build") == {package_12.package_root} - assert selected_package_roots(plan, "python", "needs_build") == {package_12.package_root} - assert selected_core_majors(plan, "needs_build") == set() - assert selected_core_majors(plan, "needs_test") == {"cu12"} - assert selected_cuda_majors(plan, "test_cuda_majors") == {"cu12"} - assert selected_sdist_variants(plan) == {"cu12"} - core_plan = plan_for("cuda_core/cuda/core/_device.py", bindings_config=config) - assert selected_core_majors(core_plan, "needs_build") == {"cu11", "cu12"} - assert selected_package_roots(core_plan, "python", "needs_test") == { - package_11.package_root, - package_12.package_root, - } +@pytest.mark.parametrize("path", ["ci/tools/env-vars", ".github/workflows/build-wheel.yml", "unknown-build-input"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_shared_or_unknown_infrastructure_requires_every_component(path): + plan = plan_for(path) + assert selected(plan, "needs_build") == ALL + assert selected(plan, "needs_test") == ALL + assert plan["baseline"]["run_id"] == "" -@pytest.mark.agent_authored(model="gpt-5.6") -def test_github_outputs_are_emitted_without_shell_json_transforms(tmp_path: Path) -> None: - output = tmp_path / "github-output" - summary = tmp_path / "github-summary" +@pytest.mark.parametrize("platform", ["linux", "windows"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_platform_test_infrastructure_reuses_wheels_but_runs_every_suite(platform): + plan = plan_for(f".github/workflows/test-wheel-{platform}.yml") + assert selected(plan, "needs_build") == set() + assert selected(plan, "needs_test") == ALL + assert {name for name, enabled in plan["jobs"]["platforms"].items() if enabled} == {platform} - main( - [ - "--release-tag", - "v13.4.1", - "--github-output", - str(output), - "--github-step-summary", - str(summary), - ] - ) - records = dict(line.split("=", maxsplit=1) for line in output.read_text(encoding="utf-8").splitlines()) - assert set(records) == {"bindings-config", "workplan"} - registry = json.loads(records["bindings-config"]) - assert [package["package_root"] for package in registry["package_roots"]] == [ - "cuda_bindings_12", - "cuda_bindings_13", - ] - assert json.loads(records["workplan"])["jobs"]["sdist_package_roots"] == { - "cuda_bindings_12": False, - "cuda_bindings_13": True, - } - assert "### CI workplan" in summary.read_text(encoding="utf-8") +@pytest.mark.parametrize( + "path", + ["README.md", "AGENTS.md", "cuda_core/docs/source/install.rst", "cuda_core/pixi.lock", "cuda_core/pixi.toml"], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_documentation_and_environment_only_changes_do_not_rebuild_packages(path): + plan = plan_for(path) + assert selected(plan, "needs_build") == set() + assert selected(plan, "needs_test") == set() -if __name__ == "__main__": - unittest.main() +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_missing_baselines_and_non_bindings_tags_require_full_coverage(): + cases = [plan_for(baseline=False), plan_for(release_tag="cuda-core-v1.3.0")] + cases.extend(plan_for(f"{package.package_root}/cuda/bindings/driver.pyx", baseline=False) for package in PACKAGES) + for plan in cases: + assert selected(plan, "needs_build") == ALL + assert selected(plan, "needs_test") == ALL + assert plan["baseline"] == {"run_id": "", "sha": ""} +@pytest.mark.parametrize("package", PACKAGES, ids=lambda package: package.package_root) @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_workplan_labels_and_summary_show_component_majors_and_exclusions() -> None: - plan = plan_for(release_tag="v12.9.11") - assert plan["labels"] == { - "build": "bindings12+meta12", - "build_platform": "bindings12", - "build_current": "baseline wheels and test extensions", - "sdist": {"cuda_bindings_12": "bindings12+meta12", "cuda_bindings_13": "excluded"}, - } - assert plan["jobs"]["windows_arm64_build"] is False - summary = format_workplan_summary(plan, DEFAULT_BINDINGS_CONFIG) - assert "| cuda-bindings | 12 | build | required |" in summary - assert "| cuda-bindings | 13 | excluded | excluded by plan |" in summary - assert "published cuda-pathfinder" in summary - assert "CUDA 12 has no Windows ARM64 toolkit" in summary +def test_release_tags_build_only_their_own_bindings_and_metapackage(package): + plan = plan_for("ci/tools/env-vars", release_tag=f"v{package.ctk_target}.99") + expected = {("bindings", package.package_root), ("python", package.package_root)} + assert selected(plan, "needs_build") == expected + assert selected(plan, "needs_test") == expected + assert plan["sources"]["pathfinder"] == "published" + assert plan["baseline"]["run_id"] == "" + assert {root for root, enabled in plan["jobs"]["sdist_package_roots"].items() if enabled} == {package.package_root} -@pytest.mark.parametrize("major", ["12", "13"]) @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_bindings_changes_reuse_core_and_test_the_matching_major(major: str) -> None: - plan = plan_for(f"cuda_bindings_{major}/cuda/bindings/driver.pyx") - assert plan["labels"]["build_platform"] == f"bindings{major}" - assert plan["labels"]["build_current"] == ("bindings13" if major == "13" else "baseline wheels and test extensions") - assert not selected_core_majors(plan, "needs_build") - assert selected_core_majors(plan, "needs_test") == {f"cu{major}"} - assert plan["baseline"] == {"run_id": "123", "sha": "base"} - summary = format_workplan_summary(plan, DEFAULT_BINDINGS_CONFIG) - assert f"| cuda-core | {major} | reuse exact baseline | required |" in summary - other_major = "13" if major == "12" else "12" - assert f"| cuda-core | {other_major} | reuse exact baseline | excluded by plan |" in summary - assert "Reusable artifacts: run `123` at `base`." in summary +def test_mixed_changes_combine_coverage_without_rebuilding_unchanged_core(): + package = PACKAGES[0] + plan = plan_for(f"{package.package_root}/cuda/bindings/driver.pyx", "cuda_core/tests/test_device.py") + own = {("bindings", package.package_root), ("python", package.package_root)} + assert selected(plan, "needs_build") == own + assert selected(plan, "needs_test") == own | CORE - # A missing exact baseline still requires a complete rebuild. - cold_plan = plan_for(f"cuda_bindings_{major}/cuda/bindings/driver.pyx", baseline=False) - assert selected_core_majors(cold_plan, "needs_build") == CUDA_VARIANTS + both = plan_for(*(f"{package.package_root}/cuda/bindings/driver.pyx" for package in PACKAGES)) + assert selected(both, "needs_build") == BINDINGS | META + assert selected(both, "needs_test") == BINDINGS | CORE | META -@pytest.mark.parametrize("platform", ["linux", "windows"]) -@pytest.mark.parametrize("scenario", ["full", "drop-local", "maintenance-only", "nightly-floor", "nightly-standard"]) @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_workflow_matrix_executes_component_selection_and_coverage_guard( - tmp_path: Path, platform: str, scenario: str -) -> None: - workflow = Path(__file__).resolve().parents[3] / ".github" / "workflows" / f"test-wheel-{platform}.yml" - document = yaml.safe_load(workflow.read_text()) - step = next(step for step in document["jobs"]["compute-matrix"]["steps"] if step.get("id") == "compute-matrix") - script = re.sub(r"^TEST_MATRIX=\$\(yq.*$", 'TEST_MATRIX="$CI_TEST_MATRIX"', step["run"], flags=re.MULTILINE) - matrix_filter = 'map(select(.CUDA_VER != "12.9.1"))' if scenario == "drop-local" else "." - script = script.replace("${{ inputs.matrix_filter }}", matrix_filter) - plan = ( - plan_for("cuda_bindings_12/cuda/bindings/driver.pyx") - if scenario == "maintenance-only" - else plan_for(baseline=False) - ) - versions = ["12.9.1", "13.4.2", "12.6.3", "13.0.2"] - if scenario == "nightly-floor": - versions = ["12.6.3"] - elif scenario == "nightly-standard": - versions = ["13.4.2"] - matrix = [ - dict(CUDA_VER=version, PY_VER="3.12", LOCAL_CTK="0", GPU="l4", GPU_COUNT="1", DRIVER="latest") - for version in versions - ] - output = tmp_path / "output" - summary = tmp_path / "summary" - env = { - **os.environ, - "BINDINGS_CONFIG": DEFAULT_BINDINGS_CONFIG.to_json(), - "BUILD_TYPE": "nightly" if scenario.startswith("nightly-") else "pull-request", - "TEST_MODE": "nightly-pytorch" if scenario == "nightly-floor" else "standard", - "WORKPLAN": "" if scenario.startswith("nightly-") else json.dumps(plan), - "ARCH": "amd64", - "GITHUB_OUTPUT": str(output), - "GITHUB_STEP_SUMMARY": str(summary), - "CI_TEST_MATRIX": json.dumps(matrix), - } - result = subprocess.run( # noqa: S603 - executes the checked-in workflow script - ["/bin/bash", "-euo", "pipefail", "-c", script], env=env, text=True, capture_output=True, check=False - ) - if scenario == "drop-local": - assert result.returncode != 0 - assert "omits required local-wheel coverage: cuda_bindings_12" in result.stderr - return - assert result.returncode == 0, result.stderr - rows = json.loads(output.read_text().strip().removeprefix("MATRIX="))["include"] - if scenario == "maintenance-only": - assert [row["SUITES"] for row in rows] == ["bindings12+core12+python12", "core12"] - elif scenario == "nightly-floor": - assert rows[0]["BINDINGS_SOURCE"] == "floor" - assert rows[0]["SUITES"] == "nightly-pytorch" - elif scenario == "full": - assert rows[0]["SUITES"] == "pathfinder+bindings12+core12+python12" - assert rows[1]["SUITES"] == "pathfinder+bindings13+core13+python13" - assert rows[2]["SUITES"] == "pathfinder+core12" - assert "Selected wheel test coverage" in summary.read_text() +def test_symlinked_package_inputs_are_not_mistaken_for_documentation_only(tmp_path: Path): + (tmp_path / "cuda_python").mkdir() + (tmp_path / "README.md").write_text("readme", encoding="utf-8") + (tmp_path / "cuda_python" / "README.md").symlink_to("../README.md") + paths = _expand_linked_paths(["README.md"], ["cuda_python/README.md"], root=tmp_path) + for changes in (paths, ["cuda_python/README.md"]): + plan = plan_for(*changes, linked_paths={"cuda_python/README.md"}) + assert selected(plan, "needs_build") == BINDINGS | META + assert selected(plan, "needs_test") == META -@pytest.mark.parametrize("platform", ["linux", "windows"]) -@pytest.mark.parametrize( - ("major", "bindings", "core", "pathfinder_source", "resolve_dependencies"), - [ - ("13", True, False, "published", True), - ("13", True, False, "artifact", True), - ("13", False, False, "artifact", True), - ("13", True, True, "artifact", False), - ("12", True, False, "published", False), - ], -) @pytest.mark.agent_authored(model="gpt-6.1-sol") -def test_metapackage_install_resolves_published_core_for_bindings_only_cuda13( - tmp_path: Path, - platform: str, - major: str, - bindings: bool, - core: bool, - pathfinder_source: str, - resolve_dependencies: bool, -) -> None: - workflow = Path(__file__).resolve().parents[3] / ".github" / "workflows" / f"test-wheel-{platform}.yml" - document = yaml.safe_load(workflow.read_text()) - step = next( - step for step in document["jobs"]["test"]["steps"] if step.get("name") == "Ensure cuda-python installable" - ) - commands = tmp_path / "bin" - commands.mkdir() - pip = commands / "pip" - pip.write_text('#!/bin/bash\nprintf "%s\\n" "$@" > "$PIP_ARGUMENTS"\n') - pip.chmod(0o755) - python = commands / "python" - python.write_text('#!/bin/bash\nprintf "cuda-pathfinder==1.4.0\\n"\n') - python.chmod(0o755) - pathfinder = tmp_path / "cuda_pathfinder" - pathfinder.mkdir() - (pathfinder / "cuda_pathfinder-1.4.0-py3-none-any.whl").touch() - bindings_dist = tmp_path / "bindings" - bindings_dist.mkdir() - bindings_wheel = bindings_dist / f"cuda_bindings-{major}.4.0-cp312-cp312-linux_x86_64.whl" - bindings_wheel.touch() - (tmp_path / f"cuda_python-{major}.4.0-py3-none-any.whl").touch() - arguments = tmp_path / "pip-arguments" - result = subprocess.run( # noqa: S603 - executes the checked-in workflow script with stub installers - ["/bin/bash", "-euo", "pipefail", "-c", step["run"]], - cwd=tmp_path, - env={ - **os.environ, - "PATH": f"{commands}{os.pathsep}{os.environ['PATH']}", - "PIP_ARGUMENTS": str(arguments), - "CUDA_BINDINGS_ARTIFACTS_DIR": str(bindings_dist), - "TEST_CUDA_MAJOR": major, - "TEST_BINDINGS": str(bindings).lower(), - "TEST_CORE": str(core).lower(), - "PATHFINDER_SOURCE": pathfinder_source, - "LOCAL_CTK": "0", - }, - text=True, - capture_output=True, - check=False, - ) - assert result.returncode == 0, result.stderr - captured = arguments.read_text().splitlines() - assert ("--no-deps" not in captured) is resolve_dependencies - if resolve_dependencies: - assert str(bindings_wheel) in captured - if pathfinder_source == "published": - assert "cuda-pathfinder==1.4.0" in captured - else: - assert "./cuda_pathfinder/cuda_pathfinder-1.4.0-py3-none-any.whl" in captured +def test_registered_source_roots_drive_bindings_selection(): + packages = tuple(replace(package, package_root=f"renamed_{index}") for index, package in enumerate(PACKAGES)) + config = replace(CONFIG, package_roots=packages) + package = packages[0] + plan = plan_for(f"{package.package_root}/cuda/bindings/driver.pyx", config=config) + own = {("bindings", package.package_root), ("python", package.package_root)} + assert selected(plan, "needs_build") == own + assert selected(plan, "needs_test") == own | {("core", package.cuda_variant)} diff --git a/ci/tools/tests/test_download_wheels.py b/ci/tools/tests/test_download_wheels.py index 323974b1b17..c348af8bc29 100644 --- a/ci/tools/tests/test_download_wheels.py +++ b/ci/tools/tests/test_download_wheels.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations +"""Keep unrelated CUDA lines and test artifacts out of release staging.""" import json import os @@ -11,7 +11,6 @@ import pytest DOWNLOAD_WHEELS = Path(__file__).parent.parent / "download-wheels" - FAKE_GH = r"""#!/usr/bin/env python3 import fnmatch import json @@ -20,190 +19,98 @@ from pathlib import Path args = sys.argv[1:] -with Path(os.environ["FAKE_GH_LOG"]).open("a", encoding="utf-8") as stream: - print(json.dumps(args), file=stream) artifacts = json.loads(os.environ["FAKE_ARTIFACTS"]) - if args[:1] == ["api"]: print(*artifacts, sep="\n") raise SystemExit(0) - if args[:2] == ["run", "download"]: - if "--name" in args: - name = args[args.index("--name") + 1] - selected = [artifact for artifact in artifacts if artifact == name] - else: - pattern = args[args.index("-p") + 1] - selected = [artifact for artifact in artifacts if fnmatch.fnmatchcase(artifact, pattern)] - if not selected: - raise SystemExit(1) - destination = Path(args[args.index("--dir") + 1]) + selector = "--name" if "--name" in args else "-p" + pattern = args[args.index(selector) + 1] + selected = [artifact for artifact in artifacts if fnmatch.fnmatchcase(artifact, pattern)] for artifact in selected: - artifact_dir = destination / artifact - artifact_dir.mkdir(parents=True) + destination = Path(args[args.index("--dir") + 1]) / artifact + destination.mkdir(parents=True) suffix = ".so" if artifact.endswith("-tests") else ".whl" - (artifact_dir / f"{artifact}{suffix}").touch() - raise SystemExit(0) - + (destination / f"{artifact}{suffix}").touch() + raise SystemExit(0 if selected else 1) raise SystemExit(2) """ -@pytest.fixture -def fake_gh(tmp_path): - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - gh = fake_bin / "gh" - gh.write_text(FAKE_GH, encoding="utf-8") - gh.chmod(0o755) - return fake_bin - - -def resolved_package(*, package_root="cuda_bindings", toolkit_version="13.3.0", release_version="13.3.1"): - return json.dumps( - {"package_root": package_root, "toolkit_version": toolkit_version, "release_version": release_version} - ) - - -def run_download(tmp_path, fake_gh, artifacts, component, *, tag="", package=None): - log = tmp_path / "gh.log" - env = os.environ.copy() - env.update( - { - "FAKE_ARTIFACTS": json.dumps(artifacts), - "FAKE_GH_LOG": str(log), - "GH_TOKEN": "test-token", - "PATH": f"{fake_gh}{os.pathsep}{env['PATH']}", - } - ) - args = [str(DOWNLOAD_WHEELS), "123", component, "NVIDIA/cuda-python", str(tmp_path / "dist")] - if tag or package is not None: - args.append(tag) +def run_download(tmp_path, artifacts, component, *, package=None, tag=""): + binary = tmp_path / "gh" + binary.write_text(FAKE_GH, encoding="utf-8") + binary.chmod(0o755) + args = [str(DOWNLOAD_WHEELS), "123", component, "NVIDIA/cuda-python", str(tmp_path / "dist"), tag] if package is not None: - args.append(package) - result = subprocess.run( # noqa: S603 + args.append(json.dumps(package)) + return subprocess.run( # noqa: S603 - the checked-in utility receives separate arguments. args, cwd=tmp_path, - env=env, + env={ + **os.environ, + "FAKE_ARTIFACTS": json.dumps(artifacts), + "GH_TOKEN": "test-token", + "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + }, check=False, capture_output=True, text=True, ) - commands = [json.loads(item) for item in log.read_text(encoding="utf-8").splitlines()] if log.exists() else [] - return result, commands -class TestDownloadWheels: - @pytest.mark.parametrize( - ("package_root", "toolkit_version", "release_version"), - ( - ("cuda_bindings", "13.3.0", "13.3.1"), - ("cuda_bindings_12", "12.9.1", "12.9.9"), - ), +@pytest.mark.parametrize("component", ["cuda-bindings", "cuda-python"]) +@pytest.mark.parametrize("major,toolkit", [(12, "12.9.1"), (13, "13.3.0")]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_release_stages_only_wheels_for_its_exact_toolkit(tmp_path, component, major, toolkit): + release = f"{toolkit.rsplit('.', 1)[0]}.99" + package = {"package_root": f"cuda_bindings_{major}", "toolkit_version": toolkit, "release_version": release} + prefix = "cuda-bindings-python312" if component == "cuda-bindings" else "cuda-python-wheel" + suffix = "-linux-64-sha" if component == "cuda-bindings" else "" + expected = f"{prefix}-cuda{toolkit}{suffix}" + result = run_download( + tmp_path, + [f"{prefix}-cuda11.8.0{suffix}", expected, f"{expected}-tests", "cuda-python-wheel"], + component, + package=package, + tag=f"v{package['release_version']}", ) - @pytest.mark.agent_authored(model="gpt-6") - def test_python_selects_exact_toolkit_artifact( - self, tmp_path, fake_gh, package_root, toolkit_version, release_version - ): - expected = f"cuda-python-wheel-cuda{toolkit_version}" - result, commands = run_download( - tmp_path, - fake_gh, - ["cuda-python-wheel", "cuda-python-wheel-cuda13.2.0", expected], - "cuda-python", - tag=f"v{release_version}", - package=resolved_package( - package_root=package_root, toolkit_version=toolkit_version, release_version=release_version - ), - ) - - assert result.returncode == 0, result.stderr - downloads = [command[command.index("--name") + 1] for command in commands if "--name" in command] - assert downloads == [expected] - assert [path.name for path in (tmp_path / "dist").glob("*.whl")] == [f"{expected}.whl"] - - @pytest.mark.parametrize("artifact", ("cuda-python-wheel", "cuda-python-wheel-cuda13.2.0")) - @pytest.mark.agent_authored(model="gpt-6") - def test_python_requires_exact_toolkit_artifact(self, tmp_path, fake_gh, artifact): - result, commands = run_download( - tmp_path, - fake_gh, - [artifact], - "cuda-python", - tag="v13.3.1", - package=resolved_package(), - ) - - assert result.returncode == 1 - assert "no unexpired release artifact for CUDA 13.3.0" in result.stderr - assert not any(command[:2] == ["run", "download"] for command in commands) - - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_bindings_pattern_contains_exact_toolkit_pin(self, tmp_path, fake_gh): - exact = "cuda-bindings-python312-cuda13.3.0-linux-64-sha" - result, commands = run_download( - tmp_path, - fake_gh, - ["cuda-bindings-python312-cuda13.2.0-linux-64-sha", exact, f"{exact}-tests"], - "cuda-bindings", - tag="v13.3.1", - package=resolved_package(), - ) - - assert result.returncode == 0, result.stderr - downloads = [command[command.index("--name") + 1] for command in commands if "--name" in command] - assert downloads == [exact] - assert [path.name for path in (tmp_path / "dist").glob("*.whl")] == [f"{exact}.whl"] - - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_release_routing_requires_resolved_package(self, tmp_path, fake_gh): - result, commands = run_download( - tmp_path, - fake_gh, - ["cuda-python-wheel-cuda13.3.0"], - "cuda-python", - tag="v13.3.1", - ) - - assert result.returncode == 1 - assert commands == [] + assert result.returncode == 0, result.stderr + assert {path.name for path in (tmp_path / "dist").iterdir()} == {f"{expected}.whl"} + + +@pytest.mark.parametrize("component", ["cuda-bindings", "cuda-python"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_release_cannot_stage_another_toolkit_or_test_artifacts(tmp_path, component): + package = {"package_root": "cuda_bindings_13", "toolkit_version": "13.3.0", "release_version": "13.3.1"} + result = run_download( + tmp_path, + ["cuda-bindings-python312-cuda13.3.0-linux-64-sha-tests", "cuda-python-wheel-cuda13.2.0"], + component, + package=package, + tag="v13.3.1", + ) + assert result.returncode != 0 + assert not list((tmp_path / "dist").glob("*.whl")) - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_bindings_test_artifact_is_not_releasable(self, tmp_path, fake_gh): - artifact = "cuda-bindings-python312-cuda13.3.0-linux-64-sha-tests" - result, _ = run_download( - tmp_path, - fake_gh, - [artifact], - "cuda-bindings", - tag="v13.3.1", - package=resolved_package(), - ) - assert result.returncode == 1 - assert "no unexpired release artifact" in result.stderr +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_release_routing_requires_a_resolved_package(tmp_path): + result = run_download(tmp_path, ["cuda-python-wheel-cuda13.3.0"], "cuda-python", tag="v13.3.1") + assert result.returncode != 0 + assert not list((tmp_path / "dist").glob("*.whl")) @pytest.mark.parametrize( - ("component", "artifacts", "expected"), - ( - ( - "all", - ["cuda-core-wheel", "cuda-core-wheel-tests", "cuda-python-wheel-cuda13.3.0", "build-metadata"], - ["cuda-core-wheel", "cuda-python-wheel-cuda13.3.0"], - ), - ( - "cuda-core", - ["cuda-core-wheel", "cuda-core-wheel-tests", "cuda-python-wheel-cuda13.3.0"], - ["cuda-core-wheel"], - ), - ), + "component,expected", + [("all", {"cuda-core-wheel", "cuda-python-wheel-cuda13.3.0"}), ("cuda-core", {"cuda-core-wheel"})], ) -@pytest.mark.agent_authored(model="gpt-5.6") -def test_non_release_selection_excludes_test_artifacts(tmp_path, fake_gh, component, artifacts, expected): - result, commands = run_download(tmp_path, fake_gh, artifacts, component) - +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_nonrelease_components_stage_wheels_without_test_artifacts(tmp_path, component, expected): + result = run_download( + tmp_path, + ["cuda-core-wheel", "cuda-core-wheel-tests", "cuda-python-wheel-cuda13.3.0", "build-metadata"], + component, + ) assert result.returncode == 0, result.stderr - downloads = [command[command.index("--name") + 1] for command in commands if "--name" in command] - assert downloads == expected - assert all("-p" not in command for command in commands) + assert {path.name for path in (tmp_path / "dist").iterdir()} == {f"{artifact}.whl" for artifact in expected} From 767ec4d6add0e3dc784966a993f6a704f9f37ba5 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 26 Oct 2026 14:18:13 -0700 Subject: [PATCH 7/8] Introduce SCM preview bootstrap with its CI build callers Signed-off-by: Ralf W. Grosse-Kunstleve --- ci/README.md | 9 ++ ci/tools/bindings_scm_version.py | 124 ++++++++++++++++++++ ci/tools/tests/test_bindings_scm_version.py | 78 ++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 ci/tools/bindings_scm_version.py create mode 100644 ci/tools/tests/test_bindings_scm_version.py diff --git a/ci/README.md b/ci/README.md index e1292fa7e56..458738ae5b5 100644 --- a/ci/README.md +++ b/ci/README.md @@ -292,7 +292,16 @@ Copying maintenance sources does not make their later branch tags ancestors of release. Follow the [Git-derived version lifecycle](../AGENTS.md#git-derived-version-lifecycle) and keep temporary preview tags local to an independent disposable clone; worktrees share tags with their repository. +CI uses `ci.tools.bindings_scm_version` in disposable build checkouts until an +appropriate maintenance milestone is reachable. It creates a local preview tag +at a validated ancestor only when required; existing suitable tags govern the +version through standard setuptools-scm. It never pushes or moves tags. +For a local maintenance preview, use an independent disposable clone (not a +linked worktree), install the CI tools, and run from its root: +```console +python -m ci.tools.bindings_scm_version --package-root cuda_bindings_12 --sha "$(git rev-parse HEAD)" +``` ### SCM Milestones and Bootstrap Targets diff --git a/ci/tools/bindings_scm_version.py b/ci/tools/bindings_scm_version.py new file mode 100644 index 00000000000..edb6e60b5c5 --- /dev/null +++ b/ci/tools/bindings_scm_version.py @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bootstrap standard SCM versioning in a disposable pre-milestone build checkout. + +Synthetic tags stay local to this checkout. Never push them or run this helper +in a shared development repository. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +from packaging.version import Version +from setuptools_scm import Configuration, get_version +from setuptools_scm.version import tag_to_version + +from . import bindings_config + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMMIT_PATTERN = re.compile(r"[0-9a-fA-F]{7,64}") + + +def read_version_config(path: Path, ctk_target: str) -> tuple[Version, list[str]]: + with path.open("rb") as stream: + metadata = tomllib.load(stream) + value = metadata.get("tool", {}).get("cuda-python", {}).get("scm-bootstrap-release") + if not isinstance(value, str): + raise ValueError(f"expected tool.cuda-python.scm-bootstrap-release in {path}") + version = Version(value) + if ( + str(version) != version.base_version + or len(version.release) != 3 + or version.release[:2] != Version(ctk_target).release + ): + raise ValueError(f"expected an exact CUDA {ctk_target} bootstrap release in {path}, got {value!r}") + scm = metadata["tool"]["setuptools_scm"] + if "tag_regex" in scm or "fallback_version" in scm: + raise ValueError(f"{path} must use standard SCM parsing without a fallback version") + command = scm.get("git_describe_command") + if ( + not isinstance(command, list) + or not all(isinstance(part, str) for part in command) + or command[:2] != ["git", "describe"] + or command.count("--match") != 1 + or command[-1] == "--match" + ): + raise ValueError(f"expected one git_describe_command --match selector in {path}") + return version, command + + +def _git(repo_root: Path, *args: str) -> str: + return subprocess.run( # noqa: S603 - Git receives separate arguments, never shell text. + ["git", *args], # noqa: S607 + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def bootstrap_version( + repo_root: Path, + commit_sha: str, + package: bindings_config.BindingsPackage, + base_ref: str = "HEAD^", +) -> str: + """Use existing SCM history, or add one local preview tag at an ancestor.""" + if COMMIT_PATTERN.fullmatch(commit_sha) is None: + raise ValueError(f"expected a 7-64 digit hexadecimal commit SHA, got {commit_sha!r}") + head = _git(repo_root, "rev-parse", "--verify", "HEAD^{commit}") + if not head.startswith(commit_sha.lower()): + raise ValueError(f"requested source SHA {commit_sha} does not match checkout HEAD {head}") + target, command = read_version_config(repo_root / package.package_root / "pyproject.toml", package.ctk_target) + selector = command[command.index("--match") + 1] + tags = _git(repo_root, "tag", "--merged", "HEAD", "--list", selector).splitlines() + scm_config = Configuration(root=repo_root) + versions = [version for tag in tags if (version := tag_to_version(tag, scm_config)) is not None] + # A previous final release already yields the next development release. + # A tag for this release (including an internal alpha) or a newer release + # always governs its own descendants through standard setuptools-scm. + previous = Version(".".join(map(str, (*target.release[:2], target.release[2] - 1)))) if target.release[2] else None + existing = any( + version.release >= target.release or (previous is not None and version >= previous) for version in versions + ) + if not existing: + base = _git(repo_root, "rev-parse", "--verify", "--end-of-options", f"{base_ref}^{{commit}}") + if base == head: + raise ValueError("the synthetic preview tag requires an ancestor before the build commit") + _git(repo_root, "merge-base", "--is-ancestor", base, head) + # No force flag: even an unreachable existing tag must never be moved. + tag = f"v{target}a0.dev0" + _git(repo_root, "-c", "tag.gpgSign=false", "tag", "--no-sign", tag, base) + print(f"Created local preview tag {tag} at validated ancestor {base}", file=sys.stderr) + + version = get_version(root=repo_root, git_describe_command=command) + if not existing and Version(version).release != target.release: + raise ValueError(f"SCM bootstrap produced release {Version(version).release}, expected {target.release}") + describe = _git(repo_root, *command[1:]) + print(f"{package.package_root}: git describe: {describe}; SCM version: {version}", file=sys.stderr) + return version + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) + parser.add_argument("--config", type=Path, default=bindings_config.DEFAULT_CONFIG) + parser.add_argument("--package-root", required=True) + parser.add_argument("--sha", required=True) + parser.add_argument("--base-ref", default="HEAD^") + args = parser.parse_args(argv) + + package = bindings_config.load_config(args.config).get_package(args.package_root) + bootstrap_version(args.repo_root, args.sha, package, args.base_ref) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/tools/tests/test_bindings_scm_version.py b/ci/tools/tests/test_bindings_scm_version.py new file mode 100644 index 00000000000..2dd04aed885 --- /dev/null +++ b/ci/tools/tests/test_bindings_scm_version.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +from packaging.version import Version + +from ci.tools import bindings_config +from ci.tools.bindings_scm_version import bootstrap_version + +PACKAGE = bindings_config.BindingsPackage("cuda_bindings_12", (12, 9, 1), "maintenance") + + +def git(repo: Path, *args: str) -> str: + return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True).stdout.strip() # noqa: S603, S607 + + +def make_repo(tmp_path: Path) -> tuple[Path, str]: + config = tmp_path / PACKAGE.package_root / "pyproject.toml" + config.parent.mkdir(parents=True) + config.write_text( + "[tool.setuptools_scm]\n" + 'git_describe_command = ["git", "describe", "--tags", "--long", "--match", "v12.9.*"]\n' + '[tool.cuda-python]\nscm-bootstrap-release = "12.9.11"\n', + encoding="utf-8", + ) + git(tmp_path, "init", "-q") + git(tmp_path, "config", "user.name", "CUDA Python CI") + git(tmp_path, "config", "user.email", "cuda-python@nvidia.com") + git(tmp_path, "config", "commit.gpgSign", "false") + git(tmp_path, "config", "tag.gpgSign", "false") + (tmp_path / "README").write_text("old main baseline\n", encoding="utf-8") + git(tmp_path, "add", "README") + git(tmp_path, "commit", "-qm", "old baseline") + git(tmp_path, "tag", "v12.9.0") + git(tmp_path, "add", ".") + git(tmp_path, "commit", "-qm", "import package") + git(tmp_path, "commit", "--allow-empty", "-qm", "activate maintenance package") + return tmp_path, git(tmp_path, "rev-parse", "HEAD") + + +@pytest.mark.agent_authored(model="gpt-6") +def test_bootstrap_creates_only_a_local_ancestor_tag_and_reports_scm(tmp_path, capsys): + repo, sha = make_repo(tmp_path) + version = bootstrap_version(repo, sha, PACKAGE) + assert Version(version).release == (12, 9, 11) + assert Version(version).is_devrelease + assert git(repo, "rev-parse", "v12.9.11a0.dev0") == git(repo, "rev-parse", "HEAD^") + assert "git describe: v12.9.11a0.dev0-1-g" in capsys.readouterr().err + assert bootstrap_version(repo, sha, PACKAGE) == version + + +@pytest.mark.parametrize("tag", ("v12.9.10", "v12.9.11a0")) +@pytest.mark.agent_authored(model="gpt-6") +def test_appropriate_reachable_tags_keep_standard_scm_progression(tmp_path, tag): + repo, sha = make_repo(tmp_path) + git(repo, "tag", tag) + assert bootstrap_version(repo, sha, PACKAGE) == tag.removeprefix("v") + git(repo, "commit", "--allow-empty", "-qm", "after retained tag") + version = bootstrap_version(repo, git(repo, "rev-parse", "HEAD"), PACKAGE) + assert ".dev1" in version + assert "v12.9.11a0.dev0" not in git(repo, "tag", "--list").splitlines() + + +@pytest.mark.agent_authored(model="gpt-6") +def test_unreachable_synthetic_tag_is_preserved_and_fails(tmp_path): + repo, sha = make_repo(tmp_path) + git(repo, "commit", "--allow-empty", "-qm", "unrelated future preview") + future = git(repo, "rev-parse", "HEAD") + git(repo, "tag", "v12.9.11a0.dev0") + git(repo, "checkout", "--detach", sha) + with pytest.raises(subprocess.CalledProcessError): + bootstrap_version(repo, sha, PACKAGE) + assert git(repo, "rev-parse", "v12.9.11a0.dev0") == future From 079a2a166db18d84a847746eb79205f0c7a25e41 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Mon, 26 Oct 2026 17:15:57 -0700 Subject: [PATCH 8/8] Remove obsolete Python 3.10 artifact-baseline exception Signed-off-by: Ralf W. Grosse-Kunstleve --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b105625a942..62b87843954 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,10 +255,6 @@ jobs: while IFS= read -r python_version; do python=${python_version//./} while IFS= read -r platform; do - # CPython 3.10 has no official Windows ARM64 build. - if [[ "$platform" == "win-arm64" && "$python_version" == "3.10" ]]; then - continue - fi for version in "${cuda_versions[@]}"; do if [[ "$platform" == "win-arm64" && "$WINDOWS_ARM64_SINGLE_CUDA_MAJOR" == "true" &&