Skip to content

Commit f07282c

Browse files
cpcloudclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 7a9a248 commit f07282c

File tree

3 files changed

+241
-0
lines changed

3 files changed

+241
-0
lines changed

.github/workflows/release.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ jobs:
8989
gh release create "${{ inputs.git-tag }}" --draft --repo "${{ github.repository }}" --title "Release ${{ inputs.git-tag }}" --notes "Release ${{ inputs.git-tag }}"
9090
fi
9191
92+
check-release-notes:
93+
runs-on: ubuntu-latest
94+
steps:
95+
- name: Checkout Source
96+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
97+
98+
- name: Set up Python
99+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
100+
with:
101+
python-version: "3.12"
102+
103+
- name: Check versioned release notes exist
104+
run: |
105+
python toolshed/check_release_notes.py \
106+
--git-tag "${{ inputs.git-tag }}" \
107+
--component "${{ inputs.component }}"
108+
92109
doc:
93110
name: Build release docs
94111
if: ${{ github.repository_owner == 'nvidia' }}
@@ -99,6 +116,7 @@ jobs:
99116
pull-requests: write
100117
needs:
101118
- check-tag
119+
- check-release-notes
102120
- determine-run-id
103121
secrets: inherit
104122
uses: ./.github/workflows/build-docs.yml
@@ -114,6 +132,7 @@ jobs:
114132
contents: write
115133
needs:
116134
- check-tag
135+
- check-release-notes
117136
- determine-run-id
118137
- doc
119138
secrets: inherit
@@ -128,6 +147,7 @@ jobs:
128147
runs-on: ubuntu-latest
129148
needs:
130149
- check-tag
150+
- check-release-notes
131151
- determine-run-id
132152
- doc
133153
environment:

