1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
name: Publish Python package
# Release of the `infino` Python package: validate the version, build one binary
# wheel per platform/arch, then upload. The version is the one committed in
# `infino-python/Cargo.toml` (stamped by `make release-prep`). A `v<version>`
# tag publishes to real PyPI after asserting the tag matches the tree — but
# ONLY for a coordinated minor/major release (patch == 0, e.g. `v0.2.0`); a
# crate-only patch tag (e.g. `v0.1.1`) is skipped, since patch is independent
# per package. A manual run (workflow_dispatch) — the path for an independent
# Python patch — publishes the tree's version to the PyPI (default) / TestPyPI
# target.
#
# Two facts shape the whole pipeline:
# - abi3 (pyo3 `abi3-py39`) → a single wheel per platform/arch covers all
# CPython >= 3.9, so the matrix is platform × arch only, never × Python.
# - The bindings depend on the core crate by path (`infino = { path = ".." }`),
# so an sdist can't build outside this repo. Distribution is wheels-only,
# and the matrix is kept wide enough that pip never falls back to source.
on:
push:
tags:
workflow_dispatch:
inputs:
target:
description: "Index to publish to"
required: true
default: pypi
type: choice
options:
# One release per index at a time; never cancel a publish in flight. A tag push
# always targets PyPI (see the validate step), so derive the index from the
# event — `inputs.target` is empty on a push and would otherwise split tag
# releases into a separate group from manual PyPI runs.
concurrency:
group: publish-python-${{ github.event_name == 'push' && 'pypi' || inputs.target }}
cancel-in-progress: false
# Least privilege by default; the publish job widens to `id-token: write`
# for PyPI Trusted Publishing (OIDC). No long-lived credentials.
permissions:
contents: read
env:
# The bindings pull the core crate as an ordinary dependency; a transitive
# deprecation warning must not fail a release build.
RUSTFLAGS: ""
jobs:
validate:
name: Validate version
# Coordinated-release gate: on a tag push, run only when the tag is a
# minor/major (patch == 0, tag ends in `.0`). A crate-only patch tag skips
# the bindings (build + publish need this job, so they skip too). A manual
# run always proceeds (independent Python patch).
if: ${{ github.event_name == 'workflow_dispatch' || endsWith(github.ref_name, '.0') }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.check.outputs.version }}
target: ${{ steps.check.outputs.target }}
steps:
- uses: actions/checkout@v5
# The version is the one committed in infino-python/Cargo.toml (stamped
# by `make release-prep`). On a tag push, assert the tag matches it — a
# coordinated release can't ship an unstamped tree. Reject a non-SemVer
# version before the matrix spins up: Cargo's `version` requires SemVer
# (it rejects PEP 440 like `0.1.0rc1`), and maturin renders SemVer to
# the wheel's PEP 440 (`0.1.0-rc.1` → `0.1.0rc1`).
- id: check
run: |
version="$(sed -n 's/^version = "\(.*\)"/\1/p' infino-python/Cargo.toml | head -1)"
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME#v}"
if [ "$tag" != "$version" ]; then
echo "::error::tag v$tag does not match infino-python/Cargo.toml version $version"
exit 1
fi
target="pypi"
else
target="${{ inputs.target }}"
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then
echo "::error::invalid release version (must be SemVer): $version"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "target=$target" >> "$GITHUB_OUTPUT"
# The version about to publish must sit on the crate's major.minor
# release line (docs/versioning.md), and the whole tree must be
# version-consistent, before the wheel matrix spins up.
- name: Check version against the crate's release line
run: python3 scripts/check_version_sync.py --release-version "${{ steps.check.outputs.version }}"
build:
name: wheel ${{ matrix.target }}
needs: validate
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
-
-
-
-
-
-
# No Windows: the core crate uses Unix-only APIs (std::os::unix
# positional writes, memmap2 madvise) that don't compile on MSVC.
steps:
- uses: actions/checkout@v5
# The core dep graph (datafusion + its sub-crates, parquet, arrow) plus
# the build target dir overflows the ~14 GB free on a stock Linux runner;
# reclaim ~30 GB before compiling.
- name: Free disk space
if: runner.os == 'Linux'
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- uses: actions/setup-python@v6
with:
python-version: "3.12"
# Version is dynamic (pyproject `dynamic = ["version"]`) → maturin reads
# it straight from the committed infino-python/Cargo.toml; `make
# release-prep` stamps Cargo.toml and Cargo.lock together, so the build
# stays --locked with no CI-side rewriting.
# `manylinux` is honored only for Linux targets and ignored elsewhere;
# musl targets need the musllinux image, every other target builds glibc.
# `sccache` caches inside the container, avoiding a host cache whose glibc mismatches it.
- name: Build wheel
uses: PyO3/maturin-action@v1
with:
command: build
target: ${{ matrix.target }}
manylinux: ${{ contains(matrix.target, 'musl') && 'musllinux_1_2' || 'auto' }}
sccache: "true"
args: --release --locked --out dist -m infino-python/Cargo.toml
# Smoke-test the wheel before publish; musl runs in Alpine (can't install on glibc).
- name: Smoke-test wheel (glibc)
if: ${{ !contains(matrix.target, 'musl') }}
shell: bash
run: |
python3 -m pip install --upgrade pip
python3 -m pip install --force-reinstall dist/*.whl pytest
python3 -m pytest infino-python/tests -v
- name: Smoke-test wheel (musl)
if: ${{ contains(matrix.target, 'musl') }}
shell: bash
run: |
docker run --rm -v "$PWD:/w" -w /w python:3.12-alpine sh -c '
pip install --force-reinstall dist/*.whl pytest &&
python -m pytest infino-python/tests -v'
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.target }}
path: dist/*.whl
if-no-files-found: error
publish:
name: Publish to ${{ needs.validate.outputs.target }}
needs:
runs-on: ubuntu-latest
# `environment` is load-bearing, not decoration: the PyPI/TestPyPI trusted
# publisher is keyed to it (plus repo + workflow file), and it can gate
# `pypi` behind required reviewers. `id-token: write` lets the upload
# authenticate over OIDC instead of a stored token.
permissions:
id-token: write
contents: read
environment: ${{ needs.validate.outputs.target }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: wheels-*
merge-multiple: true
path: dist
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate distributions
run: |
python3 -m pip install --upgrade twine
python3 -m twine check dist/*
# Trusted Publishing: no `password` — the action exchanges the OIDC
# token with the index. `repository-url` selects PyPI vs TestPyPI.
- name: Upload to ${{ needs.validate.outputs.target }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
repository-url: ${{ needs.validate.outputs.target == 'pypi' && 'https://upload.pypi.org/legacy/' || 'https://test.pypi.org/legacy/' }}