glmm 0.3.2

Standalone f64 GLMM fit kernels (OLS, GLM, LMM, GLMM) in pure Rust on faer — the validation-pinned numerics from the MCPower engine.
Documentation
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: ["v*"]

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: [verify, version-check]
    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:
          - { os: ubuntu-latest,  target: x86_64,  manylinux: '2_28' }
          - { os: macos-latest,   target: aarch64 }
          - { os: windows-latest, target: x64 }
    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: [verify, version-check]
    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: [build_wheels, build_sdist]
    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: [publish-pypi]
    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: [verify, version-check]
    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 }}