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-memory
  • com.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 out

Step 3: Build with Electron Builder

APPLE_ID="tim@do.dev" \
APPLE_APP_SPECIFIC_PASSWORD="<password>" \
APPLE_TEAM_ID="3P5HWZDUGK" \
pnpm build:mac

This 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-events

Step 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.dmg

Verify:

codesign -dvvv dist/transcribe-dev-desktop-X.Y.Z.dmg
# Should show Authority=Developer ID Application: Do Dev, LLC

Step 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" \
  --wait

Wait for: status: Accepted

Step 7: Staple the DMG

xcrun stapler staple dist/transcribe-dev-desktop-X.Y.Z.dmg

Expected: 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" \
  --remote

Verify 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 deploy

Step 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 ID

Quick 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:

  1. Entitlements - Most common cause. Verify you're NOT using allow-unsigned-executable-memory or disable-library-validation
  2. DMG not signed - The DMG must be signed separately after electron-builder creates it
  3. DMG not notarized - The DMG must be notarized separately
  4. DMG not stapled - Run xcrun stapler staple on the DMG
  5. Wrong file uploaded - Verify with shasum -a 256 that 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> --remote

Compare 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.app

Key Lessons Learned

  1. Entitlements matter more than you think - Even with valid notarization, wrong entitlements cause Gatekeeper rejection
  2. The DMG needs separate signing/notarization - Electron-builder notarizes the app but not the DMG
  3. Always use --remote with wrangler - Local emulation is the default in some contexts
  4. spctl can say "accepted" but Gatekeeper still rejects - The entitlements issue causes this discrepancy
  5. Set content-type on R2 uploads - Use application/x-apple-diskimage for DMG files

On this page