Skip to content

Commit b9696c8

Browse files
committed
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.
1 parent b5cb9b2 commit b9696c8

2 files changed

Lines changed: 113 additions & 27 deletions

File tree

ci/tools/check_release_notes.py

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
Exit codes:
1010
0 — release notes present and non-empty (or .post version, skipped)
1111
1 — release notes missing or empty
12-
2 — invalid arguments
12+
2 — invalid arguments (including unparsable tag, or component/tag-prefix mismatch)
1313
"""
1414

1515
from __future__ import annotations
@@ -26,14 +26,31 @@
2626
"cuda-python": "cuda_python",
2727
}
2828

29-
# Matches tags like "v13.1.0", "cuda-core-v0.7.0", "cuda-pathfinder-v1.5.2"
30-
TAG_RE = re.compile(r"^(?:cuda-\w+-)?v(.+)$")
29+
# Version characters are restricted to digit-prefixed word chars and dots, so
30+
# malformed inputs like "v../evil" or "v1/2/3" cannot flow into the notes path.
31+
_VERSION_PATTERN = r"\d[\w.]*"
32+
33+
# Each component has exactly one valid tag-prefix form. cuda-bindings and
34+
# cuda-python share the bare "v<version>" namespace (setuptools-scm lookup).
35+
COMPONENT_TO_TAG_RE: dict[str, re.Pattern[str]] = {
36+
"cuda-bindings": re.compile(rf"^v(?P<version>{_VERSION_PATTERN})$"),
37+
"cuda-python": re.compile(rf"^v(?P<version>{_VERSION_PATTERN})$"),
38+
"cuda-core": re.compile(rf"^cuda-core-v(?P<version>{_VERSION_PATTERN})$"),
39+
"cuda-pathfinder": re.compile(rf"^cuda-pathfinder-v(?P<version>{_VERSION_PATTERN})$"),
40+
}
41+
3142

43+
def parse_version_from_tag(git_tag: str, component: str) -> str | None:
44+
"""Extract the version string from a tag, given the target component.
3245
33-
def parse_version_from_tag(git_tag: str) -> str | None:
34-
"""Extract the bare version string (e.g. '13.1.0') from a git tag."""
35-
m = TAG_RE.match(git_tag)
36-
return m.group(1) if m else None
46+
Returns None if the tag does not match the component's expected prefix
47+
or contains characters outside the allowed version set.
48+
"""
49+
pattern = COMPONENT_TO_TAG_RE.get(component)
50+
if pattern is None:
51+
return None
52+
m = pattern.match(git_tag)
53+
return m.group("version") if m else None
3754

3855

3956
def is_post_release(version: str) -> bool:
@@ -47,20 +64,20 @@ def notes_path(package: str, version: str) -> str:
4764
def check_release_notes(git_tag: str, component: str, repo_root: str = ".") -> list[tuple[str, str]]:
4865
"""Return a list of (path, reason) for missing or empty release notes.
4966
50-
Returns an empty list when notes are present and non-empty.
67+
Returns an empty list when notes are present and non-empty, or when the
68+
tag is a .post release (no new notes required).
5169
"""
52-
version = parse_version_from_tag(git_tag)
70+
if component not in COMPONENT_TO_PACKAGE:
71+
return [("<component>", f"unknown component '{component}'")]
72+
73+
version = parse_version_from_tag(git_tag, component)
5374
if version is None:
54-
return [("<tag>", f"cannot parse version from tag '{git_tag}'")]
75+
return [("<tag>", f"cannot parse version from tag '{git_tag}' for component '{component}'")]
5576

5677
if is_post_release(version):
5778
return []
5879

59-
package = COMPONENT_TO_PACKAGE.get(component)
60-
if package is None:
61-
return [("<component>", f"unknown component '{component}'")]
62-
63-
path = notes_path(package, version)
80+
path = notes_path(COMPONENT_TO_PACKAGE[component], version)
6481
full = os.path.join(repo_root, path)
6582
if not os.path.isfile(full):
6683
return [(path, "missing")]
@@ -76,8 +93,15 @@ def main(argv: list[str] | None = None) -> int:
7693
parser.add_argument("--repo-root", default=".")
7794
args = parser.parse_args(argv)
7895

79-
version = parse_version_from_tag(args.git_tag)
80-
if version and is_post_release(version):
96+
version = parse_version_from_tag(args.git_tag, args.component)
97+
if version is None:
98+
print(
99+
f"ERROR: tag {args.git_tag!r} does not match the expected format for component {args.component!r}.",
100+
file=sys.stderr,
101+
)
102+
return 2
103+
104+
if is_post_release(version):
81105
print(f"Post-release tag ({args.git_tag}), skipping release-notes check.")
82106
return 0
83107

@@ -86,10 +110,10 @@ def main(argv: list[str] | None = None) -> int:
86110
print(f"Release notes present for tag {args.git_tag}, component {args.component}.")
87111
return 0
88112

89-
print(f"ERROR: missing or empty release notes for tag {args.git_tag}:")
113+
print(f"ERROR: missing or empty release notes for tag {args.git_tag}:", file=sys.stderr)
90114
for path, reason in problems:
91-
print(f" - {path} ({reason})")
92-
print("Add versioned release notes before releasing.")
115+
print(f" - {path} ({reason})", file=sys.stderr)
116+
print("Add versioned release notes before releasing.", file=sys.stderr)
93117
return 1
94118

95119

ci/tools/tests/test_check_release_notes.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,53 @@
1616

1717

1818
class TestParseVersionFromTag:
19-
def test_plain_tag(self):
20-
assert parse_version_from_tag("v13.1.0") == "13.1.0"
19+
def test_plain_tag_bindings(self):
20+
assert parse_version_from_tag("v13.1.0", "cuda-bindings") == "13.1.0"
21+
22+
def test_plain_tag_python(self):
23+
assert parse_version_from_tag("v13.1.0", "cuda-python") == "13.1.0"
2124

2225
def test_component_prefix_core(self):
23-
assert parse_version_from_tag("cuda-core-v0.7.0") == "0.7.0"
26+
assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-core") == "0.7.0"
2427

2528
def test_component_prefix_pathfinder(self):
26-
assert parse_version_from_tag("cuda-pathfinder-v1.5.2") == "1.5.2"
29+
assert parse_version_from_tag("cuda-pathfinder-v1.5.2", "cuda-pathfinder") == "1.5.2"
2730

2831
def test_post_release(self):
29-
assert parse_version_from_tag("v12.6.2.post1") == "12.6.2.post1"
32+
assert parse_version_from_tag("v12.6.2.post1", "cuda-bindings") == "12.6.2.post1"
3033

3134
def test_invalid_tag(self):
32-
assert parse_version_from_tag("not-a-tag") is None
35+
assert parse_version_from_tag("not-a-tag", "cuda-core") is None
3336

3437
def test_no_v_prefix(self):
35-
assert parse_version_from_tag("13.1.0") is None
38+
assert parse_version_from_tag("13.1.0", "cuda-bindings") is None
39+
40+
def test_component_prefix_mismatch(self):
41+
# cuda-core-v* must not be accepted for component=cuda-pathfinder
42+
assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-pathfinder") is None
43+
44+
def test_bare_v_rejected_for_core(self):
45+
# bare v* belongs to cuda-bindings/cuda-python, not cuda-core
46+
assert parse_version_from_tag("v0.7.0", "cuda-core") is None
47+
48+
def test_unknown_component(self):
49+
assert parse_version_from_tag("v13.1.0", "bogus") is None
50+
51+
def test_path_traversal_rejected(self):
52+
assert parse_version_from_tag("v1.0.0/../evil", "cuda-bindings") is None
53+
54+
def test_path_separator_rejected(self):
55+
assert parse_version_from_tag("v1/2/3", "cuda-bindings") is None
56+
57+
def test_leading_dot_rejected(self):
58+
assert parse_version_from_tag("v.1.0", "cuda-bindings") is None
59+
60+
def test_whitespace_rejected(self):
61+
assert parse_version_from_tag("v1.0.0 ", "cuda-bindings") is None
62+
63+
def test_trailing_suffix_rejected(self):
64+
# \w permits alphanumerics + underscore only; hyphens and shell meta-chars are out
65+
assert parse_version_from_tag("v1.0.0-extra", "cuda-bindings") is None
3666

3767

3868
class TestIsPostRelease:
@@ -79,6 +109,17 @@ def test_invalid_tag(self, tmp_path):
79109
assert len(problems) == 1
80110
assert "cannot parse" in problems[0][1]
81111

112+
def test_component_prefix_mismatch(self, tmp_path):
113+
# Pass a cuda-core tag with component=cuda-pathfinder; must be rejected.
114+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-pathfinder", str(tmp_path))
115+
assert len(problems) == 1
116+
assert "cannot parse" in problems[0][1]
117+
118+
def test_unknown_component(self, tmp_path):
119+
problems = check_release_notes("v13.1.0", "bogus", str(tmp_path))
120+
assert len(problems) == 1
121+
assert "unknown component" in problems[0][1]
122+
82123
def test_plain_v_tag(self, tmp_path):
83124
self._make_notes(tmp_path, "cuda_python", "13.1.0")
84125
problems = check_release_notes("v13.1.0", "cuda-python", str(tmp_path))
@@ -100,3 +141,24 @@ def test_failure(self, tmp_path):
100141
def test_post_skip(self, tmp_path):
101142
rc = main(["--git-tag", "v12.6.2.post1", "--component", "cuda-bindings", "--repo-root", str(tmp_path)])
102143
assert rc == 0
144+
145+
def test_unparsable_tag_returns_2(self, tmp_path):
146+
rc = main(["--git-tag", "not-a-tag", "--component", "cuda-core", "--repo-root", str(tmp_path)])
147+
assert rc == 2
148+
149+
def test_path_traversal_returns_2(self, tmp_path):
150+
rc = main(["--git-tag", "v1.0.0/../evil", "--component", "cuda-bindings", "--repo-root", str(tmp_path)])
151+
assert rc == 2
152+
153+
def test_component_prefix_mismatch_returns_2(self, tmp_path):
154+
rc = main(
155+
[
156+
"--git-tag",
157+
"cuda-core-v0.7.0",
158+
"--component",
159+
"cuda-pathfinder",
160+
"--repo-root",
161+
str(tmp_path),
162+
]
163+
)
164+
assert rc == 2

0 commit comments

Comments
 (0)