publish wheels #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Manually validate or publish a wheelhouse artifact from a completed wheels workflow. | |
| # Validation performs every local check but does not request credentials or upload anything. | |
| name: publish wheels | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| target: | |
| description: 'Validate only, publish to TestPyPI, or publish to PyPI' | |
| type: choice | |
| default: validate-only | |
| options: | |
| - validate-only | |
| - testpypi | |
| - pypi | |
| wheels_run_id: | |
| description: 'Successful wheels workflow run containing the wheelhouse' | |
| type: string | |
| required: true | |
| release_tag: | |
| description: 'Release tag whose commit produced the wheels (for example v1.0.0-rc1)' | |
| type: string | |
| required: true | |
| project: | |
| description: 'Project to publish (use all after every Trusted Publisher exists)' | |
| type: choice | |
| default: superdex-physics | |
| options: | |
| - all | |
| - superdex-physics | |
| - superdex-mesh-cli | |
| - superdex-studio | |
| - superdex-physics-debugger | |
| - superdex-physics-fp64 | |
| - superdex-robotics | |
| - superdex-robotics-fp64 | |
| - superdex-lab | |
| - superdex | |
| permissions: {} | |
| concurrency: | |
| group: superdex-publish-${{ inputs.target }} | |
| cancel-in-progress: false | |
| env: | |
| PYTHON_VERSION: '3.12' | |
| TWINE_VERSION: '6.2.0' | |
| jobs: | |
| preflight: | |
| runs-on: ubuntu-22.04 | |
| timeout-minutes: 30 | |
| outputs: | |
| matrix: ${{ steps.projects.outputs.matrix }} | |
| permissions: | |
| actions: read | |
| contents: read | |
| steps: | |
| - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| - name: Download the selected wheelhouse | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | |
| with: | |
| name: wheelhouse | |
| path: wheelhouse | |
| github-token: ${{ github.token }} | |
| repository: ${{ github.repository }} | |
| run-id: ${{ inputs.wheels_run_id }} | |
| - name: Check the selected wheels run | |
| id: wheels | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_TAG: ${{ inputs.release_tag }} | |
| REPOSITORY: ${{ github.repository }} | |
| RUN_ID: ${{ inputs.wheels_run_id }} | |
| TARGET: ${{ inputs.target }} | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| import re | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| from pathlib import Path | |
| repository = os.environ["REPOSITORY"] | |
| # Authenticate GitHub API reads with the workflow token. | |
| headers = { | |
| "Accept": "application/vnd.github+json", | |
| "Authorization": f"Bearer {os.environ['GH_TOKEN']}", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| } | |
| def github_json(path): | |
| request = urllib.request.Request( | |
| f"https://api.github.com/repos/{repository}{path}", headers=headers | |
| ) | |
| try: | |
| with urllib.request.urlopen(request, timeout=30) as response: | |
| return json.load(response) | |
| except (OSError, urllib.error.HTTPError) as error: | |
| raise SystemExit(f"GitHub API request failed: {error}") from error | |
| # Bind validation to a successful manual run of the wheels workflow. | |
| run_id = os.environ["RUN_ID"] | |
| if re.fullmatch(r"[1-9][0-9]*", run_id) is None: | |
| raise SystemExit("wheels_run_id must be a positive integer") | |
| run = github_json(f"/actions/runs/{run_id}") | |
| workflow = str(run.get("path", "")).split("@", 1)[0] | |
| if workflow != ".github/workflows/wheels.yml": | |
| raise SystemExit(f"selected run used unexpected workflow {workflow!r}") | |
| if run.get("event") != "workflow_dispatch" or run.get("conclusion") != "success": | |
| raise SystemExit("selected wheels workflow run did not complete successfully") | |
| source_sha = run.get("head_sha") | |
| if not isinstance(source_sha, str) or re.fullmatch(r"[0-9a-f]{40}", source_sha) is None: | |
| raise SystemExit("selected wheels workflow run has an invalid commit SHA") | |
| # A complete release contains exactly 23 wheels and no other files. | |
| entries = sorted(Path("wheelhouse").iterdir()) | |
| wheels = [path for path in entries if path.is_file() and path.suffix == ".whl"] | |
| if len(entries) != 23 or len(wheels) != 23: | |
| raise SystemExit(f"wheelhouse must contain exactly 23 wheels; found {len(wheels)}") | |
| # Extract and require the single version shared by every wheel. | |
| versions = set() | |
| for wheel in wheels: | |
| try: | |
| distribution_and_version, _, _, _ = wheel.stem.rsplit("-", 3) | |
| _, version = distribution_and_version.rsplit("-", 1) | |
| except ValueError as error: | |
| raise SystemExit(f"malformed wheel filename: {wheel.name}") from error | |
| versions.add(version) | |
| if len(versions) != 1: | |
| raise SystemExit(f"wheelhouse must contain one version; found {sorted(versions)}") | |
| wheel_version = versions.pop() | |
| # Require an index-appropriate tag whose version matches the wheels. | |
| target = os.environ["TARGET"] | |
| release_tag = os.environ["RELEASE_TAG"] | |
| if target == "testpypi": | |
| match = re.fullmatch( | |
| r"v(?P<version>[0-9]+(?:\.[0-9]+)+)-rc[1-9][0-9]*", | |
| release_tag, | |
| ) | |
| expected = "an exact vX.Y.Z-rcN tag" | |
| elif target == "pypi": | |
| match = re.fullmatch( | |
| r"v(?P<version>[0-9]+(?:\.[0-9]+)+)", release_tag | |
| ) | |
| expected = "an exact vX.Y.Z tag" | |
| else: | |
| match = re.fullmatch( | |
| r"v(?P<version>[0-9]+(?:\.[0-9]+)+)(?:-rc[1-9][0-9]*)?", | |
| release_tag, | |
| ) | |
| expected = "an exact vX.Y.Z or vX.Y.Z-rcN tag" | |
| if match is None: | |
| raise SystemExit(f"{target} requires {expected}") | |
| if match.group("version") != wheel_version: | |
| raise SystemExit( | |
| f"tag version {match.group('version')} does not match wheel version {wheel_version}" | |
| ) | |
| # Require the tag to identify the exact commit that built the wheels. | |
| tag_commit = github_json( | |
| f"/commits/{urllib.parse.quote(release_tag, safe='')}" | |
| ).get("sha") | |
| if tag_commit != source_sha: | |
| raise SystemExit("release tag must point to the selected wheels run commit") | |
| with open(os.environ["GITHUB_OUTPUT"], "a") as output: | |
| output.write(f"source_sha={source_sha}\n") | |
| output.write(f"version={wheel_version}\n") | |
| output.write(f"wheel_count={len(wheels)}\n") | |
| PY | |
| - name: Select projects to publish | |
| id: projects | |
| shell: bash | |
| env: | |
| PROJECT: ${{ inputs.project }} | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| # Map package names to wheel prefixes and expected platform counts. | |
| projects = { | |
| "superdex": ("superdex", 1), | |
| "superdex-lab": ("superdex_lab", 1), | |
| "superdex-mesh-cli": ("superdex_mesh_cli", 3), | |
| "superdex-physics": ("superdex_physics", 3), | |
| "superdex-physics-debugger": ("superdex_physics_debugger", 3), | |
| "superdex-physics-fp64": ("superdex_physics_fp64", 3), | |
| "superdex-robotics": ("superdex_robotics", 3), | |
| "superdex-robotics-fp64": ("superdex_robotics_fp64", 3), | |
| "superdex-studio": ("superdex_studio", 3), | |
| } | |
| # Emit one publishing job, or all nine after bootstrap is complete. | |
| selected = os.environ["PROJECT"] | |
| names = projects if selected == "all" else (selected,) | |
| include = [ | |
| { | |
| "project": name, | |
| "wheel_prefix": projects[name][0], | |
| "wheel_count": projects[name][1], | |
| } | |
| for name in names | |
| ] | |
| with open(os.environ["GITHUB_OUTPUT"], "a") as output: | |
| output.write(f"matrix={json.dumps({'include': include})}\n") | |
| PY | |
| - name: Install metadata checker | |
| shell: bash | |
| run: python -m pip install --upgrade pip "twine==${TWINE_VERSION}" | |
| - name: Check every wheel's publish metadata | |
| shell: bash | |
| run: python -m twine check wheelhouse/*.whl | |
| - name: Stage the checked wheels for publishing | |
| if: inputs.target != 'validate-only' | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: publish-input | |
| path: wheelhouse/*.whl | |
| if-no-files-found: error | |
| retention-days: 7 | |
| - name: Summarize preflight | |
| shell: bash | |
| run: | | |
| { | |
| echo '## SuperDex publication preflight' | |
| echo | |
| echo "- Target: \`${{ inputs.target }}\`" | |
| echo "- Selected wheels run: \`${{ inputs.wheels_run_id }}\`" | |
| echo "- Wheels commit: \`${{ steps.wheels.outputs.source_sha }}\`" | |
| echo "- Wheel version: \`${{ steps.wheels.outputs.version }}\`" | |
| echo "- Release tag: \`${{ inputs.release_tag }}\`" | |
| echo "- Wheels: \`${{ steps.wheels.outputs.wheel_count }}\`" | |
| echo "- Selected project: \`${{ inputs.project }}\`" | |
| if [[ '${{ inputs.target }}' == 'validate-only' ]]; then | |
| echo '- Result: validation passed; no upload was attempted' | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| publish: | |
| name: publish ${{ matrix.project }} | |
| needs: preflight | |
| if: inputs.target == 'testpypi' || inputs.target == 'pypi' | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.preflight.outputs.matrix) }} | |
| environment: | |
| name: publish-wheel-${{ matrix.project }} | |
| runs-on: ubuntu-22.04 | |
| timeout-minutes: 30 | |
| permissions: | |
| actions: read | |
| id-token: write | |
| steps: | |
| - name: Download the preflight-approved wheels | |
| uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 | |
| with: | |
| name: publish-input | |
| path: publish-input | |
| - name: Select ${{ matrix.project }} wheels | |
| shell: bash | |
| env: | |
| EXPECTED_WHEEL_COUNT: ${{ matrix.wheel_count }} | |
| WHEEL_PREFIX: ${{ matrix.wheel_prefix }} | |
| run: | | |
| mkdir project-wheels | |
| shopt -s nullglob | |
| wheels=(publish-input/"${WHEEL_PREFIX}"-*.whl) | |
| if (( ${#wheels[@]} != EXPECTED_WHEEL_COUNT )); then | |
| echo "::error::expected $EXPECTED_WHEEL_COUNT $WHEEL_PREFIX wheels; found ${#wheels[@]}" | |
| exit 1 | |
| fi | |
| cp -- "${wheels[@]}" project-wheels/ | |
| - name: Publish ${{ matrix.project }} wheels | |
| uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 | |
| with: | |
| packages-dir: project-wheels | |
| repository-url: ${{ inputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} | |
| - name: Summarize publication | |
| shell: bash | |
| run: | | |
| { | |
| echo '## SuperDex project published' | |
| echo | |
| echo "- Target: \`${{ inputs.target }}\`" | |
| echo "- Project: \`${{ matrix.project }}\`" | |
| echo "- Wheels run: \`${{ inputs.wheels_run_id }}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" |