fix(desktop): prepare unattended macOS signing from required p12
This commit is contained in:
1 parent
b06ea72226
commit
9fefcd7c06
16 files changed
+178
-24
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: d6afdb4cf108330c8711e93eea87d39754ee3504
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 97532eda76b9292da838016b81bd8cf8a3881495
|
||||
2026-08-25-electron-desktop-packaging-and-updates.md: f8825b652919147aef271accd5f4e521d7d5fc77
|
||||
2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2eb42f74dd8574afdf1348bc6abd5f5af1b979b9
|
||||
+2
@@ -87,6 +87,8 @@ Core dsh and the private Desktop Host come only from the signed application reso
|
||||
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
|
||||
|
||||
macOS signing requires a local p12 and its explicit export password. The packaging invocation owns a temporary keychain from import and preflight through all signing work, then deletes it on normal completion or failure. Explicit keychain selection prevents runtime preparation from depending on a developer's login state or electron-builder's later certificate import. Only the temporary keychain path reaches build children; the p12 password remains in the parent. CI must clean temporary credentials after forced termination.
|
||||
|
||||
The [pinned osx-sign patch](../../../../patches/@[email protected]) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
|
||||
|
||||
Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The shared Web server owns frontend and client-module responses. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC.
|
||||
|
||||
+2
@@ -87,6 +87,8 @@ Windows NSIS 分发包含固定版本构建器生成的独立 `.exe.blockmap`。
|
||||
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
|
||||
|
||||
macOS 签名必须提供本地 p12 及显式导出密码。打包调用从导入与预检开始持有临时钥匙串,覆盖所有签名工作,并在正常完成或失败后删除。显式选择钥匙串使运行时准备不依赖开发者登录状态或 electron-builder 后续的证书导入。构建子进程只接收临时钥匙串路径,p12 密码保留在父进程中。强制终止后由 CI 清理临时凭据。
|
||||
|
||||
[固定版本的 osx-sign 补丁](../../../../patches/@[email protected])在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
|
||||
|
||||
Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
|
||||
|
||||
@@ -2,6 +2,7 @@ CLAUDE.local.md
|
||||
.env
|
||||
apps/desktop/.env.windows
|
||||
apps/desktop/.env.macos
|
||||
apps/desktop/*.p12
|
||||
node_modules/
|
||||
lib/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -14,20 +14,22 @@ DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=https://harness.deepseek.com
|
||||
DSH_DESKTOP_MACOS_SIGNING_IDENTITY=
|
||||
DSH_DESKTOP_MACOS_TEAM_ID=
|
||||
|
||||
# Required local p12 containing the Developer ID Application certificate and private key.
|
||||
# CSC_KEY_PASSWORD is the p12 export password, not an Apple account or login password.
|
||||
# An explicitly empty password is accepted for an unencrypted p12.
|
||||
CSC_LINK=
|
||||
CSC_KEY_PASSWORD=
|
||||
|
||||
# Choose exactly one notarization strategy; leave the others commented out.
|
||||
APPLE_KEYCHAIN_PROFILE=
|
||||
APPLE_API_KEY=
|
||||
APPLE_API_KEY_ID=
|
||||
APPLE_API_ISSUER=
|
||||
# APPLE_KEYCHAIN_PROFILE=
|
||||
# APPLE_KEYCHAIN=
|
||||
# APPLE_API_KEY=
|
||||
# APPLE_API_KEY_ID=
|
||||
# APPLE_API_ISSUER=
|
||||
# APPLE_ID=
|
||||
# APPLE_APP_SPECIFIC_PASSWORD=
|
||||
# APPLE_TEAM_ID=
|
||||
|
||||
# Optional electron-builder certificate import instead of an existing keychain identity.
|
||||
# CSC_LINK=
|
||||
# CSC_KEY_PASSWORD=
|
||||
|
||||
# Optional upload configuration; packaging does not require these credentials.
|
||||
# DOWNLOAD_TEST_COS_BUCKET=
|
||||
# DOWNLOAD_TEST_COS_SECRET_ID=
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: c0cbe2163567c1edea346ceb9a81c96144601293
|
||||
README.zh.md: 4b82269b4a77b40f6f8628ab3ddc28981dc95de5
|
||||
README.md: 7b3c787e4656c68a9a47d7398611bfa9b8e472e3
|
||||
README.zh.md: 9558de1b9b119c0ce3a714241fbec3873b8e377f
|
||||
@@ -88,7 +88,7 @@ Workspace development runs the current CLI and private Desktop Host packages und
|
||||
|
||||
Packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
|
||||
|
||||
Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately:
|
||||
Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately:
|
||||
|
||||
```sh
|
||||
pnpm --dir apps/desktop run check:package
|
||||
@@ -152,7 +152,7 @@ The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_
|
||||
|
||||
Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification.
|
||||
|
||||
The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
|
||||
The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
|
||||
|
||||
macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements.
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Hos
|
||||
|
||||
打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
|
||||
|
||||
每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查:
|
||||
每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查:
|
||||
|
||||
```sh
|
||||
pnpm --dir apps/desktop run check:package
|
||||
@@ -152,7 +152,7 @@ pnpm run upload:mac:arm64
|
||||
|
||||
前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。
|
||||
|
||||
macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
|
||||
macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
|
||||
|
||||
macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDA
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN']
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
|
||||
/**
|
||||
* Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
|
||||
@@ -98,5 +98,9 @@ export function validateDesktopPackageEnvironment(environment, target, options =
|
||||
const credentials = resolveMacOSNotarizationEnvironment(environment)
|
||||
if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY')
|
||||
if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN')
|
||||
requireReadableFile(environment, 'CSC_LINK')
|
||||
if (environment.CSC_KEY_PASSWORD === undefined) {
|
||||
throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Own a temporary PKCS#12 signing identity for one macOS packaging invocation. */
|
||||
|
||||
/**
|
||||
* Import and authorize the required p12 before work; delete the owned keychain after work settles.
|
||||
* Children receive only its path, never the p12 password. Existing login keychains are not unlocked.
|
||||
* Abrupt process termination requires the CI runner to clean its temporary directory.
|
||||
* @param environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD.
|
||||
* @param action All signing work, settled before cleanup.
|
||||
* @param run Apple command executor.
|
||||
* @returns Resolves after work and cleanup; rejects on setup, work, or cleanup failure.
|
||||
*/
|
||||
export function withMacOSSigningKeychain(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
action: (environment: NodeJS.ProcessEnv) => Promise<void>,
|
||||
run?: (command: string, args: string[]) => void,
|
||||
): Promise<void>
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Own a temporary PKCS#12 signing identity for one macOS packaging invocation. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Execute a credential-bearing Apple command without exposing arguments or tool output on failure.
|
||||
* @param {string} command Absolute executable path.
|
||||
* @param {string[]} args Command arguments, potentially containing secrets.
|
||||
* @returns {void}
|
||||
*/
|
||||
function execute(command, args) {
|
||||
try { execFileSync(command, args, { stdio: 'pipe', timeout: 120_000 }) }
|
||||
catch (error) {
|
||||
// execFile errors contain the command line, including private-key passwords.
|
||||
throw new Error(`desktop macOS signing: ${command} ${args[0]} failed; check certificate, password, and signing access`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import and authorize the required p12 before work; delete the owned keychain after work settles.
|
||||
* Children receive only its path, never the p12 password. Existing login keychains are not unlocked.
|
||||
* Abrupt process termination requires the CI runner to clean its temporary directory.
|
||||
* @param {NodeJS.ProcessEnv} environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD.
|
||||
* @param {(environment: NodeJS.ProcessEnv) => Promise<void>} action All signing work, settled before cleanup.
|
||||
* @param {(command: string, args: string[]) => void} run Apple command executor.
|
||||
* @returns {Promise<void>} Resolves after work and cleanup; rejects on setup, work, or cleanup failure.
|
||||
*/
|
||||
export async function withMacOSSigningKeychain(environment, action, run = execute) {
|
||||
const certificate = environment.CSC_LINK
|
||||
const exportPassword = environment.CSC_KEY_PASSWORD
|
||||
if (!certificate || exportPassword === undefined) throw new Error('desktop macOS signing: CSC_LINK and CSC_KEY_PASSWORD are required')
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dsh-macos-signing-'))
|
||||
const keychain = join(directory, 'signing.keychain-db')
|
||||
const password = randomBytes(32).toString('base64')
|
||||
/** @param {string[]} args Security command arguments. */
|
||||
const security = args => run('/usr/bin/security', args)
|
||||
let created = false
|
||||
try {
|
||||
security(['create-keychain', '-p', password, keychain])
|
||||
created = true
|
||||
security(['unlock-keychain', '-p', password, keychain])
|
||||
security(['set-keychain-settings', keychain])
|
||||
security(['import', certificate, '-k', keychain, '-P', exportPassword, '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'])
|
||||
security(['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', password, keychain])
|
||||
const probe = join(directory, 'probe')
|
||||
run('/bin/cp', ['/usr/bin/true', probe])
|
||||
run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe])
|
||||
run('/usr/bin/codesign', ['--verify', '--strict', probe])
|
||||
const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain }
|
||||
delete childEnvironment.CSC_LINK
|
||||
delete childEnvironment.CSC_KEY_PASSWORD
|
||||
await action(childEnvironment)
|
||||
} finally {
|
||||
try { if (created) security(['delete-keychain', keychain]) }
|
||||
finally { rmSync(directory, { recursive: true, force: true }) }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts'
|
||||
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { createPackagingRun } from './packaging-run.mjs'
|
||||
import { withMacOSSigningKeychain } from './macos-signing-keychain.mjs'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
|
||||
@@ -291,7 +292,11 @@ async function main(): Promise<void> {
|
||||
if (run !== undefined) console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
|
||||
let success = false
|
||||
try {
|
||||
await packageTarget(invocation, environment, run)
|
||||
if (target.platform === 'darwin') {
|
||||
await withMacOSSigningKeychain(environment, signingEnvironment => packageTarget(invocation, signingEnvironment, run))
|
||||
} else {
|
||||
await packageTarget(invocation, environment, run)
|
||||
}
|
||||
success = true
|
||||
} finally { run?.finish(success) }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function assertMacOSSignatureDetails(details: string, expected: MacOSSign
|
||||
export function assertMacOSRuntimeSignatureDetails(details: string, expected: MacOSSigningEnvironment): void
|
||||
|
||||
/**
|
||||
* Sign one Mach-O file embedded in the runtime tree.
|
||||
* Sign one Mach-O file using the packaging-owned CSC_KEYCHAIN; missing setup rejects before signing.
|
||||
* @param path - Writable standalone Mach-O file.
|
||||
* @param identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
|
||||
* @param expected - Public release identity.
|
||||
|
||||
@@ -107,7 +107,7 @@ function runCodeSign(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign one Mach-O file embedded in the runtime tree.
|
||||
* Sign one Mach-O file using the packaging-owned CSC_KEYCHAIN; missing setup rejects before signing.
|
||||
* @param {string} path - Writable standalone Mach-O file.
|
||||
* @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest.
|
||||
* @param {{ signingIdentity: string, teamId: string }} expected - Public release identity.
|
||||
@@ -115,9 +115,12 @@ function runCodeSign(args) {
|
||||
* @returns {Promise<void>} Resolves after codesign exits successfully.
|
||||
*/
|
||||
export async function signMacOSRuntimeCode(path, identifier, expected, entitlements) {
|
||||
const keychain = process.env.CSC_KEYCHAIN
|
||||
if (!keychain) throw new Error('desktop macOS signing: run through the package command to prepare the signing keychain')
|
||||
await runAppleCommandAsync('/usr/bin/codesign', [
|
||||
'--force',
|
||||
'--sign', expected.signingIdentity,
|
||||
'--keychain', keychain,
|
||||
'--identifier', identifier,
|
||||
'--timestamp',
|
||||
'--options', 'runtime',
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Desktop local packaging configuration', () => {
|
||||
it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n')
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_APP_ID=com.example.mac\nAPPLE_KEYCHAIN_PROFILE=release\n')
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_APP_ID=com.example.mac\nAPPLE_KEYCHAIN_PROFILE=release\nCSC_LINK=keys/signing.p12\nCSC_KEY_PASSWORD=" # literal "\n')
|
||||
const parent = {
|
||||
PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.stale.desktop',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: '{"origin":"https://stale.example.com"}',
|
||||
@@ -41,6 +41,7 @@ describe('Desktop local packaging configuration', () => {
|
||||
})
|
||||
expect(loadDesktopPackageEnvironment('darwin', parent, directory)).toEqual({
|
||||
PATH: 'build-tools', DSH_DESKTOP_APP_ID: 'com.example.mac', APPLE_KEYCHAIN_PROFILE: 'release',
|
||||
CSC_LINK: join(directory, 'keys/signing.p12'), CSC_KEY_PASSWORD: ' # literal ',
|
||||
})
|
||||
expect(parent.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('stale-pin')
|
||||
expect(parent.dsh_desktop_mandatory_update_config).toBe('stale-policy')
|
||||
@@ -105,16 +106,16 @@ describe('Desktop local packaging configuration', () => {
|
||||
}).toThrow(/APPLE_API_KEY_ID/u)
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release' }, MACOS)
|
||||
}).not.toThrow()
|
||||
}).toThrow(/CSC_LINK/u)
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release', APPLE_API_KEY: '' }, MACOS)
|
||||
}).toThrow(/exactly one macOS notarization strategy/u)
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_ID: '[email protected]', APPLE_APP_SPECIFIC_PASSWORD: 'fixture', APPLE_TEAM_ID: 'TEAMID1234' }, MACOS)
|
||||
}).not.toThrow()
|
||||
}).toThrow(/CSC_LINK/u)
|
||||
await withDirectory(async (directory) => {
|
||||
const appleApiKey = join(directory, 'AuthKey.p8')
|
||||
const environment = { ...RELEASE, ...MAC_IDENTITY, APPLE_API_KEY: appleApiKey, APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555' }
|
||||
const environment = { ...RELEASE, ...MAC_IDENTITY, CSC_LINK: appleApiKey, CSC_KEY_PASSWORD: '', APPLE_API_KEY: appleApiKey, APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555' }
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment(environment, MACOS)
|
||||
}).toThrow(/APPLE_API_KEY must identify a readable local file/u)
|
||||
@@ -122,6 +123,11 @@ describe('Desktop local packaging configuration', () => {
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment(environment, MACOS)
|
||||
}).not.toThrow()
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment({ ...environment, CSC_KEY_PASSWORD: undefined }, MACOS)
|
||||
}).toThrow(/CSC_KEY_PASSWORD/u)
|
||||
expect(() => { validateDesktopPackageEnvironment({ ...environment, CSC_LINK: directory }, MACOS) }).toThrow(/CSC_LINK/u)
|
||||
expect(() => { validateDesktopPackageEnvironment({ ...environment, CSC_LINK: 'missing.p12' }, MACOS, { prepareOnly: true }) }).toThrow(/CSC_LINK/u)
|
||||
expect(() => {
|
||||
validateDesktopPackageEnvironment({ ...RELEASE, ...MAC_IDENTITY, APPLE_KEYCHAIN_PROFILE: 'release', APPLE_KEYCHAIN: directory }, MACOS)
|
||||
}).toThrow(/APPLE_KEYCHAIN/u)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { withMacOSSigningKeychain } from '../scripts/macos-signing-keychain.mjs'
|
||||
|
||||
const environment = { CSC_LINK: '/signing.p12', CSC_KEY_PASSWORD: 'export-secret', DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example (TEAMID1234)' }
|
||||
|
||||
describe('temporary macOS signing identity', () => {
|
||||
it('scopes signing to the imported identity and removes credentials before invoking the build', async () => {
|
||||
const run = vi.fn<(command: string, args: string[]) => void>()
|
||||
let keychain = ''
|
||||
await withMacOSSigningKeychain(environment, async (env) => {
|
||||
keychain = env.CSC_KEYCHAIN!
|
||||
expect(existsSync(dirname(keychain))).toBe(true)
|
||||
expect(env.CSC_LINK).toBeUndefined()
|
||||
expect(env.CSC_KEY_PASSWORD).toBeUndefined()
|
||||
expect(run.mock.calls.some(([command, args]) => command === '/usr/bin/codesign' && args.includes(keychain))).toBe(true)
|
||||
expect(run.mock.calls.at(-1)?.[1]).toContain('--verify')
|
||||
}, run)
|
||||
expect(run.mock.calls.at(-1)?.[1]).toEqual(['delete-keychain', keychain])
|
||||
expect(existsSync(dirname(keychain))).toBe(false)
|
||||
expect(environment.CSC_KEY_PASSWORD).toBe('export-secret')
|
||||
const create = run.mock.calls.find(([, args]) => args[0] === 'create-keychain')![1]
|
||||
const partition = run.mock.calls.find(([, args]) => args[0] === 'set-key-partition-list')![1]
|
||||
expect(partition[partition.indexOf('-k') + 1]).toBe(create[2])
|
||||
expect(create[2]).not.toBe(environment.CSC_KEY_PASSWORD)
|
||||
})
|
||||
|
||||
it.each(['import', '--force', 'build'])('cleans up and prevents subsequent work after %s fails', async (stage) => {
|
||||
const action = vi.fn(async () => { if (stage === 'build') throw Error('build failure') })
|
||||
const run = vi.fn((_command: string, args: string[]) => { if (args[0] === stage) throw Error('tool failure') })
|
||||
await expect(withMacOSSigningKeychain(environment, action, run)).rejects.toThrow(/failure/u)
|
||||
const keychain = run.mock.calls[0]![1].at(-1)!
|
||||
expect(run.mock.calls.at(-1)?.[1]).toEqual(['delete-keychain', keychain])
|
||||
expect(existsSync(dirname(keychain))).toBe(false)
|
||||
expect(action).toHaveBeenCalledTimes(stage === 'build' ? 1 : 0)
|
||||
})
|
||||
|
||||
it('allocates distinct keychains for overlapping invocations', async () => {
|
||||
const paths: string[] = []
|
||||
let release!: () => void
|
||||
const bothReady = new Promise<void>((resolve) => { release = resolve })
|
||||
const action = async (env: NodeJS.ProcessEnv) => {
|
||||
paths.push(env.CSC_KEYCHAIN!)
|
||||
if (paths.length === 2) release()
|
||||
await bothReady
|
||||
expect(paths.every(path => existsSync(dirname(path)))).toBe(true)
|
||||
}
|
||||
await Promise.all([withMacOSSigningKeychain(environment, action, () => {}), withMacOSSigningKeychain(environment, action, () => {})])
|
||||
expect(new Set(paths).size).toBe(2)
|
||||
expect(paths.every(path => !existsSync(dirname(path)))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user