Просмотр исходного кода

fix(desktop): prepare unattended macOS signing from required p12

winewill 4 дней назад
Родитель
Сommit
9fefcd7c06

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml

@@ -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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


+ 2 - 0
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -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/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `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 中。

+ 1 - 0
.gitignore

@@ -2,6 +2,7 @@ CLAUDE.local.md
 .env
 apps/desktop/.env.windows
 apps/desktop/.env.macos
+apps/desktop/*.p12
 node_modules/
 lib/
 *.tsbuildinfo

+ 10 - 8
apps/desktop/.env.macos.example

@@ -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 - 2
apps/desktop/README.i18n.yaml

@@ -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

Разница между файлами не показана из-за своего большого размера
+ 1 - 2
apps/desktop/README.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 2
apps/desktop/README.zh.md


+ 5 - 1
apps/desktop/scripts/desktop-package-environment.mjs

@@ -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)')
+    }
   }
 }

+ 16 - 0
apps/desktop/scripts/macos-signing-keychain.d.mts

@@ -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>

+ 60 - 0
apps/desktop/scripts/macos-signing-keychain.mjs

@@ -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 }) }
+  }
+}

+ 6 - 1
apps/desktop/scripts/package-target.ts

@@ -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) }
 }

+ 1 - 1
apps/desktop/scripts/verify-macos-signature.d.mts

@@ -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.

+ 4 - 1
apps/desktop/scripts/verify-macos-signature.mjs

@@ -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',

+ 10 - 4
apps/desktop/tests/desktop-package-environment.spec.ts

@@ -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: 'user@example.com', 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)

+ 53 - 0
apps/desktop/tests/macos-signing-keychain.spec.ts

@@ -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)
+  })
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов