macOS Code Signing, Notarization & Distribution Guide
This document outlines the complete process for building, signing, notarizing, and distributing the Transcribe.dev desktop app for macOS. Follow these steps exactly to avoid Gatekeeper rejection issues.
Prerequisites
Apple Developer Credentials
You need these environment variables set:
APPLE_ID="tim@do.dev"
APPLE_APP_SPECIFIC_PASSWORD="<app-specific-password>" # Generate at appleid.apple.com
APPLE_TEAM_ID="3P5HWZDUGK"Developer ID Certificate
A valid "Developer ID Application" certificate must be installed in Keychain. Verify with:
security find-identity -v -p codesigning | grep "Developer ID"Expected output:
1) 5A92F629A0E2CCA148382352A1A359C039A10AB3 "Developer ID Application: Do Dev, LLC (3P5HWZDUGK)"Critical: Entitlements File
This is the most important part. Incorrect entitlements will cause Gatekeeper to reject the app even if notarization succeeds.
The entitlements file must be at apps/desktop/build/entitlements.mac.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>DO NOT Include These Entitlements
The following entitlements cause Gatekeeper to show "Apple could not verify" errors:
com.apple.security.cs.allow-unsigned-executable-memorycom.apple.security.cs.disable-library-validation
These are sometimes recommended for Electron apps but they trigger stricter Gatekeeper checks that fail even with valid notarization.
Complete Build & Release Process
Step 1: Update Version
Edit apps/desktop/package.json:
{
"version": "X.Y.Z"
}Also update apps/api/src/routes/v1/releases.ts with the new version info.
Step 2: Clean Build
cd apps/desktop
rm -rf dist outStep 3: Build with Electron Builder
APPLE_ID="tim@do.dev" \
APPLE_APP_SPECIFIC_PASSWORD="<password>" \
APPLE_TEAM_ID="3P5HWZDUGK" \
pnpm build:macThis will:
- Build the app
- Sign the app with Developer ID
- Notarize the app with Apple
- Create the DMG
Verify the output shows: notarization successful
Step 4: Verify App Signing
# Check app is signed and notarized
spctl -a -vvv dist/mac-arm64/Transcribe.dev.app
# Expected output:
# dist/mac-arm64/Transcribe.dev.app: accepted
# source=Notarized Developer ID
# origin=Developer ID Application: Do Dev, LLC (3P5HWZDUGK)
# Verify stapling
xcrun stapler validate dist/mac-arm64/Transcribe.dev.app
# Expected: "The validate action worked!"
# Verify entitlements are correct
codesign -d --entitlements - dist/mac-arm64/Transcribe.dev.app
# Should show ONLY: allow-jit, device.audio-input, automation.apple-eventsStep 5: Sign the DMG
The DMG created by electron-builder is NOT signed. You must sign it manually:
codesign --force --sign "5A92F629A0E2CCA148382352A1A359C039A10AB3" dist/transcribe-dev-desktop-X.Y.Z.dmgVerify:
codesign -dvvv dist/transcribe-dev-desktop-X.Y.Z.dmg
# Should show Authority=Developer ID Application: Do Dev, LLCStep 6: Notarize the DMG
xcrun notarytool submit dist/transcribe-dev-desktop-X.Y.Z.dmg \
--apple-id "tim@do.dev" \
--password "<app-specific-password>" \
--team-id "3P5HWZDUGK" \
--waitWait for: status: Accepted
Step 7: Staple the DMG
xcrun stapler staple dist/transcribe-dev-desktop-X.Y.Z.dmgExpected: The staple and validate action worked!
Step 8: Final Verification
# Verify DMG passes Gatekeeper
spctl -a -vvv -t install dist/transcribe-dev-desktop-X.Y.Z.dmg
# Expected output:
# dist/transcribe-dev-desktop-X.Y.Z.dmg: accepted
# source=Notarized Developer ID
# origin=Developer ID Application: Do Dev, LLC (3P5HWZDUGK)Step 9: Upload to R2
Important: Use --remote flag and set content-type:
cd apps/api
npx wrangler r2 object put transcribe-dev/releases/transcribe-dev-desktop-X.Y.Z.dmg \
--file=../desktop/dist/transcribe-dev-desktop-X.Y.Z.dmg \
--content-type="application/x-apple-diskimage" \
--remoteVerify it says: Resource location: remote
If it says Resource location: local, the upload went to local emulation, not production!
Step 10: Update API
Edit apps/api/src/routes/v1/releases.ts:
const CURRENT_RELEASE = {
version: "X.Y.Z",
releaseDate: "YYYY-MM-DD",
downloadUrl: "https://r2.transcribe.dev/releases/transcribe-dev-desktop-X.Y.Z.dmg",
releaseNotes: `...`,
minVersion: "0.1.0",
};Deploy:
cd apps/api
npx wrangler deployStep 11: Verify Download
# Download and verify
curl -sL -o /tmp/test.dmg "https://r2.transcribe.dev/releases/transcribe-dev-desktop-X.Y.Z.dmg"
spctl -a -vvv -t install /tmp/test.dmg
# Should show: accepted, source=Notarized Developer IDQuick Reference Script
Here's a complete script for the release process:
#!/bin/bash
set -e
VERSION="X.Y.Z" # Update this
cd /Users/tim/code/working/dodotdev/transcribe-dev/apps/desktop
# Clean and build
rm -rf dist out
APPLE_ID="tim@do.dev" \
APPLE_APP_SPECIFIC_PASSWORD="<password>" \
APPLE_TEAM_ID="3P5HWZDUGK" \
pnpm build:mac
# Sign DMG
codesign --force --sign "5A92F629A0E2CCA148382352A1A359C039A10AB3" dist/transcribe-dev-desktop-${VERSION}.dmg
# Notarize DMG
xcrun notarytool submit dist/transcribe-dev-desktop-${VERSION}.dmg \
--apple-id "tim@do.dev" \
--password "<password>" \
--team-id "3P5HWZDUGK" \
--wait
# Staple DMG
xcrun stapler staple dist/transcribe-dev-desktop-${VERSION}.dmg
# Verify
spctl -a -vvv -t install dist/transcribe-dev-desktop-${VERSION}.dmg
# Upload to R2
cd ../api
npx wrangler r2 object put transcribe-dev/releases/transcribe-dev-desktop-${VERSION}.dmg \
--file=../desktop/dist/transcribe-dev-desktop-${VERSION}.dmg \
--content-type="application/x-apple-diskimage" \
--remote
# Deploy API (after updating releases.ts)
npx wrangler deploy
echo "Done! Test download from: https://r2.transcribe.dev/releases/transcribe-dev-desktop-${VERSION}.dmg"Troubleshooting
"Apple could not verify" Error
This means Gatekeeper is rejecting the app. Check:
- Entitlements - Most common cause. Verify you're NOT using
allow-unsigned-executable-memoryordisable-library-validation - DMG not signed - The DMG must be signed separately after electron-builder creates it
- DMG not notarized - The DMG must be notarized separately
- DMG not stapled - Run
xcrun stapler stapleon the DMG - Wrong file uploaded - Verify with
shasum -a 256that local and R2 files match
Verify Notarization Status
# Check notarization history
xcrun notarytool history --apple-id "tim@do.dev" --password "<password>" --team-id "3P5HWZDUGK"
# Get detailed log for a submission
xcrun notarytool log <submission-id> --apple-id "tim@do.dev" --password "<password>" --team-id "3P5HWZDUGK"R2 Upload Issues
If wrangler shows Resource location: local, it's uploading to local emulation. Always use --remote:
npx wrangler r2 object put <bucket>/<path> --file=<file> --remoteCompare with Working App
To debug, compare signatures with a known working app like VS Code:
codesign -dvvv "/Applications/Visual Studio Code.app"
codesign -d --entitlements - "/Applications/Visual Studio Code.app"Clear Quarantine for Testing
To test an app without quarantine (simulating local install):
xattr -cr /Applications/Transcribe.dev.appKey Lessons Learned
- Entitlements matter more than you think - Even with valid notarization, wrong entitlements cause Gatekeeper rejection
- The DMG needs separate signing/notarization - Electron-builder notarizes the app but not the DMG
- Always use
--remotewith wrangler - Local emulation is the default in some contexts spctlcan say "accepted" but Gatekeeper still rejects - The entitlements issue causes this discrepancy- Set
content-typeon R2 uploads - Useapplication/x-apple-diskimagefor DMG files