diff --git a/.github/RELEASE-core.md b/.github/RELEASE-core.md index 0f33dfe44f3..b1e5e9f1f42 100644 --- a/.github/RELEASE-core.md +++ b/.github/RELEASE-core.md @@ -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` 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=` 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e915e02093..f55ca1efbbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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: @@ -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" @@ -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 @@ -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] + 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" <- + ${{ !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 }}/ @@ -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 @@ -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" diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index 1c99ddb019a..44e197db3fd 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -6,8 +6,11 @@ Usage: python check_release_notes.py --git-tag --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) """ @@ -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 ```` 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 +108,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(): return [(path, "missing")] - if full.stat().st_size == 0: + if full.stat().st_size == 0 and component != "cuda-pathfinder": return [(path, "empty")] return [] @@ -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 = [ @@ -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) @@ -240,6 +251,8 @@ 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: @@ -247,6 +260,7 @@ def main(argv: list[str] | None = None) -> int: 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..805ec7bce7a 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -6,6 +6,8 @@ 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, @@ -331,3 +333,70 @@ 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()