diff --git a/.github/workflows/prerelease-cleanup.yml b/.github/workflows/prerelease-cleanup.yml new file mode 100644 index 0000000..43233ee --- /dev/null +++ b/.github/workflows/prerelease-cleanup.yml @@ -0,0 +1,45 @@ +name: Prerelease Cleanup + +# Deletes the per-branch prerelease (and its tag) when a PR is closed, +# whether merged or not. Pairs with prerelease.yml — without this the +# Releases page would accumulate stale prereleases for old branches. + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + cleanup: + name: Delete prerelease for closed PR + runs-on: ubuntu-24.04 + steps: + - name: Compute prerelease tag + id: tag + env: + REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + # Same sanitization as prerelease.yml's compute step — must stay in lockstep. + SANITIZED=$(printf '%s' "$REF" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9.-' '-' \ + | sed 's/-\{1,\}/-/g; s/^-//; s/-$//') + TAG="prerelease-${SANITIZED}" + printf 'tag=%s\n' "$TAG" >> "$GITHUB_OUTPUT" + + - name: Delete prerelease + tag (if present) + env: + TAG: ${{ steps.tag.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + echo "Deleting prerelease $TAG" + gh release delete "$TAG" --repo "$REPO" --yes --cleanup-tag + else + echo "No prerelease for $TAG (nothing to clean up)" + fi diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..2bece54 --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,504 @@ +name: Prerelease Build + +# Builds a prerelease for the current branch and publishes it as a GitHub +# pre-release with a per-branch tag (`prerelease-`). 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): +# -. +# 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-15-intel + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eadb986..7c56528 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -291,9 +291,177 @@ jobs: path: dist/utter-*-macos-arm64.dmg if-no-files-found: error + macos-intel-build: + name: Build macOS x86_64 (Intel, signed + notarized DMG) + needs: test + # macos-15-intel is the standard 4-core Intel runner — free + unlimited on + # public repos. (Don't use *-large: those are Team/Enterprise-only larger + # runners.) Native build avoids the unknowns of cross-compiling ONNX Runtime. + runs-on: macos-15-intel + 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 + # Pyke ort 2.0.0-rc.12 doesn't ship prebuilt ONNX Runtime for x86_64-apple-darwin + # (their prebuilts are arm64 + linux + windows). We build ONNX Runtime from source + # and point ort-sys at the result via ORT_LIB_PATH. The pin must match what pyke + # validates against in pykeio/ort .github/workflows/custom-static-link.yml. + ONNXRUNTIME_VERSION: v1.24.2 + + steps: + - name: Resolve checkout ref + id: resolve_ref + env: + DISPATCH_TAG: ${{ inputs.tag }} + EVENT_REF: ${{ github.ref }} + run: | + if [ -n "$DISPATCH_TAG" ]; then + printf 'ref=%s\n' "$DISPATCH_TAG" >> "$GITHUB_OUTPUT" + else + printf 'ref=%s\n' "$EVENT_REF" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ steps.resolve_ref.outputs.ref }} + + - 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: release-macos-x86_64 + + # The from-source ONNX Runtime build is the slow step (~30 min cold). Cache it + # by version so the heavy work only runs when ONNXRUNTIME_VERSION changes. + - 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 + # --skip_tests trims ~30% off the build by not compiling test executables + # (we only need the .a libs for ort-sys's static linking). Native build + # on this Intel runner — no cross-compile flags needed. + ./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: + RAW_VERSION: ${{ github.ref_name }} + DISPATCH_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + TAG="${DISPATCH_TAG:-$RAW_VERSION}" + VERSION="${TAG#v}" + 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: dist-macos-x86_64 + path: dist/utter-*-macos-x86_64.dmg + if-no-files-found: error + release: name: Publish release - needs: [build, macos-build] + needs: [build, macos-build, macos-intel-build] runs-on: ubuntu-24.04 steps: - name: Download build artifacts diff --git a/README.md b/README.md index d1a0a0a..a999981 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Local, no-cloud push-to-talk dictation for **macOS and Linux**. Hold a key, spea **Change the PTT key:** open Terminal, run `/Applications/utter.app/Contents/MacOS/utter set-key`, press and hold the key you want, release. Pick **Quit utter** from the menu-bar icon and relaunch the app to apply. +### macOS (Intel) + +Same flow as Apple Silicon, but download `utter-VERSION-macos-x86_64.dmg` instead of the arm64 build. The Intel build is functionally identical — same on-device transcription, same permission flow, same PTT behavior. Requires macOS 13 or later. + ### Linux (Fedora, RHEL, Rocky, Debian, Ubuntu — `x86_64` / `aarch64`) ```bash