Skip to content

Release Python wheels (build only) #1

Release Python wheels (build only)

Release Python wheels (build only) #1

name: Release Python
run-name: Release Python wheels (${{ inputs.publish && 'publish' || 'build only' }})
on:
pull_request:
paths:
- ".github/workflows/python.yml"
- ".github/workflows/release-crates.yml"
- ".github/workflows/release-python.yml"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- "crates/**"
- "README.md"
- "docs/**"
- "pyproject.toml"
- "python-build-constraints.txt"
- "python/**"
- "scripts/check-python-wheel.sh"
- "scripts/validate-python-release-version.sh"
- "tests/python/**"
merge_group:
push:
branches:
- main
paths:
- ".github/workflows/python.yml"
- ".github/workflows/release-crates.yml"
- ".github/workflows/release-python.yml"
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- "crates/**"
- "README.md"
- "docs/**"
- "pyproject.toml"
- "python-build-constraints.txt"
- "python/**"
- "scripts/check-python-wheel.sh"
- "scripts/validate-python-release-version.sh"
- "tests/python/**"
workflow_dispatch:
inputs:
publish:
description: "Publish validated wheels to PyPI after all builds succeed"
required: true
type: boolean
default: false
concurrency:
# Keep publication serialized, without letting PR validation occupy its queue.
group: release-python-${{ inputs.publish && 'publish' || format('{0}-{1}', github.event_name, github.ref) }}
cancel-in-progress: ${{ !inputs.publish }}
permissions:
contents: read
jobs:
release-version:
name: Read declared workspace version
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || !inputs.publish
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout release commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Read Cargo workspace version
id: version
run: |
python3 - <<'PYTHON'
import os
import tomllib
from pathlib import Path
with Path("Cargo.toml").open("rb") as manifest:
version = tomllib.load(manifest)["workspace"]["package"]["version"]
if not isinstance(version, str) or not version or any(c in version for c in "\r\n"):
raise SystemExit("Invalid Cargo workspace version")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write(f"version={version}\n")
print(f"Release version: {version}")
PYTHON
- name: Reject an existing PyPI release
if: inputs.publish
env:
AGENTIC_API_RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
--connect-timeout 10 --max-time 30 \
"https://pypi.org/pypi/agentic-api/${AGENTIC_API_RELEASE_VERSION}/json")"
case "$status" in
404) echo "Version $AGENTIC_API_RELEASE_VERSION is not published" ;;
200)
echo "::error::agentic-api $AGENTIC_API_RELEASE_VERSION is already published; bump the Cargo workspace version"
exit 1
;;
*)
echo "::error::Unable to check PyPI release availability (HTTP $status)"
exit 1
;;
esac
build-wheels:
name: Build ${{ matrix.target }} wheel artifact
needs: release-version
runs-on: ${{ matrix.runs-on }}
if: github.ref == 'refs/heads/main' || !inputs.publish
env:
AGENTIC_API_RELEASE_VERSION: ${{ needs.release-version.outputs.version }}
strategy:
fail-fast: false
matrix:
include:
- runs-on: ubuntu-22.04
os: linux
target: linux-x86_64
wheel-tag: py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64
- runs-on: macos-15-intel
os: macos
target: macos-x86_64
wheel-tag: py3-none-macosx_10_12_x86_64
deployment-target: "10.12"
- runs-on: macos-14
os: macos
target: macos-arm64
wheel-tag: py3-none-macosx_11_0_arm64
deployment-target: "11.0"
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify release version
run: scripts/validate-python-release-version.sh
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: |
pyproject.toml
python-build-constraints.txt
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: 1.98.0
components: clippy, rustfmt
- name: Cache cargo registry and build
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target/release-python/${{ matrix.target }}
key: cargo-release-python-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'python-build-constraints.txt') }}
restore-keys: |
cargo-release-python-${{ runner.os }}-${{ matrix.target }}-
cargo-${{ runner.os }}-
- name: Install uv
run: python -m pip install --constraint python-build-constraints.txt uv
- name: Verify version agreement and prepare artifact directory
id: prepare
run: |
set -euo pipefail
scripts/validate-python-release-version.sh
requested_version="$AGENTIC_API_RELEASE_VERSION"
wheel_dir="target/release-python/${{ matrix.target }}/wheels"
cargo_version="$(cargo metadata --format-version 1 --no-deps \
| jq -r '.packages[] | select(.name == "agentic-server") | .version')"
core_version="$(cargo metadata --format-version 1 --no-deps \
| jq -r '.packages[] | select(.name == "agentic-server-core") | .version')"
if [ -z "$cargo_version" ] || [ "$cargo_version" = "null" ]; then
echo "::error::agentic-server package not found in workspace metadata"
exit 1
fi
if [ "$cargo_version" != "$core_version" ]; then
echo "::error::agentic-server ($cargo_version) and agentic-server-core ($core_version) versions differ"
exit 1
fi
if [ "$cargo_version" != "$requested_version" ]; then
echo "::error::workspace version $cargo_version does not match requested release version $requested_version"
exit 1
fi
rm -rf "$wheel_dir"
mkdir -p "$wheel_dir"
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python --constraint python-build-constraints.txt maturin pytest
echo "wheel_dir=$wheel_dir" >> "$GITHUB_OUTPUT"
- name: Build pinned manylinux2014 x86_64 wheel
if: matrix.os == 'linux'
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380 # v1.49.4
with:
command: build
# The manylinux image omits OpenSSL headers. Build and statically link
# the Cargo.lock-pinned source so the wheel needs no system OpenSSL.
args: --release --locked --features vendored-openssl --out ${{ steps.prepare.outputs.wheel_dir }}
container: quay.io/pypa/manylinux2014_x86_64@sha256:95440e0e72dd3a81dc8d2cf59a84d57af661456620f5bc821ff92048d0e54ff9
manylinux: "2014"
maturin-version: v1.14.1
rust-toolchain: 1.98.0
target: x86_64-unknown-linux-gnu
# OpenSSL's Configure script needs core Perl modules (including
# IPC::Cmd) that are absent from the stripped-down manylinux image.
before-script-linux: yum install -y perl-core
- name: Build macOS wheel
if: matrix.os == 'macos'
env:
CARGO_TARGET_DIR: target/release-python/${{ matrix.target }}/cargo
MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment-target }}
run: >-
.venv/bin/python -m maturin build --release --locked
--out "${{ steps.prepare.outputs.wheel_dir }}"
- name: Validate exact wheel artifact
id: build
run: |
set -euo pipefail
scripts/validate-python-release-version.sh
requested_version="$AGENTIC_API_RELEASE_VERSION"
wheel_dir="${{ steps.prepare.outputs.wheel_dir }}"
expected_wheel_tag="${{ matrix.wheel-tag }}"
wheel_path="$wheel_dir/agentic_api-${requested_version}-${expected_wheel_tag}.whl"
if [ ! -f "$wheel_path" ]; then
echo "::error::expected wheel not found: $wheel_path"
find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' -print
exit 1
fi
wheel_count="$(find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' | wc -l | tr -d ' ')"
if [ "$wheel_count" != "1" ]; then
echo "::error::expected exactly one wheel in $wheel_dir; found $wheel_count"
exit 1
fi
uv pip install --python .venv/bin/python "$wheel_path"
AGENTIC_API_TEST_WHEEL="$wheel_path" .venv/bin/python -m pytest tests/python -q
AGENTIC_API_CHECK_PYTHON=.venv/bin/python \
AGENTIC_API_CHECK_SCRIPTS_DIR=.venv/bin \
AGENTIC_API_EXPECTED_VERSION="$requested_version" \
AGENTIC_API_EXPECTED_WHEEL_TAG="$expected_wheel_tag" \
scripts/check-python-wheel.sh "$wheel_path"
echo "wheel_path=$wheel_path" >> "$GITHUB_OUTPUT"
- name: Check Linux wheel has no system OpenSSL dependency
if: matrix.os == 'linux'
run: |
set -euo pipefail
dependencies="$(ldd .venv/bin/agentic)"
printf '%s\n' "$dependencies"
case "$dependencies" in
*libssl.so*|*libcrypto.so*|*"not found"*)
echo "::error::wheel binary has a system OpenSSL dependency or an unresolved shared library"
exit 1
;;
esac
- name: Upload wheel artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: agentic-api-${{ needs.release-version.outputs.version }}-${{ matrix.target }}
path: ${{ steps.build.outputs.wheel_path }}
if-no-files-found: error
publish:
name: Publish validated wheels to PyPI
needs: [release-version, build-wheels]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.publish
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/agentic-api/
permissions:
id-token: write
steps:
- name: Download validated wheels from this run
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: agentic-api-${{ needs.release-version.outputs.version }}-*
merge-multiple: true
path: dist/
- name: Validate publication artifacts
env:
AGENTIC_API_RELEASE_VERSION: ${{ needs.release-version.outputs.version }}
run: |
python3 - <<'PYTHON'
import os
from pathlib import Path
version = os.environ["AGENTIC_API_RELEASE_VERSION"]
tags = (
"py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64",
"py3-none-macosx_10_12_x86_64",
"py3-none-macosx_11_0_arm64",
)
expected = {f"agentic_api-{version}-{tag}.whl" for tag in tags}
actual = {path.name for path in Path("dist").iterdir() if path.is_file()}
if actual != expected:
raise SystemExit(f"Expected exactly {sorted(expected)}, found {sorted(actual)}")
PYTHON
- name: Publish wheels using Trusted Publishing
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
packages-dir: dist/