将 .app 直接分发给用户(绕过 App Store)意味着需要:归档、代码签名、公证、装订和打包。流程本身并不复杂,但陷阱不少。
Apple 的公证文档只涵盖了顺利的情况,却没有说明实际最容易出错的三件事:
/Volumes 的写入权限(需要完全磁盘访问)。所有将镜像挂载到 /Volumes/MyApp/ 并尝试复制文件的 DMG 工具现在都会失败。错误表现为构建中途报告 "internal error in Code Signing subsystem"——没有任何关于 TCC 或磁盘的提示。你可能会花整整一个小时追查签名身份,最终才发现真正的原因。codesign 报 errSecInternalComponent 意味着宿主进程无法读取 Keychain。不是证书损坏,不是私钥缺失——而是运行 codesign 的终端(或 LingCode 等工具)缺少完全磁盘访问权限。这个错误信息与其他至少六种故障模式完全相同,因此诊断起来并不直观。sign_update 工具在 DerivedData 被清除后就消失了。Xcode 会在下次归档时重新构建它,但如果你的发布脚本在重建之前运行,就会因为找不到二进制文件而失败。解决方法不是等待重建——而是直接从匹配版本的 Sparkle GitHub Release 下载预构建的 sign_update。LingCode 可以端到端地运行整个流程。以下步骤展示了它的工作方式,以及如何在每个故障出现时立刻识别它。
developer.apple.com > 证书处申请。它有别于"Apple Development"(用于 Xcode 调试构建)和"Apple Distribution"(App Store)。appleid.apple.com > 登录与安全 > 专用应用密码处生成。请勿直接使用你的 Apple ID 密码。developer.apple.com/account 中查看。notarytool 需要明确指定它。将 notarytool 凭据一次性存入 Keychain,避免每次发布时都要粘贴密钥:
xcrun notarytool store-credentials "AC_PASSWORD" \
--apple-id "[email protected]" \
--team-id "ABCDE12345" \
--password "xxxx-xxxx-xxxx-xxxx"
此后所有 notarytool 命令都通过 --keychain-profile "AC_PASSWORD" 引用凭据,永远不用明文密码。
让 LingCode 从命令行归档你的应用。归档产物是一个包含调试符号的自包含构建结果——和 Xcode Organizer 产生的一致,只是脚本化了:
xcodebuild archive \
-project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
-destination "generic/platform=macOS" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM=ABCDE12345 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)"
这里刻意选择手动签名——在非交互式脚本中使用自动签名是最容易在 CI 环境中出问题的地方,也是最容易触发令人困惑的 Keychain 提示的根源。固定身份和 Team ID 可以让构建过程可复现。
security find-identity -v -p codesigning 可列出所有可用证书;如果证书缺失,请从 developer.apple.com 重新下载。
将归档文件导出为普通的 .app Bundle。这一步中导出选项 plist 至关重要——直接分发使用 developer-id,App Store 使用 app-store:
# ExportOptions.plist
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>ABCDE12345</string>
<key>signingStyle</key>
<string>manual</string>
</dict>
</plist>
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist
完成后,build/export/MyApp.app 已存在,并且 Xcode 的导出步骤已对其签名。下一阶段会对 Xcode 未签名的部分进行补充签名——嵌入的辅助程序、框架和捆绑的二进制文件。
如果你的应用捆绑了辅助二进制文件(CLI、Node 运行时、Sparkle 更新器、Python 解释器),必须在签名外层 Bundle 之前逐一签名这些文件。顺序很重要——由内而外地签名:
# Sign each embedded binary first
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
--entitlements MyApp.entitlements \
"build/export/MyApp.app/Contents/Resources/bin/node"
codesign --force --options runtime --timestamp \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
--entitlements MyApp.entitlements \
"build/export/MyApp.app/Contents/Resources/bin/mycli"
# Then re-sign the outer app bundle
codesign --force --options runtime --timestamp --deep \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
--entitlements MyApp.entitlements \
"build/export/MyApp.app"
# Verify
codesign --verify --deep --strict --verbose=2 "build/export/MyApp.app"
spctl --assess --type execute --verbose "build/export/MyApp.app"
--options runtime 用于启用 Hardened Runtime,缺少它公证会被拒绝。--timestamp 会附加来自 Apple 服务器的安全时间戳——同样为必须项。
errSecInternalComponent 陷阱。如果 codesign 失败并提示 "errSecInternalComponent",说明你的终端(或运行 codesign 的进程)缺少完全磁盘访问权限。macOS 会阻止没有此权限的进程读取 Keychain 中的私钥材料。解决方法:系统设置 > 隐私与安全性 > 完全磁盘访问 > 添加 Terminal.app(或你实际使用的宿主进程)。重新启动终端会话后再重试。这也是从其他沙盒化宿主中运行发布脚本时可能莫名失败的原因——请从真正的 Terminal.app 窗口运行。
公证在 Apple 服务端运行。你需要将已签名的应用压缩后上传,然后等待。--wait 标志会阻塞直到结果返回——通常需要 1–5 分钟,有时超过 30 分钟:
# Zip the signed app
ditto -c -k --keepParent "build/export/MyApp.app" "build/MyApp.zip"
# Submit and block until done
xcrun notarytool submit "build/MyApp.zip" \
--keychain-profile "AC_PASSWORD" \
--wait
成功时输出会包含 status: Accepted 和一个 UUID。如果显示 Invalid,请拉取日志:
xcrun notarytool log <submission-uuid> \
--keychain-profile "AC_PASSWORD" \
notarization-log.json
JSON 日志会精确指出哪个二进制文件失败以及原因。常见原因:嵌入的辅助程序未签名(重新执行第三阶段)、缺少 Hardened Runtime 标志、缺少安全时间戳,或第三方框架使用了私有 API。
装订(Staple)会将公证票据附加到应用 Bundle 上,使 Gatekeeper 能够离线验证(用户 Mac 上的首次启动无需网络):
xcrun stapler staple "build/export/MyApp.app"
xcrun stapler validate "build/export/MyApp.app"
Bundle 现在已可以分发。你可以将 .app 直接交给用户,但为了更好的安装体验,通常还需要将其打包进 DMG。
传统 DMG 方案:用 hdiutil create 创建可读写镜像,挂载到 /Volumes/MyApp/,将应用复制进去,设置卷图标,卸载,再转换为压缩只读格式。这在 macOS 14 及更早版本上可以正常工作。
在 macOS 26.2+ 上,未沙盒化的脚本在没有完全磁盘访问权限的情况下挂载到 /Volumes/ 会静默失败,或在流程中途返回看似签名错误的提示。解决方法是改为挂载到 /private/tmp/——TCC 不会拦截该路径:
# Stage layout in a writable scratch dir
STAGING="$(mktemp -d)/MyApp-staging"
mkdir -p "$STAGING"
cp -R "build/export/MyApp.app" "$STAGING/"
ln -s /Applications "$STAGING/Applications"
# Build the DMG via /private/tmp, not /Volumes
hdiutil create -volname "MyApp" \
-srcfolder "$STAGING" \
-ov -format UDZO \
"build/MyApp.dmg"
# Sign the DMG itself
codesign --force --timestamp \
--sign "Developer ID Application: Your Name (ABCDE12345)" \
"build/MyApp.dmg"
# Notarize and staple the DMG (separately from the app)
xcrun notarytool submit "build/MyApp.dmg" \
--keychain-profile "AC_PASSWORD" --wait
xcrun stapler staple "build/MyApp.dmg"
DMG 需要单独完成一次公证。Apple 将磁盘镜像视为独立的可分发产物,如果 DMG 本身未经公证,Gatekeeper 会阻止用户下载后挂载。
hdiutil create 在 macOS 26.2+ 上报 "Resource busy" 或 "hdiutil: create failed":这是 TCC 对 /Volumes 的拦截。要么为你的终端授予完全磁盘访问权限,要么(更推荐)将暂存路径保持在 /private/tmp/ 内,永远不直接操作 /Volumes/。
如果你的应用使用 Sparkle 实现自动更新,发布脚本需要用 EdDSA 密钥为每个 DMG 签名,并将签名追加到 appcast 中。该工具就是 sign_update,从 Sparkle 源码构建而来:
sign_update build/MyApp.dmg
# Outputs: sparkle:edSignature="…" length="…"
陷阱在于:sign_update 存放在 DerivedData 中(Xcode 按需从 Sparkle Swift 包构建它)。任何 rm -rf ~/Library/Developer/Xcode/DerivedData 操作都会把它清除。发布脚本随后会因找不到二进制文件而失败,而显而易见的解法——"重建项目"——每次都要花 10 分钟以上,因为 Xcode 需要重新解析所有包依赖。
更快的解法:从匹配版本的 Sparkle GitHub Release 下载预构建的 sign_update。Sparkle 在 https://github.com/sparkle-project/Sparkle/releases/ 提供了包含 bin/sign_update 的压缩包——只需将其提交到仓库,或在发布脚本中下载:
# In your release script, before signing
if ! command -v sign_update >/dev/null; then
SPARKLE_VERSION="2.6.4" # whatever matches your project
curl -fsSL "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" \
| tar -xJ -C /tmp/sparkle
export PATH="/tmp/sparkle/bin:$PATH"
fi
sign_update build/MyApp.dmg
这样可以将发布流程与 Xcode 的 DerivedData 状态解耦。在一台全新机器上从零 checkout,分钟级别即可完成发布,而不必等待 Xcode 重新构建。
当流程中途出现问题时,请按以下顺序逐项排查——越靠前的检查越快:
security find-identity -v -p codesigning——Developer ID Application 证书是否存在且未过期?如果没有,整个流程无法启动。codesign --verify --deep --strict --verbose=2 MyApp.app——Bundle 内部是否一致?嵌入的辅助程序签名与外层 Bundle 的不匹配会在这里暴露。spctl --assess --type execute --verbose MyApp.app——Gatekeeper 此刻是否会接受这个 Bundle?公证前显示"rejected";装订后应显示"accepted source=Notarized Developer ID"。xcrun notarytool log <uuid> …——这是 Apple 对任何公证拒绝的权威说明。不要猜测,直接拉日志。stapler validate MyApp.app——公证票据是否已附加?如果跳过装订,用户在没有网络的情况下首次启动会被静默拦截。errSecInternalComponent 和 /Volumes 写入失败都源于缺少完全磁盘访问权限。将此技能放入 LingCode 的技能文件夹,然后告诉 LingCode "对这个 Mac 应用进行代码签名和公证",它就会运行完整的流程:
---
name: mac-codesign-and-notarize
description: Use when shipping a macOS app outside the App Store — codesign, notarize, staple, DMG creation, and Sparkle EdDSA signing. Triggers: 'codesign fails', 'notarization rejected', 'errSecInternalComponent', 'internal error in Code Signing subsystem', 'sign_update not found', 'DMG mount fails', /Volumes write blocked, Sparkle EdDSA issue, hardened runtime error. Actions: archive, export with developer-id method, codesign with --options runtime + --timestamp, notarytool submit --wait, stapler staple, hdiutil create DMG, sign Sparkle update. Errors: errSecInternalComponent (FDA missing), macOS 26.2+ TCC /Volumes trap, missing sign_update after DerivedData clean. Skip if: App Store submission (use ios-signing-and-app-store) or simulator builds.
---
Codesign, notarize, and staple a Mac app for direct distribution.
Phase 1 — Archive: xcodebuild archive with manual signing,
Developer ID Application identity pinned.
Phase 2 — Export: xcodebuild -exportArchive with developer-id
method in ExportOptions.plist.
Phase 3 — Codesign: sign embedded helpers inside-out before the
outer bundle. --options runtime + --timestamp required. If
codesign reports errSecInternalComponent, the host process is
missing Full Disk Access — fix in System Settings first.
Phase 4 — Notarize: ditto into a zip, notarytool submit --wait
with --keychain-profile. On Invalid, pull notarytool log for the
exact rejected binary.
Phase 5 — Staple: stapler staple, then stapler validate.
DMG step: stage inside /private/tmp (NOT /Volumes — macOS 26.2+
TCC blocks /Volumes writes without FDA). hdiutil create -format
UDZO, codesign the DMG, notarize and staple separately.
Sparkle sign_update: if missing after DerivedData clean, fetch
the prebuilt binary from the matching Sparkle GitHub release
rather than waiting for an Xcode rebuild.
Diagnostic order: security find-identity → codesign --verify →
spctl --assess → notarytool log → stapler validate → FDA check.
保存为 ~/.lingcode/skills/mac-codesign-and-notarize/SKILL.md——参阅安装技能了解确切位置以及技能的发现机制。