diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index a454e37..f1373ef 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -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