From 1eee1864606420fa72f0e679e7a2d4d124842c5a Mon Sep 17 00:00:00 2001 From: Arjuna Keshavan <33526713+arjkesh@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:29:27 -0800 Subject: [PATCH] Add workflow to prepare release tag and artifacts --- .github/workflows/release-wheel.yml | 274 ++++++++++++++++++++++++++ .github/workflows/tilegym-release.yml | 52 +++++ 2 files changed, 326 insertions(+) create mode 100644 .github/workflows/release-wheel.yml create mode 100644 .github/workflows/tilegym-release.yml diff --git a/.github/workflows/release-wheel.yml b/.github/workflows/release-wheel.yml new file mode 100644 index 0000000..6f6643d --- /dev/null +++ b/.github/workflows/release-wheel.yml @@ -0,0 +1,274 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: MIT + +name: Release Wheel (Reusable) + +# Generic reusable workflow: finds the latest successful run of a given workflow, +# downloads matching wheel artifacts from it, extracts the package version, and +# publishes a GitHub release. +# +# Inputs: +# artifact-workflow – Workflow filename whose runs contain the artifacts (e.g. tilegym-ci.yml) (required) +# artifact-pattern – Extended regex (grep -E) to filter artifact names (required) +# commit-sha – If provided, find the run at this commit and tag it here; +# otherwise use the latest successful run on `branch` +# branch – Branch to search when commit-sha is empty (default: main) +# tag-prefix – Prepended to the version string, e.g. "v" → "v1.0.0" +# prerelease – Whether to mark the GitHub release as a pre-release +# +# Behaviour when a release/tag already exists: +# The existing GitHub release and its git tag are deleted, then recreated +# pointing to the resolved commit. Release notes are auto-generated by GitHub. + +on: + workflow_call: + inputs: + artifact-workflow: + description: 'Workflow filename whose runs contain the artifacts to release (e.g. tilegym-ci.yml)' + required: true + type: string + artifact-pattern: + description: 'Extended regex (grep -E) to filter artifact names from the run' + required: true + type: string + commit-sha: + description: 'Find the run at this commit and tag it here (uses latest run on branch when empty)' + required: false + type: string + default: '' + branch: + description: 'Branch to search when commit-sha is empty' + required: false + type: string + default: 'main' + tag-prefix: + description: 'Prefix prepended to the version string (e.g. "v" → "v1.0.0")' + required: false + type: string + default: 'v' + prerelease: + description: 'Mark the GitHub release as a pre-release' + required: false + type: boolean + default: true + +permissions: + contents: write + +jobs: + release: + name: Create GitHub Release + runs-on: ubuntu-latest + steps: + # ----------------------------------------------------------------------- + # 1. Resolve the run ID and commit SHA + # - If commit-sha is provided: find the latest successful run of + # `workflow` at that exact commit. + # - Otherwise: find the latest successful run of `workflow` on `branch` + # and derive the commit from it. + # ----------------------------------------------------------------------- + - name: Resolve run ID and commit SHA + id: resolve + env: + GH_TOKEN: ${{ github.token }} + run: | + ARTIFACT_WORKFLOW="${{ inputs.artifact-workflow }}" + COMMIT_SHA="${{ inputs.commit-sha }}" + BRANCH="${{ inputs.branch }}" + REPO="${{ github.repository }}" + + if [ -n "${COMMIT_SHA}" ]; then + echo "Searching for latest successful ${ARTIFACT_WORKFLOW} run at commit ${COMMIT_SHA}..." + RUN_ID=$(gh api \ + "repos/${REPO}/actions/workflows/${ARTIFACT_WORKFLOW}/runs?head_sha=${COMMIT_SHA}&status=success&per_page=1" \ + --jq '.workflow_runs[0].id // empty') + if [ -z "${RUN_ID}" ]; then + echo "ERROR: no successful ${ARTIFACT_WORKFLOW} run found for commit ${COMMIT_SHA}" >&2 + exit 1 + fi + else + echo "Searching for latest successful ${ARTIFACT_WORKFLOW} run on branch ${BRANCH}..." + RUN_ID=$(gh api \ + "repos/${REPO}/actions/workflows/${ARTIFACT_WORKFLOW}/runs?branch=${BRANCH}&status=success&per_page=1" \ + --jq '.workflow_runs[0].id // empty') + if [ -z "${RUN_ID}" ]; then + echo "ERROR: no successful ${ARTIFACT_WORKFLOW} run found on branch ${BRANCH}" >&2 + exit 1 + fi + COMMIT_SHA=$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq '.head_sha') + fi + + echo "Resolved run ID: ${RUN_ID}" + echo "Target commit SHA: ${COMMIT_SHA}" + echo "run-id=${RUN_ID}" >> $GITHUB_OUTPUT + echo "sha=${COMMIT_SHA}" >> $GITHUB_OUTPUT + + # ----------------------------------------------------------------------- + # 2. Download all artifacts whose names match the caller-supplied regex + # ----------------------------------------------------------------------- + - name: Download matching artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + RUN_ID="${{ steps.resolve.outputs.run-id }}" + REPO="${{ github.repository }}" + mkdir -p wheels + + # Collect artifact names from the run (all pages) and filter by regex. + # grep -E returns exit 1 when there are no matches, so || true prevents + # an early failure before the explicit emptiness check below. + ARTIFACT_NAMES=$(gh api \ + "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \ + --paginate \ + --jq '.artifacts[].name' \ + | grep -E '${{ inputs.artifact-pattern }}' || true) + + if [ -z "${ARTIFACT_NAMES}" ]; then + echo "ERROR: no artifacts matched pattern '${{ inputs.artifact-pattern }}'" >&2 + echo "Available artifacts in run ${RUN_ID}:" >&2 + gh api "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \ + --paginate --jq '.artifacts[].name' >&2 + exit 1 + fi + + echo "Matched artifacts:" + echo "${ARTIFACT_NAMES}" + echo "" + + # <<< here-string feeds the variable to the loop without a subshell. + while IFS= read -r ARTIFACT_NAME; do + [ -z "${ARTIFACT_NAME}" ] && continue + echo "Downloading: ${ARTIFACT_NAME}" + gh run download "${RUN_ID}" \ + --name "${ARTIFACT_NAME}" \ + --dir "wheels/${ARTIFACT_NAME}" + done <<< "${ARTIFACT_NAMES}" + + echo "" + echo "Downloaded wheel tree:" + find wheels -type f -name "*.whl" | sort + + # ----------------------------------------------------------------------- + # 3. Read the package version out of the first wheel's METADATA file + # ----------------------------------------------------------------------- + - name: Extract package version from wheel METADATA + id: version + run: | + python3 - <<'PYEOF' + import glob, os, sys, zipfile + + whl_files = glob.glob('wheels/**/*.whl', recursive=True) + if not whl_files: + print('ERROR: no .whl files found under wheels/', file=sys.stderr) + sys.exit(1) + + whl = sorted(whl_files)[0] + print(f'Reading METADATA from: {whl}') + + with zipfile.ZipFile(whl) as z: + meta_path = next( + (n for n in z.namelist() if n.endswith('/METADATA')), + None, + ) + if meta_path is None: + print('ERROR: no METADATA entry found in wheel', file=sys.stderr) + sys.exit(1) + lines = z.read(meta_path).decode().splitlines() + + version = None + for line in lines: + if line.startswith('Version:'): + version = line.split(':', 1)[1].strip() + break + + if not version: + print('ERROR: Version field not found in METADATA', file=sys.stderr) + sys.exit(1) + + print(f'Package version: {version}') + + github_output = os.environ.get('GITHUB_OUTPUT') + if github_output: + with open(github_output, 'a') as f: + f.write(f'version={version}\n') + PYEOF + + - name: Compute release tag + id: tag + run: | + TAG="${{ inputs.tag-prefix }}${{ steps.version.outputs.version }}" + echo "Release tag: ${TAG}" + echo "tag=${TAG}" >> $GITHUB_OUTPUT + + # ----------------------------------------------------------------------- + # 4. Remove any pre-existing release and git tag for this version so that + # the re-created release always points to the correct commit. + # ----------------------------------------------------------------------- + - name: Delete existing release and tag (if any) + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.tag.outputs.tag }}" + REPO="${{ github.repository }}" + + # --cleanup-tag deletes both the GitHub release object and the git tag. + if gh release delete "${TAG}" --cleanup-tag --yes 2>/dev/null; then + echo "Deleted existing release and tag: ${TAG}" + else + echo "No release found for ${TAG}" + # A bare git tag might still exist from a previously interrupted run. + if gh api --method DELETE \ + "repos/${REPO}/git/refs/tags/${TAG}" 2>/dev/null; then + echo "Deleted orphan git tag: ${TAG}" + else + echo "No existing tag to delete for: ${TAG}" + fi + fi + + # ----------------------------------------------------------------------- + # 5. Publish the release and upload all wheels as assets + # ----------------------------------------------------------------------- + - name: Create release and upload wheels + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.tag.outputs.tag }}" + TARGET_SHA="${{ steps.resolve.outputs.sha }}" + REPO="${{ github.repository }}" + + WHEEL_FILES=$(find wheels -type f -name "*.whl" | sort | tr '\n' ' ') + if [ -z "${WHEEL_FILES}" ]; then + echo "ERROR: no .whl files found to upload" >&2 + exit 1 + fi + + # PRERELEASE_FLAG must remain UNQUOTED in the gh invocation below. + # An empty string with word-splitting collapses to nothing; if quoted, + # the empty string would be passed as a spurious positional argument. + if [ "${{ inputs.prerelease }}" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + else + PRERELEASE_FLAG="" + fi + + echo "Creating release ${TAG} targeting ${TARGET_SHA}" + echo "Pre-release: ${{ inputs.prerelease }}" + echo "Wheels:" + echo "${WHEEL_FILES}" | tr ' ' '\n' + echo "" + + # ${PRERELEASE_FLAG} and ${WHEEL_FILES} are intentionally unquoted: + # PRERELEASE_FLAG – empty string must vanish via word-splitting + # WHEEL_FILES – space-separated paths must split into individual args + # shellcheck disable=SC2086 + gh release create "${TAG}" \ + --repo "${REPO}" \ + --target "${TARGET_SHA}" \ + --title "${TAG}" \ + --generate-notes \ + ${PRERELEASE_FLAG} \ + ${WHEEL_FILES} + + echo "" + echo "Release published: https://github.com/${REPO}/releases/tag/${TAG}" diff --git a/.github/workflows/tilegym-release.yml b/.github/workflows/tilegym-release.yml new file mode 100644 index 0000000..bc90b70 --- /dev/null +++ b/.github/workflows/tilegym-release.yml @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: MIT + +name: tilegym-release + +# Publishes tilegym wheels from the latest successful tilegym-ci run to a +# GitHub pre-release. +# +# Usage: +# Trigger manually via "Run workflow". By default it releases the wheels +# from the latest successful tilegym-ci run on main. To release a specific +# commit, supply its SHA in commit-sha. +# +# The workflow downloads all tilegym-wheel-*-verified artifacts, extracts the +# package version, and creates a tagged GitHub release. If a release for that +# version already exists it is overwritten. The git tag points to the commit +# that produced the artifacts. + +on: + workflow_dispatch: + inputs: + commit-sha: + description: 'Release wheels built from this commit (uses latest successful run on main when empty)' + required: false + type: string + default: '' + prerelease: + description: 'Mark the GitHub release as a pre-release' + required: false + type: boolean + default: true + +permissions: + contents: write + +jobs: + release-tilegym: + name: Release tilegym wheels + uses: ./.github/workflows/release-wheel.yml + with: + artifact-workflow: tilegym-ci.yml + # Match only the verified tilegym wheel artifacts, e.g.: + # tilegym-wheel--py310-x86_64-verified + # tilegym-wheel--py311-arm64-verified + # Excludes tilegym-pr-wheel-* PR builds (leading tilegym-pr- won't match). + artifact-pattern: '^tilegym-wheel-[0-9a-f]+-py[0-9]+-[a-z0-9_]+-verified$' + commit-sha: ${{ inputs.commit-sha }} + branch: main + tag-prefix: 'v' + prerelease: ${{ inputs.prerelease }} + secrets: inherit