Skip to content

Commit 813d3d9

Browse files
committed
Merge remote-tracking branch 'origin/main' into naming-audit-1945
2 parents f2798cf + 97c5b2a commit 813d3d9

14 files changed

Lines changed: 401 additions & 46 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ jobs:
343343
build-type: pull-request
344344
host-platform: ${{ matrix.host-platform }}
345345
build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
346-
nruns: ${{ (github.event_name == 'schedule' && 100) || 1}}
346+
nruns: ${{ (github.event_name == 'schedule' && 5) || 1}}
347347
skip-bindings-test: ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
348348

349349
# See test-linux-64 for why test jobs are split by platform.
@@ -368,7 +368,7 @@ jobs:
368368
build-type: pull-request
369369
host-platform: ${{ matrix.host-platform }}
370370
build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
371-
nruns: ${{ (github.event_name == 'schedule' && 100) || 1}}
371+
nruns: ${{ (github.event_name == 'schedule' && 5) || 1}}
372372
skip-bindings-test: ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
373373

374374
# See test-linux-64 for why test jobs are split by platform.
@@ -393,7 +393,7 @@ jobs:
393393
build-type: pull-request
394394
host-platform: ${{ matrix.host-platform }}
395395
build-ctk-ver: ${{ needs.ci-vars.outputs.CUDA_BUILD_VER }}
396-
nruns: ${{ (github.event_name == 'schedule' && 100) || 1}}
396+
nruns: ${{ (github.event_name == 'schedule' && 5) || 1}}
397397
skip-bindings-test: ${{ !fromJSON(needs.detect-changes.outputs.test_bindings) }}
398398

399399
doc:

.github/workflows/release.yml

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ on:
2020
- cuda-bindings
2121
- cuda-pathfinder
2222
- cuda-python
23-
- all
2423
git-tag:
2524
description: "The release git tag"
2625
required: true
@@ -89,6 +88,30 @@ jobs:
8988
gh release create "${{ inputs.git-tag }}" --draft --repo "${{ github.repository }}" --title "Release ${{ inputs.git-tag }}" --notes "Release ${{ inputs.git-tag }}"
9089
fi
9190
91+
check-release-notes:
92+
runs-on: ubuntu-latest
93+
steps:
94+
- name: Checkout Source
95+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
96+
with:
97+
ref: ${{ inputs.git-tag }}
98+
99+
- name: Set up Python
100+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
101+
with:
102+
python-version: "3.12"
103+
104+
- name: Self-test release-notes checker
105+
run: |
106+
pip install pytest
107+
pytest ci/tools/tests
108+
109+
- name: Check versioned release notes exist
110+
run: |
111+
python ci/tools/check_release_notes.py \
112+
--git-tag "${{ inputs.git-tag }}" \
113+
--component "${{ inputs.component }}"
114+
92115
doc:
93116
name: Build release docs
94117
if: ${{ github.repository_owner == 'nvidia' }}
@@ -99,6 +122,7 @@ jobs:
99122
pull-requests: write
100123
needs:
101124
- check-tag
125+
- check-release-notes
102126
- determine-run-id
103127
secrets: inherit
104128
uses: ./.github/workflows/build-docs.yml
@@ -114,6 +138,7 @@ jobs:
114138
contents: write
115139
needs:
116140
- check-tag
141+
- check-release-notes
117142
- determine-run-id
118143
- doc
119144
secrets: inherit
@@ -128,11 +153,12 @@ jobs:
128153
runs-on: ubuntu-latest
129154
needs:
130155
- check-tag
156+
- check-release-notes
131157
- determine-run-id
132158
- doc
133159
environment:
134160
name: testpypi
135-
url: https://test.pypi.org/${{ inputs.component != 'all' && format('p/{0}/', inputs.component) || '' }}
161+
url: https://test.pypi.org/p/${{ inputs.component }}/
136162
permissions:
137163
id-token: write
138164
steps:
@@ -162,7 +188,7 @@ jobs:
162188
- publish-testpypi
163189
environment:
164190
name: pypi
165-
url: https://pypi.org/${{ inputs.component != 'all' && format('p/{0}/', inputs.component) || '' }}
191+
url: https://pypi.org/p/${{ inputs.component }}/
166192
permissions:
167193
id-token: write
168194
steps:

