From 0c97f40e85cdc247bbd7cb524c60335f1e3e0c0b 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/5] 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 97d58d8ae59..360554de4fb 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 00000000000..a3d8ffb8aa6 --- /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 00000000000..b3c77d939be --- /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 8b9c9115c518204b8b41d1f43079fe18de9d14ca 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/5] 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 360554de4fb..da05c605ce5 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 90fcc084d31bbfee07265bcf4172ef405610a4e5 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/5] 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 a3d8ffb8aa6..1b4677d67f1 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 b3c77d939be..b55de91aece 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, From b5cb9b238b539d0749c9d8f1d0016c102ed655a0 Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:55:46 -0400 Subject: [PATCH 4/5] ci: move release-notes checker to ci/tools and drop component=all Addresses review feedback on #1907: - Relocate check_release_notes.py (and its tests) from toolshed/ to ci/tools/, matching validate-release-wheels which is the closest conceptual neighbor. toolshed/ is for scripts we rarely re-run; this one is invoked from release.yml on every release. - Drop the `all` component. The shared-version assumption it encoded no longer matches the repo's independent tag families (v*, cuda-core-v*, cuda-pathfinder-v*). The broader release-workflow cleanup for `all` will happen in a separate pass. - Register ci/tools/tests under pytest testpaths, and run the unit tests in the check-release-notes job before invoking the script. --- .github/workflows/release.yml | 7 +++- {toolshed => ci/tools}/check_release_notes.py | 40 +++++++++---------- .../tools}/tests/test_check_release_notes.py | 14 ------- pytest.ini | 1 + 4 files changed, 25 insertions(+), 37 deletions(-) rename {toolshed => ci/tools}/check_release_notes.py (72%) rename {toolshed => ci/tools}/tests/test_check_release_notes.py (83%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da05c605ce5..2fce0fc36e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,9 +102,14 @@ jobs: 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 toolshed/check_release_notes.py \ + python ci/tools/check_release_notes.py \ --git-tag "${{ inputs.git-tag }}" \ --component "${{ inputs.component }}" diff --git a/toolshed/check_release_notes.py b/ci/tools/check_release_notes.py similarity index 72% rename from toolshed/check_release_notes.py rename to ci/tools/check_release_notes.py index 1b4677d67f1..7624f93dc8d 100644 --- a/toolshed/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -7,8 +7,8 @@ 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 + 0 — release notes present and non-empty (or .post version, skipped) + 1 — release notes missing or empty 2 — invalid arguments """ @@ -19,12 +19,11 @@ 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"], +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" @@ -48,7 +47,7 @@ def notes_path(package: str, version: 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. + Returns an empty list when notes are present and non-empty. """ version = parse_version_from_tag(git_tag) if version is None: @@ -57,26 +56,23 @@ def check_release_notes(git_tag: str, component: str, repo_root: str = ".") -> l if is_post_release(version): return [] - packages = COMPONENT_TO_PACKAGES.get(component) - if packages is None: + package = COMPONENT_TO_PACKAGE.get(component) + if package 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 + 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_PACKAGES)) + parser.add_argument("--component", required=True, choices=list(COMPONENT_TO_PACKAGE)) parser.add_argument("--repo-root", default=".") args = parser.parse_args(argv) diff --git a/toolshed/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py similarity index 83% rename from toolshed/tests/test_check_release_notes.py rename to ci/tools/tests/test_check_release_notes.py index b55de91aece..4e6e203cb79 100644 --- a/toolshed/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -74,20 +74,6 @@ 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 diff --git a/pytest.ini b/pytest.ini index 978e659bf07..d1a82feb749 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 From b9696c88c215a12f89adf19f5f8d7e2354f43cda Mon Sep 17 00:00:00 2001 From: Phillip Cloud <417981+cpcloud@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:16:56 -0400 Subject: [PATCH 5/5] ci: tighten release-notes tag parser Addresses defensive issues flagged on earlier revisions of this branch: - Replace the single permissive TAG_RE with a per-component pattern map. Each component now only matches its own tag-prefix family (cuda-core-v*, cuda-pathfinder-v*, bare v* for cuda-bindings and cuda-python), so a cuda-core tag paired with --component cuda-pathfinder is rejected rather than silently quarried for the wrong notes file. - Restrict the captured version to digit-prefixed word chars and dots so malformed inputs like "v../evil" or "v1/2/3" cannot flow into the joined notes path. - Return exit code 2 on unparsable tags and component/prefix mismatches, matching the documented CLI contract. Only genuine missing/empty notes return 1. - Route error output to stderr so stdout stays clean when the check is used as a CI gate. - Add tests for the new rejection cases. --- ci/tools/check_release_notes.py | 64 ++++++++++++------ ci/tools/tests/test_check_release_notes.py | 76 ++++++++++++++++++++-- 2 files changed, 113 insertions(+), 27 deletions(-) diff --git a/ci/tools/check_release_notes.py b/ci/tools/check_release_notes.py index 7624f93dc8d..ad2a640a865 100644 --- a/ci/tools/check_release_notes.py +++ b/ci/tools/check_release_notes.py @@ -9,7 +9,7 @@ Exit codes: 0 — release notes present and non-empty (or .post version, skipped) 1 — release notes missing or empty - 2 — invalid arguments + 2 — invalid arguments (including unparsable tag, or component/tag-prefix mismatch) """ from __future__ import annotations @@ -26,14 +26,31 @@ "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(.+)$") +# 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. -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 + 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: @@ -47,20 +64,20 @@ def notes_path(package: str, version: 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 notes are present and non-empty. + Returns an empty list when notes are present and non-empty, or when the + tag is a .post release (no new notes required). """ - version = parse_version_from_tag(git_tag) + 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}'")] + return [("", f"cannot parse version from tag '{git_tag}' for component '{component}'")] 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) + path = notes_path(COMPONENT_TO_PACKAGE[component], version) full = os.path.join(repo_root, path) if not os.path.isfile(full): return [(path, "missing")] @@ -76,8 +93,15 @@ def main(argv: list[str] | None = None) -> int: 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): + 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 @@ -86,10 +110,10 @@ def main(argv: list[str] | None = None) -> int: 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}:") + 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})") - print("Add versioned release notes before releasing.") + print(f" - {path} ({reason})", file=sys.stderr) + print("Add versioned release notes before releasing.", file=sys.stderr) return 1 diff --git a/ci/tools/tests/test_check_release_notes.py b/ci/tools/tests/test_check_release_notes.py index 4e6e203cb79..8033eca620c 100644 --- a/ci/tools/tests/test_check_release_notes.py +++ b/ci/tools/tests/test_check_release_notes.py @@ -16,23 +16,53 @@ class TestParseVersionFromTag: - def test_plain_tag(self): - assert parse_version_from_tag("v13.1.0") == "13.1.0" + 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") == "0.7.0" + 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") == "1.5.2" + 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") == "12.6.2.post1" + 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") is None + 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") is None + 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: @@ -79,6 +109,17 @@ def test_invalid_tag(self, 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)) @@ -100,3 +141,24 @@ def test_failure(self, tmp_path): 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