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
223
224
225
226
227
228
229
230
231
232
233
234
name: Publish
# One tag, one release: `v*` ships the crate to crates.io AND the Python package
# to PyPI from the same run. The Python package is a thin wrapper over the crate
# with no independent version or changelog, so it has no separate cadence.
#
# Both indexes use Trusted Publishing (OIDC) — no stored tokens.
#
# PREREQUISITES:
# - crates.io: crate exists and has a Trusted Publisher pointing at THIS repo +
# release.yml + the `release` environment. (No pending-publisher flow there,
# so 0.0.1 was published manually; this covers 0.0.2+.)
# - PyPI: a pending publisher for project `glmm` bound to this repo +
# release.yml + the same `release` environment (shared with crates.io —
# protection rules on it gate both uploads).
on:
push:
tags:
env:
CARGO_TERM_COLOR: always
jobs:
# Run the full CI suite (fmt + clippy + test + msrv + wasm + publish dry-run)
# against the tagged commit. Everything downstream is gated on this — a tag that
# fails any gate never reaches an index.
verify:
uses: ./.github/workflows/ci.yml
# The three versions are lockstep-coupled but otherwise enforced only by a
# comment in pyproject.toml. This makes drift a release-time failure.
version-check:
name: Verify tag matches all three versions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check 3-way version lockstep
shell: bash
run: |
TAG="${GITHUB_REF#refs/tags/v}"
fail=0
check() { # $1 = found version, $2 = file it came from
if [ "$1" != "$TAG" ]; then
echo "::error::$2 has version '$1', tag is '$TAG'"; fail=1
fi
}
CRATE=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
PYCRATE=$(grep -m1 '^version = ' glmm-python/Cargo.toml | cut -d'"' -f2)
PYPROJ=$(grep -m1 '^version = ' python/pyproject.toml | cut -d'"' -f2)
check "$CRATE" Cargo.toml
check "$PYCRATE" glmm-python/Cargo.toml
check "$PYPROJ" python/pyproject.toml
[ "$fail" = 0 ] || exit 1
echo "All three at $TAG."
build_wheels:
name: Build & test wheel (${{ matrix.target }} on ${{ matrix.os }})
needs:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# One abi3 wheel per platform (abi3-py310 → no per-Python matrix), built
# AND tested on its own hardware so no wheel ships untested. macOS is
# Apple Silicon only: the Intel macos-13 runner was retired Dec 2025 —
# Intel Macs fall back to the sdist.
# Linux is also built+tested by ci.yml's python job (via verify) —
# deliberate: that job gates every push, this one produces the release
# artifact with the manylinux tag. Don't deduplicate.
include:
-
-
-
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# maturin-action, not cibuildwheel: pyproject's manifest-path reaches out to
# ../glmm-python, which depends on ... cibuildwheel mounts
# only the package dir, so those paths would not resolve; this mounts the
# whole workspace.
- name: Build wheel
uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
# manylinux applies to the Linux entry only; 'auto' is a no-op on the
# macOS/Windows entries that omit it.
manylinux: ${{ matrix.manylinux || 'auto' }}
working-directory: python
args: --release --out dist
- name: Verify native extension + license are in the wheel
shell: bash
run: |
python - <<'PY'
import glob, sys, zipfile
wheels = glob.glob("python/dist/*.whl")
assert wheels, "no wheel built"
for whl in wheels:
names = zipfile.ZipFile(whl).namelist()
if not any("_native" in n for n in names):
sys.exit(f"::error::native extension (_native) missing from {whl}")
lic = [n for n in names if n.endswith("dist-info/licenses/LICENSE")]
if not lic:
sys.exit(f"::error::LICENSE missing from {whl}")
body = zipfile.ZipFile(whl).read(lic[0]).decode()
# python/LICENSE must stay a real copy, never a symlink to ../LICENSE:
# Git for Windows does not materialize symlinks without core.symlinks,
# which actions/checkout does not set, so the runner would embed the
# 10-byte string "../LICENSE" as the GPL text. This catches that.
if "GNU GENERAL PUBLIC LICENSE" not in body:
sys.exit(f"::error::LICENSE in {whl} is not the GPL text")
print(f" ok: {whl}")
PY
- name: Install the built wheel and run the suite
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install python/dist/*.whl
python -m pip install pytest
# Scratch dir + absolute tests path: `import glmm` must resolve to the
# installed wheel, never python/glmm/ in the source tree.
cd "$RUNNER_TEMP"
python -m pytest "$GITHUB_WORKSPACE/python/tests" -v
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.target }}
path: python/dist/*.whl
build_sdist:
name: Build & test sdist
needs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# Plain maturin, not maturin-action: an sdist needs no manylinux container,
# so the action's container machinery buys nothing here. (The build only
# archives sources; the Rust toolchain above is for the install step below.)
- name: Build sdist
working-directory: python
run: |
python -m pip install "maturin>=1.9,<2.0"
maturin sdist --out dist
# The sdist is the only install path for Intel Macs and Linux aarch64, so it
# gets the same gate as the wheels: install it (a real source build — hence
# the Rust toolchain above) and run the suite.
- name: Install from the sdist and run the suite
shell: bash
run: |
python -m pip install python/dist/*.tar.gz
python -m pip install pytest
cd "$RUNNER_TEMP"
python -m pytest "$GITHUB_WORKSPACE/python/tests" -v
- uses: actions/upload-artifact@v4
with:
name: sdist
path: python/dist/*.tar.gz
publish-pypi:
name: Publish to PyPI
needs:
runs-on: ubuntu-latest
environment: release # must match the PyPI trusted-publisher config
permissions:
id-token: write # mints the OIDC token PyPI verifies
steps:
- uses: actions/download-artifact@v4
with:
path: dist/
- name: Flatten artifacts
run: |
mkdir -p dist-flat
find dist/ \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist-flat/ \;
ls -la dist-flat/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist-flat/
# No skip-existing, unlike the crates.io guard below: on the real index
# "file already exists" means the version was not bumped, and that must
# fail loudly.
# Ordered last, after PyPI: both uploads are irreversible, and this way a failing
# wheel never leaves a crate release stranded without its Python counterpart. If
# this job fails after PyPI succeeded, re-run THIS JOB alone — a full workflow
# re-run dies in publish-pypi on the already-uploaded files.
publish-crates:
name: cargo publish
needs:
runs-on: ubuntu-latest
timeout-minutes: 20
# Naming an environment lets you attach required-reviewer / branch protection
# to releases. It MUST match the environment set in the crates.io Trusted
# Publisher config (or remove it in both places).
environment: release
permissions:
# Required: lets the job request the OIDC identity token crates.io verifies.
id-token: write
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# cargo publish has no --skip-existing and hard-fails on an existing version,
# so re-running a tag after a transient failure would error instead of
# converging. This guard makes it a no-op instead.
- name: Skip if this version is already on crates.io
id: guard
shell: bash
run: |
TAG="${GITHUB_REF#refs/tags/v}"
# Sparse-index path for a 4-char crate name: /gl/mm/glmm. One JSON object
# per line, one per published version. `curl -f` so a 404 (crate absent)
# yields empty rather than an HTML body.
PUBLISHED=$(curl -sf https://index.crates.io/gl/mm/glmm | jq -r '.vers' || true)
if grep -qx "$TAG" <<<"$PUBLISHED"; then
echo "glmm $TAG already on crates.io — skipping publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Authenticate to crates.io (Trusted Publishing)
if: steps.guard.outputs.skip == 'false'
id: auth
uses: rust-lang/crates-io-auth-action@v1
- name: Publish
if: steps.guard.outputs.skip == 'false'
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}