From 55e056e990fa7d3a18c71fda1c41c8b26c403a5d Mon Sep 17 00:00:00 2001 From: Jacob Alexander Date: Sat, 30 May 2026 09:45:53 -0700 Subject: [PATCH] Add scripts/release-pypi.sh for downloading CI artifacts and uploading to PyPI Automates the post-tag release flow: resolve the latest successful Build workflow run for a given tag (or take an explicit run ID), download the cibw-* artifacts via gh, flatten wheels/sdists into a dist_/ directory, and upload via twine from a dedicated .venv-release/ virtualenv. Also documents the release flow in README.md. --- README.md | 40 +++++++++++ scripts/release-pypi.sh | 153 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100755 scripts/release-pypi.sh diff --git a/README.md b/README.md index d407d57..00003d9 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,46 @@ Building a Python wheel distributiion pip wheel . ``` + +### Releasing to PyPI + +Wheels and the sdist are built by the `Build` GitHub Actions workflow +(`.github/workflows/wheels.yml`) for every push, including tag pushes. The +`scripts/release-pypi.sh` helper downloads those artifacts for a given tag (or +explicit run ID) and uploads them to PyPI via `twine`. + +Typical release flow: + +```bash +git tag v2.2.1 +git push origin v2.2.1 +# wait for the "Build" workflow run to finish successfully on GitHub + +# Download artifacts and upload to PyPI (creates dist_221/ by default). +scripts/release-pypi.sh v2.2.1 + +# Or, target a specific Actions run id: +scripts/release-pypi.sh 1234567890 +``` + +Requirements on the release machine: + +- `gh` CLI, authenticated (`gh auth login`) +- `python3` (the script creates `.venv-release/` and installs `twine` into it) +- PyPI credentials available to `twine`, e.g. `TWINE_USERNAME=__token__` and + `TWINE_PASSWORD=`, or a configured `~/.pypirc` + +The script: + +1. Resolves the latest successful `wheels.yml` run for the tag (or uses the + given run ID). +2. Downloads `cibw-*` artifacts and flattens all `*.whl` / `*.tar.gz` files + into the output directory (default `dist_` for tags, + `dist_run_` for run IDs; pass a second arg to override, and `--force` + to reuse a non-empty directory). +3. Runs `twine check`, prints the file list, and prompts before running + `twine upload`. + ## Documentation/Example There is some basic documentation [here](http://capnproto.github.io/pycapnp/). diff --git a/scripts/release-pypi.sh b/scripts/release-pypi.sh new file mode 100755 index 0000000..6a22b81 --- /dev/null +++ b/scripts/release-pypi.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Download GitHub Actions build artifacts for a tagged commit (or specific run +# ID) and upload the wheels + sdist to PyPI via twine. +# +# Usage: +# scripts/release-pypi.sh [output-dir] [--force] +# +# Examples: +# scripts/release-pypi.sh v2.2.1 +# scripts/release-pypi.sh 2.2.1 dist_221 +# scripts/release-pypi.sh 1234567890 dist_run_1234567890 +# +# Requirements: +# - gh CLI (authenticated; `gh auth status` must succeed) +# - python3 +# - Twine credentials configured in the environment or ~/.pypirc +# (TWINE_USERNAME / TWINE_PASSWORD, or an API token). + +set -euo pipefail + +WORKFLOW_FILE="wheels.yml" +ARTIFACT_PATTERN="cibw-*" +VENV_DIR=".venv-release" + +usage() { + cat >&2 < [output-dir] [--force] + + Git tag (e.g. v2.2.1 or 2.2.1) or a GitHub Actions run ID. + [output-dir] Directory to place wheels/sdist into. Defaults to + dist_ for a tag, or dist_run_ for a run ID. + --force Allow reusing a non-empty output directory. +EOF + exit 2 +} + +die() { + echo "error: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command '$1' not found in PATH" +} + +FORCE=0 +POSITIONAL=() +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + -h|--help) usage ;; + *) POSITIONAL+=("$arg") ;; + esac +done + +[[ ${#POSITIONAL[@]} -ge 1 && ${#POSITIONAL[@]} -le 2 ]] || usage + +INPUT="${POSITIONAL[0]}" +OUTPUT_DIR="${POSITIONAL[1]:-}" + +require_cmd gh +require_cmd python3 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +echo ">> verifying gh authentication" +gh auth status >/dev/null || die "gh is not authenticated; run 'gh auth login'" + +# Resolve run ID. A bare integer is treated as a run ID; otherwise we look up +# the latest successful run on the branch/tag matching the build workflow. +if [[ "$INPUT" =~ ^[0-9]+$ ]]; then + RUN_ID="$INPUT" + DEFAULT_OUT="dist_run_${RUN_ID}" + echo ">> using GitHub Actions run id: $RUN_ID" +elif [[ "$INPUT" =~ ^v?[0-9].* ]]; then + TAG="$INPUT" + TAG_NO_V="${TAG#v}" + DEFAULT_OUT="dist_${TAG_NO_V//./}" + echo ">> resolving latest successful '$WORKFLOW_FILE' run for tag '$TAG'" + RUN_ID="$(gh run list \ + --workflow="$WORKFLOW_FILE" \ + --branch "$TAG" \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId')" + if [[ -z "${RUN_ID:-}" || "$RUN_ID" == "null" ]]; then + die "no successful '$WORKFLOW_FILE' run found for tag '$TAG'" + fi + echo ">> resolved run id: $RUN_ID" +else + die "first arg must be a git tag (vX.Y.Z) or a numeric run ID" +fi + +OUTPUT_DIR="${OUTPUT_DIR:-$DEFAULT_OUT}" + +if [[ -e "$OUTPUT_DIR" ]]; then + if [[ -d "$OUTPUT_DIR" ]]; then + if [[ -n "$(ls -A "$OUTPUT_DIR" 2>/dev/null)" && "$FORCE" -ne 1 ]]; then + die "'$OUTPUT_DIR' is not empty; pass --force to reuse it" + fi + else + die "'$OUTPUT_DIR' exists and is not a directory" + fi +fi +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR_ABS="$(cd "$OUTPUT_DIR" && pwd)" + +TMP_DIR="$(mktemp -d -t pycapnp-release-XXXXXX)" +trap 'rm -rf "$TMP_DIR"' EXIT + +echo ">> downloading artifacts (pattern '$ARTIFACT_PATTERN') into $TMP_DIR" +gh run download "$RUN_ID" --dir "$TMP_DIR" --pattern "$ARTIFACT_PATTERN" + +echo ">> flattening wheels and sdists into $OUTPUT_DIR_ABS" +shopt -s nullglob globstar +moved=0 +for f in "$TMP_DIR"/**/*.whl "$TMP_DIR"/**/*.tar.gz; do + [[ -f "$f" ]] || continue + mv -n "$f" "$OUTPUT_DIR_ABS/" + moved=$((moved + 1)) +done +shopt -u globstar +[[ "$moved" -gt 0 ]] || die "no .whl or .tar.gz files found in downloaded artifacts" +echo ">> collected $moved files" + +echo ">> setting up release virtualenv at $VENV_DIR" +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + python3 -m venv "$VENV_DIR" +fi +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" +python -m pip install --quiet --upgrade pip +python -m pip install --quiet --upgrade twine + +echo ">> running 'twine check'" +python -m twine check "$OUTPUT_DIR_ABS"/* + +echo +echo "Files to upload from $OUTPUT_DIR_ABS:" +ls -1 "$OUTPUT_DIR_ABS" +echo +read -r -p "Upload these to PyPI? [y/N] " reply +case "$reply" in + [yY]|[yY][eE][sS]) ;; + *) echo "aborted; files remain in $OUTPUT_DIR_ABS"; exit 0 ;; +esac + +echo ">> uploading to PyPI via twine" +python -m twine upload "$OUTPUT_DIR_ABS"/* + +echo ">> done"