Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 58 additions & 241 deletions .github/workflows/build-macos.yml
Original file line number Diff line number Diff line change
@@ -1,294 +1,111 @@
name: Build macOS

# Release / on-demand packaging workflow.
# Per-commit compilation is covered by build-test.yml, so this only runs
# when explicitly invoked (release pipeline or manual dispatch).
on:
workflow_call:
secrets:
APPLE_CERTIFICATE:
required: true
required: false
APPLE_CERTIFICATE_PASSWORD:
required: true
required: false
KEYCHAIN_PASSWORD:
required: true
required: false
APPLE_SIGNING_IDENTITY:
required: true
required: false
APPLE_ID:
required: true
required: false
APPLE_TEAM_ID:
required: true
APPLE_PASSWORD:
required: true
workflow_dispatch:
inputs:
skip_build:
description: 'Skip build and use artifacts from a previous run'
required: false
default: false
type: boolean
run_id:
description: 'Run ID to download artifacts from (leave empty for latest)'
APPLE_PASSWORD:
required: false
type: string
push:
branches: [main]
workflow_dispatch:

jobs:
build:
name: Build macOS ${{ matrix.target }}
if: ${{ !inputs.skip_build }}
name: Build macOS ${{ matrix.arch }}
runs-on: ${{ matrix.os }}
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-13 # Intel
- os: macos-13 # Intel
target: x86_64-apple-darwin
arch: x86_64
- os: macos-14 # Apple Silicon
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
arch: aarch64

steps:
- uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

with:
targets: ${{ matrix.target }}

- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Import Apple certificates
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

# Import certificate from secrets
echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH

# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

# Import certificate to keychain
security import $CERTIFICATE_PATH -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH

- name: Build native
env:
CI: true
run: bun run tauri build

