diff --git a/.github/ISSUE_TEMPLATE/release_checklist.yml b/.github/ISSUE_TEMPLATE/release_checklist.yml index 12dad6fb716..8ed2df60e24 100644 --- a/.github/ISSUE_TEMPLATE/release_checklist.yml +++ b/.github/ISSUE_TEMPLATE/release_checklist.yml @@ -10,7 +10,7 @@ body: - type: markdown attributes: value: | - This checklist is for cuda-core releases as well as cuda-bindings patches. Please go through this checklist and ensure all tasks are completed. See [RELEASE-core.md](../RELEASE-core.md) for detailed guidance on each step (cuda.core). + This checklist is for cuda-core releases as well as cuda-bindings patches. See [RELEASE-core.md](../RELEASE-core.md) for cuda.core and [RELEASE-bindings.md](../RELEASE-bindings.md) for bindings release scope, stabilization branches, and validation. - type: checkboxes id: subpackage-tasks @@ -18,17 +18,18 @@ body: label: Tasks for cuda-core and cuda-bindings patch release options: - label: File an internal nvbug to communicate test plan & release schedule with QA - - label: Ensure all pending PRs are reviewed, tested, and merged + - label: Record the release scope and ensure all PRs selected for this release are reviewed, tested, and merged - label: Check (or update if needed) the dependency requirements - label: Sweep deprecations whose stated removal version has arrived (`grep -rn 'deprecated::' cuda_core/cuda`) and remove any that are due - label: "Finalize the doc update, including release notes (\"Note: Touching docstrings/type annotations in code is OK during code freeze, apply your best judgement!\")" - label: Update the docs for the new version - - label: Create a public release tag - - label: Wait for the tag-triggered CI run to complete, and use that run ID for release workflows (this will upload the release to PyPI) + - label: Record the exact source commit, workflow control revision, and selected package root before creating the approved public release tag + - label: Wait for the exact tag-triggered CI run to pass and complete a release dry run before approving publication - label: Publish the release at https://github.com/NVIDIA/cuda-python/releases (this will trigger the conda package update) - label: Approve the conda-package update PR - label: Post-release QA - label: Finalize the announcement update - label: Send out the announcement internally (on `#swcuda-python`) - label: Send out the announcement externally (GitHub Release -> Announcement) - - label: For a `cuda-bindings` release, make sure MAJOR and MAJOR-1 releases are made, as well as releases of `cuda-python` for MAJOR and MAJOR-1 + - label: For a `cuda-bindings` release, confirm which supported release lines are in scope using `ci/versions.yml`, and release the matching `cuda-python` package for each planned line + - label: For a bindings stabilization or emergency branch, link the PRs returning applicable fixes to `main` and record their applicability to both bindings roots diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index 6b6ee92df08..95dfd93d2cd 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -20,15 +20,14 @@ version, then publishes to PyPI and verifies again before publishing the GitHub Release without marking it as latest. A failed docs build prevents asset uploads, package publication, and finalization. -The release-note checker runs from `git-tag`, not the workflow revision. -For Pathfinder, that tag must contain a version -of `ci/tools/check_release_notes.py` that writes `version=` to -`GITHUB_OUTPUT`. The release-notes job rejects an empty version before -documentation, asset uploads, or package publication can start. Pathfinder -package publication also requires successful draft metadata preparation. -Use the retained legacy Pathfinder workflow for older tags without version -output support during the transition; do not move existing tags to add the -updated checker. +The workflow revision supplies the release tools; release notes and the +package registry (`ci/versions.yml`) come from `git-tag`. Current tooling +requires a schema-2 registry in the tagged source. The release-notes job +exports the checked version and rejects an empty version before documentation, +asset uploads, or package publication can start. Pathfinder package +publication also requires successful draft metadata preparation. +For tags that predate the registry, use compatible historical release tooling; +do not move existing tags to add the registry or updated tools. The default `dry-run` action validates preparation, builds docs, and collects release artifacts without creating a GitHub Release or publishing diff --git a/.github/workflows/release-cuda-pathfinder.yml b/.github/workflows/release-cuda-pathfinder.yml index 283ea78b603..0421a252b4e 100644 --- a/.github/workflows/release-cuda-pathfinder.yml +++ b/.github/workflows/release-cuda-pathfinder.yml @@ -60,15 +60,41 @@ jobs: echo "version=${version}" } >> "$GITHUB_OUTPUT" - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout release control plane + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + + - name: Checkout release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # The exact tag supplies the SHA for lookup-run-id; no other tags # or commit history are needed. fetch-depth: 1 # Resolve only the exact tag ref; checkout fails if the tag does not exist. ref: refs/tags/${{ steps.vars.outputs.tag }} + path: release-source + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install CI tool dependencies + run: python -m pip install -e ./ci + + - name: Validate tagged release registry + env: + RELEASE_TAG: ${{ steps.vars.outputs.tag }} + run: | + # Publication can proceed after a docs failure, so reject unsupported + # release sources here before creating a draft or publishing artifacts. + python -m ci.tools.bindings_config \ + --release-tag "$RELEASE_TAG" \ + --release-source-root release-source - name: Check release notes exist + working-directory: release-source env: VERSION: ${{ steps.vars.outputs.version }} run: | @@ -81,11 +107,12 @@ jobs: - name: Detect CI run ID id: detect-run + working-directory: release-source env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.vars.outputs.tag }} run: | - run_id=$(./ci/tools/lookup-run-id "${TAG}" "${{ github.repository }}") + run_id=$(../ci/tools/lookup-run-id --tag "${TAG}" "${{ github.repository }}") echo "run-id=${run_id}" >> "$GITHUB_OUTPUT" - name: Create draft release @@ -165,7 +192,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - ref: ${{ needs.prepare.outputs.tag }} + ref: refs/tags/${{ needs.prepare.outputs.tag }} - name: Create source archive run: | @@ -175,7 +202,7 @@ jobs: --format=tar.gz \ --prefix="${archive}/" \ --output="release/${archive}.tar.gz" \ - "${TAG}" + "refs/tags/${TAG}" sha256sum "release/${archive}.tar.gz" \ | awk '{print $1}' > "release/${archive}.tar.gz.sha256sum" diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 6c227d74f77..193027c54ff 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -23,6 +23,11 @@ on: type: boolean required: false default: false + bindings-package: + description: "Resolved CUDA bindings package JSON" + type: string + required: false + default: "" concurrency: # Keep upload locks component-specific and separate from the caller's workflow lock. @@ -40,12 +45,30 @@ jobs: runs-on: ubuntu-latest env: ARCHIVE_NAME: ${{ github.event.repository.name }}-${{ inputs.git-tag }} + RELEASE_COMPONENT: ${{ inputs.component }} + RELEASE_GIT_TAG: ${{ inputs.git-tag }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_RUN_ID: ${{ inputs.run-id }} steps: - - name: Checkout Source + - name: Checkout release control plane uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - ref: ${{ inputs.git-tag }} + fetch-tags: true + + - name: Checkout release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.git-tag }} + path: release-source + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install CI tool dependencies + run: python -m pip install -e ./ci - name: Create Release Directory run: mkdir -p release @@ -54,15 +77,15 @@ jobs: run: > git archive --format=tar.gz - --prefix="${{ env.ARCHIVE_NAME }}/" - --output="release/${{ env.ARCHIVE_NAME }}.tar.gz" - ${{ inputs.git-tag }} + --prefix="$ARCHIVE_NAME/" + --output="release/$ARCHIVE_NAME.tar.gz" + "refs/tags/$RELEASE_GIT_TAG" - name: Compute Checksum run: > - sha256sum "release/${{ env.ARCHIVE_NAME }}.tar.gz" + sha256sum "release/$ARCHIVE_NAME.tar.gz" | awk '{print $1}' - > "release/${{ env.ARCHIVE_NAME }}.tar.gz.sha256sum" + > "release/$ARCHIVE_NAME.tar.gz.sha256sum" - name: Upload Archive if: ${{ !inputs.dry-run }} @@ -70,19 +93,31 @@ jobs: GH_TOKEN: ${{ github.token }} run: > gh release upload - --clobber "${{ inputs.git-tag }}" - --repo "${{ github.repository }}" + --clobber "$RELEASE_GIT_TAG" + --repo "$RELEASE_REPOSITORY" release/* - name: Download and Validate Wheels env: + BINDINGS_PACKAGE: ${{ inputs.bindings-package }} GH_TOKEN: ${{ github.token }} run: | # Use the shared script to download wheels - ./ci/tools/download-wheels "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels" + ./ci/tools/download-wheels \ + "$RELEASE_RUN_ID" \ + "$RELEASE_COMPONENT" \ + "$RELEASE_REPOSITORY" \ + "release/wheels" \ + "$RELEASE_GIT_TAG" \ + "$BINDINGS_PACKAGE" # Validate that release wheels match the expected version from tag. - ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" + python -m ci.tools.validate_release_wheels \ + "$RELEASE_GIT_TAG" \ + "$RELEASE_COMPONENT" \ + "release/wheels" \ + --bindings-package "$BINDINGS_PACKAGE" \ + --release-source-root release-source - name: Upload Wheels if: ${{ !inputs.dry-run }} @@ -90,8 +125,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: | if [[ -d "release/wheels" && $(ls -A release/wheels 2>/dev/null | wc -l) -gt 0 ]]; then - echo "Uploading wheels to release ${{ inputs.git-tag }}" - gh release upload --clobber "${{ inputs.git-tag }}" --repo "${{ github.repository }}" release/wheels/* + echo "Uploading wheels to release $RELEASE_GIT_TAG" + gh release upload --clobber "$RELEASE_GIT_TAG" --repo "$RELEASE_REPOSITORY" release/wheels/* fi - name: Upload dry-run release artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fa9f1bd67d..f444db415aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,26 +12,10 @@ name: "CI: Release" # Before retiring the dedicated Pathfinder workflow, authorize release.yml in # its PyPI and TestPyPI trusted publishers; see .github/RELEASE-core.md. # -# Maintenance note: non-trivial changes to this workflow should be validated -# with dry-run workflow_dispatch runs before merging. Suggested focused matrix: -# - mainline: -# component=cuda-bindings -# git-tag= -# backport-git-tag=not planned -# run-id= -# dry-run-docs-branch=gh-pages-dry-run -# - backport sequence: -# 1. component=cuda-bindings -# git-tag= -# backport-git-tag= -# 2. component=cuda-python -# git-tag= -# backport-git-tag= -# run-id= -# dry-run-docs-branch=gh-pages-dry-run -# Leave run-id blank so determine-run-id is exercised. For exhaustive coverage, -# add a mainline cuda-python dry-run when changes could affect metapackage -# artifact validation, docs routing, or component-specific release behavior. +# Maintenance note: validate non-trivial changes with dry-run workflow_dispatch +# runs for bindings and metapackage tags from both maintenance and current packages. Leave +# run-id blank so automatic tag-run discovery, artifact filtering, and version +# validation are exercised. # Also exercise cuda-core and cuda-pathfinder, including a failed docs build. on: @@ -58,11 +42,6 @@ on: description: "The release git tag" required: true type: string - backport-git-tag: - description: "Mainline cuda-bindings/cuda-python only: planned backport tag, or 'not planned'. Leave blank for backport releases." - required: false - type: string - default: "" run-id: description: "The GHA run ID that generated validated artifacts (optional - auto-detects successful tag-triggered CI run for git-tag)" required: false @@ -84,6 +63,39 @@ defaults: shell: bash --noprofile --norc -xeuo pipefail {0} jobs: + resolve-bindings-package: + if: ${{ github.repository_owner == 'nvidia' }} + runs-on: ubuntu-latest + outputs: + bindings-package: ${{ steps.resolve.outputs.bindings-package }} + steps: + - name: Checkout release control plane + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.git-tag }} + path: release-source + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install CI tool dependencies + run: python -m pip install -e ./ci + + - name: Resolve bindings release package + id: resolve + env: + RELEASE_TAG: ${{ inputs.git-tag }} + run: | + bindings_package=$(python -m ci.tools.bindings_config \ + --release-tag "$RELEASE_TAG" \ + --release-source-root release-source) + echo "bindings-package=${bindings_package}" >> "$GITHUB_OUTPUT" + determine-run-id: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest @@ -102,20 +114,24 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} - PROVIDED_RUN_ID: ${{ inputs.run-id }} RELEASE_REPOSITORY: ${{ github.repository }} + REQUESTED_RUN_ID: ${{ inputs.run-id }} run: | - if [[ -n "$PROVIDED_RUN_ID" ]]; then - echo "Using provided run ID: $PROVIDED_RUN_ID" - RUN_ID="$PROVIDED_RUN_ID" + if [[ -n "$REQUESTED_RUN_ID" ]]; then + echo "Validating provided run ID: $REQUESTED_RUN_ID" + RUN_ID=$(./ci/tools/lookup-run-id \ + --tag "$RELEASE_GIT_TAG" \ + --run-id "$REQUESTED_RUN_ID" \ + "$RELEASE_REPOSITORY") else echo "Auto-detecting successful tag-triggered run ID for tag: $RELEASE_GIT_TAG" - RUN_ID=$(./ci/tools/lookup-run-id "$RELEASE_GIT_TAG" "$RELEASE_REPOSITORY") + RUN_ID=$(./ci/tools/lookup-run-id --tag "$RELEASE_GIT_TAG" "$RELEASE_REPOSITORY") echo "Auto-detected run ID: $RUN_ID" fi echo "run-id=$RUN_ID" >> "$GITHUB_OUTPUT" check-tag: + needs: resolve-bindings-package if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest steps: @@ -131,6 +147,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_ACTION: ${{ inputs.release-action }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} + RELEASE_REPOSITORY: ${{ github.repository }} DRY_RUN_DOCS_BRANCH: ${{ inputs.dry-run-docs-branch }} run: | if [[ "$RELEASE_ACTION" == "full-release" && -n "$DRY_RUN_DOCS_BRANCH" ]]; then @@ -141,19 +158,21 @@ jobs: echo "error: dry-run-docs-branch must be a non-production gh-pages-* branch" >&2 exit 1 fi + git check-ref-format "refs/tags/$RELEASE_GIT_TAG" + git rev-parse --verify "refs/tags/${RELEASE_GIT_TAG}^{commit}" + if [[ "$RELEASE_ACTION" == "dry-run" ]]; then - git rev-parse --verify "${RELEASE_GIT_TAG}^{commit}" echo "Dry-run selected; not checking or creating a GitHub release draft." exit 0 fi - mapfile -t tags < <(gh release list -R "${{ github.repository }}" --json tagName --jq '.[] | .tagName') - mapfile -t is_draft < <(gh release list -R "${{ github.repository }}" --json isDraft --jq '.[] | .isDraft') + mapfile -t tags < <(gh release list -R "$RELEASE_REPOSITORY" --json tagName --jq '.[] | .tagName') + mapfile -t is_draft < <(gh release list -R "$RELEASE_REPOSITORY" --json isDraft --jq '.[] | .isDraft') found=0 for idx in "${!tags[@]}"; do - if [[ "${tags[$idx]}" == "${{ inputs.git-tag }}" ]]; then - echo "found existing release for ${{ inputs.git-tag }}" + if [[ "${tags[$idx]}" == "$RELEASE_GIT_TAG" ]]; then + echo "found existing release for $RELEASE_GIT_TAG" found=1 if [[ "${is_draft[$idx]}" != "true" ]]; then echo "the release note is not in draft state" @@ -163,37 +182,51 @@ jobs: fi done if [[ "$found" == 0 ]]; then - echo "no release found for ${{ inputs.git-tag }}, creating draft release" - gh release create "${{ inputs.git-tag }}" --draft --repo "${{ github.repository }}" --title "Release ${{ inputs.git-tag }}" --notes "Release ${{ inputs.git-tag }}" + echo "no release found for $RELEASE_GIT_TAG, creating draft release" + gh release create "$RELEASE_GIT_TAG" \ + --verify-tag \ + --draft \ + --repo "$RELEASE_REPOSITORY" \ + --title "Release $RELEASE_GIT_TAG" \ + --notes "Release $RELEASE_GIT_TAG" fi check-release-notes: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest + needs: resolve-bindings-package outputs: version: ${{ steps.release-notes.outputs.version }} steps: - - name: Checkout Source + - name: Checkout release control plane + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout release source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.git-tag }} + ref: refs/tags/${{ inputs.git-tag }} + path: release-source - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" + - name: Install CI tool dependencies + run: python -m pip install -e ./ci + - name: Check versioned release notes exist id: release-notes env: - RELEASE_GIT_TAG: ${{ inputs.git-tag }} + BINDINGS_PACKAGE: ${{ needs.resolve-bindings-package.outputs.bindings-package }} RELEASE_COMPONENT: ${{ inputs.component }} - BACKPORT_GIT_TAG: ${{ inputs.backport-git-tag }} + RELEASE_TAG: ${{ inputs.git-tag }} run: | - python ci/tools/check_release_notes.py \ - --git-tag "$RELEASE_GIT_TAG" \ + python -m ci.tools.check_release_notes \ + --git-tag "$RELEASE_TAG" \ --component "$RELEASE_COMPONENT" \ - --backport-git-tag "$BACKPORT_GIT_TAG" + --repo-root release-source \ + --bindings-package "$BINDINGS_PACKAGE" - name: Require Pathfinder version output if: ${{ inputs.component == 'cuda-pathfinder' }} @@ -201,7 +234,7 @@ jobs: VERSION: ${{ steps.release-notes.outputs.version }} run: | [[ -n "$VERSION" ]] || { - echo "::error::The tagged Pathfinder release-note checker must emit a non-empty version output" + echo "::error::The Pathfinder release-note checker must emit a non-empty version output" exit 1 } @@ -265,6 +298,7 @@ jobs: - check-tag - check-release-notes - determine-run-id + - resolve-bindings-package secrets: inherit uses: ./.github/workflows/build-docs.yml with: @@ -274,6 +308,7 @@ jobs: is-release: true deploy-docs: ${{ inputs.release-action == 'full-release' || inputs.dry-run-docs-branch != '' }} docs-branch: ${{ (inputs.release-action == 'dry-run' && inputs.dry-run-docs-branch) || 'gh-pages' }} + bindings-package: ${{ needs.resolve-bindings-package.outputs.bindings-package }} upload-archive: name: Validate release artifacts @@ -284,6 +319,7 @@ jobs: - check-release-notes - determine-run-id - doc + - resolve-bindings-package secrets: inherit uses: ./.github/workflows/release-upload.yml with: @@ -291,6 +327,7 @@ jobs: run-id: ${{ needs.determine-run-id.outputs.run-id }} component: ${{ inputs.component }} dry-run: ${{ inputs.release-action == 'dry-run' }} + bindings-package: ${{ needs.resolve-bindings-package.outputs.bindings-package }} publish-testpypi: name: Publish wheels to TestPyPI @@ -300,6 +337,7 @@ jobs: needs.check-release-notes.result == 'success' && needs.determine-run-id.result == 'success' && needs.doc.result == 'success' && + needs.resolve-bindings-package.result == 'success' && (inputs.component != 'cuda-pathfinder' || needs.ensure-pathfinder-draft.result == 'success') }} runs-on: ubuntu-latest needs: @@ -308,6 +346,7 @@ jobs: - determine-run-id - doc - ensure-pathfinder-draft + - resolve-bindings-package environment: name: testpypi url: https://test.pypi.org/p/${{ inputs.component }}/ @@ -317,15 +356,49 @@ jobs: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.git-tag }} + path: release-source + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install CI tool dependencies + run: python -m pip install -e ./ci + - name: Download component wheels env: + BINDINGS_PACKAGE: ${{ needs.resolve-bindings-package.outputs.bindings-package }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_COMPONENT: ${{ inputs.component }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_RUN_ID: ${{ needs.determine-run-id.outputs.run-id }} + RELEASE_TAG: ${{ inputs.git-tag }} run: | - ./ci/tools/download-wheels "${{ needs.determine-run-id.outputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "dist" + ./ci/tools/download-wheels \ + "$RELEASE_RUN_ID" \ + "$RELEASE_COMPONENT" \ + "$RELEASE_REPOSITORY" \ + "dist" \ + "$RELEASE_TAG" \ + "$BINDINGS_PACKAGE" - name: Validate wheel versions for release tag + env: + BINDINGS_PACKAGE: ${{ needs.resolve-bindings-package.outputs.bindings-package }} + RELEASE_COMPONENT: ${{ inputs.component }} + RELEASE_TAG: ${{ inputs.git-tag }} run: | - ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" + python -m ci.tools.validate_release_wheels \ + "$RELEASE_TAG" \ + "$RELEASE_COMPONENT" \ + "dist" \ + --bindings-package "$BINDINGS_PACKAGE" \ + --release-source-root release-source - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 @@ -379,12 +452,14 @@ jobs: if: >- ${{ !cancelled() && inputs.release-action == 'full-release' && needs.determine-run-id.result == 'success' && needs.publish-testpypi.result == 'success' && + needs.resolve-bindings-package.result == 'success' && (inputs.component != 'cuda-pathfinder' || needs.verify-testpypi.result == 'success') }} runs-on: ubuntu-latest needs: - determine-run-id - publish-testpypi - verify-testpypi + - resolve-bindings-package environment: name: pypi url: https://pypi.org/p/${{ inputs.component }}/ @@ -394,15 +469,49 @@ jobs: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ inputs.git-tag }} + path: release-source + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install CI tool dependencies + run: python -m pip install -e ./ci + - name: Download component wheels env: + BINDINGS_PACKAGE: ${{ needs.resolve-bindings-package.outputs.bindings-package }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_COMPONENT: ${{ inputs.component }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_RUN_ID: ${{ needs.determine-run-id.outputs.run-id }} + RELEASE_TAG: ${{ inputs.git-tag }} run: | - ./ci/tools/download-wheels "${{ needs.determine-run-id.outputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "dist" + ./ci/tools/download-wheels \ + "$RELEASE_RUN_ID" \ + "$RELEASE_COMPONENT" \ + "$RELEASE_REPOSITORY" \ + "dist" \ + "$RELEASE_TAG" \ + "$BINDINGS_PACKAGE" - name: Validate wheel versions for release tag + env: + BINDINGS_PACKAGE: ${{ needs.resolve-bindings-package.outputs.bindings-package }} + RELEASE_COMPONENT: ${{ inputs.component }} + RELEASE_TAG: ${{ inputs.git-tag }} run: | - ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" + python -m ci.tools.validate_release_wheels \ + "$RELEASE_TAG" \ + "$RELEASE_COMPONENT" \ + "dist" \ + --bindings-package "$BINDINGS_PACKAGE" \ + --release-source-root release-source - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index 7da330046ba..0df56b55cac 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -4,10 +4,13 @@ """Check that versioned release-notes files exist before releasing. Usage: - python check_release_notes.py --git-tag --component + python -m ci.tools.check_release_notes --git-tag --component -Notes and release configuration come from the working tree. Successful checks -append the parsed version to GITHUB_OUTPUT when it is set. +Each normal release requires nonempty notes for its exact version and component +in the tagged source checkout. Post releases do not require new notes, except +Pathfinder requires a notes file for every release and permits empty files. + +Successful checks append the parsed version to GITHUB_OUTPUT when it is set. Exit codes: 0 — release-notes policy satisfied @@ -18,100 +21,93 @@ from __future__ import annotations import argparse +import json import os -import re import sys from pathlib import Path +from typing import Mapping -COMPONENT_TO_PACKAGE: dict[str, str] = { - "cuda-core": "cuda_core", - "cuda-bindings": "cuda_bindings_13", - "cuda-pathfinder": "cuda_pathfinder", - "cuda-python": "cuda_python", -} +from packaging.version import Version -# Version characters are restricted to digit-prefixed word chars and dots, so -# malformed inputs like "v../evil" or "v1/2/3" cannot flow into the notes path. -_VERSION_PATTERN = r"\d[\w.]*" - -# Each component has exactly one valid tag-prefix form. cuda-bindings and -# cuda-python share the bare "v" namespace (setuptools-scm lookup). -COMPONENT_TO_TAG_RE: dict[str, re.Pattern[str]] = { - "cuda-bindings": re.compile(rf"^v(?P{_VERSION_PATTERN})$"), - "cuda-python": re.compile(rf"^v(?P{_VERSION_PATTERN})$"), - "cuda-core": re.compile(rf"^cuda-core-v(?P{_VERSION_PATTERN})$"), - "cuda-pathfinder": re.compile(rf"^cuda-pathfinder-v(?P{_VERSION_PATTERN})$"), +from . import bindings_config + +COMPONENTS: dict[str, tuple[str, str]] = { + "cuda-core": ("cuda_core", "cuda-core-v"), + "cuda-bindings": ("cuda_bindings_13", "v"), + "cuda-pathfinder": ("cuda_pathfinder", "cuda-pathfinder-v"), + "cuda-python": ("cuda_python", "v"), } -BACKPORT_PLANNING_COMPONENTS = frozenset({"cuda-bindings", "cuda-python"}) -BACKPORT_NOT_PLANNED = "not planned" -BACKPORT_BRANCH_RE = re.compile(r"""^backport_branch:\s*["']?(?P[^"'\s#]+)""") -BACKPORT_BRANCH_NAME_RE = re.compile(r"^\d+\.\d+\.x$") +def _resolved_bindings_target(data: Mapping[str, object], git_tag: str) -> tuple[str, Version]: + """Read the release resolver fields consumed by this script.""" + package_root = bindings_config.parse_package_root(data.get("package_root"), "resolved package_root") + raw_version = data.get("release_version") + if not isinstance(raw_version, str): + raise bindings_config.BindingsConfigError("resolved CUDA bindings package has no release_version") + version = bindings_config.parse_pep440_version(raw_version, "resolved release_version") + tag_version = bindings_config.parse_prefixed_version(git_tag, "v") + if tag_version != version: + raise bindings_config.BindingsConfigError( + f"resolved CUDA bindings package does not match release tag {git_tag!r}" + ) + return package_root, version -def parse_version_from_tag(git_tag: str, component: str) -> str | None: - """Extract the version string from a tag, given the target component. - Returns None if the tag does not match the component's expected prefix - or contains characters outside the allowed version set. - """ - pattern = COMPONENT_TO_TAG_RE.get(component) - if pattern is None: +def _release_target_from_tag( + git_tag: str, + component: str, + bindings_package: Mapping[str, object] | None = None, +) -> tuple[str, str] | None: + """Return the release version and source tree selected by a component tag.""" + metadata = COMPONENTS.get(component) + if metadata is None: return None - m = pattern.match(git_tag) - return m.group("version") if m else None - + package_dir, prefix = metadata + if component == "cuda-bindings": + if bindings_package is None: + package = bindings_config.load_config().match_tag(git_tag) + if package is None: + return None + package_root = package.package_root + version = package.version_from_tag(git_tag) + else: + package_root, version = _resolved_bindings_target(bindings_package, git_tag) + else: + version = bindings_config.parse_prefixed_version(git_tag, prefix) + if version is None: + return None + return str(version), package_root if component == "cuda-bindings" else package_dir -def is_post_release(version: str) -> bool: - return ".post" in version +def parse_version_from_tag( + git_tag: str, + component: str, + bindings_package: Mapping[str, object] | None = None, +) -> str | None: + """Extract the version string from a tag, given the target component. -def load_backport_branch(repo_root: Path = Path(".")) -> str | None: - path = repo_root / "ci" / "versions.yml" - try: - with open(path, encoding="utf-8") as f: - for line in f: - m = BACKPORT_BRANCH_RE.match(line.strip()) - if m: - return m.group("branch") - except FileNotFoundError: - pass - github_ref_name = os.environ.get("GITHUB_REF_NAME", "") - if BACKPORT_BRANCH_NAME_RE.match(github_ref_name): - return github_ref_name - return None + Returns None if the tag does not match the component's expected prefix, + contains characters outside the allowed version set, or (for bindings) + does not select a configured package root. + """ + target = _release_target_from_tag(git_tag, component, bindings_package) + return target[0] if target is not None else None -def is_backport_version(version: str, backport_branch: str) -> bool: - if backport_branch.endswith(".x"): - return version.startswith(backport_branch[:-1]) - return version == backport_branch +def is_post_release(version: str) -> bool: + return bindings_config.parse_pep440_version(version, "release version").post is not None def notes_path(package: str, version: str) -> Path: return Path(package, "docs", "source", "release", f"{version}-notes.rst") -def check_release_notes(git_tag: str, component: str, repo_root: Path = Path(".")) -> list[tuple[str | Path, str]]: - """Return a list of (path, reason) for missing or empty release notes. - - ``path`` is the repo-relative notes path, or a ```` naming the - offending argument when the tag or component itself is the problem. - - Pathfinder requires a notes file, including for .post releases, but allows - an empty file. Other components require non-empty notes and skip .post. - """ - if component not in COMPONENT_TO_PACKAGE: - return [("", f"unknown component '{component}'")] - - version = parse_version_from_tag(git_tag, component) - if version is None: - return [("", f"cannot parse version from tag '{git_tag}' for component '{component}'")] - +def _check_release_target(version: str, package: str, component: str, repo_root: Path) -> list[tuple[str | Path, str]]: if is_post_release(version) and component != "cuda-pathfinder": return [] - path = notes_path(COMPONENT_TO_PACKAGE[component], version) + path = notes_path(package, version) full = repo_root / path if not full.is_file(): return [(path, "missing")] @@ -120,16 +116,6 @@ def check_release_notes(git_tag: str, component: str, repo_root: Path = Path("." return [] -def write_step_summary(message: str) -> None: - summary_path = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_path: - return - with open(summary_path, "a", encoding="utf-8") as f: - f.write(message) - if not message.endswith("\n"): - f.write("\n") - - def write_version_output(version: str) -> None: output_path = os.environ.get("GITHUB_OUTPUT") if output_path: @@ -137,126 +123,69 @@ def write_version_output(version: str) -> None: f.write(f"version={version}\n") -def warn_missing_backport_notes(git_tag: str, component: str, problems: list[tuple[str | Path, str]]) -> None: - print(f"WARNING: missing or empty release notes for backport tag {git_tag}:") - summary_lines = [ - "## Release Notes Reminder", - "", - f"Backport release `{git_tag}` for `{component}` is allowed to continue,", - "but the following release-note files are missing or empty in the workflow source:", - "", - ] - for path, reason in problems: - print(f"::warning file={path}::Release notes for backport tag {git_tag} are {reason}.") - print(f" - {path} ({reason})") - summary_lines.append(f"- `{path}` ({reason})") - summary_lines.extend(["", "Please add the backport release notes on `main` if they are not already present."]) - write_step_summary("\n".join(summary_lines)) - - -def validate_backport_decision( - *, +def check_release_notes( git_tag: str, component: str, - version: str, - backport_git_tag: str, - backport_branch: str | None, - repo_root: Path, -) -> tuple[int | None, list[tuple[str | Path, str]]]: - if component not in BACKPORT_PLANNING_COMPONENTS or is_post_release(version): - return None, [] - - if backport_branch is None: - print("ERROR: cannot determine backport branch from ci/versions.yml or GITHUB_REF_NAME.", file=sys.stderr) - return 2, [] - - if is_backport_version(version, backport_branch): - problems = check_release_notes(git_tag, component, repo_root) - if problems: - warn_missing_backport_notes(git_tag, component, problems) - else: - print(f"Release notes present for backport tag {git_tag}, component {component}.") - return 0, [] - - decision = backport_git_tag.strip() - if not decision: - return ( - 1, - [ - ( - "", - f"required for {component} mainline releases; use a backport tag or '{BACKPORT_NOT_PLANNED}'", - ) - ], - ) - - if decision == BACKPORT_NOT_PLANNED: - print(f"Backport release not planned for {git_tag}, skipping backport release-notes check.") - return None, [] + repo_root: Path = Path("."), + bindings_package: Mapping[str, object] | None = None, +) -> list[tuple[str | Path, str]]: + """Return a list of (path, reason) for missing or empty release notes. - backport_version = parse_version_from_tag(decision, component) - if backport_version is None: - print( - f"ERROR: backport tag {decision!r} does not match the expected format for component {component!r}.", - file=sys.stderr, - ) - return 2, [] + ``path`` is the repo-relative notes path, or a ```` naming the + offending argument when the tag or component itself is the problem. - if not is_backport_version(backport_version, backport_branch): - print( - f"ERROR: backport tag {decision!r} does not match configured backport branch {backport_branch!r}.", - file=sys.stderr, - ) - return 2, [] + Pathfinder requires a notes file, including for .post releases, but allows + an empty file. Other components require non-empty notes and skip .post. + """ + if component not in COMPONENTS: + return [("", f"unknown component '{component}'")] - problems = check_release_notes(decision, component, repo_root) - if problems: - return 1, problems - return None, [] + target = _release_target_from_tag(git_tag, component, bindings_package) + if target is None: + return [("", f"cannot parse version from tag '{git_tag}' for component '{component}'")] + version, package = target + return _check_release_target(version, package, component, repo_root) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--git-tag", required=True) - parser.add_argument("--component", required=True, choices=list(COMPONENT_TO_PACKAGE)) - parser.add_argument("--repo-root", default=Path("."), type=Path) - parser.add_argument("--backport-git-tag", default="") - parser.add_argument("--backport-branch", default="") + parser.add_argument("--component", required=True, choices=list(COMPONENTS)) + parser.add_argument( + "--repo-root", + default=Path("."), + type=Path, + help="tagged source checkout containing the component's versioned release notes", + ) + parser.add_argument( + "--bindings-package", + default="", + help="normalized CUDA bindings package JSON from the release resolver", + ) args = parser.parse_args(argv) - version = parse_version_from_tag(args.git_tag, args.component) - if version is None: + try: + bindings_package = json.loads(args.bindings_package) if args.bindings_package else None + if bindings_package is not None and not isinstance(bindings_package, dict): + raise ValueError("resolved CUDA bindings package must be a JSON object") + target = _release_target_from_tag(args.git_tag, args.component, bindings_package) + except (bindings_config.BindingsConfigError, json.JSONDecodeError, ValueError) as error: + print(f"ERROR: invalid CUDA bindings configuration: {error}", file=sys.stderr) + return 2 + if target is None: print( f"ERROR: tag {args.git_tag!r} does not match the expected format for component {args.component!r}.", file=sys.stderr, ) return 2 + version, package = target if is_post_release(version) and args.component != "cuda-pathfinder": print(f"Post-release tag ({args.git_tag}), skipping release-notes check.") write_version_output(version) return 0 - backport_branch = args.backport_branch or load_backport_branch(args.repo_root) - rc, problems = validate_backport_decision( - git_tag=args.git_tag, - component=args.component, - version=version, - backport_git_tag=args.backport_git_tag, - backport_branch=backport_branch, - repo_root=args.repo_root, - ) - if rc is not None: - if problems: - print(f"ERROR: release notes policy failed for tag {args.git_tag}:", file=sys.stderr) - for path, reason in problems: - print(f" - {path} ({reason})", file=sys.stderr) - if rc == 0: - write_version_output(version) - return rc - - if not problems: - problems = check_release_notes(args.git_tag, args.component, args.repo_root) + problems = _check_release_target(version, package, args.component, args.repo_root) if not problems: print(f"Release notes present for tag {args.git_tag}, component {args.component}.") @@ -266,7 +195,7 @@ def main(argv: list[str] | None = None) -> int: print(f"ERROR: missing or empty release notes for tag {args.git_tag}:", file=sys.stderr) for path, reason in problems: print(f" - {path} ({reason})", file=sys.stderr) - print("Add versioned release notes before releasing.", file=sys.stderr) + print("Add versioned release notes to the tagged source before releasing.", file=sys.stderr) return 1 diff --git a/ci/tools/lookup-run-id b/ci/tools/lookup-run-id index 89833cfe1bd..5e5a385957a 100755 --- a/ci/tools/lookup-run-id +++ b/ci/tools/lookup-run-id @@ -18,20 +18,22 @@ set -euo pipefail usage() { cat >&2 < [workflow-name] + $0 --tag [--run-id ] [workflow-name] $0 --branch [workflow-name] Options: --tag Find run by git tag (requires local git repo with the tag) --branch Find latest successful run on the given branch + --run-id Validate and use this exact run ID (tag mode only) --artifact Require an unexpired artifact matching this shell glob (repeatable; branch mode only) --head-sha Also print the run's head commit SHA (second line) Examples: $0 --tag v13.0.1 NVIDIA/cuda-python + $0 --tag v13.0.1 --run-id 123456789 NVIDIA/cuda-python $0 --branch main NVIDIA/cuda-python - $0 --branch 12.9.x --artifact 'cuda-bindings-python313-*' NVIDIA/cuda-python + $0 --branch main --artifact 'cuda-bindings-python315-cuda*-linux-64*' NVIDIA/cuda-python $0 --branch main --head-sha NVIDIA/cuda-python "CI" EOF exit 1 @@ -41,6 +43,7 @@ EOF MODE="" REF="" HEAD_SHA_FLAG=0 +EXPLICIT_RUN_ID="" ARTIFACT_PATTERNS=() while [[ $# -gt 0 ]]; do @@ -53,6 +56,9 @@ while [[ $# -gt 0 ]]; do [[ $# -ge 2 ]] || usage [[ -n "${MODE}" && "${MODE}" != "branch" ]] && { echo "Error: --tag and --branch are mutually exclusive" >&2; exit 1; } MODE="branch"; REF="${2}"; shift 2 ;; + --run-id) + [[ $# -ge 2 ]] || usage + EXPLICIT_RUN_ID="${2}"; shift 2 ;; --artifact) [[ $# -ge 2 ]] || usage ARTIFACT_PATTERNS+=("${2}"); shift 2 ;; @@ -66,11 +72,7 @@ while [[ $# -gt 0 ]]; do done if [[ -z "${MODE}" ]]; then - # Legacy positional interface: [workflow-name] - # Kept for backward compatibility with release.yml. - if [[ $# -lt 2 ]]; then usage; fi - MODE="tag" - REF="${1}"; shift + usage fi REPOSITORY="${1:-}"; shift || true @@ -83,6 +85,16 @@ if [[ "${MODE}" != "branch" && ${#ARTIFACT_PATTERNS[@]} -gt 0 ]]; then exit 1 fi +if [[ "${MODE}" != "tag" && -n "${EXPLICIT_RUN_ID}" ]]; then + echo "Error: --run-id is only supported with --tag" >&2 + exit 1 +fi + +if [[ -n "${EXPLICIT_RUN_ID}" && ! "${EXPLICIT_RUN_ID}" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: --run-id must be a positive integer" >&2 + exit 1 +fi + # ── Prerequisite checks ── if [[ -z "${GH_TOKEN:-}" ]]; then echo "Error: GH_TOKEN environment variable is required" >&2 @@ -246,7 +258,7 @@ fi # ── Mode: tag ── echo "Looking up run ID for tag: ${REF} in repository: ${REPOSITORY}" >&2 -if ! COMMIT_SHA=$(git rev-parse "${REF}^{commit}"); then +if ! COMMIT_SHA=$(git rev-parse --verify "refs/tags/${REF}^{commit}"); then echo "Error: Could not resolve git tag '${REF}' to a commit SHA" >&2 echo "Make sure the tag exists and you have fetched it" >&2 exit 1 @@ -255,6 +267,42 @@ fi echo "Resolved tag '${REF}' to commit: ${COMMIT_SHA}" >&2 echo "Searching for '${WORKFLOW_NAME}' workflow runs for commit: ${COMMIT_SHA} (tag: ${REF})" >&2 +if [[ -n "${EXPLICIT_RUN_ID}" ]]; then + echo "Validating explicitly requested run ID: ${EXPLICIT_RUN_ID}" >&2 + if ! RUN_DATA=$(gh run view "${EXPLICIT_RUN_ID}" \ + --repo "${REPOSITORY}" \ + --json databaseId,workflowName,status,conclusion,headSha,headBranch,event,createdAt,url); then + echo "Error: Could not inspect workflow run ${EXPLICIT_RUN_ID}" >&2 + exit 1 + fi + + if ! jq -e \ + --argjson id "${EXPLICIT_RUN_ID}" \ + --arg workflow "${WORKFLOW_NAME}" \ + --arg sha "${COMMIT_SHA}" \ + --arg tag "${REF}" \ + '.databaseId == $id + and .workflowName == $workflow + and .status == "completed" + and .conclusion == "success" + and .headSha == $sha + and .headBranch == $tag + and .event == "push"' <<< "${RUN_DATA}" >/dev/null; then + echo "Error: Run ${EXPLICIT_RUN_ID} is not a successful tag-triggered '${WORKFLOW_NAME}' run for '${REF}' at ${COMMIT_SHA}" >&2 + echo "Observed run metadata:" >&2 + jq -c '{workflowName, status, conclusion, headSha, headBranch, event}' <<< "${RUN_DATA}" >&2 + exit 1 + fi + + echo "Validated workflow run ID: ${EXPLICIT_RUN_ID} for tag '${REF}'" >&2 + echo "${EXPLICIT_RUN_ID}" + if [[ "${HEAD_SHA_FLAG}" == 1 ]]; then + echo "Head SHA: ${COMMIT_SHA}" >&2 + echo "${COMMIT_SHA}" + fi + exit 0 +fi + RUN_DATA=$(gh run list \ --repo "${REPOSITORY}" \ --commit "${COMMIT_SHA}" \ @@ -270,8 +318,11 @@ if [[ -z "${RUN_DATA}" || "${RUN_DATA}" == "[]" ]]; then exit 1 fi -RUN_ID=$(echo "${RUN_DATA}" | jq -r --arg tag "${REF}" ' - map(select(.conclusion == "success" and .event == "push" and .headBranch == $tag)) +RUN_ID=$(echo "${RUN_DATA}" | jq -r \ + --arg tag "${REF}" --arg workflow "${WORKFLOW_NAME}" --arg sha "${COMMIT_SHA}" ' + map(select(.conclusion == "success" and .status == "completed" + and .event == "push" and .headBranch == $tag + and .workflowName == $workflow and .headSha == $sha)) | sort_by(.createdAt) | reverse | .[0].databaseId // empty diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py index 30301be302c..71049a9bcb0 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -3,336 +3,243 @@ from __future__ import annotations -import sys +import json from pathlib import Path import pytest -sys.path.insert(0, str(Path(__file__).parent.parent)) -from check_release_notes import ( - check_release_notes, - is_post_release, - load_backport_branch, - main, - parse_version_from_tag, -) - - -class TestParseVersionFromTag: - def test_plain_tag_bindings(self): - assert parse_version_from_tag("v13.1.0", "cuda-bindings") == "13.1.0" - - def test_plain_tag_python(self): - assert parse_version_from_tag("v13.1.0", "cuda-python") == "13.1.0" - - def test_component_prefix_core(self): - assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-core") == "0.7.0" +from ci.tools.check_release_notes import check_release_notes, main, notes_path, parse_version_from_tag - def test_component_prefix_pathfinder(self): - assert parse_version_from_tag("cuda-pathfinder-v1.5.2", "cuda-pathfinder") == "1.5.2" - def test_post_release(self): - assert parse_version_from_tag("v12.6.2.post1", "cuda-bindings") == "12.6.2.post1" +def write_notes(root: Path, package: str, version: str, content: str = "Release notes.") -> Path: + path = root / notes_path(package, version) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path - def test_invalid_tag(self): - assert parse_version_from_tag("not-a-tag", "cuda-core") is None - def test_no_v_prefix(self): - assert parse_version_from_tag("13.1.0", "cuda-bindings") is None +def resolved_12_package(package_root: str = "cuda_bindings_12") -> dict[str, object]: + return { + "package_root": package_root, + "toolkit_version": "12.9.1", + "release_version": "12.9.8", + } - def test_component_prefix_mismatch(self): - # cuda-core-v* must not be accepted for component=cuda-pathfinder - assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-pathfinder") is None - def test_bare_v_rejected_for_core(self): - # bare v* belongs to cuda-bindings/cuda-python, not cuda-core - assert parse_version_from_tag("v0.7.0", "cuda-core") is None +@pytest.mark.parametrize( + ("tag", "component", "version"), + ( + ("v13.4.1", "cuda-bindings", "13.4.1"), + ("v13.4.1rc1", "cuda-bindings", "13.4.1rc1"), + ("v13.4.1.dev1", "cuda-bindings", "13.4.1.dev1"), + ("v12.9.8.post1", "cuda-python", "12.9.8.post1"), + ("cuda-core-v1.1.1", "cuda-core", "1.1.1"), + ("cuda-pathfinder-v1.8.1", "cuda-pathfinder", "1.8.1"), + ), +) +@pytest.mark.agent_authored(model="gpt-5.6") +def test_parse_version_from_tag(tag, component, version): + assert parse_version_from_tag(tag, component) == version - def test_unknown_component(self): - assert parse_version_from_tag("v13.1.0", "bogus") is None - def test_path_traversal_rejected(self): - assert parse_version_from_tag("v1.0.0/../evil", "cuda-bindings") is None +@pytest.mark.parametrize( + ("tag", "component"), + ( + ("not-a-tag", "cuda-core"), + ("v1.0.0/../evil", "cuda-bindings"), + ("cuda-core-v1.0.0", "cuda-pathfinder"), + ("vv13.3.0", "cuda-bindings"), + ("cuda-core-vv1.0.0", "cuda-core"), + ("cuda-core-v1!2.0.0", "cuda-core"), + ("cuda-core-v1.0.0-1", "cuda-core"), + ("cuda-core-v01.0.0", "cuda-core"), + ("cuda-core-v1.0", "cuda-core"), + ), +) +@pytest.mark.agent_authored(model="gpt-5.6") +def test_parse_version_rejects_invalid_or_mismatched_tags(tag, component): + assert parse_version_from_tag(tag, component) is None - def test_path_separator_rejected(self): - assert parse_version_from_tag("v1/2/3", "cuda-bindings") is None - def test_leading_dot_rejected(self): - assert parse_version_from_tag("v.1.0", "cuda-bindings") is None +@pytest.mark.parametrize( + ("tag", "package", "version"), + ( + ("v13.4.1", "cuda_bindings_13", "13.4.1"), + ("v12.9.8", "cuda_bindings_12", "12.9.8"), + ), +) +@pytest.mark.agent_authored(model="gpt-5.6") +def test_bindings_notes_follow_current_and_maintenance_packages(tmp_path, tag, package, version): + write_notes(tmp_path, package, version) + + assert check_release_notes(tag, "cuda-bindings", tmp_path) == [] + + +@pytest.mark.parametrize("version", ("12.9.8", "12.9.8rc1", "12.9.8.dev1")) +@pytest.mark.agent_authored(model="gpt-6") +def test_resolved_package_selects_exact_version_and_tagged_root(tmp_path, version): + package_root = "cuda_bindings_12_maintenance" + package = resolved_12_package(package_root) + package["release_version"] = version + write_notes(tmp_path, package_root, version) + + problems = check_release_notes( + f"v{version}", + "cuda-bindings", + tmp_path, + package, + ) - def test_whitespace_rejected(self): - assert parse_version_from_tag("v1.0.0 ", "cuda-bindings") is None + assert problems == [] - def test_trailing_suffix_rejected(self): - # \w permits alphanumerics + underscore only; hyphens and shell meta-chars are out - assert parse_version_from_tag("v1.0.0-extra", "cuda-bindings") is None +@pytest.mark.agent_authored(model="gpt-5.6") +def test_present_missing_and_empty_notes(tmp_path): + write_notes(tmp_path, "cuda_core", "1.1.1") + assert check_release_notes("cuda-core-v1.1.1", "cuda-core", tmp_path) == [] -class TestIsPostRelease: - def test_normal(self): - assert not is_post_release("13.1.0") + missing = check_release_notes("cuda-pathfinder-v1.8.1", "cuda-pathfinder", tmp_path) + assert missing == [(notes_path("cuda_pathfinder", "1.8.1"), "missing")] - def test_post(self): - assert is_post_release("12.6.2.post1") + write_notes(tmp_path, "cuda_python", "13.3.0", content="") + empty = check_release_notes("v13.3.0", "cuda-python", tmp_path) + assert empty == [(notes_path("cuda_python", "13.3.0"), "empty")] - def test_post_no_number(self): - assert is_post_release("1.0.0.post") +@pytest.mark.agent_authored(model="gpt-5.6") +def test_post_release_needs_no_notes(tmp_path): + assert check_release_notes("v12.9.8.post1", "cuda-bindings", tmp_path) == [] -class TestCheckReleaseNotes: - def _make_notes(self, tmp_path, pkg, version, content="Release notes."): - d = tmp_path / pkg / "docs" / "source" / "release" - d.mkdir(parents=True, exist_ok=True) - f = d / f"{version}-notes.rst" - f.write_text(content) - return f - def test_present_and_nonempty(self, tmp_path): - self._make_notes(tmp_path, "cuda_core", "0.7.0") - problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", tmp_path) - assert problems == [] +@pytest.mark.agent_authored(model="gpt-5.6") +def test_main_accepts_resolved_package_and_reports_missing_notes(tmp_path, capsys): + package = resolved_12_package() + args = [ + "--git-tag", + "v12.9.8", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--bindings-package", + json.dumps(package), + ] - def test_missing(self, tmp_path): - problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", tmp_path) - assert len(problems) == 1 - assert problems[0][1] == "missing" + assert main(args) == 1 + assert "cuda_bindings_12/docs/source/release/12.9.8-notes.rst" in capsys.readouterr().err - def test_empty(self, tmp_path): - self._make_notes(tmp_path, "cuda_core", "0.7.0", content="") - problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", tmp_path) - assert len(problems) == 1 - assert problems[0][1] == "empty" + write_notes(tmp_path, "cuda_bindings_12", "12.9.8") + assert main(args) == 0 - def test_post_release_skipped(self, tmp_path): - problems = check_release_notes("v12.6.2.post1", "cuda-bindings", tmp_path) - assert problems == [] - def test_invalid_tag(self, tmp_path): - problems = check_release_notes("not-a-tag", "cuda-core", tmp_path) - assert len(problems) == 1 - assert "cannot parse" in problems[0][1] +@pytest.mark.agent_authored(model="gpt-5.6") +def test_main_rejects_unsafe_resolved_package_root(tmp_path, capsys): + args = [ + "--git-tag", + "v12.9.8", + "--component", + "cuda-bindings", + "--repo-root", + str(tmp_path), + "--bindings-package", + json.dumps(resolved_12_package("../outside")), + ] - def test_component_prefix_mismatch(self, tmp_path): - # Pass a cuda-core tag with component=cuda-pathfinder; must be rejected. - problems = check_release_notes("cuda-core-v0.7.0", "cuda-pathfinder", tmp_path) - assert len(problems) == 1 - assert "cannot parse" in problems[0][1] + assert main(args) == 2 + assert "normalized repository-relative POSIX path" in capsys.readouterr().err - def test_unknown_component(self, tmp_path): - problems = check_release_notes("v13.1.0", "bogus", tmp_path) - assert len(problems) == 1 - assert "unknown component" in problems[0][1] - def test_plain_v_tag(self, tmp_path): - self._make_notes(tmp_path, "cuda_python", "13.1.0") - problems = check_release_notes("v13.1.0", "cuda-python", tmp_path) - assert problems == [] +def release_args(repo_root: Path, component: str, package: dict[str, object]) -> list[str]: + args = [ + "--git-tag", + "v12.9.8", + "--component", + component, + "--repo-root", + str(repo_root), + "--bindings-package", + json.dumps(package), + ] + return args -class TestLoadBackportBranch: - def test_from_versions_yml(self, tmp_path): - d = tmp_path / "ci" - d.mkdir(parents=True) - (d / "versions.yml").write_text('backport_branch: "12.9.x"\n') +@pytest.mark.parametrize( + ("component", "package"), (("cuda-bindings", "cuda_bindings_12"), ("cuda-python", "cuda_python")) +) +@pytest.mark.parametrize("problem", ("missing", "empty")) +@pytest.mark.agent_authored(model="gpt-6") +def test_notes_in_another_checkout_do_not_satisfy_release_check( + tmp_path, capsys, monkeypatch, component, package, problem +): + write_notes(tmp_path / "other", package, "12.9.8") + monkeypatch.chdir(tmp_path / "other") + if problem == "empty": + write_notes(tmp_path / "tagged", package, "12.9.8", content="") - assert load_backport_branch(tmp_path) == "12.9.x" + assert main(release_args(tmp_path / "tagged", component, resolved_12_package())) == 1 + assert f"{notes_path(package, '12.9.8')} ({problem})" in capsys.readouterr().err - def test_from_github_ref_name_for_legacy_backport_branch(self, tmp_path, monkeypatch): - monkeypatch.setenv("GITHUB_REF_NAME", "12.9.x") - assert load_backport_branch(tmp_path) == "12.9.x" +@pytest.mark.agent_authored(model="gpt-6") +def test_metapackage_requires_its_own_notes(tmp_path, capsys): + write_notes(tmp_path, "cuda_bindings_12", "12.9.8") - def test_ignores_non_backport_github_ref_name(self, tmp_path, monkeypatch): - monkeypatch.setenv("GITHUB_REF_NAME", "main") + assert main(release_args(tmp_path, "cuda-python", resolved_12_package())) == 1 + assert "cuda_python/docs/source/release/12.9.8-notes.rst (missing)" in capsys.readouterr().err - assert load_backport_branch(tmp_path) is None + write_notes(tmp_path, "cuda_python", "12.9.8") + assert main(release_args(tmp_path, "cuda-python", resolved_12_package())) == 0 -class TestMain: - def _make_notes(self, tmp_path, pkg, version, content="Release notes."): - d = tmp_path / pkg / "docs" / "source" / "release" - d.mkdir(parents=True, exist_ok=True) - (d / f"{version}-notes.rst").write_text(content) +@pytest.mark.parametrize("component", ("cuda-bindings", "cuda-python")) +@pytest.mark.parametrize("notes_version", ("12.9.7", "12.9.9")) +@pytest.mark.agent_authored(model="gpt-6") +def test_release_rejects_other_version_notes(tmp_path, capsys, component, notes_version): + write_notes(tmp_path, "cuda_bindings_12", notes_version) + write_notes(tmp_path, "cuda_python", notes_version) - def test_success(self, tmp_path): - d = tmp_path / "cuda_core" / "docs" / "source" / "release" - d.mkdir(parents=True) - (d / "0.7.0-notes.rst").write_text("Notes here.") - rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)]) - assert rc == 0 - - def test_failure(self, tmp_path): - rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)]) - assert rc == 1 - - def test_post_skip(self, tmp_path): - rc = main(["--git-tag", "v12.6.2.post1", "--component", "cuda-bindings", "--repo-root", str(tmp_path)]) - assert rc == 0 - - def test_unparsable_tag_returns_2(self, tmp_path): - rc = main(["--git-tag", "not-a-tag", "--component", "cuda-core", "--repo-root", str(tmp_path)]) - assert rc == 2 - - def test_path_traversal_returns_2(self, tmp_path): - rc = main(["--git-tag", "v1.0.0/../evil", "--component", "cuda-bindings", "--repo-root", str(tmp_path)]) - assert rc == 2 - - def test_component_prefix_mismatch_returns_2(self, tmp_path): - rc = main( - [ - "--git-tag", - "cuda-core-v0.7.0", - "--component", - "cuda-pathfinder", - "--repo-root", - str(tmp_path), - ] - ) - assert rc == 2 - - def test_mainline_bindings_requires_backport_decision(self, tmp_path, capsys): - rc = main( - [ - "--git-tag", - "v13.3.0", - "--component", - "cuda-bindings", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - ] - ) + assert main(release_args(tmp_path, component, resolved_12_package())) == 1 + assert "ERROR: missing or empty release notes" in capsys.readouterr().err - captured = capsys.readouterr() - assert rc == 1 - assert "" in captured.err - - def test_mainline_bindings_accepts_not_planned(self, tmp_path): - self._make_notes(tmp_path, "cuda_bindings_13", "13.3.0") - - rc = main( - [ - "--git-tag", - "v13.3.0", - "--component", - "cuda-bindings", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - "--backport-git-tag", - "not planned", - ] - ) - assert rc == 0 - - def test_mainline_bindings_checks_planned_backport_notes(self, tmp_path, capsys): - self._make_notes(tmp_path, "cuda_bindings_13", "13.3.0") - - rc = main( - [ - "--git-tag", - "v13.3.0", - "--component", - "cuda-bindings", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - "--backport-git-tag", - "v12.9.7", - ] - ) +@pytest.mark.parametrize( + ("tag", "resolved_version"), + ( + ("v12.9.8", "12.9.9"), + ("v12.9.8rc1", "12.9.8"), + ("v12.9.8.dev1", "12.9.8"), + ("v12.9.8", "12.9.8.post1"), + ), +) +@pytest.mark.agent_authored(model="gpt-6") +def test_release_rejects_resolver_version_mismatch(tmp_path, capsys, tag, resolved_version): + write_notes(tmp_path, "cuda_bindings_12", resolved_version) + package = resolved_12_package() + package["release_version"] = resolved_version + args = release_args(tmp_path, "cuda-bindings", package) + args[args.index("--git-tag") + 1] = tag - captured = capsys.readouterr() - assert rc == 1 - assert "12.9.7-notes.rst" in captured.err - - def test_mainline_bindings_accepts_planned_backport_notes(self, tmp_path): - self._make_notes(tmp_path, "cuda_bindings_13", "13.3.0") - self._make_notes(tmp_path, "cuda_bindings_13", "12.9.7") - - rc = main( - [ - "--git-tag", - "v13.3.0", - "--component", - "cuda-bindings", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - "--backport-git-tag", - "v12.9.7", - ] - ) + assert main(args) == 2 + assert "does not match release tag" in capsys.readouterr().err - assert rc == 0 - - def test_mainline_cuda_python_accepts_planned_backport_notes(self, tmp_path): - self._make_notes(tmp_path, "cuda_python", "13.3.0") - self._make_notes(tmp_path, "cuda_python", "12.9.7") - - rc = main( - [ - "--git-tag", - "v13.3.0", - "--component", - "cuda-python", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - "--backport-git-tag", - "v12.9.7", - ] - ) - assert rc == 0 - - def test_backport_bindings_missing_notes_warns_without_failing(self, tmp_path, monkeypatch, capsys): - summary_path = tmp_path / "summary.md" - monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) - - rc = main( - [ - "--git-tag", - "v12.9.7", - "--component", - "cuda-bindings", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - ] - ) - - captured = capsys.readouterr() - assert rc == 0 - assert "::warning file=cuda_bindings_13/docs/source/release/12.9.7-notes.rst::" in captured.out - assert "12.9.7-notes.rst" in summary_path.read_text() - - def test_mainline_bindings_rejects_non_backport_tag(self, tmp_path): - self._make_notes(tmp_path, "cuda_bindings_13", "13.3.0") - - rc = main( - [ - "--git-tag", - "v13.3.0", - "--component", - "cuda-bindings", - "--repo-root", - str(tmp_path), - "--backport-branch", - "12.9.x", - "--backport-git-tag", - "v13.2.0", - ] - ) +@pytest.mark.parametrize( + ("component", "tag"), + ( + ("cuda-bindings", "v12.9.8.post1"), + ("cuda-python", "v12.9.8.post1"), + ("cuda-core", "cuda-core-v1.1.1.post1"), + ), +) +@pytest.mark.agent_authored(model="gpt-6") +def test_main_skips_post_release_without_notes(tmp_path, capsys, component, tag): + package = resolved_12_package() + package["release_version"] = "12.9.8.post1" + args = release_args(tmp_path, component, package) + args[args.index("--git-tag") + 1] = tag - assert rc == 2 + assert main(args) == 0 + assert "skipping release-notes check" in capsys.readouterr().out @pytest.mark.parametrize("version", ["1.5.2", "1.5.2.post1"]) @@ -370,25 +277,26 @@ def test_pathfinder_missing_notes_fail_without_version_output(tmp_path, monkeypa @pytest.mark.parametrize( - "component,tag,version,extra_args", + "component,tag,version,package", [ - ("cuda-core", "cuda-core-v0.7.0", "0.7.0", []), - ("cuda-bindings", "v12.9.7", "12.9.7", ["--backport-branch", "12.9.x"]), - ("cuda-python", "v13.3.0", "13.3.0", ["--backport-branch", "12.9.x", "--backport-git-tag", "not planned"]), - ("cuda-bindings", "v12.9.7.post1", "12.9.7.post1", []), + ("cuda-core", "cuda-core-v1.1.1", "1.1.1", "cuda_core"), + ("cuda-core", "cuda-core-v1.1.1.post1", "1.1.1.post1", "cuda_core"), + ("cuda-bindings", "v12.9.8", "12.9.8", "cuda_bindings_12"), + ("cuda-bindings", "v13.4.1", "13.4.1", "cuda_bindings_13"), + ("cuda-bindings", "v12.9.8.post1", "12.9.8.post1", "cuda_bindings_12"), + ("cuda-python", "v13.3.0", "13.3.0", "cuda_python"), + ("cuda-python", "v12.9.8.post1", "12.9.8.post1", "cuda_python"), ], ) @pytest.mark.agent_authored(model="gpt-6.1") -def test_successful_checks_expose_version_for_every_policy(tmp_path, monkeypatch, component, tag, version, extra_args): - if component == "cuda-core" or version == "13.3.0": - notes = tmp_path / component.replace("-", "_") / "docs" / "source" / "release" / f"{version}-notes.rst" - notes.parent.mkdir(parents=True) - notes.write_text("Release notes.") +def test_successful_checks_expose_version_for_every_policy(tmp_path, monkeypatch, component, tag, version, package): + if ".post" not in version: + write_notes(tmp_path, package, version) output = tmp_path / "output" output.write_text("existing=value\n") monkeypatch.setenv("GITHUB_OUTPUT", str(output)) - assert main(["--git-tag", tag, "--component", component, "--repo-root", str(tmp_path), *extra_args]) == 0 + assert main(["--git-tag", tag, "--component", component, "--repo-root", str(tmp_path)]) == 0 assert output.read_text() == f"existing=value\nversion={version}\n" @@ -400,3 +308,23 @@ def test_invalid_pathfinder_tags_do_not_expose_version(tmp_path, monkeypatch, ta assert main(["--git-tag", tag, "--component", "cuda-pathfinder", "--repo-root", str(tmp_path)]) == 2 assert not output.exists() + + +@pytest.mark.parametrize( + ("component", "tag", "package", "version"), + ( + ("cuda-bindings", "v12.9.8", "cuda_bindings_12", "12.9.8"), + ("cuda-python", "v12.9.8", "cuda_python", "12.9.8"), + ("cuda-core", "cuda-core-v1.1.1", "cuda_core", "1.1.1"), + ), +) +@pytest.mark.parametrize("problem", ("missing", "empty")) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_missing_or_empty_notes_do_not_expose_version(tmp_path, monkeypatch, component, tag, package, version, problem): + if problem == "empty": + write_notes(tmp_path, package, version, content="") + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert main(["--git-tag", tag, "--component", component, "--repo-root", str(tmp_path)]) == 1 + assert not output.exists() diff --git a/ci/tools/tests/test_validate_release_wheels.py b/ci/tools/tests/test_validate_release_wheels.py new file mode 100644 index 00000000000..eb5c5ababbf --- /dev/null +++ b/ci/tools/tests/test_validate_release_wheels.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +from ci.tools.validate_release_wheels import WheelTarget, expected_binary_targets + + +def resolved_package() -> str: + return json.dumps( + { + "package_root": "alternate_bindings_12_8", + "toolkit_version": "12.8.0", + "release_version": "12.8.0", + }, + separators=(",", ":"), + ) + + +def write_release_matrix( + root: Path, + *, + python_versions: tuple[str, ...] = ("3.12",), + platforms: tuple[str, ...] = ("linux-64",), +) -> None: + config = root / "ci" / "versions.yml" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(yaml.safe_dump({"python": {"supported": list(python_versions)}}), encoding="utf-8") + ci_workflow = root / ".github" / "workflows" / "ci.yml" + ci_workflow.parent.mkdir(parents=True, exist_ok=True) + ci_workflow.write_text( + yaml.safe_dump( + { + "jobs": { + f"build-{platform}": { + "uses": "./.github/workflows/build-wheel.yml", + "with": {"host-platform": platform}, + } + for platform in platforms + } + } + ), + encoding="utf-8", + ) + + +def run_validator( + wheel_dir: Path, + release_source_root: Path, + *extra_args: str, + git_tag: str = "v12.8.0", + component: str = "cuda-bindings", +) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - invokes the repository script under test + [ + sys.executable, + "-m", + "ci.tools.validate_release_wheels", + git_tag, + component, + str(wheel_dir), + "--release-source-root", + str(release_source_root), + *extra_args, + ], + check=False, + capture_output=True, + text=True, + ) + + +@pytest.fixture +def release_source(tmp_path): + root = tmp_path / "release-source" + write_release_matrix(root) + return root + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_tag_authoritative_package_validates_after_control_registry_moves_on(tmp_path, release_source): + (tmp_path / "cuda_bindings-12.8.0-cp312-cp312-manylinux_2_28_x86_64.whl").touch() + + without_resolved_package = run_validator(tmp_path, release_source) + with_resolved_package = run_validator( + tmp_path, + release_source, + "--bindings-package", + resolved_package(), + ) + + assert without_resolved_package.returncode == 1 + assert with_resolved_package.returncode == 0, with_resolved_package.stderr + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_resolved_package_must_match_the_release_tag(tmp_path, release_source): + (tmp_path / "cuda_bindings-12.8.0-cp312-cp312-manylinux_2_28_x86_64.whl").touch() + package = json.loads(resolved_package()) + package["release_version"] = "12.9.0" + + result = run_validator(tmp_path, release_source, "--bindings-package", json.dumps(package)) + + assert result.returncode == 1 + assert "does not match release tag" in result.stderr + + +@pytest.mark.agent_authored(model="gpt-5.6") +def test_unexpected_distribution_is_rejected(tmp_path, release_source): + (tmp_path / "cuda_bindings-12.8.0-cp312-cp312-manylinux_2_28_x86_64.whl").touch() + unexpected_wheel = "cuda_core-12.8.0-py3-none-any.whl" + (tmp_path / unexpected_wheel).touch() + + result = run_validator(tmp_path, release_source, "--bindings-package", resolved_package()) + + assert result.returncode == 1 + assert f"{unexpected_wheel}: unexpected distribution 'cuda_core'" in result.stderr + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_complete_binary_wheel_matrix_is_required(tmp_path): + release_source = tmp_path / "release-source" + write_release_matrix( + release_source, + python_versions=("3.11", "3.14t", "3.15", "3.15t"), + platforms=("linux-64", "win-64"), + ) + wheel_names = ( + "cuda_bindings-12.8.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", + "cuda_bindings-12.8.0-cp311-cp311-win_amd64.whl", + "cuda_bindings-12.8.0-cp314-cp314t-manylinux_2_28_x86_64.whl", + "cuda_bindings-12.8.0-cp315-cp315-manylinux_2_28_x86_64.whl", + "cuda_bindings-12.8.0-cp315-cp315-win_amd64.whl", + "cuda_bindings-12.8.0-cp315-cp315t-manylinux_2_28_x86_64.whl", + "cuda_bindings-12.8.0-cp315-cp315t-win_amd64.whl", + "cuda_bindings-12.8.0-cp314-cp314t-win_amd64.whl", + ) + for wheel_name in wheel_names: + (tmp_path / wheel_name).touch() + + complete = run_validator( + tmp_path, + release_source, + "--bindings-package", + resolved_package(), + ) + (tmp_path / wheel_names[-1]).unlink() + partial = run_validator( + tmp_path, + release_source, + "--bindings-package", + resolved_package(), + ) + + assert complete.returncode == 0, complete.stderr + assert partial.returncode == 1 + assert "Missing expected cuda_bindings wheel targets: cp314-cp314t-win_amd64" in partial.stderr + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_windows_arm64_targets_follow_the_tagged_toolkit_line(tmp_path): + release_source = tmp_path / "release-source" + write_release_matrix( + release_source, + python_versions=("3.10", "3.11"), + platforms=("linux-64", "win-arm64"), + ) + + cuda_12 = expected_binary_targets(release_source, {"toolkit_version": "12.9.1"}) + cuda_13 = expected_binary_targets(release_source, {"toolkit_version": "13.4.1"}) + + assert cuda_12 == { + WheelTarget("cp310", "cp310", "manylinux-x86_64"), + WheelTarget("cp311", "cp311", "manylinux-x86_64"), + } + assert cuda_13 == cuda_12 | {WheelTarget("cp311", "cp311", "win_arm64")} + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_cuda_core_uses_the_complete_binary_wheel_matrix(tmp_path): + release_source = tmp_path / "release-source" + write_release_matrix(release_source, platforms=("linux-64", "win-64")) + (tmp_path / "cuda_core-1.2.0-cp312-cp312-manylinux_2_28_x86_64.whl").touch() + (tmp_path / "cuda_core-1.2.0-cp312-cp312-win_amd64.whl").touch() + + result = run_validator( + tmp_path, + release_source, + "--bindings-package", + resolved_package(), + git_tag="cuda-core-v1.2.0", + component="cuda-core", + ) + + assert result.returncode == 0, result.stderr diff --git a/ci/tools/validate-release-wheels b/ci/tools/validate-release-wheels deleted file mode 100755 index 52d24df4731..00000000000 --- a/ci/tools/validate-release-wheels +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 - -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# SPDX-License-Identifier: Apache-2.0 - -"""Validate downloaded release wheels against the requested release tag.""" - -from __future__ import annotations - -import argparse -import sys -from collections import defaultdict -from pathlib import Path - -from check_release_notes import parse_version_from_tag - -COMPONENT_TO_DISTRIBUTIONS: dict[str, set[str]] = { - "cuda-core": {"cuda_core"}, - "cuda-bindings": {"cuda_bindings"}, - "cuda-pathfinder": {"cuda_pathfinder"}, - "cuda-python": {"cuda_python"}, - "all": {"cuda_core", "cuda_bindings", "cuda_pathfinder", "cuda_python"}, -} - -COMPONENT_TO_TAG_COMPONENTS: dict[str, tuple[str, ...]] = { - "cuda-core": ("cuda-core",), - "cuda-bindings": ("cuda-bindings",), - "cuda-pathfinder": ("cuda-pathfinder",), - "cuda-python": ("cuda-python",), - "all": ("cuda-core", "cuda-bindings", "cuda-pathfinder", "cuda-python"), -} - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Validate that wheel versions match the release tag. " - "This rejects dev/local wheel versions for release uploads." - ) - ) - parser.add_argument("git_tag", help="Release git tag (for example: v13.0.0)") - parser.add_argument("component", choices=sorted(COMPONENT_TO_DISTRIBUTIONS.keys())) - parser.add_argument("wheel_dir", help="Directory containing wheel files") - return parser.parse_args() - - -def version_from_tag(tag: str, component: str) -> str: - versions = { - version - for tag_component in COMPONENT_TO_TAG_COMPONENTS[component] - if (version := parse_version_from_tag(tag, tag_component)) is not None - } - if len(versions) == 1: - return versions.pop() - raise ValueError( - "Unsupported git tag format " - f"{tag!r} for component {component!r}; expected vX.Y.Z[.postN], " - "cuda-core-vX.Y.Z[.postN], or cuda-pathfinder-vX.Y.Z[.postN]." - ) - - -def parse_wheel_dist_and_version(path: Path) -> tuple[str, str]: - # Wheel name format starts with: {distribution}-{version}-... - parts = path.stem.split("-") - if len(parts) < 5: - raise ValueError(f"Invalid wheel filename format: {path.name}") - return parts[0], parts[1] - - -def main() -> int: - args = parse_args() - try: - expected_version = version_from_tag(args.git_tag, args.component) - except ValueError as exc: - print(f"Error: {exc}", file=sys.stderr) - return 1 - - expected_distributions = COMPONENT_TO_DISTRIBUTIONS[args.component] - wheel_dir = Path(args.wheel_dir) - - wheels = sorted(wheel_dir.glob("*.whl")) - if not wheels: - print(f"Error: No wheel files found in {wheel_dir}", file=sys.stderr) - return 1 - - seen_versions: dict[str, set[str]] = defaultdict(set) - errors: list[str] = [] - - for wheel in wheels: - try: - distribution, version = parse_wheel_dist_and_version(wheel) - except ValueError as exc: - errors.append(str(exc)) - continue - - if distribution not in expected_distributions: - continue - - seen_versions[distribution].add(version) - - if ".dev" in version or "+" in version: - errors.append( - f"{wheel.name}: wheel version {version!r} contains dev/local markers " - "(.dev or +), which is not allowed for release uploads." - ) - - if version != expected_version: - errors.append( - f"{wheel.name}: wheel version {version!r} does not match expected " - f"release version {expected_version!r} from git tag {args.git_tag!r}." - ) - - missing_distributions = sorted(expected_distributions - set(seen_versions)) - if missing_distributions: - errors.append("Missing expected component wheels in download set: " + ", ".join(missing_distributions)) - - for distribution, versions in sorted(seen_versions.items()): - if len(versions) > 1: - errors.append( - f"Expected one release version for {distribution}, found multiple: " + ", ".join(sorted(versions)) - ) - - if errors: - print("Wheel validation failed:", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - return 1 - - print( - "Validated release wheels for component " - f"{args.component} at version {expected_version} from tag {args.git_tag}." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ci/tools/validate_release_wheels.py b/ci/tools/validate_release_wheels.py new file mode 100644 index 00000000000..9d8f683037e --- /dev/null +++ b/ci/tools/validate_release_wheels.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Validate downloaded release wheels against the requested release tag.""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +import yaml +from packaging.tags import Tag +from packaging.utils import InvalidWheelFilename, parse_wheel_filename + +from .check_release_notes import COMPONENTS, parse_version_from_tag + +_BINARY_DISTRIBUTIONS = frozenset({"cuda_bindings", "cuda_core"}) +_VERSIONS_CONFIG = Path("ci/versions.yml") +_CI_WORKFLOW = Path(".github/workflows/ci.yml") + + +@dataclass(frozen=True, order=True) +class WheelTarget: + interpreter: str + abi: str + platform: str + + def __str__(self) -> str: + return f"{self.interpreter}-{self.abi}-{self.platform}" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Validate that wheel versions match the release tag. " + "This rejects dev/local wheel versions for release uploads." + ) + ) + parser.add_argument("git_tag", help="Release git tag (for example: v13.0.0)") + parser.add_argument("component", choices=[*sorted(COMPONENTS), "all"]) + parser.add_argument("wheel_dir", help="Directory containing wheel files") + parser.add_argument( + "--bindings-package", + default="", + help="normalized CUDA bindings package JSON from the release resolver", + ) + parser.add_argument( + "--release-source-root", + type=Path, + help="tagged source tree whose CI build matrix produced the wheels", + ) + return parser.parse_args() + + +def version_from_tag( + tag: str, + component: str, + bindings_package: Mapping[str, object] | None = None, +) -> str: + versions = { + version + for tag_component in (COMPONENTS if component == "all" else (component,)) + if ( + version := parse_version_from_tag( + tag, + tag_component, + bindings_package if tag_component == "cuda-bindings" else None, + ) + ) + is not None + } + if len(versions) == 1: + return versions.pop() + raise ValueError( + "Unsupported git tag format " + f"{tag!r} for component {component!r}; expected vX.Y.Z[.postN], " + "cuda-core-vX.Y.Z[.postN], or cuda-pathfinder-vX.Y.Z[.postN]." + ) + + +def _load_yaml_mapping(path: Path) -> Mapping[str, object]: + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as error: + raise ValueError(f"could not read release build configuration {path}: {error}") from error + if not isinstance(raw, dict): + raise ValueError(f"release build configuration {path} must contain a mapping") + return raw + + +def _string_list(value: object, label: str) -> list[str]: + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value): + raise ValueError(f"{label} must be a non-empty list of strings") + return value + + +def _release_python_versions(release_source_root: Path) -> list[str]: + path = release_source_root / _VERSIONS_CONFIG + raw = _load_yaml_mapping(path) + try: + value = raw["python"]["supported"] + except (KeyError, TypeError) as error: + raise ValueError(f"could not find python.supported in {path}") from error + versions = _string_list(value, f"{path} python.supported") + if len(versions) != len(set(versions)): + raise ValueError(f"{path} python.supported must not contain duplicates") + return versions + + +def _release_platforms(release_source_root: Path) -> list[str]: + path = release_source_root / _CI_WORKFLOW + raw = _load_yaml_mapping(path) + try: + jobs = raw["jobs"] + except KeyError as error: + raise ValueError(f"could not find jobs in {path}") from error + if not isinstance(jobs, dict): + raise ValueError(f"{path} jobs must be a mapping") + + platforms: list[str] = [] + for job_name, value in jobs.items(): + if not isinstance(value, dict) or value.get("uses") != "./.github/workflows/build-wheel.yml": + continue + inputs = value.get("with", {}) + if not isinstance(inputs, dict): + raise ValueError(f"{path} job {job_name!r} inputs must be a mapping") + platform = inputs.get("host-platform") + if isinstance(platform, str) and "${{" not in platform: + platforms.append(platform) + continue + try: + matrix = value["strategy"]["matrix"]["host-platform"] + except (KeyError, TypeError) as error: + raise ValueError(f"could not resolve the host-platform matrix for {path} job {job_name!r}") from error + platforms.extend(_string_list(matrix, f"{path} job {job_name!r} host-platform matrix")) + + if not platforms: + raise ValueError(f"could not find any build-wheel host platforms in {path}") + if len(platforms) != len(set(platforms)): + raise ValueError(f"{path} contains duplicate build-wheel host platforms") + return platforms + + +def _python_target(version: str) -> tuple[str, str]: + free_threaded = version.endswith("t") + digits = version.removesuffix("t").replace(".", "") + if not digits.isdecimal(): + raise ValueError(f"unsupported Python build-matrix version: {version!r}") + interpreter = f"cp{digits}" + return interpreter, f"{interpreter}t" if free_threaded else interpreter + + +def _platform_target(platform: str) -> str: + targets = { + "linux-64": "manylinux-x86_64", + "linux-aarch64": "manylinux-aarch64", + "win-64": "win_amd64", + "win-arm64": "win_arm64", + } + try: + return targets[platform] + except KeyError as error: + raise ValueError(f"unsupported release build platform: {platform!r}") from error + + +def expected_binary_targets( + release_source_root: Path, + bindings_package: Mapping[str, object] | None, +) -> set[WheelTarget]: + python_versions = _release_python_versions(release_source_root) + platforms = _release_platforms(release_source_root) + + if "win-arm64" in platforms: + toolkit_version = None if bindings_package is None else bindings_package.get("toolkit_version") + if not isinstance(toolkit_version, str): + raise ValueError("resolved CUDA bindings package must supply toolkit_version for matrix validation") + try: + toolkit_major, toolkit_minor = (int(part) for part in toolkit_version.split(".", maxsplit=2)[:2]) + except ValueError as error: + raise ValueError(f"invalid resolved toolkit_version: {toolkit_version!r}") from error + if (toolkit_major, toolkit_minor) < (13, 4): + platforms.remove("win-arm64") + + targets: set[WheelTarget] = set() + for version in python_versions: + interpreter, abi = _python_target(version) + for platform in platforms: + if platform == "win-arm64" and version == "3.10": + continue + targets.add(WheelTarget(interpreter, abi, _platform_target(platform))) + return targets + + +def _normalized_platform(platform: str) -> str: + if platform.startswith("manylinux") and platform.endswith("_x86_64"): + return "manylinux-x86_64" + if platform.startswith("manylinux") and platform.endswith("_aarch64"): + return "manylinux-aarch64" + return platform + + +def _wheel_target(tags: frozenset[Tag], wheel_name: str) -> WheelTarget: + targets = {WheelTarget(tag.interpreter, tag.abi, _normalized_platform(tag.platform)) for tag in tags} + if len(targets) != 1: + raise ValueError(f"{wheel_name}: expected one wheel target, found {len(targets)}") + return targets.pop() + + +def main() -> int: + args = parse_args() + try: + bindings_package = json.loads(args.bindings_package) if args.bindings_package else None + if bindings_package is not None and not isinstance(bindings_package, dict): + raise ValueError("resolved CUDA bindings package must be a JSON object") + expected_version = version_from_tag(args.git_tag, args.component, bindings_package) + expected_targets = { + distribution: ( + expected_binary_targets(args.release_source_root, bindings_package) + if distribution in _BINARY_DISTRIBUTIONS and args.release_source_root is not None + else {WheelTarget("py3", "none", "any")} + ) + for distribution in ( + {component.replace("-", "_") for component in COMPONENTS} + if args.component == "all" + else {args.component.replace("-", "_")} + ) + } + binary_distributions = set(expected_targets) & _BINARY_DISTRIBUTIONS + if binary_distributions and args.release_source_root is None: + raise ValueError("--release-source-root is required to validate binary wheel matrices") + except (json.JSONDecodeError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + expected_distributions = set(expected_targets) + wheel_dir = Path(args.wheel_dir) + + wheels = sorted(wheel_dir.glob("*.whl")) + if not wheels: + print(f"Error: No wheel files found in {wheel_dir}", file=sys.stderr) + return 1 + + seen_targets: dict[str, set[WheelTarget]] = {distribution: set() for distribution in expected_distributions} + errors: list[str] = [] + + for wheel in wheels: + try: + parsed_distribution, version, build, tags = parse_wheel_filename(wheel.name) + distribution = str(parsed_distribution).replace("-", "_") + target = _wheel_target(tags, wheel.name) + except (InvalidWheelFilename, ValueError) as exc: + errors.append(f"Invalid wheel filename {wheel.name!r}: {exc}") + continue + + if distribution not in expected_distributions: + errors.append( + f"{wheel.name}: unexpected distribution {distribution!r} for component " + f"{args.component!r}; expected one of: " + ", ".join(sorted(expected_distributions)) + ) + continue + + if build: + errors.append(f"{wheel.name}: wheel build tags are not expected for a release") + + if target in seen_targets[distribution]: + errors.append(f"{wheel.name}: duplicate wheel target {target} for {distribution}") + seen_targets[distribution].add(target) + + if version.is_devrelease or version.local is not None: + errors.append( + f"{wheel.name}: wheel version {str(version)!r} contains dev/local markers " + "(.dev or +), which is not allowed for release uploads." + ) + + if str(version) != expected_version: + errors.append( + f"{wheel.name}: wheel version {str(version)!r} does not match expected " + f"release version {expected_version!r} from git tag {args.git_tag!r}." + ) + + missing_distributions = sorted(distribution for distribution, targets in seen_targets.items() if not targets) + if missing_distributions: + errors.append("Missing expected component wheels in download set: " + ", ".join(missing_distributions)) + + for distribution, expected in sorted(expected_targets.items()): + missing = sorted(expected - seen_targets[distribution]) + unexpected = sorted(seen_targets[distribution] - expected) + if missing: + errors.append(f"Missing expected {distribution} wheel targets: " + ", ".join(map(str, missing))) + if unexpected: + errors.append(f"Unexpected {distribution} wheel targets: " + ", ".join(map(str, unexpected))) + + if errors: + print("Wheel validation failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print( + "Validated release wheels for component " + f"{args.component} at version {expected_version} from tag {args.git_tag}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())