|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: Apache-2.0 |
| 4 | + |
| 5 | +# One-click release workflow for cuda-pathfinder. |
| 6 | +# |
| 7 | +# Provide a version number. The workflow automatically finds the CI run and |
| 8 | +# creates the git tag, creates a draft GitHub release with the standard |
| 9 | +# body, builds versioned docs, uploads source archive + wheels to the |
| 10 | +# release, publishes to TestPyPI, verifies the install, publishes to PyPI, |
| 11 | +# verifies again, and finally marks the release as published. |
| 12 | + |
| 13 | +name: "Release: cuda-pathfinder" |
| 14 | + |
| 15 | +on: |
| 16 | + workflow_dispatch: |
| 17 | + inputs: |
| 18 | + version: |
| 19 | + description: "Version to release (e.g. 1.3.5)" |
| 20 | + required: true |
| 21 | + type: string |
| 22 | + |
| 23 | +concurrency: |
| 24 | + group: release-cuda-pathfinder |
| 25 | + cancel-in-progress: false |
| 26 | + |
| 27 | +defaults: |
| 28 | + run: |
| 29 | + shell: bash --noprofile --norc -xeuo pipefail {0} |
| 30 | + |
| 31 | +jobs: |
| 32 | + # -------------------------------------------------------------------------- |
| 33 | + # Validate inputs, find the CI run, create the tag + draft release. |
| 34 | + # -------------------------------------------------------------------------- |
| 35 | + prepare: |
| 36 | + runs-on: ubuntu-latest |
| 37 | + permissions: |
| 38 | + contents: write |
| 39 | + outputs: |
| 40 | + tag: ${{ steps.vars.outputs.tag }} |
| 41 | + version: ${{ steps.vars.outputs.version }} |
| 42 | + run-id: ${{ steps.detect-run.outputs.run-id }} |
| 43 | + ctk-ver: ${{ steps.ctk.outputs.ctk-ver }} |
| 44 | + steps: |
| 45 | + - name: Verify running on default branch |
| 46 | + run: | |
| 47 | + if [[ "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]]; then |
| 48 | + echo "::error::This workflow must be triggered from the default branch (${{ github.event.repository.default_branch }}). Got: ${{ github.ref_name }} (select the correct branch in the 'Use workflow from' dropdown)." |
| 49 | + exit 1 |
| 50 | + fi |
| 51 | +
|
| 52 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 53 | + with: |
| 54 | + fetch-depth: 0 |
| 55 | + |
| 56 | + - name: Validate version |
| 57 | + id: vars |
| 58 | + env: |
| 59 | + VERSION_INPUT: ${{ inputs.version }} |
| 60 | + run: | |
| 61 | + # Strip leading "v" if present (common typo) |
| 62 | + version="${VERSION_INPUT#v}" |
| 63 | + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then |
| 64 | + echo "::error::Version must be MAJOR.MINOR.PATCH, got: ${version}" |
| 65 | + exit 1 |
| 66 | + fi |
| 67 | + tag="cuda-pathfinder-v${version}" |
| 68 | + { |
| 69 | + echo "tag=${tag}" |
| 70 | + echo "version=${version}" |
| 71 | + } >> "$GITHUB_OUTPUT" |
| 72 | +
|
| 73 | + - name: Check release notes exist |
| 74 | + env: |
| 75 | + VERSION: ${{ steps.vars.outputs.version }} |
| 76 | + run: | |
| 77 | + notes="cuda_pathfinder/docs/source/release/${VERSION}-notes.rst" |
| 78 | + if [[ ! -f "${notes}" ]]; then |
| 79 | + echo "::error::Release notes not found: ${notes}" |
| 80 | + echo "Create the release notes file before running this workflow." |
| 81 | + exit 1 |
| 82 | + fi |
| 83 | +
|
| 84 | + - name: Read CTK build version |
| 85 | + id: ctk |
| 86 | + run: | |
| 87 | + ctk_ver=$(yq '.cuda.build.version' ci/versions.yml) |
| 88 | + echo "ctk-ver=${ctk_ver}" >> "$GITHUB_OUTPUT" |
| 89 | +
|
| 90 | + - name: Create tag |
| 91 | + env: |
| 92 | + TAG: ${{ steps.vars.outputs.tag }} |
| 93 | + run: | |
| 94 | + if git rev-parse "${TAG}" >/dev/null 2>&1; then |
| 95 | + echo "Tag ${TAG} already exists" |
| 96 | + else |
| 97 | + git tag "${TAG}" |
| 98 | + git push origin "${TAG}" |
| 99 | + fi |
| 100 | +
|
| 101 | + - name: Detect CI run ID |
| 102 | + id: detect-run |
| 103 | + env: |
| 104 | + GH_TOKEN: ${{ github.token }} |
| 105 | + TAG: ${{ steps.vars.outputs.tag }} |
| 106 | + run: | |
| 107 | + run_id=$(./ci/tools/lookup-run-id "${TAG}" "${{ github.repository }}") |
| 108 | + echo "run-id=${run_id}" >> "$GITHUB_OUTPUT" |
| 109 | +
|
| 110 | + - name: Create draft release |
| 111 | + env: |
| 112 | + GH_TOKEN: ${{ github.token }} |
| 113 | + TAG: ${{ steps.vars.outputs.tag }} |
| 114 | + VERSION: ${{ steps.vars.outputs.version }} |
| 115 | + run: | |
| 116 | + # If the release exists and is already published, stop early. |
| 117 | + existing_draft=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null || echo "missing") |
| 118 | + if [[ "${existing_draft}" == "false" ]]; then |
| 119 | + echo "::error::Release ${TAG} already exists and is published. Cannot re-release." |
| 120 | + exit 1 |
| 121 | + fi |
| 122 | + if [[ "${existing_draft}" == "true" ]]; then |
| 123 | + echo "Draft release ${TAG} already exists, skipping creation" |
| 124 | + exit 0 |
| 125 | + fi |
| 126 | + cat > /tmp/release-body.md <<BODY |
| 127 | + ## Release notes |
| 128 | +
|
| 129 | + - https://nvidia.github.io/cuda-python/cuda-pathfinder/latest/release/${VERSION}-notes.html |
| 130 | +
|
| 131 | + ## Documentation |
| 132 | +
|
| 133 | + - https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/ |
| 134 | +
|
| 135 | + ## PyPI |
| 136 | +
|
| 137 | + - https://pypi.org/project/cuda-pathfinder/${VERSION}/ |
| 138 | +
|
| 139 | + ## Conda |
| 140 | +
|
| 141 | + - https://anaconda.org/conda-forge/cuda-pathfinder/files?version=${VERSION} |
| 142 | + - \`conda install conda-forge::cuda-pathfinder=${VERSION}\` |
| 143 | + BODY |
| 144 | + gh release create "${TAG}" \ |
| 145 | + --repo "${{ github.repository }}" \ |
| 146 | + --draft \ |
| 147 | + --latest=false \ |
| 148 | + --title "cuda-pathfinder v${VERSION}" \ |
| 149 | + --notes-file /tmp/release-body.md |
| 150 | +
|
| 151 | + # -------------------------------------------------------------------------- |
| 152 | + # Build and deploy versioned docs. |
| 153 | + # -------------------------------------------------------------------------- |
| 154 | + docs: |
| 155 | + needs: prepare |
| 156 | + if: ${{ github.repository_owner == 'nvidia' }} |
| 157 | + permissions: |
| 158 | + id-token: write |
| 159 | + contents: write |
| 160 | + pull-requests: write |
| 161 | + secrets: inherit |
| 162 | + uses: ./.github/workflows/build-docs.yml |
| 163 | + with: |
| 164 | + build-ctk-ver: ${{ needs.prepare.outputs.ctk-ver }} |
| 165 | + component: cuda-pathfinder |
| 166 | + git-tag: ${{ needs.prepare.outputs.tag }} |
| 167 | + run-id: ${{ needs.prepare.outputs.run-id }} |
| 168 | + is-release: true |
| 169 | + |
| 170 | + # -------------------------------------------------------------------------- |
| 171 | + # Upload source archive and wheels to the GitHub release. |
| 172 | + # Runs even if docs fail -- assets are independent and the finalize |
| 173 | + # job's docs-URL check will warn if docs aren't deployed yet. |
| 174 | + # -------------------------------------------------------------------------- |
| 175 | + upload-assets: |
| 176 | + needs: [prepare, docs] |
| 177 | + if: ${{ !cancelled() && needs.prepare.result == 'success' }} |
| 178 | + runs-on: ubuntu-latest |
| 179 | + permissions: |
| 180 | + contents: write |
| 181 | + env: |
| 182 | + TAG: ${{ needs.prepare.outputs.tag }} |
| 183 | + RUN_ID: ${{ needs.prepare.outputs.run-id }} |
| 184 | + steps: |
| 185 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 186 | + with: |
| 187 | + fetch-depth: 0 |
| 188 | + ref: ${{ needs.prepare.outputs.tag }} |
| 189 | + |
| 190 | + - name: Create source archive |
| 191 | + run: | |
| 192 | + archive="${{ github.event.repository.name }}-${TAG}" |
| 193 | + mkdir -p release |
| 194 | + git archive \ |
| 195 | + --format=tar.gz \ |
| 196 | + --prefix="${archive}/" \ |
| 197 | + --output="release/${archive}.tar.gz" \ |
| 198 | + "${TAG}" |
| 199 | + sha256sum "release/${archive}.tar.gz" \ |
| 200 | + | awk '{print $1}' > "release/${archive}.tar.gz.sha256sum" |
| 201 | +
|
| 202 | + - name: Download wheels |
| 203 | + env: |
| 204 | + GH_TOKEN: ${{ github.token }} |
| 205 | + run: | |
| 206 | + ./ci/tools/download-wheels "${RUN_ID}" "cuda-pathfinder" "${{ github.repository }}" "release/wheels" |
| 207 | +
|
| 208 | + - name: Upload to release |
| 209 | + env: |
| 210 | + GH_TOKEN: ${{ github.token }} |
| 211 | + run: | |
| 212 | + gh release upload "${TAG}" \ |
| 213 | + --repo "${{ github.repository }}" \ |
| 214 | + --clobber \ |
| 215 | + release/*.tar.gz release/*.sha256sum release/wheels/*.whl |
| 216 | +
|
| 217 | + # -------------------------------------------------------------------------- |
| 218 | + # Publish to TestPyPI. |
| 219 | + # -------------------------------------------------------------------------- |
| 220 | + publish-testpypi: |
| 221 | + needs: [prepare, docs] |
| 222 | + if: ${{ !cancelled() && needs.prepare.result == 'success' }} |
| 223 | + runs-on: ubuntu-latest |
| 224 | + environment: |
| 225 | + name: testpypi |
| 226 | + url: https://test.pypi.org/p/cuda-pathfinder/ |
| 227 | + permissions: |
| 228 | + id-token: write |
| 229 | + env: |
| 230 | + RUN_ID: ${{ needs.prepare.outputs.run-id }} |
| 231 | + steps: |
| 232 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 233 | + |
| 234 | + - name: Download wheels |
| 235 | + env: |
| 236 | + GH_TOKEN: ${{ github.token }} |
| 237 | + run: | |
| 238 | + ./ci/tools/download-wheels "${RUN_ID}" "cuda-pathfinder" "${{ github.repository }}" "dist" |
| 239 | +
|
| 240 | + - name: Publish to TestPyPI |
| 241 | + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 |
| 242 | + with: |
| 243 | + repository-url: https://test.pypi.org/legacy/ |
| 244 | + |
| 245 | + # -------------------------------------------------------------------------- |
| 246 | + # Verify the TestPyPI package installs and imports correctly. |
| 247 | + # -------------------------------------------------------------------------- |
| 248 | + verify-testpypi: |
| 249 | + needs: [prepare, publish-testpypi] |
| 250 | + runs-on: ubuntu-latest |
| 251 | + env: |
| 252 | + VERSION: ${{ needs.prepare.outputs.version }} |
| 253 | + steps: |
| 254 | + - name: Install from TestPyPI and verify |
| 255 | + run: | |
| 256 | + python3 -m venv /tmp/verify |
| 257 | + source /tmp/verify/bin/activate |
| 258 | + for attempt in 1 2 3 4 5 6; do |
| 259 | + if pip install \ |
| 260 | + --index-url https://test.pypi.org/simple/ \ |
| 261 | + --extra-index-url https://pypi.org/simple/ \ |
| 262 | + "cuda-pathfinder==${VERSION}"; then |
| 263 | + break |
| 264 | + fi |
| 265 | + if [[ "${attempt}" -eq 6 ]]; then |
| 266 | + echo "::error::Failed to install cuda-pathfinder==${VERSION} from TestPyPI after 6 attempts" |
| 267 | + exit 1 |
| 268 | + fi |
| 269 | + echo "Attempt ${attempt}: not available yet, retrying in 30s..." |
| 270 | + sleep 30 |
| 271 | + done |
| 272 | + installed=$(python -c "from cuda.pathfinder import __version__; print(__version__)") |
| 273 | + if [[ "${installed}" != "${VERSION}" ]]; then |
| 274 | + echo "::error::Version mismatch: expected ${VERSION}, got ${installed}" |
| 275 | + exit 1 |
| 276 | + fi |
| 277 | + echo "TestPyPI verification passed: cuda-pathfinder==${installed}" |
| 278 | +
|
| 279 | + # -------------------------------------------------------------------------- |
| 280 | + # Publish to PyPI. |
| 281 | + # -------------------------------------------------------------------------- |
| 282 | + publish-pypi: |
| 283 | + needs: [prepare, verify-testpypi] |
| 284 | + runs-on: ubuntu-latest |
| 285 | + environment: |
| 286 | + name: pypi |
| 287 | + url: https://pypi.org/p/cuda-pathfinder/ |
| 288 | + permissions: |
| 289 | + id-token: write |
| 290 | + env: |
| 291 | + RUN_ID: ${{ needs.prepare.outputs.run-id }} |
| 292 | + steps: |
| 293 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 294 | + |
| 295 | + - name: Download wheels |
| 296 | + env: |
| 297 | + GH_TOKEN: ${{ github.token }} |
| 298 | + run: | |
| 299 | + ./ci/tools/download-wheels "${RUN_ID}" "cuda-pathfinder" "${{ github.repository }}" "dist" |
| 300 | +
|
| 301 | + - name: Publish to PyPI |
| 302 | + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 |
| 303 | + |
| 304 | + # -------------------------------------------------------------------------- |
| 305 | + # Verify the PyPI package installs and imports correctly. |
| 306 | + # -------------------------------------------------------------------------- |
| 307 | + verify-pypi: |
| 308 | + needs: [prepare, publish-pypi] |
| 309 | + runs-on: ubuntu-latest |
| 310 | + env: |
| 311 | + VERSION: ${{ needs.prepare.outputs.version }} |
| 312 | + steps: |
| 313 | + - name: Install from PyPI and verify |
| 314 | + run: | |
| 315 | + python3 -m venv /tmp/verify |
| 316 | + source /tmp/verify/bin/activate |
| 317 | + for attempt in 1 2 3 4 5 6; do |
| 318 | + if pip install "cuda-pathfinder==${VERSION}"; then |
| 319 | + break |
| 320 | + fi |
| 321 | + if [[ "${attempt}" -eq 6 ]]; then |
| 322 | + echo "::error::Failed to install cuda-pathfinder==${VERSION} from PyPI after 6 attempts" |
| 323 | + exit 1 |
| 324 | + fi |
| 325 | + echo "Attempt ${attempt}: not available yet, retrying in 30s..." |
| 326 | + sleep 30 |
| 327 | + done |
| 328 | + installed=$(python -c "from cuda.pathfinder import __version__; print(__version__)") |
| 329 | + if [[ "${installed}" != "${VERSION}" ]]; then |
| 330 | + echo "::error::Version mismatch: expected ${VERSION}, got ${installed}" |
| 331 | + exit 1 |
| 332 | + fi |
| 333 | + echo "PyPI verification passed: cuda-pathfinder==${installed}" |
| 334 | +
|
| 335 | + # -------------------------------------------------------------------------- |
| 336 | + # Verify docs and publish the release (mark non-draft). |
| 337 | + # -------------------------------------------------------------------------- |
| 338 | + finalize: |
| 339 | + needs: [prepare, verify-pypi, upload-assets] |
| 340 | + runs-on: ubuntu-latest |
| 341 | + permissions: |
| 342 | + contents: write |
| 343 | + env: |
| 344 | + TAG: ${{ needs.prepare.outputs.tag }} |
| 345 | + VERSION: ${{ needs.prepare.outputs.version }} |
| 346 | + steps: |
| 347 | + - name: Verify docs URL |
| 348 | + run: | |
| 349 | + url="https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/" |
| 350 | + echo "Checking ${url}" |
| 351 | + for attempt in 1 2 3 4 5; do |
| 352 | + status=$(curl -sL -o /dev/null -w '%{http_code}' "${url}") |
| 353 | + if [[ "${status}" == "200" ]]; then |
| 354 | + echo "Docs URL is live" |
| 355 | + exit 0 |
| 356 | + fi |
| 357 | + echo "Attempt ${attempt}: HTTP ${status}, retrying in 30s..." |
| 358 | + sleep 30 |
| 359 | + done |
| 360 | + echo "::warning::Docs URL returned HTTP ${status} after 5 attempts -- docs may not be deployed yet" |
| 361 | +
|
| 362 | + - name: Verify release is still a draft |
| 363 | + env: |
| 364 | + GH_TOKEN: ${{ github.token }} |
| 365 | + run: | |
| 366 | + is_draft=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft') |
| 367 | + if [[ "${is_draft}" != "true" ]]; then |
| 368 | + echo "::error::Release ${TAG} is no longer a draft (was it published manually?)" |
| 369 | + exit 1 |
| 370 | + fi |
| 371 | +
|
| 372 | + - name: Publish release |
| 373 | + env: |
| 374 | + GH_TOKEN: ${{ github.token }} |
| 375 | + run: | |
| 376 | + gh release edit "${TAG}" \ |
| 377 | + --repo "${{ github.repository }}" \ |
| 378 | + --draft=false \ |
| 379 | + --latest=false |
| 380 | + url="https://github.com/${{ github.repository }}/releases/tag/${TAG}" |
| 381 | + echo "Release ${TAG} published: ${url}" |
| 382 | + { |
| 383 | + echo "### cuda-pathfinder v${VERSION} released" |
| 384 | + echo "" |
| 385 | + echo "- **Release**: ${url}" |
| 386 | + echo "- **PyPI**: https://pypi.org/project/cuda-pathfinder/${VERSION}/" |
| 387 | + echo "- **Docs**: https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/" |
| 388 | + } >> "$GITHUB_STEP_SUMMARY" |
0 commit comments