From f07282ccda954059de34fdba6b4ea461bfdd5c59 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:17:38 -0400 Subject: [PATCH 1/3] ci: check versioned release notes exist before releasing Add a check-release-notes job to the release workflow that verifies the versioned release-notes file (e.g. 13.1.0-notes.rst) exists and is non-empty for each package being released. The job blocks doc, upload-archive, and publish-testpypi via needs: gates. Helper script at toolshed/check_release_notes.py parses the git tag, maps component to package directories, and checks file presence. Post-release tags (.postN) are silently skipped. Tests cover tag parsing, component mapping, missing/empty detection, and the CLI. Refs #1326 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 20 ++++ toolshed/check_release_notes.py | 103 ++++++++++++++++++ toolshed/tests/test_check_release_notes.py | 118 +++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 toolshed/check_release_notes.py create mode 100644 toolshed/tests/test_check_release_notes.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97d58d8ae5..360554de4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,23 @@ 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 + + - 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 +116,7 @@ jobs: pull-requests: write needs: - check-tag + - check-release-notes - determine-run-id secrets: inherit uses: ./.github/workflows/build-docs.yml @@ -114,6 +132,7 @@ jobs: contents: write needs: - check-tag + - check-release-notes - determine-run-id - doc secrets: inherit @@ -128,6 +147,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..a3d8ffb8aa --- /dev/null +++ b/toolshed/check_release_notes.py @@ -0,0 +1,103 @@ +# 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..b3c77d939b --- /dev/null +++ b/toolshed/tests/test_check_release_notes.py @@ -0,0 +1,118 @@ +# 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 + +import pytest + +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 From ce50b2cfdacd74581b557c962af28f61a26856d9 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:11:14 -0400 Subject: [PATCH 2/3] fix: checkout release tag ref in check-release-notes job Ensures the release-notes check validates the tagged tree, not the default branch HEAD. Without this, manually triggered runs could validate the wrong commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 360554de4f..da05c605ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,8 @@ jobs: 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 From 0e74555bd16a8aa723071d125d42ac2a7ff4dbd8 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:08:29 -0400 Subject: [PATCH 3/3] style: fix ruff lint and format issues Co-Authored-By: Claude Opus 4.6 (1M context) --- toolshed/check_release_notes.py | 4 +--- toolshed/tests/test_check_release_notes.py | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/toolshed/check_release_notes.py b/toolshed/check_release_notes.py index a3d8ffb8aa..1b4677d67f 100644 --- a/toolshed/check_release_notes.py +++ b/toolshed/check_release_notes.py @@ -45,9 +45,7 @@ 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]]: +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. diff --git a/toolshed/tests/test_check_release_notes.py b/toolshed/tests/test_check_release_notes.py index b3c77d939b..b55de91aec 100644 --- a/toolshed/tests/test_check_release_notes.py +++ b/toolshed/tests/test_check_release_notes.py @@ -6,8 +6,6 @@ import os import sys -import pytest - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from check_release_notes import ( check_release_notes,