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..ad2a640a86 --- /dev/null +++ b/ci/tools/check_release_notes.py @@ -0,0 +1,121 @@ +# 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 (including unparsable tag, or component/tag-prefix mismatch) +""" + +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", +} + +# Version characters are restricted to digit-prefixed word chars and dots, so +# malformed inputs like "v../evil" or "v1/2/3" cannot flow into the notes path. +_VERSION_PATTERN = r"\d[\w.]*" + +# Each component has exactly one valid tag-prefix form. cuda-bindings and +# cuda-python share the bare "v" namespace (setuptools-scm lookup). +COMPONENT_TO_TAG_RE: dict[str, re.Pattern[str]] = { + "cuda-bindings": re.compile(rf"^v(?P{_VERSION_PATTERN})$"), + "cuda-python": re.compile(rf"^v(?P{_VERSION_PATTERN})$"), + "cuda-core": re.compile(rf"^cuda-core-v(?P{_VERSION_PATTERN})$"), + "cuda-pathfinder": re.compile(rf"^cuda-pathfinder-v(?P{_VERSION_PATTERN})$"), +} + + +def parse_version_from_tag(git_tag: str, component: str) -> str | None: + """Extract the version string from a tag, given the target component. + + Returns None if the tag does not match the component's expected prefix + or contains characters outside the allowed version set. + """ + pattern = COMPONENT_TO_TAG_RE.get(component) + if pattern is None: + return None + m = pattern.match(git_tag) + return m.group("version") 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, or when the + tag is a .post release (no new notes required). + """ + if component not in COMPONENT_TO_PACKAGE: + return [("", f"unknown component '{component}'")] + + version = parse_version_from_tag(git_tag, component) + if version is None: + return [("", f"cannot parse version from tag '{git_tag}' for component '{component}'")] + + if is_post_release(version): + return [] + + path = notes_path(COMPONENT_TO_PACKAGE[component], 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, args.component) + if version is None: + print( + f"ERROR: tag {args.git_tag!r} does not match the expected format for component {args.component!r}.", + file=sys.stderr, + ) + return 2 + + if 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}:", file=sys.stderr) + for path, reason in problems: + print(f" - {path} ({reason})", file=sys.stderr) + print("Add versioned release notes before releasing.", file=sys.stderr) + 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..8033eca620 --- /dev/null +++ b/ci/tools/tests/test_check_release_notes.py @@ -0,0 +1,164 @@ +# 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_bindings(self): + assert parse_version_from_tag("v13.1.0", "cuda-bindings") == "13.1.0" + + def test_plain_tag_python(self): + assert parse_version_from_tag("v13.1.0", "cuda-python") == "13.1.0" + + def test_component_prefix_core(self): + assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-core") == "0.7.0" + + def test_component_prefix_pathfinder(self): + assert parse_version_from_tag("cuda-pathfinder-v1.5.2", "cuda-pathfinder") == "1.5.2" + + def test_post_release(self): + assert parse_version_from_tag("v12.6.2.post1", "cuda-bindings") == "12.6.2.post1" + + def test_invalid_tag(self): + assert parse_version_from_tag("not-a-tag", "cuda-core") is None + + def test_no_v_prefix(self): + assert parse_version_from_tag("13.1.0", "cuda-bindings") is None + + def test_component_prefix_mismatch(self): + # cuda-core-v* must not be accepted for component=cuda-pathfinder + assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-pathfinder") is None + + def test_bare_v_rejected_for_core(self): + # bare v* belongs to cuda-bindings/cuda-python, not cuda-core + assert parse_version_from_tag("v0.7.0", "cuda-core") is None + + def test_unknown_component(self): + assert parse_version_from_tag("v13.1.0", "bogus") is None + + def test_path_traversal_rejected(self): + assert parse_version_from_tag("v1.0.0/../evil", "cuda-bindings") is None + + def test_path_separator_rejected(self): + assert parse_version_from_tag("v1/2/3", "cuda-bindings") is None + + def test_leading_dot_rejected(self): + assert parse_version_from_tag("v.1.0", "cuda-bindings") is None + + def test_whitespace_rejected(self): + assert parse_version_from_tag("v1.0.0 ", "cuda-bindings") is None + + def test_trailing_suffix_rejected(self): + # \w permits alphanumerics + underscore only; hyphens and shell meta-chars are out + assert parse_version_from_tag("v1.0.0-extra", "cuda-bindings") 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_component_prefix_mismatch(self, tmp_path): + # Pass a cuda-core tag with component=cuda-pathfinder; must be rejected. + problems = check_release_notes("cuda-core-v0.7.0", "cuda-pathfinder", str(tmp_path)) + assert len(problems) == 1 + assert "cannot parse" in problems[0][1] + + def test_unknown_component(self, tmp_path): + problems = check_release_notes("v13.1.0", "bogus", str(tmp_path)) + assert len(problems) == 1 + assert "unknown component" 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 + + def test_unparsable_tag_returns_2(self, tmp_path): + rc = main(["--git-tag", "not-a-tag", "--component", "cuda-core", "--repo-root", str(tmp_path)]) + assert rc == 2 + + def test_path_traversal_returns_2(self, tmp_path): + rc = main(["--git-tag", "v1.0.0/../evil", "--component", "cuda-bindings", "--repo-root", str(tmp_path)]) + assert rc == 2 + + def test_component_prefix_mismatch_returns_2(self, tmp_path): + rc = main( + [ + "--git-tag", + "cuda-core-v0.7.0", + "--component", + "cuda-pathfinder", + "--repo-root", + str(tmp_path), + ] + ) + assert rc == 2 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