Skip to content

Commit 406ecfb

Browse files
rwgkcursoragent
andauthored
ci: require tag-triggered artifacts for release uploads (#1606)
Build CI on release tags and reject wheel artifacts that do not match the requested tag version. This prevents setuptools-scm dev/local wheels from being published when tags are created after merge CI. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7a73c60 commit 406ecfb

File tree

6 files changed

+162
-16
lines changed

6 files changed

+162
-16
lines changed

.github/ISSUE_TEMPLATE/release_checklist.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ body:
2626
- label: "Finalize the doc update, including release notes (\"Note: Touching docstrings/type annotations in code is OK during code freeze, apply your best judgement!\")"
2727
- label: Update the docs for the new version
2828
- label: Create a public release tag
29+
- label: Wait for the tag-triggered CI run to complete, and use that run ID for release workflows
2930
- label: If any code change happens, rebuild the wheels from the new tag
3031
- label: Update the conda recipe & release conda packages
3132
- label: Upload conda packages to nvidia channel

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ on:
1515
branches:
1616
- "pull-request/[0-9]+"
1717
- "main"
18+
tags:
19+
# Build release artifacts from tag refs so setuptools-scm resolves exact
20+
# release versions instead of .dev+local variants.
21+
- "v*"
22+
- "cuda-core-v*"
23+
- "cuda-pathfinder-v*"
1824
schedule:
1925
# every 24 hours at midnight UTC
2026
- cron: "0 0 * * *"

.github/workflows/release-upload.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ jobs:
7979
# Use the shared script to download wheels
8080
./ci/tools/download-wheels "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels"
8181
82+
# Validate that release wheels match the expected version from tag.
83+
./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels"
84+
8285
# Upload wheels to the release
8386
if [[ -d "release/wheels" && $(ls -A release/wheels 2>/dev/null | wc -l) -gt 0 ]]; then
8487
echo "Uploading wheels to release ${{ inputs.git-tag }}"

.github/workflows/release.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ on:
2424
required: true
2525
type: string
2626
run-id:
27-
description: "The GHA run ID that generated validated artifacts (optional - will be auto-detected from git tag if not provided)"
27+
description: "The GHA run ID that generated validated artifacts (optional - auto-detects successful tag-triggered CI run for git-tag)"
2828
required: false
2929
type: string
3030
default: ""
@@ -64,7 +64,7 @@ jobs:
6464
echo "Using provided run ID: ${{ inputs.run-id }}"
6565
echo "run-id=${{ inputs.run-id }}" >> $GITHUB_OUTPUT
6666
else
67-
echo "Auto-detecting run ID for tag: ${{ inputs.git-tag }}"
67+
echo "Auto-detecting successful tag-triggered run ID for tag: ${{ inputs.git-tag }}"
6868
RUN_ID=$(./ci/tools/lookup-run-id "${{ inputs.git-tag }}" "${{ github.repository }}")
6969
echo "Auto-detected run ID: $RUN_ID"
7070
echo "run-id=$RUN_ID" >> $GITHUB_OUTPUT
@@ -165,6 +165,10 @@ jobs:
165165
run: |
166166
./ci/tools/download-wheels "${{ needs.determine-run-id.outputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "dist"
167167
168+
- name: Validate wheel versions for release tag
169+
run: |
170+
./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist"
171+
168172
- name: Publish package distributions to PyPI
169173
if: ${{ inputs.wheel-dst == 'pypi' }}
170174
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0

ci/tools/lookup-run-id

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# SPDX-License-Identifier: Apache-2.0
66

77
# A utility script to find the GitHub Actions workflow run ID for a given git tag.
8-
# This script looks for the CI workflow run that corresponds to the commit of the given tag.
8+
# This script requires a successful CI run that was triggered by the tag push.
99

1010
set -euo pipefail
1111

@@ -54,16 +54,16 @@ fi
5454
echo "Resolved tag '${GIT_TAG}' to commit: ${COMMIT_SHA}" >&2
5555

5656
# Find workflow runs for this commit
57-
echo "Searching for '${WORKFLOW_NAME}' workflow runs for commit: ${COMMIT_SHA}" >&2
57+
echo "Searching for '${WORKFLOW_NAME}' workflow runs for commit: ${COMMIT_SHA} (tag: ${GIT_TAG})" >&2
5858

59-
# Get workflow runs for the commit, filter by workflow name and successful status
59+
# Get completed workflow runs for this commit.
6060
RUN_DATA=$(gh run list \
6161
--repo "${REPOSITORY}" \
6262
--commit "${COMMIT_SHA}" \
6363
--workflow "${WORKFLOW_NAME}" \
6464
--status completed \
65-
--json databaseId,workflowName,status,conclusion,headSha \
66-
--limit 10)
65+
--json databaseId,workflowName,status,conclusion,headSha,headBranch,event,createdAt,url \
66+
--limit 50)
6767

