diff --git a/.github/workflows/release-cuda-pathfinder.yml b/.github/workflows/release-cuda-pathfinder.yml new file mode 100644 index 0000000000..2f4a30eccd --- /dev/null +++ b/.github/workflows/release-cuda-pathfinder.yml @@ -0,0 +1,388 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +# One-click release workflow for cuda-pathfinder. +# +# Provide a version number. The workflow automatically finds the CI run and +# creates the git tag, creates a draft GitHub release with the standard +# body, builds versioned docs, uploads source archive + wheels to the +# release, publishes to TestPyPI, verifies the install, publishes to PyPI, +# verifies again, and finally marks the release as published. + +name: "Release: cuda-pathfinder" + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release (e.g. 1.3.5)" + required: true + type: string + +concurrency: + group: release-cuda-pathfinder + cancel-in-progress: false + +defaults: + run: + shell: bash --noprofile --norc -xeuo pipefail {0} + +jobs: + # -------------------------------------------------------------------------- + # Validate inputs, find the CI run, create the tag + draft release. + # -------------------------------------------------------------------------- + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + tag: ${{ steps.vars.outputs.tag }} + version: ${{ steps.vars.outputs.version }} + run-id: ${{ steps.detect-run.outputs.run-id }} + ctk-ver: ${{ steps.ctk.outputs.ctk-ver }} + steps: + - name: Verify running on default branch + run: | + if [[ "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]]; then + 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)." + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Validate version + id: vars + env: + VERSION_INPUT: ${{ inputs.version }} + run: | + # Strip leading "v" if present (common typo) + version="${VERSION_INPUT#v}" + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must be MAJOR.MINOR.PATCH, got: ${version}" + exit 1 + fi + tag="cuda-pathfinder-v${version}" + { + echo "tag=${tag}" + echo "version=${version}" + } >> "$GITHUB_OUTPUT" + + - name: Check release notes exist + env: + VERSION: ${{ steps.vars.outputs.version }} + run: | + notes="cuda_pathfinder/docs/source/release/${VERSION}-notes.rst" + if [[ ! -f "${notes}" ]]; then + echo "::error::Release notes not found: ${notes}" + echo "Create the release notes file before running this workflow." + exit 1 + fi + + - name: Read CTK build version + id: ctk + run: | + ctk_ver=$(yq '.cuda.build.version' ci/versions.yml) + echo "ctk-ver=${ctk_ver}" >> "$GITHUB_OUTPUT" + + - name: Create tag + env: + TAG: ${{ steps.vars.outputs.tag }} + run: | + if git rev-parse "${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists" + else + git tag "${TAG}" + git push origin "${TAG}" + fi + + - name: Detect CI run ID + id: detect-run + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.vars.outputs.tag }} + run: | + run_id=$(./ci/tools/lookup-run-id "${TAG}" "${{ github.repository }}") + echo "run-id=${run_id}" >> "$GITHUB_OUTPUT" + + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.vars.outputs.tag }} + VERSION: ${{ steps.vars.outputs.version }} + run: | + # If the release exists and is already published, stop early. + existing_draft=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft' 2>/dev/null || echo "missing") + if [[ "${existing_draft}" == "false" ]]; then + echo "::error::Release ${TAG} already exists and is published. Cannot re-release." + exit 1 + fi + if [[ "${existing_draft}" == "true" ]]; then + echo "Draft release ${TAG} already exists, skipping creation" + exit 0 + fi + cat > /tmp/release-body.md < "release/${archive}.tar.gz.sha256sum" + + - name: Download wheels + env: + GH_TOKEN: ${{ github.token }} + run: | + ./ci/tools/download-wheels "${RUN_ID}" "cuda-pathfinder" "${{ github.repository }}" "release/wheels" + + - name: Upload to release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${TAG}" \ + --repo "${{ github.repository }}" \ + --clobber \ + release/*.tar.gz release/*.sha256sum release/wheels/*.whl + + # -------------------------------------------------------------------------- + # Publish to TestPyPI. + # -------------------------------------------------------------------------- + publish-testpypi: + needs: [prepare, docs] + if: ${{ !cancelled() && needs.prepare.result == 'success' }} + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/cuda-pathfinder/ + permissions: + id-token: write + env: + RUN_ID: ${{ needs.prepare.outputs.run-id }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download wheels + env: + GH_TOKEN: ${{ github.token }} + run: | + ./ci/tools/download-wheels "${RUN_ID}" "cuda-pathfinder" "${{ github.repository }}" "dist" + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + repository-url: https://test.pypi.org/legacy/ + + # -------------------------------------------------------------------------- + # Verify the TestPyPI package installs and imports correctly. + # -------------------------------------------------------------------------- + verify-testpypi: + needs: [prepare, publish-testpypi] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - name: Install from TestPyPI and verify + run: | + python3 -m venv /tmp/verify + source /tmp/verify/bin/activate + for attempt in 1 2 3 4 5 6; do + if pip install \ + --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ \ + "cuda-pathfinder==${VERSION}"; then + break + fi + if [[ "${attempt}" -eq 6 ]]; then + echo "::error::Failed to install cuda-pathfinder==${VERSION} from TestPyPI after 6 attempts" + exit 1 + fi + echo "Attempt ${attempt}: not available yet, retrying in 30s..." + sleep 30 + 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 + echo "TestPyPI verification passed: cuda-pathfinder==${installed}" + + # -------------------------------------------------------------------------- + # Publish to PyPI. + # -------------------------------------------------------------------------- + publish-pypi: + needs: [prepare, verify-testpypi] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/cuda-pathfinder/ + permissions: + id-token: write + env: + RUN_ID: ${{ needs.prepare.outputs.run-id }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download wheels + env: + GH_TOKEN: ${{ github.token }} + run: | + ./ci/tools/download-wheels "${RUN_ID}" "cuda-pathfinder" "${{ github.repository }}" "dist" + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + + # -------------------------------------------------------------------------- + # Verify the PyPI package installs and imports correctly. + # -------------------------------------------------------------------------- + verify-pypi: + needs: [prepare, publish-pypi] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - name: Install from PyPI and verify + run: | + python3 -m venv /tmp/verify + source /tmp/verify/bin/activate + for attempt in 1 2 3 4 5 6; do + if pip install "cuda-pathfinder==${VERSION}"; then + break + fi + if [[ "${attempt}" -eq 6 ]]; then + echo "::error::Failed to install cuda-pathfinder==${VERSION} from PyPI after 6 attempts" + exit 1 + fi + echo "Attempt ${attempt}: not available yet, retrying in 30s..." + sleep 30 + 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 + echo "PyPI verification passed: cuda-pathfinder==${installed}" + + # -------------------------------------------------------------------------- + # Verify docs and publish the release (mark non-draft). + # -------------------------------------------------------------------------- + finalize: + needs: [prepare, verify-pypi, upload-assets] + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - name: Verify docs URL + run: | + url="https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/" + echo "Checking ${url}" + for attempt in 1 2 3 4 5; do + status=$(curl -sL -o /dev/null -w '%{http_code}' "${url}") + if [[ "${status}" == "200" ]]; then + echo "Docs URL is live" + exit 0 + fi + echo "Attempt ${attempt}: HTTP ${status}, retrying in 30s..." + sleep 30 + done + echo "::warning::Docs URL returned HTTP ${status} after 5 attempts -- docs may not be deployed yet" + + - 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 + url="https://github.com/${{ github.repository }}/releases/tag/${TAG}" + echo "Release ${TAG} published: ${url}" + { + echo "### cuda-pathfinder v${VERSION} released" + echo "" + echo "- **Release**: ${url}" + echo "- **PyPI**: https://pypi.org/project/cuda-pathfinder/${VERSION}/" + echo "- **Docs**: https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/" + } >> "$GITHUB_STEP_SUMMARY"