diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97d58d8ae5..da05c605ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,25 @@ 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: Check versioned release notes exist + run: | + python toolshed/check_release_notes.py \ + --git-tag "${{ inputs.git-tag }}" \ + --component "${{ inputs.component }}" + doc: name: Build release docs if: ${{ github.repository_owner == 'nvidia' }} @@ -99,6 +118,7 @@ jobs: pull-requests: write needs: - check-tag + - check-release-notes - determine-run-id secrets: inherit uses: ./.github/workflows/build-docs.yml @@ -114,6 +134,7 @@ jobs: contents: write needs: - check-tag + - check-release-notes - determine-run-id - doc secrets: inherit @@ -128,6 +149,7 @@ jobs: runs-on: ubuntu-latest needs: - check-tag + - check-release-notes - determine-run-id - doc environment: diff --git a/toolshed/check_release_notes.py b/toolshed/check_release_notes.py new file mode 100644 index 0000000000..1b4677d67f --- /dev/null +++ b/toolshed/check_release_notes.py @@ -0,0 +1,101 @@ +# 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 — all release notes present and non-empty (or .post version, skipped) + 1 — one or more release notes missing or empty + 2 — invalid arguments +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys + +COMPONENT_TO_PACKAGES: dict[str, list[str]] = { + "cuda-core": ["cuda_core"], + "cuda-bindings": ["cuda_bindings"], + "cuda-pathfinder": ["cuda_pathfinder"], + "cuda-python": ["cuda_python"], + "all": ["cuda_bindings", "cuda_core", "cuda_pathfinder", "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 all 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 [] + + packages = COMPONENT_TO_PACKAGES.get(component) + if packages is None: + return [("", f"unknown component '{component}'")] + + problems = [] + for pkg in packages: + path = notes_path(pkg, version) + full = os.path.join(repo_root, path) + if not os.path.isfile(full): + problems.append((path, "missing")) + elif os.path.getsize(full) == 0: + problems.append((path, "empty")) + + return problems + + +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_PACKAGES)) + 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/toolshed/tests/test_check_release_notes.py b/toolshed/tests/test_check_release_notes.py new file mode 100644 index 0000000000..b55de91aec --- /dev/null +++ b/toolshed/tests/test_check_release_notes.py @@ -0,0 +1,116 @@ +# 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_component_all(self, tmp_path): + for pkg in ("cuda_bindings", "cuda_core", "cuda_pathfinder", "cuda_python"): + self._make_notes(tmp_path, pkg, "13.1.0") + problems = check_release_notes("v13.1.0", "all", str(tmp_path)) + assert problems == [] + + def test_component_all_partial_missing(self, tmp_path): + self._make_notes(tmp_path, "cuda_bindings", "13.1.0") + self._make_notes(tmp_path, "cuda_core", "13.1.0") + problems = check_release_notes("v13.1.0", "all", str(tmp_path)) + assert len(problems) == 2 + missing_pkgs = {p.split("/")[0] for p, _ in problems} + assert missing_pkgs == {"cuda_pathfinder", "cuda_python"} + + 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