# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # SPDX-License-Identifier: Apache-2.0 name: "CI: Release" # Manually-triggered release workflow. Creates a release draft if one doesn't exist # for the given tag, or uses an existing draft, then publishes the selected wheels # to TestPyPI followed by PyPI. The dry-run mode validates the release path # without publishing to external release surfaces. # Pathfinder additionally prepares metadata, verifies both indexes, and publishes its draft. # # 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. # Also exercise cuda-core and cuda-pathfinder, including a failed docs build. on: workflow_dispatch: inputs: component: description: "Component to release" required: true type: choice options: - cuda-core - cuda-bindings - cuda-pathfinder - cuda-python release-action: description: "What to run" required: true type: choice options: - dry-run - full-release default: dry-run git-tag: 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 type: string default: "" dry-run-docs-branch: description: "Dry-run only: optional gh-pages-* branch to receive generated docs, for example gh-pages-dry-run" required: false type: string default: "" concurrency: # Serialize releases per component without canceling an active run. group: release-${{ inputs.component }} cancel-in-progress: false defaults: run: shell: bash --noprofile --norc -xeuo pipefail {0} jobs: determine-run-id: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest outputs: run-id: ${{ steps.lookup-run-id.outputs.run-id }} steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # lookup-run-id resolves the git tag to a SHA; we need tags but not history. fetch-depth: 1 fetch-tags: true - name: Determine Run ID id: lookup-run-id env: GH_TOKEN: ${{ github.token }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} PROVIDED_RUN_ID: ${{ inputs.run-id }} RELEASE_REPOSITORY: ${{ github.repository }} run: | if [[ -n "$PROVIDED_RUN_ID" ]]; then echo "Using provided run ID: $PROVIDED_RUN_ID" RUN_ID="$PROVIDED_RUN_ID" 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") echo "Auto-detected run ID: $RUN_ID" fi echo "run-id=$RUN_ID" >> "$GITHUB_OUTPUT" check-tag: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Dry-run validation resolves the requested tag locally; we need tags but not history. fetch-depth: 1 fetch-tags: true - name: Check release tag and draft state env: GH_TOKEN: ${{ github.token }} RELEASE_ACTION: ${{ inputs.release-action }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} DRY_RUN_DOCS_BRANCH: ${{ inputs.dry-run-docs-branch }} run: | if [[ "$RELEASE_ACTION" == "full-release" && -n "$DRY_RUN_DOCS_BRANCH" ]]; then echo "error: dry-run-docs-branch is only valid with release-action=dry-run" >&2 exit 1 fi if [[ "$RELEASE_ACTION" == "dry-run" && -n "$DRY_RUN_DOCS_BRANCH" && ! "$DRY_RUN_DOCS_BRANCH" =~ ^gh-pages-[[:alnum:]._/-]+$ ]]; then echo "error: dry-run-docs-branch must be a non-production gh-pages-* branch" >&2 exit 1 fi 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') found=0 for idx in "${!tags[@]}"; do if [[ "${tags[$idx]}" == "${{ inputs.git-tag }}" ]]; then echo "found existing release for ${{ inputs.git-tag }}" found=1 if [[ "${is_draft[$idx]}" != "true" ]]; then echo "the release note is not in draft state" exit 1 fi break 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 }}" fi check-release-notes: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest outputs: version: ${{ steps.release-notes.outputs.version }} steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.git-tag }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "~3.12.16" # floor: first 3.12 with eMudhra ssl fix (CVE-2026-115557) - name: Check versioned release notes exist id: release-notes env: RELEASE_GIT_TAG: ${{ inputs.git-tag }} RELEASE_COMPONENT: ${{ inputs.component }} BACKPORT_GIT_TAG: ${{ inputs.backport-git-tag }} run: | python ci/tools/check_release_notes.py \ --git-tag "$RELEASE_GIT_TAG" \ --component "$RELEASE_COMPONENT" \ --backport-git-tag "$BACKPORT_GIT_TAG" - name: Require Pathfinder version output if: ${{ inputs.component == 'cuda-pathfinder' }} env: VERSION: ${{ steps.release-notes.outputs.version }} run: | [[ -n "$VERSION" ]] || { echo "::error::The tagged Pathfinder release-note checker must emit a non-empty version output" exit 1 } ensure-pathfinder-draft: name: Prepare Pathfinder release draft if: ${{ github.repository_owner == 'nvidia' && inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} needs: [check-tag, check-release-notes] runs-on: ubuntu-latest permissions: contents: write env: TAG: ${{ inputs.git-tag }} VERSION: ${{ needs.check-release-notes.outputs.version }} GH_TOKEN: ${{ github.token }} steps: - name: Set metadata for a Pathfinder draft run: | [[ -n "$VERSION" ]] || { echo "::error::Empty version output"; exit 1; } # Recognize the shared placeholder across retries without overwriting curated drafts. is_placeholder=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft,name,body \ | jq -r --arg placeholder "Release ${TAG}" ' if .isDraft != true then error("Release is not a draft") else .name == $placeholder and .body == $placeholder end ') if [[ "$is_placeholder" != "true" ]]; then echo "Reusing draft release ${TAG}" exit 0 fi cat > "${RUNNER_TEMP}/release-body.md" <- ${{ !cancelled() && inputs.release-action == 'full-release' && needs.check-tag.result == 'success' && needs.check-release-notes.result == 'success' && needs.determine-run-id.result == 'success' && needs.doc.result == 'success' && (inputs.component != 'cuda-pathfinder' || needs.ensure-pathfinder-draft.result == 'success') }} runs-on: ubuntu-latest needs: - check-tag - check-release-notes - determine-run-id - doc - ensure-pathfinder-draft environment: name: testpypi url: https://test.pypi.org/p/${{ inputs.component }}/ permissions: id-token: write steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download component wheels env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ./ci/tools/download-wheels "${{ needs.determine-run-id.outputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "dist" - name: Validate wheel versions for release tag run: | ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: repository-url: https://test.pypi.org/legacy/ verify-testpypi: name: Verify Pathfinder on TestPyPI if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} needs: [check-release-notes, publish-testpypi] runs-on: ubuntu-latest permissions: {} env: VERSION: ${{ needs.check-release-notes.outputs.version }} INDEX_URL: https://test.pypi.org/simple/ EXTRA_INDEX_URL: https://pypi.org/simple/ INDEX_NAME: TestPyPI steps: &verify-pathfinder-steps - name: Set up Python # Floor the interpreter so the venv below inherits an ssl module that # rejects the compromised eMudhra keys (CVE-2026-115557). setup-python # puts its shim first on PATH, so `python3 -m venv` uses it. uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "~3.12.16" # floor: first 3.12 with eMudhra ssl fix - name: Create verification environment run: | [[ -n "$VERSION" ]] || { echo "::error::Empty version output"; exit 1; } python3 -m venv "${RUNNER_TEMP}/verify" - name: Harden pip # These jobs have no checkout; $/ loads the action from the workflow's commit. uses: $/.github/actions/harden-pip with: python: ${{ runner.temp }}/verify/bin/python - name: Install Pathfinder from index and verify run: | source "${RUNNER_TEMP}/verify/bin/activate" index_args=() if [[ -n "$INDEX_URL" ]]; then index_args+=( --index-url "$INDEX_URL" ) fi if [[ -n "$EXTRA_INDEX_URL" ]]; then index_args+=( --extra-index-url "$EXTRA_INDEX_URL" ) fi max_attempts=6 retry_seconds=30 for ((attempt=1; attempt<=max_attempts; attempt++)); do if pip install "${index_args[@]}" "cuda-pathfinder==${VERSION}"; then break fi if (( attempt == max_attempts )); then echo "::error::Failed to install cuda-pathfinder==${VERSION} from ${INDEX_NAME} after ${max_attempts} attempts" exit 1 fi sleep "$retry_seconds" done installed=$(python -c "from cuda.pathfinder import __version__; print(__version__)") if [[ "$installed" != "$VERSION" ]]; then echo "::error::Version mismatch: expected ${VERSION}, got ${installed}" exit 1 fi publish-pypi: name: Publish wheels to PyPI if: >- ${{ !cancelled() && inputs.release-action == 'full-release' && needs.determine-run-id.result == 'success' && needs.publish-testpypi.result == 'success' && (inputs.component != 'cuda-pathfinder' || needs.verify-testpypi.result == 'success') }} runs-on: ubuntu-latest needs: - determine-run-id - publish-testpypi - verify-testpypi environment: name: pypi url: https://pypi.org/p/${{ inputs.component }}/ permissions: id-token: write steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download component wheels env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | ./ci/tools/download-wheels "${{ needs.determine-run-id.outputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "dist" - name: Validate wheel versions for release tag run: | ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 verify-pypi: name: Verify Pathfinder on PyPI if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} needs: [check-release-notes, publish-pypi] runs-on: ubuntu-latest permissions: {} env: VERSION: ${{ needs.check-release-notes.outputs.version }} INDEX_URL: "" EXTRA_INDEX_URL: "" INDEX_NAME: PyPI steps: *verify-pathfinder-steps finalize-pathfinder: name: Publish Pathfinder GitHub release if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} needs: [doc, check-release-notes, ensure-pathfinder-draft, publish-pypi, verify-pypi, upload-archive] runs-on: ubuntu-latest permissions: contents: write env: TAG: ${{ inputs.git-tag }} VERSION: ${{ needs.check-release-notes.outputs.version }} steps: - name: Verify docs URL run: | [[ -n "$VERSION" ]] || { echo "::error::Empty version output"; exit 1; } url="https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/" status=$(curl -sL -o /dev/null -w '%{http_code}' "$url") if [[ "$status" != "200" ]]; then echo "::warning::Docs URL returned HTTP ${status} -- docs may not be deployed yet" fi - name: Verify release is still a draft env: GH_TOKEN: ${{ github.token }} run: | is_draft=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft') if [[ "$is_draft" != "true" ]]; then echo "::error::Release ${TAG} is no longer a draft (was it published manually?)" exit 1 fi - name: Publish release env: GH_TOKEN: ${{ github.token }} run: | gh release edit "$TAG" --repo "${{ github.repository }}" --draft=false --latest=false docs_url="https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/" echo "$docs_url" printf '%s\n' "$docs_url" >> "$GITHUB_STEP_SUMMARY"