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
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
name: Publish Python package
# Release of the `infino` Python package: validate the version, build one binary
# wheel per platform/arch, then upload. A `v<version>` tag publishes to real
# PyPI with the version derived from the tag — 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 — chooses the
# version and 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:
version:
description: "Release version, SemVer (e.g. 0.1.0, 0.1.0-rc.1)"
required: true
type: string
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:
# On a tag push, derive the version from the tag (`v0.1.0` -> `0.1.0`) and
# publish to real PyPI; a manual run uses its inputs (PyPI default).
# 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: |
if [ "${{ github.event_name }}" = "push" ]; then
version="${GITHUB_REF_NAME#v}"
target="pypi"
else
version="${{ inputs.version }}"
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"
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 from Cargo.toml. Stamp it into Cargo.toml *and* Cargo.lock so the
# build stays --locked. python3 over sed: one script, every runner,
# Windows included.
- name: Stamp version
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
python3 - "$VERSION" <<'PY'
import pathlib, re, sys
version = sys.argv[1]
toml = pathlib.Path("infino-python/Cargo.toml")
patched, count = re.subn(
r'(?m)^version = "[^"]*"', f'version = "{version}"',
toml.read_text(), count=1)
assert count == 1, "[package] version not found in Cargo.toml"
toml.write_text(patched)
lock = pathlib.Path("infino-python/Cargo.lock")
patched, count = re.subn(
r'(?ms)(\[\[package\]\]\nname = "infino-python"\nversion = ")[^"]*(")',
rf"\g<1>{version}\g<2>", lock.read_text(), count=1)
assert count == 1, "infino-python entry not found in Cargo.lock"
lock.write_text(patched)
print(f"stamped infino-python -> {version}")
PY
# `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/' }}