#!/usr/bin/env bash # # Code sign a macOS .app bundle for distribution # Usage: codesign_mac_app.sh [entitlements_path] # # Environment variables: # SIGNING_IDENTITY - The signing identity (default: "Developer ID Application") # KEYCHAIN_PASSWORD - Password to unlock the keychain (optional, CI only) # KEYCHAIN_NAME - Name of the keychain containing the signing certificate (optional, CI only) # When set, looks for certificate in this specific keychain. # When not set, searches all keychains (local dev mode). set -euxo pipefail APP_PATH="$1" ENTITLEMENTS_PATH="${2:-}" SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}" # KEYCHAIN_NAME is set by CI workflow - don't set a default here so we can detect if we're in CI if [ ! -d "$APP_PATH" ]; then echo "ERROR: App not found at $APP_PATH" exit 1 fi # Unlock keychain if password and keychain name are provided (CI environment) if [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${KEYCHAIN_NAME:-}" ]; then security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" || true fi # Get the SHA-1 hash of the signing certificate # Using the hash avoids "ambiguous" errors when the same identity exists in multiple keychains # We look in the build keychain specifically if KEYCHAIN_NAME is set (CI environment) # Otherwise fall back to searching all keychains (local dev) if [ -n "${KEYCHAIN_NAME:-}" ]; then # CI environment: look for certificate in the build keychain specifically KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db" echo "Looking for signing identity in build keychain: $KEYCHAIN_PATH" echo "Available identities in build keychain:" security find-identity -v -p codesigning "$KEYCHAIN_PATH" CERT_HASH=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}') else # Local dev: search all keychains echo "Available codesigning identities:" security find-identity -v -p codesigning CERT_HASH=$(security find-identity -v -p codesigning | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}') fi if [ -z "$CERT_HASH" ]; then echo "ERROR: No valid signing identity found" exit 1 fi echo "Using certificate hash: $CERT_HASH" # Use the hash as the signing identity to avoid ambiguity SIGNING_IDENTITY="$CERT_HASH" echo "=== Signing nested components first ===" # Sign all dylibs find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do echo "Signing dylib: $item" codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" done # Sign all bundles (plugins) find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do echo "Signing bundle: $item" codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" done # Sign XPC services (but skip ones inside Sparkle.framework - they're already signed) find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do if [[ "$item" == *"Sparkle.framework"* ]]; then echo "Skipping Sparkle XPC service (pre-signed): $item" continue fi echo "Signing XPC service: $item" codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" done # Sign nested apps (but skip ones inside Sparkle.framework - they're already signed) find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do if [[ "$item" == *"Sparkle.framework"* ]]; then echo "Skipping Sparkle nested app (pre-signed): $item" continue fi echo "Signing nested app: $item" codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" done # Sign standalone executables inside frameworks (but skip Sparkle.framework internals) find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do if [[ "$item" == *"Sparkle.framework"* ]]; then echo "Skipping Sparkle executable (pre-signed): $item" continue fi echo "Signing executable: $item" codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" done # Sign all frameworks (after their contents are signed) # Use --deep for Sparkle.framework to handle its XPC services find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do if [[ "$item" == *"Sparkle.framework" ]]; then echo "Signing Sparkle framework with --deep: $item" codesign --deep --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" else echo "Signing framework: $item" codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$item" fi done echo "=== Signing main app bundle ===" if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then echo "Using entitlements: $ENTITLEMENTS_PATH" codesign --force --verify --verbose --timestamp --options runtime \ --entitlements "$ENTITLEMENTS_PATH" \ --sign "$SIGNING_IDENTITY" "$APP_PATH" else codesign --force --verify --verbose --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$APP_PATH" fi echo "=== Verifying signature ===" codesign --verify --verbose=4 "$APP_PATH" echo "=== Checking Gatekeeper assessment ===" spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized" echo "Code signing complete: $APP_PATH"