6868
if [[ -z "${RUN_DATA}" || "${RUN_DATA}" == "[]" ]]; then
6969
echo "Error: No completed '${WORKFLOW_NAME}' workflow runs found for commit ${COMMIT_SHA}" >&2
@@ -72,16 +72,21 @@ if [[ -z "${RUN_DATA}" || "${RUN_DATA}" == "[]" ]]; then
7272
exit 1
7373
fi
7474

75-
# Filter for successful runs (conclusion = success) and extract the run ID from the first one
76-
RUN_ID=$(echo "${RUN_DATA}" | jq -r '.[] | select(.conclusion == "success") | .databaseId' | head -1)
77-
78-
if [[ -z "${RUN_ID}" || "${RUN_ID}" == "null" ]]; then
79-
echo "Error: No successful '${WORKFLOW_NAME}' workflow runs found for commit ${COMMIT_SHA}" >&2
80-
echo "Available workflow runs for this commit:" >&2
81-
gh run list --repo "$REPOSITORY" --commit "${COMMIT_SHA}" --limit 10 || true
75+
# Filter for successful push runs from the tag ref.
76+
RUN_ID=$(echo "${RUN_DATA}" | jq -r --arg tag "${GIT_TAG}" '
77+
map(select(.conclusion == "success" and .event == "push" and .headBranch == $tag))
78+
| sort_by(.createdAt)
79+
| reverse
80+
| .[0].databaseId // empty
81+
')
82+
83+
if [[ -z "${RUN_ID}" ]]; then
84+
echo "Error: No successful '${WORKFLOW_NAME}' workflow runs found for tag '${GIT_TAG}'." >&2
85+
echo "This release workflow now requires artifacts from a tag-triggered CI run." >&2
86+
echo "If you just pushed the tag, wait for CI on that tag to finish and retry." >&2
8287
echo "" >&2
83-
echo "Completed runs with their conclusions:" >&2
84-
echo "${RUN_DATA}" | jq -r '.[] | "\(.databaseId): \(.conclusion)"' >&2
88+
echo "Completed runs for commit ${COMMIT_SHA}:" >&2
89+
echo "${RUN_DATA}" | jq -r '.[] | "\(.databaseId): event=\(.event // "null"), headBranch=\(.headBranch // "null"), conclusion=\(.conclusion // "null"), status=\(.status // "null"), createdAt=\(.createdAt // "null")"' >&2
8590
exit 1
8691
fi
8792

ci/tools/validate-release-wheels

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env python3
2+
3+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
"""Validate downloaded release wheels against the requested release tag."""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import re
13+
import sys
14+
from collections import defaultdict
15+
from pathlib import Path
16+
17+
COMPONENT_TO_DISTRIBUTIONS: dict[str, set[str]] = {
18+
"cuda-core": {"cuda_core"},
19+
"cuda-bindings": {"cuda_bindings"},
20+
"cuda-pathfinder": {"cuda_pathfinder"},
21+
"cuda-python": {"cuda_python"},
22+
"all": {"cuda_core", "cuda_bindings", "cuda_pathfinder", "cuda_python"},
23+
}
24+
25+
TAG_PATTERNS = (
26+
re.compile(r"^v(?P<version>\d+\.\d+\.\d+)"),
27+
re.compile(r"^cuda-core-v(?P<version>\d+\.\d+\.\d+)"),
28+
re.compile(r"^cuda-pathfinder-v(?P<version>\d+\.\d+\.\d+)"),
29+
)
30+
31+
32+
def parse_args() -> argparse.Namespace:
33+
parser = argparse.ArgumentParser(
34+
description=(
35+
"Validate that wheel versions match the release tag. "
36+
"This rejects dev/local wheel versions for release uploads."
37+
)
38+
)
39+
parser.add_argument("git_tag", help="Release git tag (for example: v13.0.0)")
40+
parser.add_argument("component", choices=sorted(COMPONENT_TO_DISTRIBUTIONS.keys()))
41+
parser.add_argument("wheel_dir", help="Directory containing wheel files")
42+
return parser.parse_args()
43+
44+
45+
def version_from_tag(tag: str) -> str:
46+
for pattern in TAG_PATTERNS:
47+
match = pattern.match(tag)
48+
if match:
49+
return match.group("version")
50+
raise ValueError(
51+
"Unsupported git tag format "
52+
f"{tag!r}; expected tags beginning with vX.Y.Z, cuda-core-vX.Y.Z, "
53+
"or cuda-pathfinder-vX.Y.Z."
54+
)
55+
56+
57+
def parse_wheel_dist_and_version(path: Path) -> tuple[str, str]:
58+
# Wheel name format starts with: {distribution}-{version}-...
59+
parts = path.stem.split("-")
60+
if len(parts) < 5:
61+
raise ValueError(f"Invalid wheel filename format: {path.name}")
62+
return parts[0], parts[1]
63+
64+
65+
def main() -> int:
66+
args = parse_args()
67+
expected_version = version_from_tag(args.git_tag)
68+
expected_distributions = COMPONENT_TO_DISTRIBUTIONS[args.component]
69+
wheel_dir = Path(args.wheel_dir)
70+
71+
wheels = sorted(wheel_dir.glob("*.whl"))
72+
if not wheels:
73+
print(f"Error: No wheel files found in {wheel_dir}", file=sys.stderr)
74+
return 1
75+
76+
seen_versions: dict[str, set[str]] = defaultdict(set)
77+
errors: list[str] = []
78+
79+
for wheel in wheels:
80+
try:
81+
distribution, version = parse_wheel_dist_and_version(wheel)
82+
except ValueError as exc:
83+
errors.append(str(exc))
84+
continue
85+
86+
if distribution not in expected_distributions:
87+
continue
88+
89+
seen_versions[distribution].add(version)
90+
91+
if ".dev" in version or "+" in version:
92+
errors.append(
93+
f"{wheel.name}: wheel version {version!r} contains dev/local markers "
94+
"(.dev or +), which is not allowed for release uploads."
95+
)
96+
97+
if version != expected_version:
98+
errors.append(
99+
f"{wheel.name}: wheel version {version!r} does not match expected "
100+
f"release version {expected_version!r} from git tag {args.git_tag!r}."
101+
)
102+
103+
missing_distributions = sorted(expected_distributions - set(seen_versions))
104+
if missing_distributions:
105+
errors.append("Missing expected component wheels in download set: " + ", ".join(missing_distributions))
106+
107+
for distribution, versions in sorted(seen_versions.items()):
108+
if len(versions) > 1:
109+
errors.append(
110+
f"Expected one release version for {distribution}, found multiple: " + ", ".join(sorted(versions))
111+
)
112+
113+
if errors:
114+
print("Wheel validation failed:", file=sys.stderr)
115+
for error in errors:
116+
print(f" - {error}", file=sys.stderr)
117+
return 1
118+
119+
print(
120+
"Validated release wheels for component "
121+
f"{args.component} at version {expected_version} from tag {args.git_tag}."
122+
)
123+
return 0
124+
125+
126+
if __name__ == "__main__":
127+
raise SystemExit(main())

0 commit comments

Comments
 (0)