- name: Upload architecture-specific artifacts
uses: actions/upload-artifact@v4
with:
name: macos-${{ matrix.arch }}
path: |
src-tauri/target/release/bundle/macos/opcode.app
src-tauri/target/release/bundle/dmg/*.dmg
retention-days: 1

universal:
name: Create Universal Binary
needs: [build]
if: ${{ !cancelled() && (needs.build.result == 'success' || needs.build.result == 'skipped') }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Download artifacts from current workflow
if: ${{ !inputs.skip_build }}
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: artifacts

- name: Download artifacts from specific run
if: ${{ inputs.skip_build && inputs.run_id != '' }}
uses: dawidd6/action-download-artifact@v3
with:
workflow: build-macos.yml
run_id: ${{ inputs.run_id }}
name: macos-*
path: artifacts

- name: Download artifacts from latest run
if: ${{ inputs.skip_build && inputs.run_id == '' }}
uses: dawidd6/action-download-artifact@v3
with:
workflow: build-macos.yml
workflow_conclusion: success
name: macos-*
path: artifacts

- name: List downloaded artifacts
run: |
echo "📁 Artifact structure:"
find artifacts -type f -name "*.app" -o -name "*.dmg" | head -20
echo ""
echo "📁 Full directory structure:"
ls -la artifacts/
ls -la artifacts/macos-aarch64/ || echo "macos-aarch64 directory not found"
ls -la artifacts/macos-x86_64/ || echo "macos-x86_64 directory not found"

- name: Import Apple certificates

# Signing only happens when Apple Developer secrets are configured.
# Without them the build still succeeds, producing an unsigned bundle.
- name: Import Apple certificate
if: ${{ env.APPLE_CERTIFICATE != '' }}
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

# Import certificate from secrets
echo -n "$APPLE_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH

# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

# Import certificate to keychain
security import $CERTIFICATE_PATH -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH

- name: Create universal app
run: |
# Create temp directory
mkdir -p dmg_temp

# Extract zip files if they exist
if [ -f "artifacts/macos-aarch64.zip" ]; then
echo "📦 Extracting macos-aarch64.zip..."
unzip -q artifacts/macos-aarch64.zip -d artifacts/macos-aarch64/
fi

if [ -f "artifacts/macos-x86_64.zip" ]; then
echo "📦 Extracting macos-x86_64.zip..."
unzip -q artifacts/macos-x86_64.zip -d artifacts/macos-x86_64/
fi

# Find the actual app paths
AARCH64_APP=$(find artifacts/macos-aarch64 -name "opcode.app" -type d | head -1)
X86_64_APP=$(find artifacts/macos-x86_64 -name "opcode.app" -type d | head -1)

if [ -z "$AARCH64_APP" ] || [ -z "$X86_64_APP" ]; then
echo "❌ Could not find app bundles"
echo "AARCH64_APP: $AARCH64_APP"
echo "X86_64_APP: $X86_64_APP"
exit 1
fi

echo "✅ Found app bundles:"
echo " ARM64: $AARCH64_APP"
echo " x86_64: $X86_64_APP"

# Copy ARM64 app as base
cp -R "$AARCH64_APP" dmg_temp/

# Create universal binary using lipo
lipo -create -output dmg_temp/opcode.app/Contents/MacOS/opcode \
"$AARCH64_APP/Contents/MacOS/opcode" \
"$X86_64_APP/Contents/MacOS/opcode"

# Ensure executable permissions are set
chmod +x dmg_temp/opcode.app/Contents/MacOS/opcode

echo "✅ Universal binary created"
lipo -info dmg_temp/opcode.app/Contents/MacOS/opcode

- name: Sign app bundle
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
codesign --sign "$APPLE_SIGNING_IDENTITY" \
--timestamp \
--options runtime \
--force \
--deep \
--entitlements src-tauri/entitlements.plist \
dmg_temp/opcode.app

- name: Create DMG
run: |
hdiutil create -volname "opcode Installer" \
-srcfolder dmg_temp \
-ov -format UDZO opcode.dmg

- name: Sign DMG
CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
echo -n "$APPLE_CERTIFICATE" | base64 --decode -o "$CERTIFICATE_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"

- name: Build Tauri app
env:
# Tauri signs + notarizes when these are set; otherwise it builds unsigned.
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
codesign --sign "$APPLE_SIGNING_IDENTITY" \
--timestamp \
--force opcode.dmg

- name: Notarize DMG
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: bun run tauri build --target ${{ matrix.target }}

- name: Collect artifacts
run: |
# Store notarization credentials
xcrun notarytool store-credentials "notarytool-profile" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_PASSWORD"

# Submit for notarization
xcrun notarytool submit opcode.dmg \
--keychain-profile "notarytool-profile" \
--wait

- name: Staple notarization
run: xcrun stapler staple opcode.dmg

- name: Verify DMG
run: |
spctl -a -t open -vvv --context context:primary-signature opcode.dmg
echo "✅ DMG verification complete"

- name: Create artifacts directory
run: |
mkdir -p dist/macos-universal
cp opcode.dmg dist/macos-universal/

# Also save the app bundle using ditto to preserve permissions and signatures
ditto -c -k --sequesterRsrc --keepParent \
dmg_temp/opcode.app dist/macos-universal/opcode.app.zip

# Generate checksum
shasum -a 256 dist/macos-universal/* > dist/macos-universal/checksums.txt

mkdir -p dist/macos-${{ matrix.arch }}
cp src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg dist/macos-${{ matrix.arch }}/ || true
if ls dist/macos-${{ matrix.arch }}/*.dmg >/dev/null 2>&1; then
cd dist/macos-${{ matrix.arch }}
shasum -a 256 * > checksums.txt
fi

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: macos-universal
path: dist/macos-universal/*

- name: Cleanup
if: always()
name: macos-${{ matrix.arch }}
path: dist/macos-${{ matrix.arch }}/*
if-no-files-found: warn
retention-days: 7

- name: Cleanup keychain
if: ${{ always() && env.APPLE_CERTIFICATE != '' }}
run: |
echo "🧹 Cleaning up temporary directories..."
rm -rf dmg_temp temp_x86 artifacts

# Clean up keychain
if [ -n "$RUNNER_TEMP" ] && [ -f "$RUNNER_TEMP/app-signing.keychain-db" ]; then
security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" || true
fi

echo "✅ Cleanup complete"
Loading