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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
name: Publish
# One tag, one release: `v*` ships the crate to crates.io, the Python package to
# PyPI, AND the fastglmm R package to r-universe 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.
#
# Folding the r-universe publish into this workflow avoids a concurrency
# collision: two separate workflows firing on `v*` and both calling ci.yml
# would collide in ci.yml's concurrency group (keyed only on github.ref) at
# group ci-refs/tags/<tag>, and cancel-in-progress would kill one — taking its
# whole release run down. A single ci.yml call per tag avoids that.
#
# 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 four 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 four versions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check 4-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)
RDESC=$(grep -m1 '^Version: ' r/DESCRIPTION | cut -d' ' -f2)
check "$CRATE" Cargo.toml
check "$PYCRATE" glmm-python/Cargo.toml
check "$PYPROJ" python/pyproject.toml
check "$RDESC" r/DESCRIPTION
[ "$fail" = 0 ] || exit 1
echo "All four 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 LGPL text. This catches that.
if "GNU LESSER GENERAL PUBLIC LICENSE" not in body:
sys.exit(f"::error::LICENSE in {whl} is not the LGPL 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 }}
# r-universe builds fastglmm straight from a git ref via `R CMD build`, which
# strips out-of-package path deps (r/ depends on ../glmm-r → ../ the kernel), so
# it cannot build the repo tree directly. Instead this stages the self-contained,
# offline-vendored package (cran-tarball.sh --vendor) and force-pushes that tree
# as the *root* of an orphan `r-universe` branch. r-universe's registry (a
# separate repo the user controls) points at that branch and rebuilds on ref
# change. main never holds the vendored tree — the branch is a generated artifact.
#
# Gated on verify + version-check only: the vendored package bundles the kernel
# source, so it needs neither crates.io nor PyPI to have published first. Runs in
# parallel with the wheel builds. version-check above already asserts r/DESCRIPTION
# matches the tag, so no separate r-only version check is needed.
publish-r-universe:
name: Stage + push r-universe branch
needs:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write # force-push the generated r-universe branch with GITHUB_TOKEN
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: r-lib/actions/setup-r@v2
- name: Stage self-contained, vendored package
run: r/tools/cran-tarball.sh --vendor
- name: Force-push staged tree as the r-universe branch root
working-directory: target/cran-stage/fastglmm
run: |
git init -q
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -q -m "r-universe: fastglmm ${GITHUB_REF#refs/tags/v}"
git push --force "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:r-universe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}