Skip to content

CI: Release

CI: Release #244

Workflow file for this run

# 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=<latest cuda-bindings release tag>
# backport-git-tag=not planned
# run-id=<blank>
# dry-run-docs-branch=gh-pages-dry-run
# - backport sequence:
# 1. component=cuda-bindings
# git-tag=<latest cuda-bindings backport release tag>
# backport-git-tag=<blank>
# 2. component=cuda-python
# git-tag=<matching cuda-python backport release tag>
# backport-git-tag=<blank>
# run-id=<blank>
# 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
# $/ resolves at the workflow's commit. ./ would resolve against the
# release tag checkout, which can predate this action.
uses: $/.github/actions/setup-hardened-python
with:
python-version: "3.12"
- 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" <<BODY
## Release notes
- https://nvidia.github.io/cuda-python/cuda-pathfinder/latest/release/${VERSION}-notes.html
## Documentation
- https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/
## PyPI
- https://pypi.org/project/cuda-pathfinder/${VERSION}/
## Conda
- https://anaconda.org/conda-forge/cuda-pathfinder/files?version=${VERSION}
- \`conda install conda-forge::cuda-pathfinder=${VERSION}\`
BODY
gh release edit "$TAG" --repo "${{ github.repository }}" \
--latest=false \
--title "cuda-pathfinder v${VERSION}" \
--notes-file "${RUNNER_TEMP}/release-body.md"
doc:
name: Build release docs
if: ${{ github.repository_owner == 'nvidia' }}
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
id-token: write
contents: write
pull-requests: write
needs:
- check-tag
- check-release-notes
- determine-run-id
secrets: inherit
uses: ./.github/workflows/build-docs.yml
with:
component: ${{ inputs.component }}
git-tag: ${{ inputs.git-tag }}
run-id: ${{ needs.determine-run-id.outputs.run-id }}
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' }}
upload-archive:
name: Validate release artifacts
permissions:
contents: write
needs:
- check-tag
- check-release-notes
- determine-run-id
- doc
secrets: inherit
uses: ./.github/workflows/release-upload.yml
with:
git-tag: ${{ inputs.git-tag }}
run-id: ${{ needs.determine-run-id.outputs.run-id }}
component: ${{ inputs.component }}
dry-run: ${{ inputs.release-action == 'dry-run' }}
publish-testpypi:
name: Publish wheels to TestPyPI
if: >-
${{ !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: Require version output
run: |
[[ -n "$VERSION" ]] || { echo "::error::Empty version output"; exit 1; }
- name: Create verification environment
# These jobs have no checkout; $/ loads the action from the workflow's commit.
uses: $/.github/actions/setup-hardened-python
with:
python-version: "3.12"
venv: ${{ runner.temp }}/verify
- 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"