diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed6547d..5044891 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,9 +18,8 @@ jobs: - name: Install build dependencies run: pip install ".[build]" - - name: Build binary with PyInstaller - run: pyinstaller --onefile --name liminate --collect-all liminate build/entry.py - + # The certificate comes before the build: PyInstaller signs what it + # collects with it (build/build_macos_release.sh says why). - name: Import signing certificate env: APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -51,11 +50,8 @@ jobs: fi echo "SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" - - name: Codesign binary - run: | - codesign --force --options runtime --timestamp \ - --sign "$SIGNING_IDENTITY" dist/liminate - codesign --verify --verbose dist/liminate + - name: Build, sign and run the binary + run: build/build_macos_release.sh - name: Notarize binary env: @@ -185,8 +181,13 @@ jobs: cp artifacts/liminate-windows-x64/liminate-windows-x64.exe release/ chmod +x release/liminate-macos-arm64 release/liminate-linux-x64 + # An asset already on the release stays. When Actions can't run, the + # macOS binary is built and attached by hand (RELEASING.md), and + # downstream builds pin its SHA-256; a later run of this workflow fills + # in the rest without replacing it. - uses: softprops/action-gh-release@v3 with: + overwrite_files: false files: | release/liminate-macos-arm64 release/liminate-linux-x64 diff --git a/RELEASING.md b/RELEASING.md index 15ee028..9536357 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -94,6 +94,38 @@ The binary lands at `dist/liminate`. The `--collect-all liminate` flag is required by the src/ layout — without it PyInstaller misses submodules like `packs/timer.py`. +The macOS release binary is built by `build/build_macos_release.sh` +instead, which the workflow also calls. It passes +`--codesign-identity`, so the Python.framework inside the one-file +binary is signed with the same team as the binary itself. Without that, +the hardened runtime's library validation refuses the framework and the +binary exits before running anything, which is what happened to +0.18.2's macOS asset. `codesign --verify` passes either way, so the +script also runs the built binary. + +--- + +## When Actions can't run + +The macOS asset can be released by hand with the same recipe: + +```bash +python3.12 -m venv .venv-release && . .venv-release/bin/activate +pip install ".[build]" +SIGNING_IDENTITY="Developer ID Application: ()" \ + build/build_macos_release.sh +ditto -c -k dist/liminate liminate.zip +xcrun notarytool submit liminate.zip --keychain-profile --wait +mv dist/liminate dist/liminate-macos-arm64 +git tag v0.x.x && git push origin v0.x.x +gh release create v0.x.x dist/liminate-macos-arm64 --title v0.x.x --notes "…" +``` + +When Actions runs again, re-run the tag's `Release` workflow. It builds +the Linux and Windows binaries, publishes to PyPI, and leaves the +macOS asset already on the release alone (`overwrite_files: false`), +so a SHA-256 pinned downstream stays valid. + --- ## Homebrew tap (manual, future) diff --git a/build/build_macos_release.sh b/build/build_macos_release.sh new file mode 100755 index 0000000..eaa502c --- /dev/null +++ b/build/build_macos_release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# The macOS release binary: build, sign, and run it. release.yml calls this, +# and so does a local release when Actions can't run, so both produce the +# binary the same way. Needs SIGNING_IDENTITY (a Developer ID Application +# identity) and the [build] extra installed. Notarization is a separate step. +set -euo pipefail +: "${SIGNING_IDENTITY:?set SIGNING_IDENTITY to a Developer ID Application identity}" + +# --codesign-identity signs every binary PyInstaller collects with our +# identity, above all the Python.framework a one-file build unpacks at launch. +# Without it that framework keeps its original team's signature, and the +# hardened runtime's library validation refuses to load it into a process +# signed by ours: the binary exits before running a line. 0.18.2's did. +pyinstaller --noconfirm --onefile --name liminate --collect-all liminate \ + --codesign-identity "$SIGNING_IDENTITY" build/entry.py + +codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/liminate +codesign --verify --strict --verbose dist/liminate + +# Run it the way a user does. 0.18.2 passed codesign --verify and still +# could not start. +dist/liminate --version +dist/liminate --quiet --test examples/program1_basics.limn > /dev/null diff --git a/pyproject.toml b/pyproject.toml index d1bbdd6..8fb5178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ where = ["src"] [project] name = "liminate" -version = "0.18.2" +version = "0.18.3" description = "A prose-as-syntax language designed from the human end." requires-python = ">=3.10" license = "Apache-2.0" diff --git a/tests/fixtures/conformance-0.18.2.json b/tests/fixtures/conformance-0.18.3.json similarity index 99% rename from tests/fixtures/conformance-0.18.2.json rename to tests/fixtures/conformance-0.18.3.json index 68abd4f..7b902c4 100644 --- a/tests/fixtures/conformance-0.18.2.json +++ b/tests/fixtures/conformance-0.18.3.json @@ -1579,6 +1579,6 @@ } ], "generator": "scripts/gen_conformance_corpus.py", - "language_version": "0.18.2", + "language_version": "0.18.3", "surface": "tokenize -> reorder -> parse -> analyze -> render" }