ci/tools/check_release_notes.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 — release notes present and non-empty (or .post version, skipped)
11+
1 — release notes missing or empty
12+
2 — invalid arguments (including unparsable tag, or component/tag-prefix mismatch)
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import os
19+
import re
20+
import sys
21+
22+
COMPONENT_TO_PACKAGE: dict[str, str] = {
23+
"cuda-core": "cuda_core",
24+
"cuda-bindings": "cuda_bindings",
25+
"cuda-pathfinder": "cuda_pathfinder",
26+
"cuda-python": "cuda_python",
27+
}
28+
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+
42+
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.
45+
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
54+
55+
56+
def is_post_release(version: str) -> bool:
57+
return ".post" in version
58+
59+
60+
def notes_path(package: str, version: str) -> str:
61+
return os.path.join(package, "docs", "source", "release", f"{version}-notes.rst")
62+
63+
64+
def check_release_notes(git_tag: str, component: str, repo_root: str = ".") -> list[tuple[str, str]]:
65+
"""Return a list of (path, reason) for missing or empty release notes.
66+
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).
69+
"""
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)
74+
if version is None:
75+
return [("<tag>", f"cannot parse version from tag '{git_tag}' for component '{component}'")]
76+
77+
if is_post_release(version):
78+
return []
79+
80+
path = notes_path(COMPONENT_TO_PACKAGE[component], version)
81+
full = os.path.join(repo_root, path)
82+
if not os.path.isfile(full):
83+
return [(path, "missing")]
84+
if os.path.getsize(full) == 0:
85+
return [(path, "empty")]
86+
return []
87+
88+
89+
def main(argv: list[str] | None = None) -> int:
90+
parser = argparse.ArgumentParser(description=__doc__)
91+
parser.add_argument("--git-tag", required=True)
92+
parser.add_argument("--component", required=True, choices=list(COMPONENT_TO_PACKAGE))
93+
parser.add_argument("--repo-root", default=".")
94+
args = parser.parse_args(argv)
95+
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):
105+
print(f"Post-release tag ({args.git_tag}), skipping release-notes check.")
106+
return 0
107+
108+
problems = check_release_notes(args.git_tag, args.component, args.repo_root)
109+
if not problems:
110+
print(f"Release notes present for tag {args.git_tag}, component {args.component}.")
111+
return 0
112+
113+
print(f"ERROR: missing or empty release notes for tag {args.git_tag}:", file=sys.stderr)
114+
for path, reason in problems:
115+
print(f" - {path} ({reason})", file=sys.stderr)
116+
print("Add versioned release notes before releasing.", file=sys.stderr)
117+
return 1
118+
119+
120+
if __name__ == "__main__":
121+
sys.exit(main())
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10+
from check_release_notes import (
11+
check_release_notes,
12+
is_post_release,
13+
main,
14+
parse_version_from_tag,
15+
)
16+
17+
18+
class TestParseVersionFromTag:
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"
24+
25+
def test_component_prefix_core(self):
26+
assert parse_version_from_tag("cuda-core-v0.7.0", "cuda-core") == "0.7.0"
27+
28+
def test_component_prefix_pathfinder(self):
29+
assert parse_version_from_tag("cuda-pathfinder-v1.5.2", "cuda-pathfinder") == "1.5.2"
30+
31+
def test_post_release(self):
32+
assert parse_version_from_tag("v12.6.2.post1", "cuda-bindings") == "12.6.2.post1"
33+
34+
def test_invalid_tag(self):
35+
assert parse_version_from_tag("not-a-tag", "cuda-core") is None
36+
37+
def test_no_v_prefix(self):
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
66+
67+
68+
class TestIsPostRelease:
69+
def test_normal(self):
70+
assert not is_post_release("13.1.0")
71+
72+
def test_post(self):
73+
assert is_post_release("12.6.2.post1")
74+
75+
def test_post_no_number(self):
76+
assert is_post_release("1.0.0.post")
77+
78+
79+
class TestCheckReleaseNotes:
80+
def _make_notes(self, tmp_path, pkg, version, content="Release notes."):
81+
d = tmp_path / pkg / "docs" / "source" / "release"
82+
d.mkdir(parents=True, exist_ok=True)
83+
f = d / f"{version}-notes.rst"
84+
f.write_text(content)
85+
return f
86+
87+
def test_present_and_nonempty(self, tmp_path):
88+
self._make_notes(tmp_path, "cuda_core", "0.7.0")
89+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path))
90+
assert problems == []
91+
92+
def test_missing(self, tmp_path):
93+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path))
94+
assert len(problems) == 1
95+
assert problems[0][1] == "missing"
96+
97+
def test_empty(self, tmp_path):
98+
self._make_notes(tmp_path, "cuda_core", "0.7.0", content="")
99+
problems = check_release_notes("cuda-core-v0.7.0", "cuda-core", str(tmp_path))
100+
assert len(problems) == 1
101+
assert problems[0][1] == "empty"
102+
103+
def test_post_release_skipped(self, tmp_path):
104+
problems = check_release_notes("v12.6.2.post1", "cuda-bindings", str(tmp_path))
105+
assert problems == []
106+
107+
def test_invalid_tag(self, tmp_path):
108+
problems = check_release_notes("not-a-tag", "cuda-core", str(tmp_path))
109+
assert len(problems) == 1
110+
assert "cannot parse" in problems[0][1]
111+
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+
123+
def test_plain_v_tag(self, tmp_path):
124+
self._make_notes(tmp_path, "cuda_python", "13.1.0")
125+
problems = check_release_notes("v13.1.0", "cuda-python", str(tmp_path))
126+
assert problems == []
127+
128+
129+
class TestMain:
130+
def test_success(self, tmp_path):
131+
d = tmp_path / "cuda_core" / "docs" / "source" / "release"
132+
d.mkdir(parents=True)
133+
(d / "0.7.0-notes.rst").write_text("Notes here.")
134+
rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)])
135+
assert rc == 0
136+
137+
def test_failure(self, tmp_path):
138+
rc = main(["--git-tag", "cuda-core-v0.7.0", "--component", "cuda-core", "--repo-root", str(tmp_path)])
139+
assert rc == 1
140+
141+
def test_post_skip(self, tmp_path):
142+
rc = main(["--git-tag", "v12.6.2.post1", "--component", "cuda-bindings", "--repo-root", str(tmp_path)])
143+
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)