Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 182 additions & 30 deletions .github/workflows/build-macos.yml
Original file line number Diff line number Diff line change
@@ -1,52 +1,204 @@
name: Build wheels for macOS
name: "Build wheels for macOS (+optional release)"
# Hybrid approach: try to build with brew lzo, fall back to included sources

on:
workflow_dispatch:
inputs:
tag:
description: >
Release tag — optional (e.g. v1.16).
Leave blank to build wheels only (no release will be created).
Existing tag → wheels are replaced on the existing release.
New tag → git tag is created from HEAD and a new release is published.
Format: vMAJOR.MINOR[.PATCH][-prerelease]
required: false
type: string
default: ""

permissions:
contents: write # required to create tags, releases

jobs:
build-macos:
runs-on: macos-latest
# ───────────────────────────────────────────────────────────────────────────
# VALIDATE INPUT
# ───────────────────────────────────────────────────────────────────────────
validate-tag:
runs-on: ubuntu-latest
# Skip this job when the tag input is blank — no release
if: inputs.tag != ''
steps:
- name: Check tag format
run: |
TAG="${{ inputs.tag }}"
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?(-[a-zA-Z0-9._-]+)?$ ]]; then
echo "::error::Tag '${TAG}' is not valid. Must match vMAJOR.MINOR[.PATCH][-prerelease]"
exit 1
fi
echo "Tag '${TAG}' is valid."

# ───────────────────────────────────────────────────────────────────────────
# BUILD
# ───────────────────────────────────────────────────────────────────────────
build:
# When no tag is provided, validate-tag is skipped (result = 'skipped'),
needs: validate-tag
if: always() && (needs.validate-tag.result == 'success' || needs.validate-tag.result == 'skipped')
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
python-version:
- "3.8"
# - "3.8"
# - "3.9"
# - "3.10"
# - "3.11"
# - "3.12"
- "3.10"
- "3.11"
- "3.12"
- "3.13"
name: ${{ matrix.python-version }}
- "3.14"
os:
- macos-14
- macos-15 # currently (2026) same as macos-latest
name: "Python ${{ matrix.python-version }}"
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
persist-credentials: false
- name: Run cmake
python-version: ${{ matrix.python-version }}

- name: Install build frontend
run: python -m pip install --upgrade pip build

# try brew LZO build first
- name: Build wheel (brew LZO)
id: build_brew
# CFLAGS — clang needs to find lzo/lzo1x.h (not in /usr/include on arm64 macOS)
# LDFLAGS — linker needs to find liblzo2.dylib
# -rpath — embeds the runtime library path into the .so so Python
# finds liblzo2.dylib at import time without DYLD_LIBRARY_PATH
run: |
LZO_PREFIX=$(brew --prefix lzo)
CFLAGS="-I${LZO_PREFIX}/include" \
LDFLAGS="-L${LZO_PREFIX}/lib -Wl,-rpath,${LZO_PREFIX}/lib" \
python -m build --wheel
continue-on-error: true

# Fallback: build with local LZO sources
- name: Build LZO with cmake
id: build_lzo
if: steps.build_brew.outcome == 'failure'
working-directory: ./lzo-2.10
run: |
sed -i '' 's/cmake_minimum_required(VERSION [0-9.]* *FATAL_ERROR)/cmake_minimum_required(VERSION 3.5)/' CMakeLists.txt
grep cmake_minimum_required CMakeLists.txt
mkdir build
cd build
cmake -DCMAKE_INSTALL_PREFIX=.. ..
- name: Build lzo static lib
working-directory: ./lzo-2.10/build
run: |
make
make install
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Build wheel
env:
LZO_DIR: ./lzo-2.10
run: |
python -m pip install -U pip wheel build
python -m build
ls -l dist
- name: Upload artifact
uses: actions/upload-artifact@v4
continue-on-error: true

- name: Build wheel with local LZO
id: build_local
if: steps.build_brew.outcome == 'failure' && steps.build_lzo.outcome == 'success'
run: |
CFLAGS="-I./lzo-2.10/include" \
LDFLAGS="-L./lzo-2.10/lib -Wl,-rpath,./lzo-2.10/lib" \
python -m build --wheel

# Fail the job if nothing worked
- name: Abort on fail
if: |
steps.build_brew.outcome == 'failure' &&
(steps.build_lzo.outcome == 'failure' || steps.build_local.outcome == 'failure' || steps.build_local.outcome == 'skipped')
run: |
echo "Both brew and local LZO builds failed."
exit 1

- name: Smoke-test the wheel
run: |
pip install dist/*.whl
python -c "
import lzo
print('lzo version:', lzo.__version__)
data = b'hello world' * 42
compressed = lzo.compress(data)
assert lzo.decompress(compressed) == data
print('compress/decompress round-trip OK')
"

- name: Upload wheel artifact
uses: actions/upload-artifact@v6
with:
name: wheels
path: dist
overwrite: true
name: wheel-py-${{ matrix.os }}-${{ matrix.python-version }}
path: dist/*.whl
if-no-files-found: error

# ───────────────────────────────────────────────────────────────────────────
# PUBLISH — collect wheels, tag if needed, make the release.
# Skip if no tag
# ───────────────────────────────────────────────────────────────────────────
publish:
name: Publish GitHub release
needs: build
# Skip when tag is blank — build-only run, no release wanted.
if: always() && inputs.tag != '' && needs.build.result == 'success'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
# must fetch full history so a new tag can be created if it does not exist
fetch-depth: 0
persist-credentials: true

- name: Download all wheel artifacts
uses: actions/download-artifact@v7
with:
pattern: wheel-py*
merge-multiple: true
path: dist/

- name: List wheels to publish
run: ls -lh dist/

- name: Create git tag if it does not exist
run: |
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q "${TAG}"; then
echo "Tag ${TAG} already exists — skipping tag creation."
else
echo "Tag ${TAG} does not exist — creating it from HEAD."
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}"
echo "Tag ${TAG} pushed."
fi

- name: Create or update GitHub release
# gh release create → used when no release exists yet for this tag.
# • Automatically uses the tag that was pushed (or the existing one).
# • --generate-notes asks GitHub to auto-fill the release notes from
# commits since the previous release.
#
# gh release upload --clobber → used when a release already exists.
# • --clobber deletes any asset with the same filename before
# re-uploading, giving us idempotent replace semantics.
# • WARNING: gh documents that if the upload fails after --clobber,
# the original asset is gone. Acceptable here because we always
# have the artifact zip as a fallback.
run: |
if gh release view "${TAG}" > /dev/null 2>&1; then
echo "Release for ${TAG} already exists — replacing assets."
gh release upload "${TAG}" dist/*.whl --clobber
else
echo "No release for ${TAG} yet — creating release and uploading assets."
gh release create "${TAG}" dist/*.whl \
--title "${TAG}" \
--generate-notes
fi