From 09cbb42f335dc89d947811f059c048f0541c02dd Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Wed, 7 Oct 2026 14:57:51 -0700 Subject: [PATCH 01/11] CI: Integrate cuda-pathfinder into the shared release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Refs #3012. Integrate Pathfinder’s automated release process into release.yml, sharing documentation and artifact-upload jobs while preserving the release process for other products. - Preserve Pathfinder’s release-note checks, successful tag-triggered CI requirement, draft metadata, checkout-free publishing, and installation verification on TestPyPI and PyPI. - Require successful documentation before uploading assets or publishing packages. Finalize the Pathfinder GitHub release only after successful uploads and PyPI verification, without marking it as latest. - Reuse release-upload.yml for source archives, checksums, and validated wheels. Load Pathfinder validation tools from the workflow revision so older release tags remain supported. - Reduce duplication with YAML anchors for publishing and verification steps. - Preserve Pathfinder release serialization and default-branch enforcement for full releases, while supporting the shared dry-run mode. - Keep other products’ publishing behavior, upload concurrency, and manual GitHub release finalization unchanged. Rollout The existing release-cuda-pathfinder.yml remains unchanged during migration. Pathfinder’s trusted publishers on both PyPI and TestPyPI must authorize release.yml before its first full release. Retire the legacy workflow and publisher registrations afterward. Validation Workflow linting passes. Live workflow-dispatch and trusted-publishing validation remain outstanding. --- .github/RELEASE-core.md | 24 +- .github/workflows/release-upload.yml | 36 ++- .github/workflows/release.yml | 345 +++++++++++++++++++++++++-- 3 files changed, 368 insertions(+), 37 deletions(-) diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index 0f33dfe44f3..d8a943f322c 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -6,9 +6,31 @@ This document covers the `cuda.core` release process. For other packages: `cuda-bindings` and `cuda-python` involve a private repository and are not documented here; `cuda-pathfinder` is largely automated by the -[release-cuda-pathfinder.yml](workflows/release-cuda-pathfinder.yml) +[CI: Release](workflows/release.yml) workflow. +To release `cuda-pathfinder`, dispatch **CI: Release** from the default +branch with `component` set to `cuda-pathfinder`, `release-action` set +to `full-release`, and `git-tag` set to the existing +`cuda-pathfinder-v` tag. The workflow finds the successful +tag-triggered CI run, checks release notes, creates or reuses a draft, +builds and deploys versioned docs, and uploads the source archive, checksum, +and wheels. It publishes to TestPyPI and PyPI, verifies each installation, +then publishes the GitHub Release without marking it as latest. A failed +docs build prevents asset uploads, package publication, and finalization. + +The default `dry-run` action validates preparation, builds docs, and +collects release artifacts without creating a GitHub Release or publishing +packages. It can be dispatched from a development branch; use +`dry-run-docs-branch` only when a preview deployment is needed. + +Before the first Pathfinder release through this workflow, its trusted +publishers on **both PyPI and TestPyPI** must authorize `release.yml`. +Until that migration is complete, `release-cuda-pathfinder.yml` remains +available. Keep its existing publisher +registrations during the transition; retire the old workflow and its +registrations after the consolidated workflow is authorized on both indexes. + Each section below provides detailed guidance for a step in the [Release Checklist](ISSUE_TEMPLATE/release_checklist.yml). To start a release, create a new issue from that template and work through it item by diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index bd66b84ed76..81246284a0f 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -25,13 +25,10 @@ on: default: false concurrency: - # Concurrency group that uses the workflow name and PR number if available - # or commit SHA as a fallback. If a new build is triggered under that - # concurrency group while a previous build is running it will be canceled. - # Repeated pushes to a PR will cancel all previous builds, while multiple - # merges to main will not cancel. - group: ${{ github.workflow }}-${{ github.ref_name || github.sha }} - cancel-in-progress: true + # Pathfinder releases are serialized by the caller. Keep their uploads out + # of the other components' existing cancellation group. + group: ${{ inputs.component == 'cuda-pathfinder' && format('release-upload-cuda-pathfinder-{0}', github.run_id) || format('{0}-{1}', github.workflow, github.ref_name || github.sha) }} + cancel-in-progress: ${{ inputs.component != 'cuda-pathfinder' }} permissions: contents: write @@ -51,6 +48,16 @@ jobs: fetch-depth: 1 ref: ${{ inputs.git-tag }} + - name: Checkout Pathfinder Release Tools + if: ${{ inputs.component == 'cuda-pathfinder' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Older Pathfinder tags may predate release-wheel validation tooling. + ref: ${{ github.workflow_sha }} + path: .release-tools + sparse-checkout: ci/tools + persist-credentials: false + - name: Create Release Directory run: mkdir -p release @@ -69,7 +76,7 @@ jobs: > "release/${{ env.ARCHIVE_NAME }}.tar.gz.sha256sum" - name: Upload Archive - if: ${{ !inputs.dry-run }} + if: ${{ !inputs.dry-run && inputs.component != 'cuda-pathfinder' }} env: GH_TOKEN: ${{ github.token }} run: > @@ -81,19 +88,24 @@ jobs: - name: Download and Validate Wheels env: GH_TOKEN: ${{ github.token }} + RELEASE_TOOLS_DIR: ${{ inputs.component == 'cuda-pathfinder' && '.release-tools/ci/tools' || 'ci/tools' }} run: | # Use the shared script to download wheels - ./ci/tools/download-wheels "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels" + "${RELEASE_TOOLS_DIR}/download-wheels" "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels" # Validate that release wheels match the expected version from tag. - ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" + "${RELEASE_TOOLS_DIR}/validate-release-wheels" "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" - - name: Upload Wheels + - name: Upload Wheels and Pathfinder Archive if: ${{ !inputs.dry-run }} env: GH_TOKEN: ${{ github.token }} + COMPONENT: ${{ inputs.component }} run: | - if [[ -d "release/wheels" && $(ls -A release/wheels 2>/dev/null | wc -l) -gt 0 ]]; then + if [[ "${COMPONENT}" == "cuda-pathfinder" ]]; then + gh release upload --clobber "${{ inputs.git-tag }}" --repo "${{ github.repository }}" \ + release/*.tar.gz release/*.sha256sum release/wheels/*.whl + elif [[ -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/* fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e915e02093..1b7b2a2d84c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,10 @@ name: "CI: Release" # 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 verifies both indexes and publishes the GitHub draft. +# Every component requires successful release docs before uploading or publishing. +# 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: @@ -29,6 +33,7 @@ name: "CI: Release" # 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: @@ -70,6 +75,11 @@ on: type: string default: "" +concurrency: + # Keep Pathfinder full releases serialized; other dispatches remain independent. + group: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' && 'release-cuda-pathfinder' || format('release-{0}', github.run_id) }} + cancel-in-progress: false + defaults: run: shell: bash --noprofile --norc -xeuo pipefail {0} @@ -92,13 +102,34 @@ jobs: id: lookup-run-id env: GH_TOKEN: ${{ github.token }} + RELEASE_COMPONENT: ${{ inputs.component }} + RELEASE_GIT_TAG: ${{ inputs.git-tag }} + PROVIDED_RUN_ID: ${{ inputs.run-id }} + RELEASE_REPOSITORY: ${{ github.repository }} run: | - if [[ -n "${{ inputs.run-id }}" ]]; then - echo "Using provided run ID: ${{ inputs.run-id }}" - RUN_ID="${{ inputs.run-id }}" + if [[ -n "$PROVIDED_RUN_ID" ]]; then + echo "Using provided run ID: $PROVIDED_RUN_ID" + RUN_ID="$PROVIDED_RUN_ID" + if [[ "$RELEASE_COMPONENT" == "cuda-pathfinder" ]]; then + if [[ ! "$RUN_ID" =~ ^[0-9]+$ ]]; then + echo "::error::Pathfinder run-id must be a numeric CI run ID" + exit 1 + fi + tag_sha=$(git rev-parse --verify "refs/tags/${RELEASE_GIT_TAG}^{commit}") + workflow_id=$(gh api "repos/${RELEASE_REPOSITORY}/actions/workflows/ci.yml" --jq '.id') + gh api "repos/${RELEASE_REPOSITORY}/actions/runs/${RUN_ID}" \ + | jq -e --arg sha "$tag_sha" --arg tag "$RELEASE_GIT_TAG" --argjson workflow "$workflow_id" ' + .head_sha == $sha and .head_branch == $tag and + .workflow_id == $workflow and .event == "push" and + .status == "completed" and .conclusion == "success" + ' >/dev/null || { + echo "::error::Pathfinder run-id must identify successful tag-triggered CI for ${RELEASE_GIT_TAG}" + exit 1 + } + fi else - echo "Auto-detecting successful tag-triggered run ID for tag: ${{ inputs.git-tag }}" - RUN_ID=$(./ci/tools/lookup-run-id "${{ inputs.git-tag }}" "${{ github.repository }}") + 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" @@ -107,6 +138,16 @@ jobs: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest steps: + - name: Require default branch for Pathfinder full releases + if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "::error::Pathfinder full releases must be dispatched from the default branch (${DEFAULT_BRANCH})" + exit 1 + fi + - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -117,6 +158,7 @@ jobs: - name: Check release tag and draft state env: GH_TOKEN: ${{ github.token }} + RELEASE_COMPONENT: ${{ inputs.component }} RELEASE_ACTION: ${{ inputs.release-action }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} DRY_RUN_DOCS_BRANCH: ${{ inputs.dry-run-docs-branch }} @@ -129,6 +171,11 @@ jobs: echo "error: dry-run-docs-branch must be a non-production gh-pages-* branch" >&2 exit 1 fi + if [[ "$RELEASE_COMPONENT" == "cuda-pathfinder" ]]; then + git rev-parse --verify "refs/tags/${RELEASE_GIT_TAG}^{commit}" + echo "Pathfinder draft creation runs after all release prerequisites are validated." + exit 0 + 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." @@ -158,11 +205,22 @@ jobs: check-release-notes: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest + outputs: + version: ${{ steps.pathfinder-version.outputs.version }} steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.git-tag }} + ref: ${{ inputs.component == 'cuda-pathfinder' && format('refs/tags/{0}', inputs.git-tag) || inputs.git-tag }} + + - name: Checkout Pathfinder release tools + if: ${{ inputs.component == 'cuda-pathfinder' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + path: .release-tools + sparse-checkout: ci/tools + persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -170,15 +228,114 @@ jobs: python-version: "3.12" - name: Check versioned release notes exist + if: ${{ inputs.component != 'cuda-pathfinder' }} run: | python ci/tools/check_release_notes.py \ --git-tag "${{ inputs.git-tag }}" \ --component "${{ inputs.component }}" \ --backport-git-tag "${{ inputs.backport-git-tag }}" + - name: Check Pathfinder release notes + id: pathfinder-version + if: ${{ inputs.component == 'cuda-pathfinder' }} + env: + RELEASE_GIT_TAG: ${{ inputs.git-tag }} + PYTHONPATH: .release-tools/ci/tools + run: | + python - <<'PY' + import os + from pathlib import Path + from check_release_notes import parse_version_from_tag + + tag = os.environ["RELEASE_GIT_TAG"] + version = parse_version_from_tag(tag, "cuda-pathfinder") + if version is None: + raise SystemExit(f"::error::Invalid Pathfinder release tag: {tag}") + notes = Path("cuda_pathfinder/docs/source/release", f"{version}-notes.rst") + if not notes.is_file(): + raise SystemExit(f"::error::Release notes not found: {notes}") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"version={version}\n") + PY + + 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, determine-run-id] + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ inputs.git-tag }} + VERSION: ${{ needs.check-release-notes.outputs.version }} + GH_TOKEN: ${{ github.token }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + REPOSITORY_NAME: ${{ github.event.repository.name }} + steps: + - name: Create or reuse Pathfinder draft + run: | + # REST's tag endpoint only returns published releases; GraphQL also finds drafts. + existing_draft=$(gh api graphql -f query=' + query($owner: String!, $name: String!, $tag: String!) { + repository(owner: $owner, name: $name) { + release(tagName: $tag) { isDraft } + } + }' -f owner="$REPOSITORY_OWNER" -f name="$REPOSITORY_NAME" -f tag="$TAG" \ + --jq '.data.repository.release | if . == null then "missing" else .isDraft end') + case "$existing_draft" in + false) + echo "::error::Release ${TAG} already exists and is published. Cannot re-release." + exit 1 + ;; + true) + echo "Reusing draft release ${TAG}" + exit 0 + ;; + missing) ;; + *) + echo "::error::Could not determine draft state for ${TAG}" + exit 1 + ;; + esac + cat > "${RUNNER_TEMP}/release-body.md" <- + ${{ + !cancelled() && github.repository_owner == 'nvidia' && + needs.check-tag.result == 'success' && + needs.check-release-notes.result == 'success' && + needs.determine-run-id.result == 'success' && + ( + (inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' && + needs.ensure-pathfinder-draft.result == 'success') || + ((inputs.component != 'cuda-pathfinder' || inputs.release-action == 'dry-run') && + needs.ensure-pathfinder-draft.result == 'skipped') + ) + }} # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: id-token: write @@ -188,6 +345,7 @@ jobs: - check-tag - check-release-notes - determine-run-id + - ensure-pathfinder-draft secrets: inherit uses: ./.github/workflows/build-docs.yml with: @@ -200,6 +358,11 @@ jobs: upload-archive: name: Validate release artifacts + if: >- + ${{ !cancelled() && needs.doc.result == 'success' && + needs.check-tag.result == 'success' && + needs.check-release-notes.result == 'success' && + needs.determine-run-id.result == 'success' }} permissions: contents: write needs: @@ -217,7 +380,11 @@ jobs: publish-testpypi: name: Publish wheels to TestPyPI - if: ${{ inputs.release-action == 'full-release' }} + if: >- + ${{ !cancelled() && inputs.release-action == 'full-release' && + needs.doc.result == 'success' && needs.check-tag.result == 'success' && + needs.check-release-notes.result == 'success' && + needs.determine-run-id.result == 'success' }} runs-on: ubuntu-latest needs: - check-tag @@ -230,51 +397,181 @@ jobs: permissions: id-token: write steps: - - name: Checkout Source + - &publisher-checkout + name: Checkout Source + if: ${{ inputs.component != 'cuda-pathfinder' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Download component wheels + - &download-component-wheels + name: Download component wheels + if: ${{ inputs.component != 'cuda-pathfinder' }} 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 + - &validate-component-wheels + name: Validate wheel versions for release tag + if: ${{ inputs.component != 'cuda-pathfinder' }} run: | ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" + - &download-pathfinder-wheels + name: Download Pathfinder wheels + if: ${{ inputs.component == 'cuda-pathfinder' }} + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ needs.determine-run-id.outputs.run-id }} + run: | + # Publisher jobs consume only CI artifacts, without checking out source. + mkdir -p dist + gh run download "$RUN_ID" -p "cuda-pathfinder*" -R "${{ github.repository }}" + shopt -s nullglob globstar + for artifact_dir in cuda-*; do + if [[ ! -d "$artifact_dir" || "$artifact_dir" == *-tests ]]; then + continue + fi + wheels=( "$artifact_dir"/**/*.whl ) + if (( ${#wheels[@]} > 0 )); then + mv "${wheels[@]}" dist/ + fi + done + rm -rf cuda-* + ls -la dist + - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ + verify-testpypi: + name: Verify Pathfinder on TestPyPI + if: >- + ${{ !cancelled() && inputs.component == 'cuda-pathfinder' && + inputs.release-action == 'full-release' && needs.doc.result == 'success' && + needs.publish-testpypi.result == 'success' && needs.check-release-notes.result == 'success' }} + needs: [doc, 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: Install Pathfinder from index and verify + run: | + python3 -m venv "${RUNNER_TEMP}/verify" + 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: ${{ inputs.release-action == 'full-release' }} + if: >- + ${{ !cancelled() && inputs.release-action == 'full-release' && + needs.doc.result == 'success' && needs.determine-run-id.result == 'success' && + needs.publish-testpypi.result == 'success' && + ( + (inputs.component == 'cuda-pathfinder' && needs.verify-testpypi.result == 'success') || + (inputs.component != 'cuda-pathfinder' && needs.verify-testpypi.result == 'skipped') + ) }} runs-on: ubuntu-latest needs: - determine-run-id + - doc - 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 + - *publisher-checkout + - *download-component-wheels + - *validate-component-wheels + - *download-pathfinder-wheels - - 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: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - - name: Validate wheel versions for release tag + verify-pypi: + name: Verify Pathfinder on PyPI + if: >- + ${{ !cancelled() && inputs.component == 'cuda-pathfinder' && + inputs.release-action == 'full-release' && needs.doc.result == 'success' && + needs.publish-pypi.result == 'success' && needs.check-release-notes.result == 'success' }} + needs: [doc, 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: >- + ${{ !cancelled() && inputs.component == 'cuda-pathfinder' && + inputs.release-action == 'full-release' && needs.doc.result == 'success' && + needs.check-release-notes.result == 'success' && + needs.verify-pypi.result == 'success' && needs.upload-archive.result == 'success' }} + needs: [doc, check-release-notes, 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: | - ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" + 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: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + - 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 - # TODO: add another job to make the release leave the draft state? + - 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" From 9eea6d522430db8eb84f782278baf025eab3c0b6 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Wed, 7 Oct 2026 15:11:23 -0700 Subject: [PATCH 02/11] Implement coderabbit suggestions --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b7b2a2d84c..11ac755f691 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -384,13 +384,15 @@ jobs: ${{ !cancelled() && inputs.release-action == 'full-release' && needs.doc.result == 'success' && needs.check-tag.result == 'success' && needs.check-release-notes.result == 'success' && - needs.determine-run-id.result == 'success' }} + needs.determine-run-id.result == 'success' && + (inputs.component != 'cuda-pathfinder' || needs.upload-archive.result == 'success') }} runs-on: ubuntu-latest needs: - check-tag - check-release-notes - determine-run-id - doc + - upload-archive environment: name: testpypi url: https://test.pypi.org/p/${{ inputs.component }}/ From 35be9f46e6f24a5445e4c9c090bda97c68d56ad9 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Mon, 12 Oct 2026 13:08:59 -0700 Subject: [PATCH 03/11] Remove cuda-pathfinder specific ordering, but leave cuda-pathfinder specific work, except for verify test/pypi, which is removed. --- .github/RELEASE-core.md | 8 +- .github/workflows/release-upload.yml | 39 ++-- .github/workflows/release.yml | 260 ++------------------- ci/tools/check_release_notes.py | 117 ++++++++-- ci/tools/tests/test_check_release_notes.py | 238 +++++++++++++++++++ 5 files changed, 375 insertions(+), 287 deletions(-) diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index d8a943f322c..d27b88ed7b0 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -9,14 +9,14 @@ documented here; `cuda-pathfinder` is largely automated by the [CI: Release](workflows/release.yml) workflow. -To release `cuda-pathfinder`, dispatch **CI: Release** from the default -branch with `component` set to `cuda-pathfinder`, `release-action` set +To release `cuda-pathfinder`, dispatch **CI: Release** with +`component` set to `cuda-pathfinder`, `release-action` set to `full-release`, and `git-tag` set to the existing `cuda-pathfinder-v` tag. The workflow finds the successful tag-triggered CI run, checks release notes, creates or reuses a draft, builds and deploys versioned docs, and uploads the source archive, checksum, -and wheels. It publishes to TestPyPI and PyPI, verifies each installation, -then publishes the GitHub Release without marking it as latest. A failed +and wheels. It publishes to TestPyPI followed by PyPI, then publishes the +GitHub Release without marking it as latest. A failed docs build prevents asset uploads, package publication, and finalization. The default `dry-run` action validates preparation, builds docs, and diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 81246284a0f..39dc4409622 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -25,10 +25,13 @@ on: default: false concurrency: - # Pathfinder releases are serialized by the caller. Keep their uploads out - # of the other components' existing cancellation group. - group: ${{ inputs.component == 'cuda-pathfinder' && format('release-upload-cuda-pathfinder-{0}', github.run_id) || format('{0}-{1}', github.workflow, github.ref_name || github.sha) }} - cancel-in-progress: ${{ inputs.component != 'cuda-pathfinder' }} + # Concurrency group that uses the workflow name and PR number if available + # or commit SHA as a fallback. If a new build is triggered under that + # concurrency group while a previous build is running it will be canceled. + # Repeated pushes to a PR will cancel all previous builds, while multiple + # merges to main will not cancel. + group: ${{ github.workflow }}-${{ github.ref_name || github.sha }} + cancel-in-progress: true permissions: contents: write @@ -46,17 +49,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - ref: ${{ inputs.git-tag }} - - - name: Checkout Pathfinder Release Tools - if: ${{ inputs.component == 'cuda-pathfinder' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Older Pathfinder tags may predate release-wheel validation tooling. - ref: ${{ github.workflow_sha }} - path: .release-tools - sparse-checkout: ci/tools - persist-credentials: false + # Use current release tooling while archiving the requested tag below. + fetch-tags: true - name: Create Release Directory run: mkdir -p release @@ -76,7 +70,7 @@ jobs: > "release/${{ env.ARCHIVE_NAME }}.tar.gz.sha256sum" - name: Upload Archive - if: ${{ !inputs.dry-run && inputs.component != 'cuda-pathfinder' }} + if: ${{ !inputs.dry-run }} env: GH_TOKEN: ${{ github.token }} run: > @@ -88,24 +82,19 @@ jobs: - name: Download and Validate Wheels env: GH_TOKEN: ${{ github.token }} - RELEASE_TOOLS_DIR: ${{ inputs.component == 'cuda-pathfinder' && '.release-tools/ci/tools' || 'ci/tools' }} run: | # Use the shared script to download wheels - "${RELEASE_TOOLS_DIR}/download-wheels" "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels" + ./ci/tools/download-wheels "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels" # Validate that release wheels match the expected version from tag. - "${RELEASE_TOOLS_DIR}/validate-release-wheels" "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" + ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" - - name: Upload Wheels and Pathfinder Archive + - name: Upload Wheels if: ${{ !inputs.dry-run }} env: GH_TOKEN: ${{ github.token }} - COMPONENT: ${{ inputs.component }} run: | - if [[ "${COMPONENT}" == "cuda-pathfinder" ]]; then - gh release upload --clobber "${{ inputs.git-tag }}" --repo "${{ github.repository }}" \ - release/*.tar.gz release/*.sha256sum release/wheels/*.whl - elif [[ -d "release/wheels" && $(ls -A release/wheels 2>/dev/null | wc -l) -gt 0 ]]; then + 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/* fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11ac755f691..6fd8716400d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,7 @@ name: "CI: Release" # 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 verifies both indexes and publishes the GitHub draft. -# Every component requires successful release docs before uploading or publishing. +# Pathfinder additionally prepares standard release metadata and publishes its draft. # Before retiring the dedicated Pathfinder workflow, authorize release.yml in # its PyPI and TestPyPI trusted publishers; see .github/RELEASE-core.md. # @@ -75,11 +74,6 @@ on: type: string default: "" -concurrency: - # Keep Pathfinder full releases serialized; other dispatches remain independent. - group: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' && 'release-cuda-pathfinder' || format('release-{0}', github.run_id) }} - cancel-in-progress: false - defaults: run: shell: bash --noprofile --norc -xeuo pipefail {0} @@ -102,7 +96,6 @@ jobs: id: lookup-run-id env: GH_TOKEN: ${{ github.token }} - RELEASE_COMPONENT: ${{ inputs.component }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} PROVIDED_RUN_ID: ${{ inputs.run-id }} RELEASE_REPOSITORY: ${{ github.repository }} @@ -110,23 +103,6 @@ jobs: if [[ -n "$PROVIDED_RUN_ID" ]]; then echo "Using provided run ID: $PROVIDED_RUN_ID" RUN_ID="$PROVIDED_RUN_ID" - if [[ "$RELEASE_COMPONENT" == "cuda-pathfinder" ]]; then - if [[ ! "$RUN_ID" =~ ^[0-9]+$ ]]; then - echo "::error::Pathfinder run-id must be a numeric CI run ID" - exit 1 - fi - tag_sha=$(git rev-parse --verify "refs/tags/${RELEASE_GIT_TAG}^{commit}") - workflow_id=$(gh api "repos/${RELEASE_REPOSITORY}/actions/workflows/ci.yml" --jq '.id') - gh api "repos/${RELEASE_REPOSITORY}/actions/runs/${RUN_ID}" \ - | jq -e --arg sha "$tag_sha" --arg tag "$RELEASE_GIT_TAG" --argjson workflow "$workflow_id" ' - .head_sha == $sha and .head_branch == $tag and - .workflow_id == $workflow and .event == "push" and - .status == "completed" and .conclusion == "success" - ' >/dev/null || { - echo "::error::Pathfinder run-id must identify successful tag-triggered CI for ${RELEASE_GIT_TAG}" - exit 1 - } - fi 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") @@ -138,16 +114,6 @@ jobs: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest steps: - - name: Require default branch for Pathfinder full releases - if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then - echo "::error::Pathfinder full releases must be dispatched from the default branch (${DEFAULT_BRANCH})" - exit 1 - fi - - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -158,7 +124,6 @@ jobs: - name: Check release tag and draft state env: GH_TOKEN: ${{ github.token }} - RELEASE_COMPONENT: ${{ inputs.component }} RELEASE_ACTION: ${{ inputs.release-action }} RELEASE_GIT_TAG: ${{ inputs.git-tag }} DRY_RUN_DOCS_BRANCH: ${{ inputs.dry-run-docs-branch }} @@ -171,11 +136,6 @@ jobs: echo "error: dry-run-docs-branch must be a non-production gh-pages-* branch" >&2 exit 1 fi - if [[ "$RELEASE_COMPONENT" == "cuda-pathfinder" ]]; then - git rev-parse --verify "refs/tags/${RELEASE_GIT_TAG}^{commit}" - echo "Pathfinder draft creation runs after all release prerequisites are validated." - exit 0 - 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." @@ -206,21 +166,13 @@ jobs: if: ${{ github.repository_owner == 'nvidia' }} runs-on: ubuntu-latest outputs: - version: ${{ steps.pathfinder-version.outputs.version }} + version: ${{ steps.release-notes.outputs.version }} steps: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.component == 'cuda-pathfinder' && format('refs/tags/{0}', inputs.git-tag) || inputs.git-tag }} - - - name: Checkout Pathfinder release tools - if: ${{ inputs.component == 'cuda-pathfinder' }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - path: .release-tools - sparse-checkout: ci/tools - persist-credentials: false + fetch-depth: 1 + fetch-tags: true - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -228,40 +180,18 @@ jobs: python-version: "3.12" - name: Check versioned release notes exist - if: ${{ inputs.component != 'cuda-pathfinder' }} + id: release-notes run: | python ci/tools/check_release_notes.py \ --git-tag "${{ inputs.git-tag }}" \ + --git-ref "${{ inputs.git-tag }}" \ --component "${{ inputs.component }}" \ --backport-git-tag "${{ inputs.backport-git-tag }}" - - name: Check Pathfinder release notes - id: pathfinder-version - if: ${{ inputs.component == 'cuda-pathfinder' }} - env: - RELEASE_GIT_TAG: ${{ inputs.git-tag }} - PYTHONPATH: .release-tools/ci/tools - run: | - python - <<'PY' - import os - from pathlib import Path - from check_release_notes import parse_version_from_tag - - tag = os.environ["RELEASE_GIT_TAG"] - version = parse_version_from_tag(tag, "cuda-pathfinder") - if version is None: - raise SystemExit(f"::error::Invalid Pathfinder release tag: {tag}") - notes = Path("cuda_pathfinder/docs/source/release", f"{version}-notes.rst") - if not notes.is_file(): - raise SystemExit(f"::error::Release notes not found: {notes}") - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - output.write(f"version={version}\n") - PY - 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, determine-run-id] + needs: [check-tag, check-release-notes] runs-on: ubuntu-latest permissions: contents: write @@ -269,34 +199,19 @@ jobs: TAG: ${{ inputs.git-tag }} VERSION: ${{ needs.check-release-notes.outputs.version }} GH_TOKEN: ${{ github.token }} - REPOSITORY_OWNER: ${{ github.repository_owner }} - REPOSITORY_NAME: ${{ github.event.repository.name }} steps: - - name: Create or reuse Pathfinder draft + - name: Set metadata for a Pathfinder draft run: | - # REST's tag endpoint only returns published releases; GraphQL also finds drafts. - existing_draft=$(gh api graphql -f query=' - query($owner: String!, $name: String!, $tag: String!) { - repository(owner: $owner, name: $name) { - release(tagName: $tag) { isDraft } - } - }' -f owner="$REPOSITORY_OWNER" -f name="$REPOSITORY_NAME" -f tag="$TAG" \ - --jq '.data.repository.release | if . == null then "missing" else .isDraft end') - case "$existing_draft" in - false) - echo "::error::Release ${TAG} already exists and is published. Cannot re-release." - exit 1 - ;; - true) - echo "Reusing draft release ${TAG}" - exit 0 - ;; - missing) ;; - *) - echo "::error::Could not determine draft state for ${TAG}" - exit 1 - ;; - esac + # 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() && github.repository_owner == 'nvidia' && - needs.check-tag.result == 'success' && - needs.check-release-notes.result == 'success' && - needs.determine-run-id.result == 'success' && - ( - (inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' && - needs.ensure-pathfinder-draft.result == 'success') || - ((inputs.component != 'cuda-pathfinder' || inputs.release-action == 'dry-run') && - needs.ensure-pathfinder-draft.result == 'skipped') - ) - }} + if: ${{ github.repository_owner == 'nvidia' }} # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: id-token: write @@ -345,7 +247,6 @@ jobs: - check-tag - check-release-notes - determine-run-id - - ensure-pathfinder-draft secrets: inherit uses: ./.github/workflows/build-docs.yml with: @@ -358,11 +259,6 @@ jobs: upload-archive: name: Validate release artifacts - if: >- - ${{ !cancelled() && needs.doc.result == 'success' && - needs.check-tag.result == 'success' && - needs.check-release-notes.result == 'success' && - needs.determine-run-id.result == 'success' }} permissions: contents: write needs: @@ -380,19 +276,13 @@ jobs: publish-testpypi: name: Publish wheels to TestPyPI - if: >- - ${{ !cancelled() && inputs.release-action == 'full-release' && - needs.doc.result == 'success' && needs.check-tag.result == 'success' && - needs.check-release-notes.result == 'success' && - needs.determine-run-id.result == 'success' && - (inputs.component != 'cuda-pathfinder' || needs.upload-archive.result == 'success') }} + if: ${{ inputs.release-action == 'full-release' }} runs-on: ubuntu-latest needs: - check-tag - check-release-notes - determine-run-id - doc - - upload-archive environment: name: testpypi url: https://test.pypi.org/p/${{ inputs.component }}/ @@ -401,12 +291,10 @@ jobs: steps: - &publisher-checkout name: Checkout Source - if: ${{ inputs.component != 'cuda-pathfinder' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - &download-component-wheels name: Download component wheels - if: ${{ inputs.component != 'cuda-pathfinder' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -414,98 +302,21 @@ jobs: - &validate-component-wheels name: Validate wheel versions for release tag - if: ${{ inputs.component != 'cuda-pathfinder' }} run: | ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" - - &download-pathfinder-wheels - name: Download Pathfinder wheels - if: ${{ inputs.component == 'cuda-pathfinder' }} - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ needs.determine-run-id.outputs.run-id }} - run: | - # Publisher jobs consume only CI artifacts, without checking out source. - mkdir -p dist - gh run download "$RUN_ID" -p "cuda-pathfinder*" -R "${{ github.repository }}" - shopt -s nullglob globstar - for artifact_dir in cuda-*; do - if [[ ! -d "$artifact_dir" || "$artifact_dir" == *-tests ]]; then - continue - fi - wheels=( "$artifact_dir"/**/*.whl ) - if (( ${#wheels[@]} > 0 )); then - mv "${wheels[@]}" dist/ - fi - done - rm -rf cuda-* - ls -la dist - - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ - verify-testpypi: - name: Verify Pathfinder on TestPyPI - if: >- - ${{ !cancelled() && inputs.component == 'cuda-pathfinder' && - inputs.release-action == 'full-release' && needs.doc.result == 'success' && - needs.publish-testpypi.result == 'success' && needs.check-release-notes.result == 'success' }} - needs: [doc, 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: Install Pathfinder from index and verify - run: | - python3 -m venv "${RUNNER_TEMP}/verify" - 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.doc.result == 'success' && needs.determine-run-id.result == 'success' && - needs.publish-testpypi.result == 'success' && - ( - (inputs.component == 'cuda-pathfinder' && needs.verify-testpypi.result == 'success') || - (inputs.component != 'cuda-pathfinder' && needs.verify-testpypi.result == 'skipped') - ) }} + if: ${{ inputs.release-action == 'full-release' }} runs-on: ubuntu-latest needs: - determine-run-id - - doc - publish-testpypi - - verify-testpypi environment: name: pypi url: https://pypi.org/p/${{ inputs.component }}/ @@ -515,35 +326,14 @@ jobs: - *publisher-checkout - *download-component-wheels - *validate-component-wheels - - *download-pathfinder-wheels - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - verify-pypi: - name: Verify Pathfinder on PyPI - if: >- - ${{ !cancelled() && inputs.component == 'cuda-pathfinder' && - inputs.release-action == 'full-release' && needs.doc.result == 'success' && - needs.publish-pypi.result == 'success' && needs.check-release-notes.result == 'success' }} - needs: [doc, 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: >- - ${{ !cancelled() && inputs.component == 'cuda-pathfinder' && - inputs.release-action == 'full-release' && needs.doc.result == 'success' && - needs.check-release-notes.result == 'success' && - needs.verify-pypi.result == 'success' && needs.upload-archive.result == 'success' }} - needs: [doc, check-release-notes, verify-pypi, upload-archive] + if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }} + needs: [doc, check-release-notes, ensure-pathfinder-draft, publish-pypi, upload-archive] runs-on: ubuntu-latest permissions: contents: write diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index 1c99ddb019a..ece0825e40d 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -5,9 +5,15 @@ Usage: python check_release_notes.py --git-tag --component + python check_release_notes.py --git-tag --component --git-ref refs/tags/ + +By default, notes and release configuration come from the working tree. +--git-ref reads those files from the given ref without changing the checkout, +so the workflow's current checker can validate older release tags. Successful +checks append the parsed version to GITHUB_OUTPUT when it is set. Exit codes: - 0 — release notes present and non-empty (or .post version, skipped) + 0 — release-notes policy satisfied 1 — release notes missing or empty 2 — invalid arguments (including unparsable tag, or component/tag-prefix mismatch) """ @@ -17,6 +23,7 @@ import argparse import os import re +import subprocess import sys from pathlib import Path @@ -63,16 +70,41 @@ def is_post_release(version: str) -> bool: return ".post" in version -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 +def read_repo_file(path: Path, repo_root: Path, git_ref: str | None = None) -> bytes | None: + """Read a file from the working tree or an already-resolved Git commit.""" + if git_ref is None: + full = repo_root / path + return full.read_bytes() if full.is_file() else None + + object_name = f"{git_ref}:{path.as_posix()}" + result = subprocess.run( + ["git", "cat-file", "--batch-check=%(objecttype)"], # noqa: S607 + input=f"{object_name}\n", + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + object_type = result.stdout.strip() + if object_type == f"{object_name} missing" or object_type == "tree": + return None + if object_type != "blob": + raise ValueError(f"Cannot read release-notes source {object_name!r}: unexpected object type {object_type!r}") + return subprocess.run( # noqa: S603 - the resolved commit and path are passed without a shell. + ["git", "show", object_name], # noqa: S607 + cwd=repo_root, + check=True, + capture_output=True, + ).stdout + + +def load_backport_branch(repo_root: Path = Path("."), git_ref: str | None = None) -> str | None: + contents = read_repo_file(Path("ci", "versions.yml"), repo_root, git_ref) + if contents is not None: + for line in contents.decode("utf-8").splitlines(): + m = BACKPORT_BRANCH_RE.match(line.strip()) + if m: + return m.group("branch") github_ref_name = os.environ.get("GITHUB_REF_NAME", "") if BACKPORT_BRANCH_NAME_RE.match(github_ref_name): return github_ref_name @@ -89,14 +121,16 @@ 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]]: +def check_release_notes( + git_tag: str, component: str, repo_root: Path = Path("."), git_ref: str | None = None +) -> 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. - Returns an empty list when notes are present and non-empty, or when the - tag is a .post release (no new notes required). + 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}'")] @@ -105,14 +139,14 @@ def check_release_notes(git_tag: str, component: str, repo_root: Path = Path("." if version is None: return [("", f"cannot parse version from tag '{git_tag}' for component '{component}'")] - if is_post_release(version): + if is_post_release(version) and component != "cuda-pathfinder": return [] path = notes_path(COMPONENT_TO_PACKAGE[component], version) - full = repo_root / path - if not full.is_file(): + contents = read_repo_file(path, repo_root, git_ref) + if contents is None: return [(path, "missing")] - if full.stat().st_size == 0: + if not contents and component != "cuda-pathfinder": return [(path, "empty")] return [] @@ -127,6 +161,13 @@ def write_step_summary(message: str) -> None: f.write("\n") +def write_version_output(version: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path: + with open(output_path, "a", encoding="utf-8") as f: + 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 = [ @@ -152,6 +193,7 @@ def validate_backport_decision( backport_git_tag: str, backport_branch: str | None, repo_root: Path, + git_ref: str | None = None, ) -> tuple[int | None, list[tuple[str | Path, str]]]: if component not in BACKPORT_PLANNING_COMPONENTS or is_post_release(version): return None, [] @@ -161,7 +203,7 @@ def validate_backport_decision( return 2, [] if is_backport_version(version, backport_branch): - problems = check_release_notes(git_tag, component, repo_root) + problems = check_release_notes(git_tag, component, repo_root, git_ref) if problems: warn_missing_backport_notes(git_tag, component, problems) else: @@ -199,7 +241,7 @@ def validate_backport_decision( ) return 2, [] - problems = check_release_notes(decision, component, repo_root) + problems = check_release_notes(decision, component, repo_root, git_ref) if problems: return 1, problems return None, [] @@ -210,6 +252,9 @@ def main(argv: list[str] | None = None) -> int: 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( + "--git-ref", help="Read notes and release configuration from this Git ref without checking it out" + ) parser.add_argument("--backport-git-tag", default="") parser.add_argument("--backport-branch", default="") args = parser.parse_args(argv) @@ -222,11 +267,33 @@ def main(argv: list[str] | None = None) -> int: ) return 2 - if is_post_release(version): + source_ref = args.git_ref + try: + if args.git_ref is not None: + args.git_ref = subprocess.run( # noqa: S603 - --end-of-options prevents the ref from becoming an option. + ["git", "rev-parse", "--verify", "--end-of-options", f"{args.git_ref}^{{commit}}"], # noqa: S607 + cwd=args.repo_root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + return check_policy(args, version) + except subprocess.CalledProcessError as exc: + detail = exc.stderr.decode("utf-8") if isinstance(exc.stderr, bytes) else exc.stderr + print(f"ERROR: cannot read Git release-notes source {source_ref!r}: {detail.strip()}", file=sys.stderr) + return 2 + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +def check_policy(args: argparse.Namespace, version: str) -> int: + 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) + backport_branch = args.backport_branch or load_backport_branch(args.repo_root, args.git_ref) rc, problems = validate_backport_decision( git_tag=args.git_tag, component=args.component, @@ -234,19 +301,23 @@ def main(argv: list[str] | None = None) -> int: backport_git_tag=args.backport_git_tag, backport_branch=backport_branch, repo_root=args.repo_root, + git_ref=args.git_ref, ) 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_notes(args.git_tag, args.component, args.repo_root, args.git_ref) if not problems: print(f"Release notes present for tag {args.git_tag}, component {args.component}.") + write_version_output(version) return 0 print(f"ERROR: missing or empty release notes for tag {args.git_tag}:", file=sys.stderr) diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py index e08eac6610d..df07bb2b106 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -3,9 +3,12 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).parent.parent)) from check_release_notes import ( check_release_notes, @@ -16,6 +19,35 @@ ) +@pytest.fixture +def git_source(tmp_path, monkeypatch): + """Model read-only Git commands without creating commits in test fixtures.""" + files = {} + calls = [] + commit = "1" * 40 + + def run(command, **kwargs): + assert kwargs["cwd"] == tmp_path + assert kwargs["check"] is True + calls.append(command) + if command[1] == "rev-parse": + return subprocess.CompletedProcess(command, 0, stdout=f"{commit}\n", stderr="") + if command[1] == "cat-file": + object_name = kwargs["input"].strip() + ref, path = object_name.split(":", 1) + assert ref == commit + stdout = "blob\n" if path in files else f"{object_name} missing\n" + return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="") + if command[1] == "show": + ref, path = command[2].split(":", 1) + assert ref == commit + return subprocess.CompletedProcess(command, 0, stdout=files[path], stderr=b"") + pytest.fail(f"Unexpected Git command: {command}") + + monkeypatch.setattr("check_release_notes.subprocess.run", run) + return files, calls + + class TestParseVersionFromTag: def test_plain_tag_bindings(self): assert parse_version_from_tag("v13.1.0", "cuda-bindings") == "13.1.0" @@ -331,3 +363,209 @@ def test_mainline_bindings_rejects_non_backport_tag(self, tmp_path): ) assert rc == 2 + + +@pytest.mark.parametrize("version", ["1.5.2", "1.5.2.post1"]) +@pytest.mark.parametrize("contents", ["", "Release notes."]) +@pytest.mark.agent_authored(model="gpt-6.1") +def test_pathfinder_notes_allow_empty_files_and_require_post_notes(tmp_path, monkeypatch, version, contents): + notes = tmp_path / "cuda_pathfinder" / "docs" / "source" / "release" / f"{version}-notes.rst" + notes.parent.mkdir(parents=True) + notes.write_text(contents) + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert ( + main( + ["--git-tag", f"cuda-pathfinder-v{version}", "--component", "cuda-pathfinder", "--repo-root", str(tmp_path)] + ) + == 0 + ) + assert output.read_text() == f"version={version}\n" + + +@pytest.mark.parametrize("version", ["1.5.2", "1.5.2.post1"]) +@pytest.mark.agent_authored(model="gpt-6.1") +def test_pathfinder_missing_notes_fail_without_version_output(tmp_path, monkeypatch, version): + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert ( + main( + ["--git-tag", f"cuda-pathfinder-v{version}", "--component", "cuda-pathfinder", "--repo-root", str(tmp_path)] + ) + == 1 + ) + assert not output.exists() + + +@pytest.mark.parametrize( + "component,tag,version,extra_args", + [ + ("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", []), + ], +) +@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.") + 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 output.read_text() == f"existing=value\nversion={version}\n" + + +@pytest.mark.parametrize("tag", ["invalid", "cuda-core-v1.5.2"]) +@pytest.mark.agent_authored(model="gpt-6.1") +def test_invalid_pathfinder_tags_do_not_expose_version(tmp_path, monkeypatch, tag): + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert main(["--git-tag", tag, "--component", "cuda-pathfinder", "--repo-root", str(tmp_path)]) == 2 + assert not output.exists() + + +@pytest.mark.parametrize("contents,expected", [(b"", 0), (b"Release notes.", 0), (None, 1)]) +@pytest.mark.agent_authored(model="gpt-6.1") +def test_git_ref_reads_pathfinder_post_notes_instead_of_worktree(tmp_path, monkeypatch, git_source, contents, expected): + files, calls = git_source + path = "cuda_pathfinder/docs/source/release/1.5.2.post1-notes.rst" + worktree_notes = tmp_path / path + worktree_notes.parent.mkdir(parents=True) + worktree_notes.write_text("Working tree notes must not decide the tagged release policy.") + if contents is not None: + files[path] = contents + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert ( + main( + [ + "--git-tag", + "cuda-pathfinder-v1.5.2.post1", + "--component", + "cuda-pathfinder", + "--repo-root", + str(tmp_path), + "--git-ref", + "refs/tags/cuda-pathfinder-v1.5.2.post1", + ] + ) + == expected + ) + assert calls[0] == [ + "git", + "rev-parse", + "--verify", + "--end-of-options", + "refs/tags/cuda-pathfinder-v1.5.2.post1^{commit}", + ] + assert worktree_notes.read_text() == "Working tree notes must not decide the tagged release policy." + assert output.exists() == (expected == 0) + + +@pytest.mark.parametrize("component", ["cuda-bindings", "cuda-python"]) +@pytest.mark.parametrize("backport_present,expected", [(True, 0), (False, 1)]) +@pytest.mark.agent_authored(model="gpt-6.1") +def test_git_ref_reads_release_backport_configuration_and_both_notes( + tmp_path, monkeypatch, git_source, component, backport_present, expected +): + files, _ = git_source + files["ci/versions.yml"] = b'backport_branch: "12.9.x"\n' + package = component.replace("-", "_") + files[f"{package}/docs/source/release/13.3.0-notes.rst"] = b"Mainline notes." + if backport_present: + files[f"{package}/docs/source/release/12.9.7-notes.rst"] = b"Backport notes." + (tmp_path / "ci").mkdir() + (tmp_path / "ci" / "versions.yml").write_text('backport_branch: "12.8.x"\n') + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert ( + main( + [ + "--git-tag", + "v13.3.0", + "--component", + component, + "--repo-root", + str(tmp_path), + "--git-ref", + "refs/tags/v13.3.0", + "--backport-git-tag", + "v12.9.7", + ] + ) + == expected + ) + assert output.exists() == (expected == 0) + + +@pytest.mark.agent_authored(model="gpt-6.1") +def test_missing_git_ref_is_an_error_not_missing_notes(tmp_path, monkeypatch, capsys): + def run(command, **kwargs): + assert command[1] == "rev-parse" + raise subprocess.CalledProcessError(128, command, stderr="fatal: Needed a single revision\n") + + monkeypatch.setattr("check_release_notes.subprocess.run", run) + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert ( + main( + [ + "--git-tag", + "cuda-pathfinder-v1.5.2", + "--component", + "cuda-pathfinder", + "--repo-root", + str(tmp_path), + "--git-ref", + "refs/tags/missing", + ] + ) + == 2 + ) + assert "cannot read Git release-notes source" in capsys.readouterr().err + assert not output.exists() + + +@pytest.mark.agent_authored(model="gpt-6.1") +def test_git_read_failure_is_not_treated_as_missing_notes(tmp_path, monkeypatch, git_source, capsys): + files, _ = git_source + files["cuda_pathfinder/docs/source/release/1.5.2-notes.rst"] = b"Release notes." + run = subprocess.run + + def failing_show(command, **kwargs): + if command[1] == "show": + raise subprocess.CalledProcessError(128, command, stderr=b"fatal: cannot read blob\n") + return run(command, **kwargs) + + monkeypatch.setattr("check_release_notes.subprocess.run", failing_show) + output = tmp_path / "output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + + assert ( + main( + [ + "--git-tag", + "cuda-pathfinder-v1.5.2", + "--component", + "cuda-pathfinder", + "--repo-root", + str(tmp_path), + "--git-ref", + "refs/tags/cuda-pathfinder-v1.5.2", + ] + ) + == 2 + ) + assert "cannot read blob" in capsys.readouterr().err + assert not output.exists() From becb095ced1b8f3f577b95f3a7970e24025f2706 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Mon, 12 Oct 2026 13:16:43 -0700 Subject: [PATCH 04/11] Fix refs --- .github/workflows/release-upload.yml | 3 +-- .github/workflows/release.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 39dc4409622..bd66b84ed76 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -49,8 +49,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - # Use current release tooling while archiving the requested tag below. - fetch-tags: true + ref: ${{ inputs.git-tag }} - name: Create Release Directory run: mkdir -p release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fd8716400d..67b5bd63241 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -172,7 +172,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - fetch-tags: true + ref: ${{ inputs.git-tag }} - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -184,7 +184,6 @@ jobs: run: | python ci/tools/check_release_notes.py \ --git-tag "${{ inputs.git-tag }}" \ - --git-ref "${{ inputs.git-tag }}" \ --component "${{ inputs.component }}" \ --backport-git-tag "${{ inputs.backport-git-tag }}" From 26cdef3f7d2574afcc1a92e935318b34d37fd9d1 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Tue, 13 Oct 2026 08:13:04 -0700 Subject: [PATCH 05/11] Remove unnecessary deduplication --- .github/workflows/release.yml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67b5bd63241..f8b242d4b95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -288,19 +288,16 @@ jobs: permissions: id-token: write steps: - - &publisher-checkout - name: Checkout Source + - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - &download-component-wheels - name: Download component wheels + - 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" - - &validate-component-wheels - name: Validate wheel versions for release tag + - name: Validate wheel versions for release tag run: | ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" @@ -322,9 +319,18 @@ jobs: permissions: id-token: write steps: - - *publisher-checkout - - *download-component-wheels - - *validate-component-wheels + - 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 From 688346a5d7f96a6ce75ec53829e666ebff251bca Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Tue, 13 Oct 2026 08:33:01 -0700 Subject: [PATCH 06/11] Fix coderabbit suggestions --- .github/workflows/release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8b242d4b95..cc9e7158efe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -173,6 +173,12 @@ jobs: with: fetch-depth: 1 ref: ${{ inputs.git-tag }} + persist-credentials: false + + - name: Fetch release tag + env: + TAG: ${{ inputs.git-tag }} + run: git fetch --depth=1 origin "refs/tags/${TAG}:refs/tags/${TAG}" - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -201,6 +207,7 @@ jobs: 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}" ' @@ -348,6 +355,7 @@ jobs: 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 From 00626354ba0a5c9b574db3e5dbb96a7040f9e80c Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Tue, 13 Oct 2026 08:35:42 -0700 Subject: [PATCH 07/11] Fix whitespace --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc9e7158efe..936b1de290d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,7 +176,7 @@ jobs: persist-credentials: false - name: Fetch release tag - env: + env: TAG: ${{ inputs.git-tag }} run: git fetch --depth=1 origin "refs/tags/${TAG}:refs/tags/${TAG}" From a4f5ee45d9e06dd2ccb05037cf2c0c195db43c81 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Tue, 13 Oct 2026 08:51:34 -0700 Subject: [PATCH 08/11] Coderabbit commets --- .github/RELEASE-core.md | 10 ++++++++++ .github/workflows/release.yml | 10 +++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index d27b88ed7b0..832725b084a 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -19,6 +19,16 @@ and wheels. It publishes to TestPyPI followed by PyPI, then publishes 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 a Pathfinder `full-release`, that tag must contain a version +of `ci/tools/check_release_notes.py` that writes `version=` to +`GITHUB_OUTPUT`. Metadata preparation and finalization reject an empty +version. Do not dispatch `full-release` for older tags without that support: +the GitHub Release cannot be finalized, although shared documentation, +asset upload, and package publication jobs can still run. Use the retained +legacy Pathfinder workflow for those tags during the transition; do not +move existing tags to add the updated checker. + The default `dry-run` action validates preparation, builds docs, and collects release artifacts without creating a GitHub Release or publishing packages. It can be dispatched from a development branch; use diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 936b1de290d..e8a347b6c2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,11 +187,15 @@ jobs: - 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 "${{ inputs.git-tag }}" \ - --component "${{ inputs.component }}" \ - --backport-git-tag "${{ inputs.backport-git-tag }}" + --git-tag "$RELEASE_GIT_TAG" \ + --component "$RELEASE_COMPONENT" \ + --backport-git-tag "$BACKPORT_GIT_TAG" ensure-pathfinder-draft: name: Prepare Pathfinder release draft From 6ed23810106334c691cf01064d003ab1bb3cb2b4 Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Tue, 13 Oct 2026 13:32:24 -0700 Subject: [PATCH 09/11] Address coderabbit comments --- .github/RELEASE-core.md | 14 +++++++------- .github/workflows/release.yml | 23 +++++++++++++++++++++-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index 832725b084a..b1e5e9f1f42 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -20,14 +20,14 @@ 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 a Pathfinder `full-release`, that tag must contain a version +For Pathfinder, that tag must contain a version of `ci/tools/check_release_notes.py` that writes `version=` to -`GITHUB_OUTPUT`. Metadata preparation and finalization reject an empty -version. Do not dispatch `full-release` for older tags without that support: -the GitHub Release cannot be finalized, although shared documentation, -asset upload, and package publication jobs can still run. Use the retained -legacy Pathfinder workflow for those tags during the transition; do not -move existing tags to add the updated checker. +`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 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.yml b/.github/workflows/release.yml index e8a347b6c2e..be6840868e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,6 +197,16 @@ jobs: --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' }} @@ -286,13 +296,20 @@ jobs: publish-testpypi: name: Publish wheels to TestPyPI - if: ${{ inputs.release-action == 'full-release' }} + 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 }}/ @@ -319,7 +336,9 @@ jobs: publish-pypi: name: Publish wheels to PyPI - if: ${{ inputs.release-action == 'full-release' }} + if: >- + ${{ !cancelled() && inputs.release-action == 'full-release' && + needs.determine-run-id.result == 'success' && needs.publish-testpypi.result == 'success' }} runs-on: ubuntu-latest needs: - determine-run-id From 13da9cdd72e3648af8109fe756b696f0be1ed7fc Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Wed, 14 Oct 2026 13:24:51 -0700 Subject: [PATCH 10/11] Remove unnecessary additional step --- .github/workflows/release.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be6840868e5..11c01e8e2c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,11 +175,6 @@ jobs: ref: ${{ inputs.git-tag }} persist-credentials: false - - name: Fetch release tag - env: - TAG: ${{ inputs.git-tag }} - run: git fetch --depth=1 origin "refs/tags/${TAG}:refs/tags/${TAG}" - - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: From ed6331154fb99166c1599453378193d0ac8ddffc Mon Sep 17 00:00:00 2001 From: Jason Pascucci Date: Thu, 15 Oct 2026 08:11:13 -0700 Subject: [PATCH 11/11] Remove unused --git-ref, and drive-by changes. --- .github/workflows/release.yml | 2 - ci/tools/check_release_notes.py | 97 +++--------- ci/tools/tests/test_check_release_notes.py | 169 --------------------- 3 files changed, 20 insertions(+), 248 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11c01e8e2c3..f55ca1efbbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,9 +171,7 @@ jobs: - name: Checkout Source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 1 ref: ${{ inputs.git-tag }} - persist-credentials: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index ece0825e40d..44e197db3fd 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -5,12 +5,9 @@ Usage: python check_release_notes.py --git-tag --component - python check_release_notes.py --git-tag --component --git-ref refs/tags/ -By default, notes and release configuration come from the working tree. ---git-ref reads those files from the given ref without changing the checkout, -so the workflow's current checker can validate older release tags. Successful -checks append the parsed version to GITHUB_OUTPUT when it is set. +Notes and release configuration come from the working tree. Successful checks +append the parsed version to GITHUB_OUTPUT when it is set. Exit codes: 0 — release-notes policy satisfied @@ -23,7 +20,6 @@ import argparse import os import re -import subprocess import sys from pathlib import Path @@ -70,41 +66,16 @@ def is_post_release(version: str) -> bool: return ".post" in version -def read_repo_file(path: Path, repo_root: Path, git_ref: str | None = None) -> bytes | None: - """Read a file from the working tree or an already-resolved Git commit.""" - if git_ref is None: - full = repo_root / path - return full.read_bytes() if full.is_file() else None - - object_name = f"{git_ref}:{path.as_posix()}" - result = subprocess.run( - ["git", "cat-file", "--batch-check=%(objecttype)"], # noqa: S607 - input=f"{object_name}\n", - cwd=repo_root, - check=True, - capture_output=True, - text=True, - ) - object_type = result.stdout.strip() - if object_type == f"{object_name} missing" or object_type == "tree": - return None - if object_type != "blob": - raise ValueError(f"Cannot read release-notes source {object_name!r}: unexpected object type {object_type!r}") - return subprocess.run( # noqa: S603 - the resolved commit and path are passed without a shell. - ["git", "show", object_name], # noqa: S607 - cwd=repo_root, - check=True, - capture_output=True, - ).stdout - - -def load_backport_branch(repo_root: Path = Path("."), git_ref: str | None = None) -> str | None: - contents = read_repo_file(Path("ci", "versions.yml"), repo_root, git_ref) - if contents is not None: - for line in contents.decode("utf-8").splitlines(): - m = BACKPORT_BRANCH_RE.match(line.strip()) - if m: - return m.group("branch") +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 @@ -121,9 +92,7 @@ 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("."), git_ref: str | None = None -) -> list[tuple[str | Path, str]]: +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 @@ -143,10 +112,10 @@ def check_release_notes( return [] path = notes_path(COMPONENT_TO_PACKAGE[component], version) - contents = read_repo_file(path, repo_root, git_ref) - if contents is None: + full = repo_root / path + if not full.is_file(): return [(path, "missing")] - if not contents and component != "cuda-pathfinder": + if full.stat().st_size == 0 and component != "cuda-pathfinder": return [(path, "empty")] return [] @@ -193,7 +162,6 @@ def validate_backport_decision( backport_git_tag: str, backport_branch: str | None, repo_root: Path, - git_ref: str | None = None, ) -> tuple[int | None, list[tuple[str | Path, str]]]: if component not in BACKPORT_PLANNING_COMPONENTS or is_post_release(version): return None, [] @@ -203,7 +171,7 @@ def validate_backport_decision( return 2, [] if is_backport_version(version, backport_branch): - problems = check_release_notes(git_tag, component, repo_root, git_ref) + problems = check_release_notes(git_tag, component, repo_root) if problems: warn_missing_backport_notes(git_tag, component, problems) else: @@ -241,7 +209,7 @@ def validate_backport_decision( ) return 2, [] - problems = check_release_notes(decision, component, repo_root, git_ref) + problems = check_release_notes(decision, component, repo_root) if problems: return 1, problems return None, [] @@ -252,9 +220,6 @@ def main(argv: list[str] | None = None) -> int: 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( - "--git-ref", help="Read notes and release configuration from this Git ref without checking it out" - ) parser.add_argument("--backport-git-tag", default="") parser.add_argument("--backport-branch", default="") args = parser.parse_args(argv) @@ -267,33 +232,12 @@ def main(argv: list[str] | None = None) -> int: ) return 2 - source_ref = args.git_ref - try: - if args.git_ref is not None: - args.git_ref = subprocess.run( # noqa: S603 - --end-of-options prevents the ref from becoming an option. - ["git", "rev-parse", "--verify", "--end-of-options", f"{args.git_ref}^{{commit}}"], # noqa: S607 - cwd=args.repo_root, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - return check_policy(args, version) - except subprocess.CalledProcessError as exc: - detail = exc.stderr.decode("utf-8") if isinstance(exc.stderr, bytes) else exc.stderr - print(f"ERROR: cannot read Git release-notes source {source_ref!r}: {detail.strip()}", file=sys.stderr) - return 2 - except ValueError as exc: - print(f"ERROR: {exc}", file=sys.stderr) - return 2 - - -def check_policy(args: argparse.Namespace, version: str) -> int: 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, args.git_ref) + 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, @@ -301,7 +245,6 @@ def check_policy(args: argparse.Namespace, version: str) -> int: backport_git_tag=args.backport_git_tag, backport_branch=backport_branch, repo_root=args.repo_root, - git_ref=args.git_ref, ) if rc is not None: if problems: @@ -313,7 +256,7 @@ def check_policy(args: argparse.Namespace, version: str) -> int: return rc if not problems: - problems = check_release_notes(args.git_tag, args.component, args.repo_root, args.git_ref) + problems = check_release_notes(args.git_tag, args.component, args.repo_root) if not problems: print(f"Release notes present for tag {args.git_tag}, component {args.component}.") diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py index df07bb2b106..805ec7bce7a 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -3,7 +3,6 @@ from __future__ import annotations -import subprocess import sys from pathlib import Path @@ -19,35 +18,6 @@ ) -@pytest.fixture -def git_source(tmp_path, monkeypatch): - """Model read-only Git commands without creating commits in test fixtures.""" - files = {} - calls = [] - commit = "1" * 40 - - def run(command, **kwargs): - assert kwargs["cwd"] == tmp_path - assert kwargs["check"] is True - calls.append(command) - if command[1] == "rev-parse": - return subprocess.CompletedProcess(command, 0, stdout=f"{commit}\n", stderr="") - if command[1] == "cat-file": - object_name = kwargs["input"].strip() - ref, path = object_name.split(":", 1) - assert ref == commit - stdout = "blob\n" if path in files else f"{object_name} missing\n" - return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="") - if command[1] == "show": - ref, path = command[2].split(":", 1) - assert ref == commit - return subprocess.CompletedProcess(command, 0, stdout=files[path], stderr=b"") - pytest.fail(f"Unexpected Git command: {command}") - - monkeypatch.setattr("check_release_notes.subprocess.run", run) - return files, calls - - class TestParseVersionFromTag: def test_plain_tag_bindings(self): assert parse_version_from_tag("v13.1.0", "cuda-bindings") == "13.1.0" @@ -430,142 +400,3 @@ 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("contents,expected", [(b"", 0), (b"Release notes.", 0), (None, 1)]) -@pytest.mark.agent_authored(model="gpt-6.1") -def test_git_ref_reads_pathfinder_post_notes_instead_of_worktree(tmp_path, monkeypatch, git_source, contents, expected): - files, calls = git_source - path = "cuda_pathfinder/docs/source/release/1.5.2.post1-notes.rst" - worktree_notes = tmp_path / path - worktree_notes.parent.mkdir(parents=True) - worktree_notes.write_text("Working tree notes must not decide the tagged release policy.") - if contents is not None: - files[path] = contents - output = tmp_path / "output" - monkeypatch.setenv("GITHUB_OUTPUT", str(output)) - - assert ( - main( - [ - "--git-tag", - "cuda-pathfinder-v1.5.2.post1", - "--component", - "cuda-pathfinder", - "--repo-root", - str(tmp_path), - "--git-ref", - "refs/tags/cuda-pathfinder-v1.5.2.post1", - ] - ) - == expected - ) - assert calls[0] == [ - "git", - "rev-parse", - "--verify", - "--end-of-options", - "refs/tags/cuda-pathfinder-v1.5.2.post1^{commit}", - ] - assert worktree_notes.read_text() == "Working tree notes must not decide the tagged release policy." - assert output.exists() == (expected == 0) - - -@pytest.mark.parametrize("component", ["cuda-bindings", "cuda-python"]) -@pytest.mark.parametrize("backport_present,expected", [(True, 0), (False, 1)]) -@pytest.mark.agent_authored(model="gpt-6.1") -def test_git_ref_reads_release_backport_configuration_and_both_notes( - tmp_path, monkeypatch, git_source, component, backport_present, expected -): - files, _ = git_source - files["ci/versions.yml"] = b'backport_branch: "12.9.x"\n' - package = component.replace("-", "_") - files[f"{package}/docs/source/release/13.3.0-notes.rst"] = b"Mainline notes." - if backport_present: - files[f"{package}/docs/source/release/12.9.7-notes.rst"] = b"Backport notes." - (tmp_path / "ci").mkdir() - (tmp_path / "ci" / "versions.yml").write_text('backport_branch: "12.8.x"\n') - output = tmp_path / "output" - monkeypatch.setenv("GITHUB_OUTPUT", str(output)) - - assert ( - main( - [ - "--git-tag", - "v13.3.0", - "--component", - component, - "--repo-root", - str(tmp_path), - "--git-ref", - "refs/tags/v13.3.0", - "--backport-git-tag", - "v12.9.7", - ] - ) - == expected - ) - assert output.exists() == (expected == 0) - - -@pytest.mark.agent_authored(model="gpt-6.1") -def test_missing_git_ref_is_an_error_not_missing_notes(tmp_path, monkeypatch, capsys): - def run(command, **kwargs): - assert command[1] == "rev-parse" - raise subprocess.CalledProcessError(128, command, stderr="fatal: Needed a single revision\n") - - monkeypatch.setattr("check_release_notes.subprocess.run", run) - output = tmp_path / "output" - monkeypatch.setenv("GITHUB_OUTPUT", str(output)) - - assert ( - main( - [ - "--git-tag", - "cuda-pathfinder-v1.5.2", - "--component", - "cuda-pathfinder", - "--repo-root", - str(tmp_path), - "--git-ref", - "refs/tags/missing", - ] - ) - == 2 - ) - assert "cannot read Git release-notes source" in capsys.readouterr().err - assert not output.exists() - - -@pytest.mark.agent_authored(model="gpt-6.1") -def test_git_read_failure_is_not_treated_as_missing_notes(tmp_path, monkeypatch, git_source, capsys): - files, _ = git_source - files["cuda_pathfinder/docs/source/release/1.5.2-notes.rst"] = b"Release notes." - run = subprocess.run - - def failing_show(command, **kwargs): - if command[1] == "show": - raise subprocess.CalledProcessError(128, command, stderr=b"fatal: cannot read blob\n") - return run(command, **kwargs) - - monkeypatch.setattr("check_release_notes.subprocess.run", failing_show) - output = tmp_path / "output" - monkeypatch.setenv("GITHUB_OUTPUT", str(output)) - - assert ( - main( - [ - "--git-tag", - "cuda-pathfinder-v1.5.2", - "--component", - "cuda-pathfinder", - "--repo-root", - str(tmp_path), - "--git-ref", - "refs/tags/cuda-pathfinder-v1.5.2", - ] - ) - == 2 - ) - assert "cannot read blob" in capsys.readouterr().err - assert not output.exists()