feat(macos): add Intel (x86_64) build with separate DMG #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Prerelease Build | |
| # Builds a prerelease for the current branch and publishes it as a GitHub | |
| # pre-release with a per-branch tag (`prerelease-<sanitized-branch>`). Each run | |
| # replaces the prior prerelease for that branch in place — never more than one | |
| # prerelease per branch at a time, regardless of how many incremental commits | |
| # land. Pre-releases don't appear as "Latest" on the Releases page, so the | |
| # main release timeline stays clean. | |
| # | |
| # Version scheme (semver-compliant pre-release): | |
| # <cargo-version>-<sanitized-branch>.<short-sha> | |
| # e.g. 0.3.0-feat-macos-universal-binary.abc1234 | |
| # | |
| # Triggers: | |
| # - PR opened / pushed to / reopened: auto-build the head commit | |
| # - workflow_dispatch: for branches that don't have a PR yet | |
| # | |
| # Concurrency: in-flight builds for the same branch are cancelled by newer | |
| # pushes. Burst pushes only build the latest commit, not every intermediate. | |
| # | |
| # Fork PR caveat: GitHub doesn't expose secrets to PRs from forks, so a | |
| # fork PR's run will fail at the Apple signing step. This is by design. | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: prerelease-${{ github.head_ref || github.ref_name }} | |
| cancel-in-progress: true | |
| env: | |
| NFPM_VERSION: 2.46.3 | |
| jobs: | |
| version: | |
| name: Compute prerelease version | |
| runs-on: ubuntu-24.04 | |
| outputs: | |
| version: ${{ steps.compute.outputs.version }} | |
| tag: ${{ steps.compute.outputs.tag }} | |
| sha: ${{ steps.compute.outputs.sha }} | |
| ref: ${{ steps.compute.outputs.ref }} | |
| steps: | |
| - name: Resolve head ref + sha | |
| id: resolve_ref | |
| env: | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| HEAD_REF: ${{ github.head_ref }} | |
| EVENT_SHA: ${{ github.sha }} | |
| EVENT_REF: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| # PR events: head.sha is the commit being tested; head_ref is the source branch. | |
| # workflow_dispatch: github.sha + github.ref_name point at the dispatched branch's tip. | |
| if [ -n "$HEAD_SHA" ]; then | |
| REF="$HEAD_REF" | |
| SHA="$HEAD_SHA" | |
| else | |
| REF="$EVENT_REF" | |
| SHA="$EVENT_SHA" | |
| fi | |
| printf 'ref=%s\n' "$REF" >> "$GITHUB_OUTPUT" | |
| printf 'sha=%s\n' "$SHA" >> "$GITHUB_OUTPUT" | |
| - name: Checkout (for Cargo.toml read) | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ steps.resolve_ref.outputs.sha }} | |
| - name: Compute version + tag | |
| id: compute | |
| env: | |
| REF: ${{ steps.resolve_ref.outputs.ref }} | |
| SHA: ${{ steps.resolve_ref.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| CARGO_VERSION=$(awk -F'"' '/^version = /{print $2; exit}' Cargo.toml) | |
| # Sanitize branch name to match semver pre-release identifier syntax | |
| # (`[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*`): lowercase, replace anything | |
| # other than [a-z0-9.-] with `-`, collapse runs, trim edges. | |
| SANITIZED=$(printf '%s' "$REF" \ | |
| | tr '[:upper:]' '[:lower:]' \ | |
| | tr -c 'a-z0-9.-' '-' \ | |
| | sed 's/-\{1,\}/-/g; s/^-//; s/-$//') | |
| SHORT_SHA="${SHA:0:7}" | |
| VERSION="${CARGO_VERSION}-${SANITIZED}.${SHORT_SHA}" | |
| TAG="prerelease-${SANITIZED}" | |
| printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" | |
| printf 'tag=%s\n' "$TAG" >> "$GITHUB_OUTPUT" | |
| printf 'sha=%s\n' "$SHA" >> "$GITHUB_OUTPUT" | |
| printf 'ref=%s\n' "$REF" >> "$GITHUB_OUTPUT" | |
| echo "Prerelease version: $VERSION" | |
| echo "Prerelease tag: $TAG" | |
| build-linux: | |
| name: Build Linux ${{ matrix.arch }} | |
| needs: version | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runner: ubuntu-24.04 | |
| arch: amd64 | |
| rust_target: x86_64-unknown-linux-gnu | |
| - runner: ubuntu-24.04-arm | |
| arch: arm64 | |
| rust_target: aarch64-unknown-linux-gnu | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.version.outputs.sha }} | |
| - name: Install system build deps | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y --no-install-recommends \ | |
| libssl-dev pkg-config clang cmake g++ curl | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.rust_target }} | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: prerelease-${{ matrix.arch }} | |
| - name: Build release binary | |
| env: | |
| RUST_TARGET: ${{ matrix.rust_target }} | |
| run: cargo build --release --locked --target "$RUST_TARGET" | |
| - name: Stage binary for nfpm | |
| env: | |
| RUST_TARGET: ${{ matrix.rust_target }} | |
| run: | | |
| mkdir -p target/release | |
| cp "target/${RUST_TARGET}/release/utter" target/release/utter | |
| - name: Install nfpm | |
| env: | |
| ARCH_DEB: ${{ matrix.arch }} | |
| run: | | |
| curl -sSfL \ | |
| "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${ARCH_DEB}.deb" \ | |
| -o /tmp/nfpm.deb | |
| sudo dpkg -i /tmp/nfpm.deb | |
| - name: Build .deb and .rpm | |
| working-directory: packaging | |
| env: | |
| UTTER_VERSION: ${{ needs.version.outputs.version }} | |
| UTTER_ARCH: ${{ matrix.arch }} | |
| run: | | |
| export UTTER_VERSION UTTER_ARCH | |
| mkdir -p ../dist | |
| nfpm pkg --packager deb --target ../dist/ | |
| nfpm pkg --packager rpm --target ../dist/ | |
| - name: Build plain binary tarball | |
| env: | |
| VERSION: ${{ needs.version.outputs.version }} | |
| UTTER_ARCH: ${{ matrix.arch }} | |
| run: | | |
| STAGE="utter-${VERSION}-linux-${UTTER_ARCH}" | |
| mkdir -p "dist/${STAGE}" | |
| cp target/release/utter "dist/${STAGE}/" | |
| cp README.md LICENSE NOTICE AGENTS.md CLAUDE.md BACKLOG.md "dist/${STAGE}/" | |
| cp -r packaging "dist/${STAGE}/" | |
| cp -r scripts "dist/${STAGE}/" | |
| tar -C dist -czf "dist/${STAGE}.tar.gz" "${STAGE}" | |
| rm -rf "dist/${STAGE}" | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: prerelease-dist-${{ matrix.arch }} | |
| path: dist/* | |
| if-no-files-found: error | |
| build-macos-arm64: | |
| name: Build macOS arm64 (signed + notarized DMG) | |
| needs: version | |
| runs-on: macos-14 | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| UTTER_SIGN_IDENTITY: "Developer ID Application: Josh Guice (YKQ46WD7SL)" | |
| RUST_TARGET: aarch64-apple-darwin | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.version.outputs.sha }} | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ env.RUST_TARGET }} | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: prerelease-macos-arm64 | |
| - name: Import Developer ID cert into temp keychain | |
| env: | |
| CERT_B64: ${{ secrets.APPLE_DEV_ID_CERT_BASE64 }} | |
| CERT_PASS: ${{ secrets.APPLE_DEV_ID_CERT_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN_PASS=$(uuidgen) | |
| KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" | |
| security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" | |
| printf '%s' "$CERT_B64" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$CERT_PASS" \ | |
| -T /usr/bin/codesign -T /usr/bin/security | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g') | |
| security set-key-partition-list \ | |
| -S apple-tool:,apple:,codesign: \ | |
| -s -k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" | |
| rm -f "$RUNNER_TEMP/cert.p12" | |
| security find-identity -v -p codesigning "$KEYCHAIN_PATH" | |
| - name: Build release binary (arm64) | |
| run: cargo build --release --locked --target "$RUST_TARGET" | |
| - name: Stage binary for bundle | |
| run: | | |
| mkdir -p target/release | |
| cp "target/${RUST_TARGET}/release/utter" target/release/utter | |
| - name: Build + sign .app bundle | |
| run: ./scripts/make-bundle.sh | |
| - name: Notarize + staple app bundle | |
| run: | | |
| set -euo pipefail | |
| ditto -c -k --keepParent target/release/utter.app "$RUNNER_TEMP/utter.zip" | |
| xcrun notarytool submit "$RUNNER_TEMP/utter.zip" \ | |
| --apple-id "$APPLE_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --wait | |
| xcrun stapler staple target/release/utter.app | |
| xcrun stapler validate target/release/utter.app | |
| - name: Install create-dmg | |
| run: brew install create-dmg | |
| - name: Create DMG | |
| env: | |
| VERSION: ${{ needs.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| DMG_NAME="utter-${VERSION}-macos-arm64.dmg" | |
| mkdir -p dist | |
| STAGE="$RUNNER_TEMP/dmg-stage" | |
| rm -rf "$STAGE" | |
| mkdir -p "$STAGE" | |
| cp -R target/release/utter.app "$STAGE/" | |
| create-dmg \ | |
| --volname "utter" \ | |
| --window-pos 200 120 \ | |
| --window-size 540 380 \ | |
| --icon-size 100 \ | |
| --icon "utter.app" 140 190 \ | |
| --app-drop-link 400 190 \ | |
| --hide-extension "utter.app" \ | |
| --no-internet-enable \ | |
| "dist/${DMG_NAME}" \ | |
| "$STAGE" | |
| - name: Sign + notarize + staple DMG | |
| run: | | |
| set -euo pipefail | |
| DMG=$(ls dist/utter-*-macos-arm64.dmg) | |
| codesign --force --sign "$UTTER_SIGN_IDENTITY" --timestamp "$DMG" | |
| xcrun notarytool submit "$DMG" \ | |
| --apple-id "$APPLE_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --wait | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| - name: Upload DMG artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: prerelease-dist-macos-arm64 | |
| path: dist/utter-*-macos-arm64.dmg | |
| if-no-files-found: error | |
| build-macos-x86_64: | |
| name: Build macOS x86_64 (Intel, signed + notarized DMG) | |
| needs: version | |
| runs-on: macos-14-large | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| UTTER_SIGN_IDENTITY: "Developer ID Application: Josh Guice (YKQ46WD7SL)" | |
| RUST_TARGET: x86_64-apple-darwin | |
| ONNXRUNTIME_VERSION: v1.24.2 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.version.outputs.sha }} | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ env.RUST_TARGET }} | |
| - name: Cache cargo | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: prerelease-macos-x86_64 | |
| - name: Cache ONNX Runtime build | |
| id: cache-onnxruntime | |
| uses: actions/cache@v4 | |
| with: | |
| path: onnxruntime/build/MacOS/Release | |
| key: onnxruntime-${{ env.ONNXRUNTIME_VERSION }}-x86_64-apple-darwin-v1 | |
| - name: Build ONNX Runtime from source (x86_64) | |
| if: steps.cache-onnxruntime.outputs.cache-hit != 'true' | |
| env: | |
| VERSION: ${{ env.ONNXRUNTIME_VERSION }} | |
| run: | | |
| set -euo pipefail | |
| git clone https://github.com/microsoft/onnxruntime --recursive \ | |
| --branch "$VERSION" --single-branch --depth 1 | |
| cd onnxruntime | |
| ./build.sh --update --build --config Release --parallel \ | |
| --compile_no_warning_as_error --skip_submodule_sync --skip_tests | |
| - name: Import Developer ID cert into temp keychain | |
| env: | |
| CERT_B64: ${{ secrets.APPLE_DEV_ID_CERT_BASE64 }} | |
| CERT_PASS: ${{ secrets.APPLE_DEV_ID_CERT_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN_PASS=$(uuidgen) | |
| KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" | |
| security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" | |
| printf '%s' "$CERT_B64" | base64 --decode > "$RUNNER_TEMP/cert.p12" | |
| security import "$RUNNER_TEMP/cert.p12" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$CERT_PASS" \ | |
| -T /usr/bin/codesign -T /usr/bin/security | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | sed 's/"//g') | |
| security set-key-partition-list \ | |
| -S apple-tool:,apple:,codesign: \ | |
| -s -k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" | |
| rm -f "$RUNNER_TEMP/cert.p12" | |
| security find-identity -v -p codesigning "$KEYCHAIN_PATH" | |
| - name: Build release binary (x86_64) against from-source ONNX Runtime | |
| env: | |
| ORT_LIB_PATH: ${{ github.workspace }}/onnxruntime/build/MacOS/Release | |
| run: cargo build --release --locked --target "$RUST_TARGET" | |
| - name: Stage binary for bundle | |
| run: | | |
| mkdir -p target/release | |
| cp "target/${RUST_TARGET}/release/utter" target/release/utter | |
| - name: Build + sign .app bundle | |
| run: ./scripts/make-bundle.sh | |
| - name: Notarize + staple app bundle | |
| run: | | |
| set -euo pipefail | |
| ditto -c -k --keepParent target/release/utter.app "$RUNNER_TEMP/utter.zip" | |
| xcrun notarytool submit "$RUNNER_TEMP/utter.zip" \ | |
| --apple-id "$APPLE_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --wait | |
| xcrun stapler staple target/release/utter.app | |
| xcrun stapler validate target/release/utter.app | |
| - name: Install create-dmg | |
| run: brew install create-dmg | |
| - name: Create DMG | |
| env: | |
| VERSION: ${{ needs.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| DMG_NAME="utter-${VERSION}-macos-x86_64.dmg" | |
| mkdir -p dist | |
| STAGE="$RUNNER_TEMP/dmg-stage" | |
| rm -rf "$STAGE" | |
| mkdir -p "$STAGE" | |
| cp -R target/release/utter.app "$STAGE/" | |
| create-dmg \ | |
| --volname "utter" \ | |
| --window-pos 200 120 \ | |
| --window-size 540 380 \ | |
| --icon-size 100 \ | |
| --icon "utter.app" 140 190 \ | |
| --app-drop-link 400 190 \ | |
| --hide-extension "utter.app" \ | |
| --no-internet-enable \ | |
| "dist/${DMG_NAME}" \ | |
| "$STAGE" | |
| - name: Sign + notarize + staple DMG | |
| run: | | |
| set -euo pipefail | |
| DMG=$(ls dist/utter-*-macos-x86_64.dmg) | |
| codesign --force --sign "$UTTER_SIGN_IDENTITY" --timestamp "$DMG" | |
| xcrun notarytool submit "$DMG" \ | |
| --apple-id "$APPLE_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --wait | |
| xcrun stapler staple "$DMG" | |
| xcrun stapler validate "$DMG" | |
| - name: Upload DMG artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: prerelease-dist-macos-x86_64 | |
| path: dist/utter-*-macos-x86_64.dmg | |
| if-no-files-found: error | |
| publish: | |
| name: Publish prerelease | |
| needs: [version, build-linux, build-macos-arm64, build-macos-x86_64] | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: Checkout (for tag operations) | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.version.outputs.sha }} | |
| fetch-depth: 0 | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: dist | |
| pattern: prerelease-dist-* | |
| merge-multiple: true | |
| - name: List artifacts | |
| run: ls -la dist/ | |
| # Replace any prior prerelease for this branch in place: delete the | |
| # existing release + tag (if present), then publish a fresh one. This | |
| # keeps exactly one prerelease per branch on the Releases page. | |
| - name: Delete prior prerelease + tag (if any) | |
| env: | |
| TAG: ${{ needs.version.outputs.tag }} | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| echo "Deleting existing prerelease $TAG" | |
| gh release delete "$TAG" --yes --cleanup-tag | |
| else | |
| echo "No existing prerelease for $TAG" | |
| fi | |
| - name: Publish prerelease | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.version.outputs.tag }} | |
| target_commitish: ${{ needs.version.outputs.sha }} | |
| name: "Prerelease: ${{ needs.version.outputs.ref }} @ ${{ needs.version.outputs.version }}" | |
| body: | | |
| Prerelease build for branch `${{ needs.version.outputs.ref }}` at commit ${{ needs.version.outputs.sha }}. | |
| **Version:** `${{ needs.version.outputs.version }}` | |
| This pre-release is replaced in place on every push to the branch. | |
| It is automatically deleted when the branch's PR is closed. | |
| prerelease: true | |
| make_latest: 'false' | |
| files: | | |
| dist/utter_*.deb | |
| dist/utter-*.rpm | |
| dist/utter-*.tar.gz | |
| dist/utter-*-macos-*.dmg | |
| fail_on_unmatched_files: true |