Skip to content
34 changes: 33 additions & 1 deletion .github/RELEASE-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,41 @@
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** with
`component` set to `cuda-pathfinder`, `release-action` set
to `full-release`, and `git-tag` set to the existing
`cuda-pathfinder-v<VERSION>` 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 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 Pathfinder, that tag must contain a version
of `ci/tools/check_release_notes.py` that writes `version=<VERSION>` to
`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
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
Expand Down
140 changes: 129 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ 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 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.
#
# Maintenance note: non-trivial changes to this workflow should be validated
# with dry-run workflow_dispatch runs before merging. Suggested focused matrix:
Expand All @@ -29,6 +32,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:
Expand Down Expand Up @@ -92,13 +96,16 @@ jobs:
id: lookup-run-id
env:
GH_TOKEN: ${{ github.token }}
RELEASE_GIT_TAG: ${{ inputs.git-tag }}
PROVIDED_RUN_ID: ${{ inputs.run-id }}
RELEASE_REPOSITORY: ${{ github.repository }}
run: |
if [[ -n "${{ 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"
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"
Expand Down Expand Up @@ -158,6 +165,8 @@ jobs:
check-release-notes:
if: ${{ github.repository_owner == 'nvidia' }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.release-notes.outputs.version }}
steps:
- name: Checkout Source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -170,11 +179,74 @@ jobs:
python-version: "3.12"

- name: Check versioned release notes exist
id: release-notes
env:
RELEASE_GIT_TAG: ${{ inputs.git-tag }}
RELEASE_COMPONENT: ${{ inputs.component }}
BACKPORT_GIT_TAG: ${{ inputs.backport-git-tag }}
run: |
python ci/tools/check_release_notes.py \
--git-tag "${{ 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"

- name: Require Pathfinder version output
if: ${{ inputs.component == 'cuda-pathfinder' }}
env:
VERSION: ${{ steps.release-notes.outputs.version }}
run: |
[[ -n "$VERSION" ]] || {
echo "::error::The tagged Pathfinder release-note checker must emit a non-empty version output"
exit 1
}

ensure-pathfinder-draft:
name: Prepare Pathfinder release draft
if: ${{ github.repository_owner == 'nvidia' && inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }}
needs: [check-tag, check-release-notes]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
runs-on: ubuntu-latest
permissions:
contents: write
env:
TAG: ${{ inputs.git-tag }}
VERSION: ${{ needs.check-release-notes.outputs.version }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Set metadata for a Pathfinder draft
run: |
[[ -n "$VERSION" ]] || { echo "::error::Empty version output"; exit 1; }
# Recognize the shared placeholder across retries without overwriting curated drafts.
is_placeholder=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft,name,body \
| jq -r --arg placeholder "Release ${TAG}" '
if .isDraft != true then error("Release is not a draft")
else .name == $placeholder and .body == $placeholder end
')
if [[ "$is_placeholder" != "true" ]]; then
echo "Reusing draft release ${TAG}"
exit 0
fi
cat > "${RUNNER_TEMP}/release-body.md" <<BODY
## Release notes

- https://nvidia.github.io/cuda-python/cuda-pathfinder/latest/release/${VERSION}-notes.html

## Documentation

- https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/

## PyPI

- https://pypi.org/project/cuda-pathfinder/${VERSION}/

## Conda

- https://anaconda.org/conda-forge/cuda-pathfinder/files?version=${VERSION}
- \`conda install conda-forge::cuda-pathfinder=${VERSION}\`
BODY
gh release edit "$TAG" --repo "${{ github.repository }}" \
--latest=false \
--title "cuda-pathfinder v${VERSION}" \
--notes-file "${RUNNER_TEMP}/release-body.md"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
doc:
name: Build release docs
Expand Down Expand Up @@ -217,13 +289,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 }}/
Expand All @@ -250,7 +329,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
Expand All @@ -277,4 +358,41 @@ jobs:
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

# TODO: add another job to make the release leave the draft state?
finalize-pathfinder:
name: Publish Pathfinder GitHub release
if: ${{ inputs.component == 'cuda-pathfinder' && inputs.release-action == 'full-release' }}
needs: [doc, check-release-notes, ensure-pathfinder-draft, publish-pypi, upload-archive]
runs-on: ubuntu-latest
permissions:
contents: write
env:
TAG: ${{ inputs.git-tag }}
VERSION: ${{ needs.check-release-notes.outputs.version }}
steps:
- name: Verify docs URL
run: |
[[ -n "$VERSION" ]] || { echo "::error::Empty version output"; exit 1; }
url="https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/"
status=$(curl -sL -o /dev/null -w '%{http_code}' "$url")
if [[ "$status" != "200" ]]; then
echo "::warning::Docs URL returned HTTP ${status} -- docs may not be deployed yet"
fi

- name: Verify release is still a draft
env:
GH_TOKEN: ${{ github.token }}
run: |
is_draft=$(gh release view "$TAG" --repo "${{ github.repository }}" --json isDraft --jq '.isDraft')
if [[ "$is_draft" != "true" ]]; then
echo "::error::Release ${TAG} is no longer a draft (was it published manually?)"
exit 1
fi

- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit "$TAG" --repo "${{ github.repository }}" --draft=false --latest=false
docs_url="https://nvidia.github.io/cuda-python/cuda-pathfinder/${VERSION}/"
echo "$docs_url"
printf '%s\n' "$docs_url" >> "$GITHUB_STEP_SUMMARY"
26 changes: 20 additions & 6 deletions ci/tools/check_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
Usage:
python check_release_notes.py --git-tag <tag> --component <component>

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 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)
"""
Expand Down Expand Up @@ -95,8 +98,8 @@ def check_release_notes(git_tag: str, component: str, repo_root: Path = Path("."
``path`` is the repo-relative notes path, or a ``<placeholder>`` 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 [("<component>", f"unknown component '{component}'")]
Expand All @@ -105,14 +108,14 @@ def check_release_notes(git_tag: str, component: str, repo_root: Path = Path("."
if version is None:
return [("<tag>", 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():
return [(path, "missing")]
if full.stat().st_size == 0:
if full.stat().st_size == 0 and component != "cuda-pathfinder":
return [(path, "empty")]
return []

Expand All @@ -127,6 +130,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 = [
Expand Down Expand Up @@ -222,8 +232,9 @@ def main(argv: list[str] | None = None) -> int:
)
return 2

if is_post_release(version):
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)
Expand All @@ -240,13 +251,16 @@ def main(argv: list[str] | None = None) -> int:
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)

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)
Expand Down
Loading
Loading