toolshed/check_release_notes.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Check that versioned release-notes files exist before releasing.
5+
6+
Usage:
7+
python check_release_notes.py --git-tag <tag> --component <component>
8+
9+
Exit codes:
10+
0 — all release notes present and non-empty (or .post version, skipped)
11+
1 — one or more release notes missing or empty
12+
2 — invalid arguments
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import os
19+
import re
20+
import sys
21+
22+
COMPONENT_TO_PACKAGES: dict[str, list[str]] = {
23+
"cuda-core": ["cuda_core"],
24+
"cuda-bindings": ["cuda_bindings"],
25+
"cuda-pathfinder": ["cuda_pathfinder"],
26+
"cuda-python": ["cuda_python"],
27+
"all": ["cuda_bindings", "cuda_core", "cuda_pathfinder", "cuda_python"],
28+
}
29+
30+
# Matches tags like "v13.1.0", "cuda-core-v0.7.0", "cuda-pathfinder-v1.5.2"
31+
TAG_RE = re.compile(r"^(?:cuda-\w+-)?v(.+)$")
32+
33+
34+
def parse_version_from_tag(git_tag: str) -> str | None:
35+
"""Extract the bare version string (e.g. '13.1.0') from a git tag."""
36+
m = TAG_RE.match(git_tag)
37+
return m.group(1) if m else None
38+
39+
40+
def is_post_release(version: str) -> bool:
41+
return ".post" in version
42+
43+
44+
def notes_path(package: str, version: str) -> str:
45+
return os.path.join(package, "docs", "source", "release", f"{version}-notes.rst")
46+
47+
48+
def check_release_notes(
49+
git_tag: str, component: str, repo_root: str = "."
50+
) -> list[tuple[str, str]]:
51+
"""Return a list of (path, reason) for missing or empty release notes.
52+
53+
Returns an empty list when all notes are present and non-empty.
54+
"""
55+
version = parse_version_from_tag(git_tag)
56+
if version is None:
57+
return [("<tag>", f"cannot parse version from tag '{git_tag}'")]
58+
59+
if is_post_release(version):
60+
return []
61+
62+
packages = COMPONENT_TO_PACKAGES.get(component)
63+
if packages is None:
64+
return [("<component>", f"unknown component '{component}'")]
65+
66+
problems = []
67+
for pkg in packages:
68+
path = notes_path(pkg, version)
69+
full = os.path.join(repo_root, path)
70+
if not os.path.isfile(full):
71+
problems.append((path, "missing"))
72+
elif os.path.getsize(full) == 0:
73+
problems.append((path, "empty"))
74+
75+
return problems
76+
77+
78+
def main(argv: list[str] | None = None) -> int:
79+
parser = argparse.ArgumentParser(description=__doc__)
80+
parser.add_argument("--git-tag", required=True)
81+
parser.add_argument("--component", required=True, choices=list(COMPONENT_TO_PACKAGES))
82+
parser.add_argument("--repo-root", default=".")
83+
args = parser.parse_args(argv)
84+
85+
version = parse_version_from_tag(args.git_tag)
86+
if version and is_post_release(version):
87+
print(f"Post-release tag ({args.git_tag}), skipping release-notes check.")
88+
return 0
89+
90+
problems = check_release_notes(args.git_tag, args.component, args.repo_root)
91+
if not problems:
92+
print(f"Release notes present for tag {args.git_tag}, component {args.component}.")
93+
return 0
94+
95+
print(f"ERROR: missing or empty release notes for tag {args.git_tag}:")
96+
for path, reason in problems:
97+
print(f" - {path} ({reason})")
98+
print("Add versioned release notes before releasing.")
99+
return 1
100+
101+
102+
if __name__ == "__main__":
103+
sys.exit(main())
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import os
7+
import sys
8+
9+
import pytest
10+
11+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
12+
from check_release_notes import (
13+
check_release_notes,
14+
is_post_release,
15+
main,
16+
parse_version_from_tag,
17+
)
18+
19+
20+
class TestParseVersionFromTag:
21+
def test_plain_tag(self):
22+
assert parse_version_from_tag("v13.1.0") == "13.1.0"
23+
24+
def test_component_prefix_core(self):
25+
assert parse_version_from_tag("cuda-core-v0.7.0") == "0.7.0"
26+
27+
def test_component_prefix_pathfinder(self):
28+
assert parse_version_from_tag("cuda-pathfinder-v1.5.2") == "1.5.2"
29+
30+
def test_post_release(self):
31+
assert parse_version_from_tag("v12.6.2.post1") == "12.6.2.post1"
32+
33+
def test_invalid_tag(self):
34+
assert parse_version_from_tag("not-a-tag") is None
35+
36+
def test_no_v_prefix(self):
37+
assert parse_version_from_tag("13.1.0") is None
38+
39+
40+
class TestIsPostRelease:
41+
def test_normal(self):
42+
assert not is_post_release("13.1.0")
43+
44+
def test_post(self):
45+
assert is_post_release("12.6.2.post1")
46+
47+
def test_post_no_number(self):
48+
assert is_post_release("1.0.0.post")
49+
50+
51+
class TestCheckReleaseNotes:
52+
def _make_notes(self, tmp_path, pkg, version, content="Release notes."):
53+
d = tmp_path / pkg / "docs" / "source" / "release"
54+
d.mkdir(parents=True, exist_ok=True)
55+
f = d / f"{version}-notes.rst"
56+
f.write_text(content)
57+
return f
58+
59+
def test_present_and_nonempty(self, tmp_path):
60+
self._make_notes(tmp_path, "cuda_core", "0.7.0")
61+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path))
62+
assert problems == []
63+
64+
def test_missing(self, tmp_path):
65+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path))
66+
assert len(problems) == 1
67+
assert problems[0][1] == "missing"
68+
69+
def test_empty(self, tmp_path):
70+
self._make_notes(tmp_path, "cuda_core", "0.7.0", content="")
71+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path))
72+
assert len(problems) == 1
73+
assert problems[0][1] == "empty"
74+
75+
def test_post_release_skipped(self, tmp_path):
76+
problems = check_release_notes("v12.6.2.post1", "cuda-bindings", str(tmp_path))
77+
assert problems == []
78+
79+
def test_component_all(self, tmp_path):
80+
for pkg in ("cuda_bindings", "cuda_core", "cuda_pathfinder", "cuda_python"):
81+
self._make_notes(tmp_path, pkg, "13.1.0")
82+
problems = check_release_notes("v13.1.0", "all", str(tmp_path))
83+
assert problems == []
84+
85+
def test_component_all_partial_missing(self, tmp_path):
86+
self._make_notes(tmp_path, "cuda_bindings", "13.1.0")
87+
self._make_notes(tmp_path, "cuda_core", "13.1.0")
88+
problems = check_release_notes("v13.1.0", "all", str(tmp_path))
89+
assert len(problems) == 2
90+
missing_pkgs = {p.split("/")[0] for p, _ in problems}
91+
assert missing_pkgs == {"cuda_pathfinder", "cuda_python"}
92+
93+
def test_invalid_tag(self, tmp_path):
94+
problems = check_release_notes("not-a-tag", "cuda-core", str(tmp_path))
95+
assert len(problems) == 1
96+
assert "cannot parse" in problems[0][1]
97+
98+
def test_plain_v_tag(self, tmp_path):
99+
self._make_notes(tmp_path, "cuda_python", "13.1.0")
100+
problems = check_release_notes("v13.1.0", "cuda-python", str(tmp_path))
101+
assert problems == []
102+
103+
104+
class TestMain:
105+
def test_success(self, tmp_path):
106+
d = tmp_path / "cuda_core" / "docs" / "source" / "release"
107+
d.mkdir(parents=True)
108+
(d / "0.7.0-notes.rst").write_text("Notes here.")
109+
rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)])
110+
assert rc == 0
111+
112+
def test_failure(self, tmp_path):
113+
rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)])
114+
assert rc == 1
115+
116+
def test_post_skip(self, tmp_path):
117+
rc = main(["--git-tag", "v12.6.2.post1", "--component", "cuda-bindings", "--repo-root", str(tmp_path)])
118+
assert rc == 0

0 commit comments

Comments
 (0)