Skip to content

feat(macos): add Intel (x86_64) build with separate DMG #1

feat(macos): add Intel (x86_64) build with separate DMG

feat(macos): add Intel (x86_64) build with separate DMG #1

Workflow file for this run

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