diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97d58d8ae5..2fce0fc36e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,30 @@ jobs: gh release create "${{ inputs.git-tag }}" --draft --repo "${{ github.repository }}" --title "Release ${{ inputs.git-tag }}" --notes "Release ${{ inputs.git-tag }}" fi + check-release-notes: + runs-on: ubuntu-latest + steps: + - name: Checkout Source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.git-tag }} + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Self-test release-notes checker + run: | + pip install pytest + pytest ci/tools/tests + + - name: Check versioned release notes exist + run: | + python ci/tools/check_release_notes.py \ + --git-tag "${{ inputs.git-tag }}" \ + --component "${{ inputs.component }}" + doc: name: Build release docs if: ${{ github.repository_owner == 'nvidia' }} @@ -99,6 +123,7 @@ jobs: pull-requests: write needs: - check-tag + - check-release-notes - determine-run-id secrets: inherit uses: ./.github/workflows/build-docs.yml @@ -114,6 +139,7 @@ jobs: contents: write needs: - check-tag + - check-release-notes - determine-run-id - doc secrets: inherit @@ -128,6 +154,7 @@ jobs: runs-on: ubuntu-latest needs: - check-tag + - check-release-notes - determine-run-id - doc environment: diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py new file mode 100644 index 0000000000..7624f93dc8 --- /dev/null +++ b/ci/tools/check_release_notes.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check that versioned release-notes files exist before releasing. + +Usage: + python check_release_notes.py --git-tag --component + +Exit codes: + 0 — release notes present and non-empty (or .post version, skipped) + 1 — release notes missing or empty + 2 — invalid arguments +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys + +COMPONENT_TO_PACKAGE: dict[str, str] = { + "cuda-core": "cuda_core", + "cuda-bindings": "cuda_bindings", + "cuda-pathfinder": "cuda_pathfinder", + "cuda-python": "cuda_python", +} + +# Matches tags like "v13.1.0", "cuda-core-v0.7.0", "cuda-pathfinder-v1.5.2" +TAG_RE = re.compile(r"^(?:cuda-\w+-)?v(.+)$") + + +def parse_version_from_tag(git_tag: str) -> str | None: + """Extract the bare version string (e.g. '13.1.0') from a git tag.""" + m = TAG_RE.match(git_tag) + return m.group(1) if m else None + + +def is_post_release(version: str) -> bool: + return ".post" in version + + +def notes_path(package: str, version: str) -> str: + return os.path.join(package, "docs", "source", "release", f"{version}-notes.rst") + + +def check_release_notes(git_tag: str, component: str, repo_root: str = ".") -> list[tuple[str, str]]: + """Return a list of (path, reason) for missing or empty release notes. + + Returns an empty list when notes are present and non-empty. + """ + version = parse_version_from_tag(git_tag) + if version is None: + return [("", f"cannot parse version from tag '{git_tag}'")] + + if is_post_release(version): + return [] + + package = COMPONENT_TO_PACKAGE.get(component) + if package is None: + return [("", f"unknown component '{component}'")] + + path = notes_path(package, version) + full = os.path.join(repo_root, path) + if not os.path.isfile(full): + return [(path, "missing")] + if os.path.getsize(full) == 0: + return [(path, "empty")] + return [] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--git-tag", required=True) + parser.add_argument("--component", required=True, choices=list(COMPONENT_TO_PACKAGE)) + parser.add_argument("--repo-root", default=".") + args = parser.parse_args(argv) + + version = parse_version_from_tag(args.git_tag) + if version and is_post_release(version): + print(f"Post-release tag ({args.git_tag}), skipping release-notes check.") + return 0 + + 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}.") + return 0 + + print(f"ERROR: missing or empty release notes for tag {args.git_tag}:") + for path, reason in problems: + print(f" - {path} ({reason})") + print("Add versioned release notes before releasing.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py new file mode 100644 index 0000000000..4e6e203cb7 --- /dev/null +++ b/ci/tools/tests/test_check_release_notes.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from check_release_notes import ( + check_release_notes, + is_post_release, + main, + parse_version_from_tag, +) + + +class TestParseVersionFromTag: + def test_plain_tag(self): + assert parse_version_from_tag("v13.1.0") == "13.1.0" + + def test_component_prefix_core(self): + assert parse_version_from_tag("cuda-core-v0.7.0") == "0.7.0" + + def test_component_prefix_pathfinder(self): + assert parse_version_from_tag("cuda-pathfinder-v1.5.2") == "1.5.2" + + def test_post_release(self): + assert parse_version_from_tag("v12.6.2.post1") == "12.6.2.post1" + + def test_invalid_tag(self): + assert parse_version_from_tag("not-a-tag") is None + + def test_no_v_prefix(self): + assert parse_version_from_tag("13.1.0") is None + + +class TestIsPostRelease: + def test_normal(self): + assert not is_post_release("13.1.0") + + def test_post(self): + assert is_post_release("12.6.2.post1") + + def test_post_no_number(self): + assert is_post_release("1.0.0.post") + + +class TestCheckReleaseNotes: + def _make_notes(self, tmp_path, pkg, version, content="Release notes."): + d = tmp_path / pkg / "docs" / "source" / "release" + d.mkdir(parents=True, exist_ok=True) + f = d / f"{version}-notes.rst" + f.write_text(content) + return f + + def test_present_and_nonempty(self, tmp_path): + self._make_notes(tmp_path, "cuda_core", "0.7.0") + problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path)) + assert problems == [] + + def test_missing(self, tmp_path): + problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path)) + assert len(problems) == 1 + assert problems[0][1] == "missing" + + def test_empty(self, tmp_path): + self._make_notes(tmp_path, "cuda_core", "0.7.0", content="") + problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path)) + assert len(problems) == 1 + assert problems[0][1] == "empty" + + def test_post_release_skipped(self, tmp_path): + problems = check_release_notes("v12.6.2.post1", "cuda-bindings", str(tmp_path)) + assert problems == [] + + def test_invalid_tag(self, tmp_path): + problems = check_release_notes("not-a-tag", "cuda-core", str(tmp_path)) + assert len(problems) == 1 + assert "cannot parse" in problems[0][1] + + def test_plain_v_tag(self, tmp_path): + self._make_notes(tmp_path, "cuda_python", "13.1.0") + problems = check_release_notes("v13.1.0", "cuda-python", str(tmp_path)) + assert problems == [] + + +class TestMain: + def test_success(self, tmp_path): + d = tmp_path / "cuda_core" / "docs" / "source" / "release" + d.mkdir(parents=True) + (d / "0.7.0-notes.rst").write_text("Notes here.") + rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)]) + assert rc == 0 + + def test_failure(self, tmp_path): + rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)]) + assert rc == 1 + + def test_post_skip(self, tmp_path): + rc = main(["--git-tag", "v12.6.2.post1", "--component", "cuda-bindings", "--repo-root", str(tmp_path)]) + assert rc == 0 diff --git a/pytest.ini b/pytest.ini index 978e659bf0..d1a82feb74 100644 --- a/pytest.ini +++ b/pytest.ini @@ -12,6 +12,7 @@ testpaths = cuda_bindings/tests cuda_core/tests tests/integration + ci/tools/tests markers = pathfinder: tests for cuda_pathfinder