From f75e0f8ee214813410cc30a644a36fbea51a7fe6 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Wed, 11 Feb 2026 14:01:49 -0800 Subject: [PATCH] ci: require tag-triggered artifacts for release uploads 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 --- .github/ISSUE_TEMPLATE/release_checklist.yml | 1 + .github/workflows/ci.yml | 6 + .github/workflows/release-upload.yml | 3 + .github/workflows/release.yml | 8 +- ci/tools/lookup-run-id | 33 +++-- ci/tools/validate-release-wheels | 127 +++++++++++++++++++ 6 files changed, 162 insertions(+), 16 deletions(-) create mode 100755 ci/tools/validate-release-wheels diff --git a/.github/ISSUE_TEMPLATE/release_checklist.yml b/.github/ISSUE_TEMPLATE/release_checklist.yml index 0fa2765797..1c8dbc36ff 100644 --- a/.github/ISSUE_TEMPLATE/release_checklist.yml +++ b/.github/ISSUE_TEMPLATE/release_checklist.yml @@ -26,6 +26,7 @@ body: - label: "Finalize the doc update, including release notes (\"Note: Touching docstrings/type annotations in code is OK during code freeze, apply your best judgement!\")" - label: Update the docs for the new version - label: Create a public release tag + - label: Wait for the tag-triggered CI run to complete, and use that run ID for release workflows - label: If any code change happens, rebuild the wheels from the new tag - label: Update the conda recipe & release conda packages - label: Upload conda packages to nvidia channel diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2583f858ad..f1dd28eff1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,12 @@ on: branches: - "pull-request/[0-9]+" - "main" + tags: + # Build release artifacts from tag refs so setuptools-scm resolves exact + # release versions instead of .dev+local variants. + - "v*" + - "cuda-core-v*" + - "cuda-pathfinder-v*" schedule: # every 24 hours at midnight UTC - cron: "0 0 * * *" diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 0894e595fc..52a34e6c77 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -79,6 +79,9 @@ jobs: # Use the shared script to download wheels ./ci/tools/download-wheels "${{ inputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "release/wheels" + # Validate that release wheels match the expected version from tag. + ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "release/wheels" + # Upload wheels to the release if [[ -d "release/wheels" && $(ls -A release/wheels 2>/dev/null | wc -l) -gt 0 ]]; then echo "Uploading wheels to release ${{ inputs.git-tag }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68ce7c5716..9744f4e03d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ on: required: true type: string run-id: - description: "The GHA run ID that generated validated artifacts (optional - will be auto-detected from git tag if not provided)" + description: "The GHA run ID that generated validated artifacts (optional - auto-detects successful tag-triggered CI run for git-tag)" required: false type: string default: "" @@ -64,7 +64,7 @@ jobs: echo "Using provided run ID: ${{ inputs.run-id }}" echo "run-id=${{ inputs.run-id }}" >> $GITHUB_OUTPUT else - echo "Auto-detecting run ID for tag: ${{ inputs.git-tag }}" + echo "Auto-detecting successful tag-triggered run ID for tag: ${{ inputs.git-tag }}" RUN_ID=$(./ci/tools/lookup-run-id "${{ inputs.git-tag }}" "${{ github.repository }}") echo "Auto-detected run ID: $RUN_ID" echo "run-id=$RUN_ID" >> $GITHUB_OUTPUT @@ -165,6 +165,10 @@ jobs: run: | ./ci/tools/download-wheels "${{ needs.determine-run-id.outputs.run-id }}" "${{ inputs.component }}" "${{ github.repository }}" "dist" + - name: Validate wheel versions for release tag + run: | + ./ci/tools/validate-release-wheels "${{ inputs.git-tag }}" "${{ inputs.component }}" "dist" + - name: Publish package distributions to PyPI if: ${{ inputs.wheel-dst == 'pypi' }} uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/ci/tools/lookup-run-id b/ci/tools/lookup-run-id index db2f84b792..092f8369ae 100755 --- a/ci/tools/lookup-run-id +++ b/ci/tools/lookup-run-id @@ -5,7 +5,7 @@ # SPDX-License-Identifier: Apache-2.0 # A utility script to find the GitHub Actions workflow run ID for a given git tag. -# This script looks for the CI workflow run that corresponds to the commit of the given tag. +# This script requires a successful CI run that was triggered by the tag push. set -euo pipefail @@ -54,16 +54,16 @@ fi echo "Resolved tag '${GIT_TAG}' to commit: ${COMMIT_SHA}" >&2 # Find workflow runs for this commit -echo "Searching for '${WORKFLOW_NAME}' workflow runs for commit: ${COMMIT_SHA}" >&2 +echo "Searching for '${WORKFLOW_NAME}' workflow runs for commit: ${COMMIT_SHA} (tag: ${GIT_TAG})" >&2 -# Get workflow runs for the commit, filter by workflow name and successful status +# Get completed workflow runs for this commit. RUN_DATA=$(gh run list \ --repo "${REPOSITORY}" \ --commit "${COMMIT_SHA}" \ --workflow "${WORKFLOW_NAME}" \ --status completed \ - --json databaseId,workflowName,status,conclusion,headSha \ - --limit 10) + --json databaseId,workflowName,status,conclusion,headSha,headBranch,event,createdAt,url \ + --limit 50) if [[ -z "${RUN_DATA}" || "${RUN_DATA}" == "[]" ]]; then 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 exit 1 fi -# Filter for successful runs (conclusion = success) and extract the run ID from the first one -RUN_ID=$(echo "${RUN_DATA}" | jq -r '.[] | select(.conclusion == "success") | .databaseId' | head -1) - -if [[ -z "${RUN_ID}" || "${RUN_ID}" == "null" ]]; then - echo "Error: No successful '${WORKFLOW_NAME}' workflow runs found for commit ${COMMIT_SHA}" >&2 - echo "Available workflow runs for this commit:" >&2 - gh run list --repo "$REPOSITORY" --commit "${COMMIT_SHA}" --limit 10 || true +# Filter for successful push runs from the tag ref. +RUN_ID=$(echo "${RUN_DATA}" | jq -r --arg tag "${GIT_TAG}" ' + map(select(.conclusion == "success" and .event == "push" and .headBranch == $tag)) + | sort_by(.createdAt) + | reverse + | .[0].databaseId // empty +') + +if [[ -z "${RUN_ID}" ]]; then + echo "Error: No successful '${WORKFLOW_NAME}' workflow runs found for tag '${GIT_TAG}'." >&2 + echo "This release workflow now requires artifacts from a tag-triggered CI run." >&2 + echo "If you just pushed the tag, wait for CI on that tag to finish and retry." >&2 echo "" >&2 - echo "Completed runs with their conclusions:" >&2 - echo "${RUN_DATA}" | jq -r '.[] | "\(.databaseId): \(.conclusion)"' >&2 + echo "Completed runs for commit ${COMMIT_SHA}:" >&2 + echo "${RUN_DATA}" | jq -r '.[] | "\(.databaseId): event=\(.event // "null"), headBranch=\(.headBranch // "null"), conclusion=\(.conclusion // "null"), status=\(.status // "null"), createdAt=\(.createdAt // "null")"' >&2 exit 1 fi diff --git a/ci/tools/validate-release-wheels b/ci/tools/validate-release-wheels new file mode 100755 index 0000000000..5757ca17bc --- /dev/null +++ b/ci/tools/validate-release-wheels @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Validate downloaded release wheels against the requested release tag.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import defaultdict +from pathlib import Path + +COMPONENT_TO_DISTRIBUTIONS: dict[str, set[str]] = { + "cuda-core": {"cuda_core"}, + "cuda-bindings": {"cuda_bindings"}, + "cuda-pathfinder": {"cuda_pathfinder"}, + "cuda-python": {"cuda_python"}, + "all": {"cuda_core", "cuda_bindings", "cuda_pathfinder", "cuda_python"}, +} + +TAG_PATTERNS = ( + re.compile(r"^v(?P\d+\.\d+\.\d+)"), + re.compile(r"^cuda-core-v(?P\d+\.\d+\.\d+)"), + re.compile(r"^cuda-pathfinder-v(?P\d+\.\d+\.\d+)"), +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Validate that wheel versions match the release tag. " + "This rejects dev/local wheel versions for release uploads." + ) + ) + parser.add_argument("git_tag", help="Release git tag (for example: v13.0.0)") + parser.add_argument("component", choices=sorted(COMPONENT_TO_DISTRIBUTIONS.keys())) + parser.add_argument("wheel_dir", help="Directory containing wheel files") + return parser.parse_args() + + +def version_from_tag(tag: str) -> str: + for pattern in TAG_PATTERNS: + match = pattern.match(tag) + if match: + return match.group("version") + raise ValueError( + "Unsupported git tag format " + f"{tag!r}; expected tags beginning with vX.Y.Z, cuda-core-vX.Y.Z, " + "or cuda-pathfinder-vX.Y.Z." + ) + + +def parse_wheel_dist_and_version(path: Path) -> tuple[str, str]: + # Wheel name format starts with: {distribution}-{version}-... + parts = path.stem.split("-") + if len(parts) < 5: + raise ValueError(f"Invalid wheel filename format: {path.name}") + return parts[0], parts[1] + + +def main() -> int: + args = parse_args() + expected_version = version_from_tag(args.git_tag) + expected_distributions = COMPONENT_TO_DISTRIBUTIONS[args.component] + wheel_dir = Path(args.wheel_dir) + + wheels = sorted(wheel_dir.glob("*.whl")) + if not wheels: + print(f"Error: No wheel files found in {wheel_dir}", file=sys.stderr) + return 1 + + seen_versions: dict[str, set[str]] = defaultdict(set) + errors: list[str] = [] + + for wheel in wheels: + try: + distribution, version = parse_wheel_dist_and_version(wheel) + except ValueError as exc: + errors.append(str(exc)) + continue + + if distribution not in expected_distributions: + continue + + seen_versions[distribution].add(version) + + if ".dev" in version or "+" in version: + errors.append( + f"{wheel.name}: wheel version {version!r} contains dev/local markers " + "(.dev or +), which is not allowed for release uploads." + ) + + if version != expected_version: + errors.append( + f"{wheel.name}: wheel version {version!r} does not match expected " + f"release version {expected_version!r} from git tag {args.git_tag!r}." + ) + + missing_distributions = sorted(expected_distributions - set(seen_versions)) + if missing_distributions: + errors.append("Missing expected component wheels in download set: " + ", ".join(missing_distributions)) + + for distribution, versions in sorted(seen_versions.items()): + if len(versions) > 1: + errors.append( + f"Expected one release version for {distribution}, found multiple: " + ", ".join(sorted(versions)) + ) + + if errors: + print("Wheel validation failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + print( + "Validated release wheels for component " + f"{args.component} at version {expected_version} from tag {args.git_tag}." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())