qcal / .github /workflows /release.yml
Claude
Add tag-triggered PyPI release workflow with OIDC trusted publishing
2091813 unverified
name: Release to PyPI
# Tag-triggered release for `qcal-copilot`. Pipeline:
#
# build → publish-testpypi → smoke-test-testpypi → publish-pypi → github-release
#
# Uses PyPI Trusted Publishing (OIDC), so no long-lived tokens in secrets.
# BEFORE this workflow can publish, configure the trusted publisher once on
# each index:
#
# TestPyPI: https://test.pypi.org/manage/account/publishing/
# PyPI: https://pypi.org/manage/account/publishing/
#
# For both, register:
# repo owner: athurlow
# repo name: qcal
# workflow: release.yml
# environment: pypi (for PyPI)
# testpypi (for TestPyPI)
#
# Matching GitHub environments `pypi` and `testpypi` must exist with
# `id-token: write` permitted — that's the only way OIDC tokens get minted.
#
# Manual dry-run: Actions tab → Run workflow → set `testpypi_only: true`
# to publish a pre-release to TestPyPI without touching PyPI. The tag push
# path always publishes to both.
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
testpypi_only:
description: "Publish to TestPyPI only (skip PyPI + GitHub release)"
type: boolean
default: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# -------------------------------------------------------------------------
# 1. Build sdist + wheel once. Downstream jobs consume the artifact so the
# exact bytes published to TestPyPI are the same ones published to PyPI.
# -------------------------------------------------------------------------
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build tooling
run: python -m pip install --upgrade pip build
# Fail the release if pyproject.toml's version doesn't match the tag,
# so we never ship a 0.1.0 wheel tagged as v0.2.0 (or vice versa).
# Skipped on workflow_dispatch since there's no tag to compare against.
- name: Verify pyproject version matches tag
if: github.event_name == 'push'
run: |
tag_version="${GITHUB_REF_NAME#v}"
pkg_version=$(python -c "
import tomllib, pathlib
print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])
")
if [ "${tag_version}" != "${pkg_version}" ]; then
echo "::error::Tag v${tag_version} does not match pyproject.toml version ${pkg_version}."
echo "Bump pyproject.toml or retag before pushing."
exit 1
fi
echo "Version check OK: ${pkg_version}"
- name: Build distributions
run: python -m build
- name: List built artifacts
run: ls -la dist/
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
if-no-files-found: error
# -------------------------------------------------------------------------
# 2. Publish to TestPyPI first, always.
# -------------------------------------------------------------------------
publish-testpypi:
name: Publish to TestPyPI
needs: build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/project/qcal-copilot/
permissions:
id-token: write # required for Trusted Publishing OIDC
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
# TestPyPI sometimes has leftover versions from prior dry runs;
# don't fail the release over it.
skip-existing: true
# -------------------------------------------------------------------------
# 3. Smoke-test: install from TestPyPI in a clean runner and assert that
# the CLI + Python imports work. If this fails, do NOT publish to PyPI.
# -------------------------------------------------------------------------
smoke-test-testpypi:
name: Smoke-test TestPyPI install
needs: publish-testpypi
runs-on: ubuntu-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install from TestPyPI
run: |
# TestPyPI doesn't mirror transitive deps — fall back to PyPI for those.
# Retry a few times because TestPyPI's CDN takes 30-60s to propagate
# a fresh upload before the new version becomes resolvable.
for attempt in 1 2 3 4 5; do
if python -m pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
qcal-copilot; then
exit 0
fi
echo "Attempt ${attempt} failed, sleeping before retry..."
sleep 15
done
echo "::error::pip install from TestPyPI failed after 5 attempts."
exit 1
- name: Verify CLI + imports
run: |
qcal version
python -c "
import qcal
from qcal import analyzer, codegen, data, decoder, fit, simulator, config, cli
assert qcal.__version__, 'missing __version__'
print('smoke test OK, version =', qcal.__version__)
"
# -------------------------------------------------------------------------
# 4. Publish to real PyPI. Skipped on workflow_dispatch dry runs.
# -------------------------------------------------------------------------
publish-pypi:
name: Publish to PyPI
needs: smoke-test-testpypi
if: github.event_name == 'push' || github.event.inputs.testpypi_only != 'true'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/qcal-copilot/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
# -------------------------------------------------------------------------
# 5. Attach the built wheel + sdist to a GitHub Release, with auto-generated
# notes. Tag-push only.
# -------------------------------------------------------------------------
github-release:
name: Create GitHub Release
needs: publish-pypi
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true