Skip to content

Commit b8f04c7

Browse files
committed
ci: require tag-triggered artifacts for release uploads (NVIDIA#1606)
Backport of NVIDIA#1606 to 12.9.x. Adapted for the 12.9.x branch workflow structure: - ci.yml: add tag push triggers (v*, cuda-core-v*, cuda-pathfinder-v*) so setuptools-scm resolves exact release versions from tag refs - release.yml: make run-id optional with auto-detection from tag-triggered CI runs via ci/tools/lookup-run-id; add wheel version validation via ci/tools/validate-release-wheels before publishing - Add ci/tools/lookup-run-id: finds the successful tag-triggered CI run for a given release tag - Add ci/tools/validate-release-wheels: rejects dev/local wheel versions and enforces version match against the release tag - release_checklist.yml: add reminder to wait for tag-triggered CI
1 parent 1e0ae86 commit b8f04c7

5 files changed

Lines changed: 277 additions & 4 deletions

File tree

.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
- "12.9.x"
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

1925
jobs:
2026
ci-vars:

.github/workflows/release.yml

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ on:
2424
required: true
2525
type: string
2626
run-id:
27-
description: "The GHA run ID that generated validated artifacts"
28-
required: true
27+
description: "The GHA run ID that generated validated artifacts (optional - auto-detects successful tag-triggered CI run for git-tag)"
28+
required: false
2929
type: string
30+
default: ""
3031
build-ctk-ver:
3132
type: string
3233
required: true
@@ -43,6 +44,31 @@ defaults:
4344
shell: bash --noprofile --norc -xeuo pipefail {0}
4445

4546
jobs:
47+
determine-run-id:
48+
runs-on: ubuntu-latest
49+
outputs:
50+
run-id: ${{ steps.lookup-run-id.outputs.run-id }}
51+
steps:
52+
- name: Checkout Source
53+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
54+
with:
55+
fetch-depth: 0
56+
57+
- name: Determine Run ID
58+
id: lookup-run-id
59+
env:
60+
GH_TOKEN: ${{ github.token }}
61+
run: |
62+
if [[ -n "${{ inputs.run-id }}" ]]; then
63+
echo "Using provided run ID: ${{ inputs.run-id }}"
64+
echo "run-id=${{ inputs.run-id }}" >> $GITHUB_OUTPUT
65+
else
66+
echo "Auto-detecting successful tag-triggered run ID for tag: ${{ inputs.git-tag }}"
67+
RUN_ID=$(./ci/tools/lookup-run-id "${{ inputs.git-tag }}" "${{ github.repository }}")
68+
echo "Auto-detected run ID: $RUN_ID"
69+
echo "run-id=$RUN_ID" >> $GITHUB_OUTPUT
70+
fi
71+
4672
check-tag:
4773
runs-on: ubuntu-latest
4874
steps:
@@ -86,13 +112,14 @@ jobs:
86112
pull-requests: write
87113
needs:
88114
- check-tag
115+
- determine-run-id
89116
secrets: inherit
90117
uses: ./.github/workflows/build-docs.yml
91118
with:
92119
build-ctk-ver: ${{ inputs.build-ctk-ver }}
93120
component: ${{ inputs.component }}
94121
git-tag: ${{ inputs.git-tag }}
95-
run-id: ${{ inputs.run-id }}
122+
run-id: ${{ needs.determine-run-id.outputs.run-id }}
96123
is-release: true
97124

98125
upload-archive:
@@ -111,17 +138,21 @@ jobs:
111138
runs-on: ubuntu-latest
112139
needs:
113140
- check-tag
141+
- determine-run-id
114142
environment:
115143
name: ${{ inputs.wheel-dst }}
116144
url: https://${{ (inputs.wheel-dst == 'testpypi' && 'test.') || '' }}pypi.org/p/${{ inputs.component }}/
117145
permissions:
118146
id-token: write
119147
steps:
148+
- name: Checkout Source
149+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
150+
120151
- name: Download component wheels
121152
env:
122153
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
123154
run: |
124-
gh run download ${{ inputs.run-id }} -p "${{ inputs.component }}*" -R ${{ github.repository }}
155+
gh run download ${{ needs.determine-run-id.outputs.run-id }} -p "${{ inputs.component }}*" -R ${{ github.repository }}
125156
mkdir dist
126157
for p in ${{ inputs.component }}*
127158
do
@@ -133,6 +164,10 @@ jobs:
133164
done
134165
rm -rf ${{ inputs.component }}*
135166
167+
- name: Validate wheel versions for release tag
168+
run: |
169+
./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist"
170+
136171
- name: Publish package distributions to PyPI
137172
if: ${{ inputs.wheel-dst == 'pypi' }}
138173
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0

ci/tools/lookup-run-id

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env bash
2+
3+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
# A utility script to find the GitHub Actions workflow run ID for a given git tag.
8+
# This script requires a successful CI run that was triggered by the tag push.
9+
10+
set -euo pipefail
11+
12+
# Check required arguments
13+
if [[ $# -lt 2 ]]; then
14+
echo "Usage: $0 <git-tag> <repository> [workflow-name]" >&2
15+
echo " git-tag: The git tag to find the corresponding workflow run for" >&2
16+
echo " repository: The GitHub repository (e.g., NVIDIA/cuda-python)" >&2
17+
echo " workflow-name: Optional workflow name to filter by (default: CI)" >&2
18+
echo "" >&2
19+
echo "Examples:" >&2
20+
echo " $0 v13.0.1 NVIDIA/cuda-python" >&2
21+
echo " $0 v13.0.1 NVIDIA/cuda-python \"CI\"" >&2
22+
exit 1
23+
fi
24+
25+
GIT_TAG="${1}"
26+
REPOSITORY="${2}"
27+
WORKFLOW_NAME="${3:-CI}"
28+
29+
# Ensure we have required tools
30+
if [[ -z "${GH_TOKEN:-}" ]]; then
31+
echo "Error: GH_TOKEN environment variable is required" >&2
32+
exit 1
33+
fi
34+
35+
if ! command -v jq >/dev/null 2>&1; then
36+
echo "Error: jq is required but not installed" >&2
37+
exit 1
38+
fi
39+
40+
if ! command -v gh >/dev/null 2>&1; then
41+
echo "Error: GitHub CLI (gh) is required but not installed" >&2
42+
exit 1
43+
fi
44+
45+
echo "Looking up run ID for tag: ${GIT_TAG} in repository: ${REPOSITORY}" >&2
46+
47+
# Resolve git tag to commit SHA
48+
if ! COMMIT_SHA=$(git rev-parse "${GIT_TAG}"); then
49+
echo "Error: Could not resolve git tag '${GIT_TAG}' to a commit SHA" >&2
50+
echo "Make sure the tag exists and you have fetched it" >&2
51+
exit 1
52+
fi
53+
54+
echo "Resolved tag '${GIT_TAG}' to commit: ${COMMIT_SHA}" >&2
55+
56+
# Find workflow runs for this commit
57+
echo "Searching for '${WORKFLOW_NAME}' workflow runs for commit: ${COMMIT_SHA} (tag: ${GIT_TAG})" >&2
58+
59+
# Get completed workflow runs for this commit.
60+
RUN_DATA=$(gh run list \
61+
--repo "${REPOSITORY}" \
62+
--commit "${COMMIT_SHA}" \
63+
--workflow "${WORKFLOW_NAME}" \
64+
--status completed \
65+
--json databaseId,workflowName,status,conclusion,headSha,headBranch,event,createdAt,url \
66+
--limit 50)
67+
68+
if [[ -z "${RUN_DATA}" || "${RUN_DATA}" == "[]" ]]; then
69+
echo "Error: No completed '${WORKFLOW_NAME}' workflow runs found for commit ${COMMIT_SHA}" >&2
70+
echo "Available workflow runs for this commit:" >&2
71+
gh run list --repo "${REPOSITORY}" --commit "${COMMIT_SHA}" --limit 10 || true
72+
exit 1
73+
fi
74+
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
87+
echo "" >&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
90+
exit 1
91+
fi
92+
93+
echo "Found workflow run ID: ${RUN_ID} for tag '${GIT_TAG}'" >&2
94+
95+
# Verify the run has the expected artifacts by checking if there are any artifacts
96+
echo "Verifying artifacts exist for run ${RUN_ID}..." >&2
97+
ARTIFACT_LIST=$(gh run view "${RUN_ID}" --repo "${REPOSITORY}" --json url || echo "")
98+
99+
if [[ -z "${ARTIFACT_LIST}" ]]; then
100+
echo "Warning: Could not verify artifacts for workflow run ${RUN_ID}" >&2
101+
fi
102+
103+
# Output the run ID (this is what gets used by calling scripts)
104+
echo "${RUN_ID}"

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)