name: Python Release to PyPI
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
publish-target:
description: "none = build/test only; testpypi = rehearsal publish to TestPyPI"
type: choice
options:
- none
- testpypi
default: none
permissions:
contents: read
env:
PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1"
jobs:
build-release-wheels:
name: Build release wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-2025]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
fail-fast: false
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with:
python-version: ${{ matrix.python-version }}
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
manylinux: auto
target: ${{ matrix.os == 'macos-latest' && 'universal2-apple-darwin' || '' }}
args: --release --out dist -i python${{ matrix.python-version }}
- name: Upload wheels
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-wheels-${{ matrix.os }}-${{ matrix.python-version }}
path: dist
build-cross-linux-wheels:
name: Build cross-compiled ${{ matrix.target }} wheel (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
matrix:
target: [aarch64-unknown-linux-gnu, i686-unknown-linux-gnu]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
fail-fast: false
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with:
python-version: ${{ matrix.python-version }}
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
target: ${{ matrix.target }}
manylinux: auto
args: --release --out dist -i python${{ matrix.python-version }}
- name: Upload wheels
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-wheels-${{ matrix.target }}-${{ matrix.python-version }}
path: dist
build-sdist:
name: Build sdist
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-sdist
path: dist
test-sdist:
name: Test install from sdist
needs: build-sdist
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with:
python-version: "3.12"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-sdist
path: dist
- name: Build from sdist and smoke-test
run: |
# Compiles the extension from source — proves the sdist is the
# working fallback for platforms outside the wheel matrix.
pip install dist/mrrc-*.tar.gz
# Run from outside the repo root: python -c puts the CWD on
# sys.path, and the source ./mrrc/ directory (no compiled
# extension) would shadow the installed package.
cd "$RUNNER_TEMP"
python -c "
import mrrc
data = open('$GITHUB_WORKSPACE/tests/data/simple_book.mrc','rb').read()
records = list(mrrc.MARCReader(data))
assert len(records) == 1 and records[0].title == 'The Great Gatsby'
print('sdist install OK from', mrrc.__file__)
"
validate-cross-wheels:
name: Validate ${{ matrix.target }} wheel (Python ${{ matrix.python-version }})
needs: build-cross-linux-wheels
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
target: [aarch64-unknown-linux-gnu, i686-unknown-linux-gnu]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
fail-fast: false
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-wheels-${{ matrix.target }}-${{ matrix.python-version }}
path: dist
- name: Validate wheel exists with correct platform tag
run: |
if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
EXPECTED_TAG="aarch64"
elif [ "${{ matrix.target }}" = "i686-unknown-linux-gnu" ]; then
EXPECTED_TAG="i686"
fi
WHEEL=$(ls dist/*.whl 2>/dev/null | head -1)
if [ -z "$WHEEL" ]; then
echo "ERROR: No .whl file found in dist/"
ls -la dist/
exit 1
fi
echo "Found wheel: $WHEEL"
if echo "$WHEEL" | grep -q "$EXPECTED_TAG"; then
echo "OK: Wheel filename contains expected tag '$EXPECTED_TAG'"
else
echo "ERROR: Wheel filename does not contain expected tag '$EXPECTED_TAG'"
exit 1
fi
smoke-test-cross-wheels:
name: Import-smoke ${{ matrix.target }} wheel (Python 3.12)
needs: build-cross-linux-wheels
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
target: [aarch64-unknown-linux-gnu, i686-unknown-linux-gnu]
fail-fast: false
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-wheels-${{ matrix.target }}-3.12
path: dist
- name: Set up QEMU (aarch64 emulation)
if: matrix.target == 'aarch64-unknown-linux-gnu'
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3
- name: Write import-smoke script
run: |
# Executed with python -I so neither the CWD nor the script
# directory lands on sys.path; otherwise the source ./mrrc/
# directory (no compiled extension) shadows the installed wheel.
cat > smoke.py <<'EOF'
import mrrc
data = open("tests/data/simple_book.mrc", "rb").read()
records = list(mrrc.MARCReader(data))
assert len(records) == 1
assert records[0].title == "The Great Gatsby"
print("wheel import OK:", mrrc.__file__)
EOF
- name: Import-smoke aarch64 wheel under QEMU
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
docker run --rm --platform linux/arm64 -v "$PWD":/work -w /work \
python:3.12-slim \
sh -c 'pip install --no-cache-dir dist/*.whl && python -I smoke.py'
- name: Import-smoke i686 wheel in 32-bit container
if: matrix.target == 'i686-unknown-linux-gnu'
run: |
# i686 binaries run natively on the x86_64 runner kernel; linux32
# sets the uname personality so pip accepts the i686 wheel tag.
docker run --rm --platform linux/386 -v "$PWD":/work -w /work \
quay.io/pypa/manylinux2014_i686 \
linux32 sh -c '/opt/python/cp312-cp312/bin/pip install --no-cache-dir dist/*.whl && /opt/python/cp312-cp312/bin/python -I smoke.py'
test-release-wheels:
name: Test release wheels on ${{ matrix.os }}
needs: build-release-wheels
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-2025]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
fail-fast: false
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-wheels-${{ matrix.os }}-${{ matrix.python-version }}
path: dist
- name: Install wheel and test dependencies
shell: bash
run: |
# Install the wheel together with the [dev] extra (which extends
# [test] via self-reference) so test runtime deps + type checkers
# stay in sync with pyproject.toml as a single source of truth.
WHEEL=$(ls dist/mrrc-*.whl | head -1)
uv pip install --system "${WHEEL}[dev]" pytest-rerunfailures
- name: Run pytest (core tests, excludes benchmarks)
shell: bash
env:
RERUN_FLAGS: ${{ runner.os == 'macOS' && '--reruns 2 --reruns-delay 5' || '' }}
run: pytest tests/python/ -m "not benchmark" -v $RERUN_FLAGS
- name: Run type checking
run: |
mypy tests/python/ --strict || true
pyright tests/python/ || true
publish-pypi:
name: Publish to PyPI
needs: [test-release-wheels, validate-cross-wheels, smoke-test-cross-wheels, test-sdist]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
id-token: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: release-*
path: dist
merge-multiple: true
- name: List artifacts
run: ls -l dist/
- name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags/')
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b with:
skip-existing: true
- name: Publish to TestPyPI (rehearsal)
if: github.event_name == 'workflow_dispatch' && inputs.publish-target == 'testpypi'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true
github-release:
name: Create GitHub Release
needs: publish-pypi
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 with:
sparse-checkout: CHANGELOG.md
sparse-checkout-cone-mode: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
pattern: release-*
path: dist
merge-multiple: true
- name: Extract release notes from CHANGELOG
run: |
VERSION="${GITHUB_REF_NAME#v}"
# Extract the section for this version (between its header and the next ## header)
NOTES=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md)
# Write to file to avoid shell quoting issues
echo "$NOTES" > release_notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda with:
files: dist/*
body_path: release_notes.md
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') }}