package-macos.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. /** Build the ZIP and DMG from separate signed application copies with overlapping notarization. */
  2. import { execFile } from 'node:child_process'
  3. import { mkdtemp, rename, rm, stat } from 'node:fs/promises'
  4. import { basename, dirname, join } from 'node:path'
  5. import { promisify } from 'node:util'
  6. import { notarize } from '@electron/notarize'
  7. import {
  8. resolveMacOSNotarizationEnvironment,
  9. resolveMacOSSigningEnvironment,
  10. } from './desktop-release-environment.mjs'
  11. import {
  12. desktopUpdateMetadataFilename,
  13. resolveDesktopAutoUpdateConfig,
  14. } from './desktop-auto-update-environment.mjs'
  15. import { verifyMacOSAppUpdateConfig } from './macos-app-update-config.mjs'
  16. import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs'
  17. const execute = promisify(execFile)
  18. /** One electron-builder artifact made from an already signed application. */
  19. export interface DesktopPrepackagedArtifact {
  20. readonly format: 'dmg' | 'zip'
  21. readonly appPath: string
  22. readonly output: string
  23. }
  24. /** A signed macOS directory build and its final release destination. */
  25. export interface MacOSArtifactRequest {
  26. readonly arch: 'arm64' | 'x64'
  27. readonly version: string
  28. readonly artifactsRoot: string
  29. readonly environment: NodeJS.ProcessEnv
  30. }
  31. /** Apple-tool operations replaced by deterministic fixtures in orchestration tests. */
  32. export interface MacOSArtifactOperations {
  33. readonly copyApp: (source: string, destination: string) => Promise<void>
  34. readonly notarize: (options: ReturnType<typeof resolveMacOSNotarizationEnvironment> & { appPath: string }) => Promise<void>
  35. readonly verifySignature: typeof verifyMacOSSignature
  36. readonly verifyNotarization: typeof verifyMacOSNotarizedApplication
  37. }
  38. const operations: MacOSArtifactOperations = {
  39. async copyApp(source, destination) {
  40. await execute('/usr/bin/ditto', [source, destination])
  41. },
  42. notarize,
  43. verifySignature: verifyMacOSSignature,
  44. verifyNotarization: verifyMacOSNotarizedApplication,
  45. }
  46. async function timed(label: string, action: () => Promise<void>): Promise<void> {
  47. const start = performance.now()
  48. process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`)
  49. await action()
  50. process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`)
  51. }
  52. /**
  53. * Notarize independent App/DMG copies concurrently, then promote their completed artifacts.
  54. * Both lanes settle before cleanup or rejection. The ZIP contains a stapled App; the DMG
  55. * carries its own ticket and encloses the signed App without an individually stapled ticket.
  56. * @param request - Signed directory build, release version, architecture, and credentials.
  57. * @param build - Runs electron-builder with publishing disabled; resolves only after its DMG
  58. * notarization and verification hook succeeds, and rejects on build or hook failure.
  59. * @param apple - Apple signing, copying, and notarization operations.
  60. * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory.
  61. */
  62. export async function packageMacOSArtifacts(
  63. request: MacOSArtifactRequest,
  64. build: (artifact: DesktopPrepackagedArtifact) => Promise<void>,
  65. apple: MacOSArtifactOperations = operations,
  66. ): Promise<void> {
  67. const { arch, version, artifactsRoot, environment } = request
  68. const expected = resolveMacOSSigningEnvironment(environment)
  69. const credentials = resolveMacOSNotarizationEnvironment(environment)
  70. const update = resolveDesktopAutoUpdateConfig(environment, 'darwin', arch)
  71. const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
  72. const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-'))
  73. const zipApp = join(root, 'zip', basename(appPath))
  74. const dmgApp = join(root, 'dmg', basename(appPath))
  75. const zipOutput = join(root, 'zip-artifacts')
  76. const dmgOutput = join(root, 'dmg-artifacts')
  77. try {
  78. await verifyMacOSAppUpdateConfig(appPath, update)
  79. await apple.copyApp(appPath, zipApp)
  80. await apple.copyApp(appPath, dmgApp)
  81. await verifyMacOSAppUpdateConfig(zipApp, update)
  82. await verifyMacOSAppUpdateConfig(dmgApp, update)
  83. apple.verifySignature(zipApp, expected)
  84. apple.verifySignature(dmgApp, expected)
  85. const results = await Promise.allSettled([
  86. timed('App notarization and ZIP', async () => {
  87. await apple.notarize({ appPath: zipApp, ...credentials })
  88. apple.verifyNotarization(zipApp, expected)
  89. await build({ format: 'zip', appPath: zipApp, output: zipOutput })
  90. }),
  91. timed('DMG creation and notarization', async () => {
  92. await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput })
  93. }),
  94. ])
  95. const failures = results.filter(result => result.status === 'rejected')
  96. if (failures.length > 0) {
  97. throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed')
  98. }
  99. await verifyMacOSAppUpdateConfig(zipApp, update)
  100. await verifyMacOSAppUpdateConfig(dmgApp, update)
  101. apple.verifySignature(zipApp, expected)
  102. apple.verifySignature(dmgApp, expected)
  103. const base = `deepseek-harness-${version}-mac-${arch}`
  104. const artifacts = [
  105. [dmgOutput, `${base}.dmg`],
  106. [zipOutput, `${base}.zip`],
  107. [zipOutput, `${base}.zip.blockmap`],
  108. [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')],
  109. ] as const
  110. for (const [output, filename] of artifacts) {
  111. const file = join(output, filename)
  112. const details = await stat(file)
  113. if (!details.isFile() || details.size === 0) {
  114. throw new Error(`desktop macOS packaging: missing or empty artifact ${file}`)
  115. }
  116. }
  117. for (const [output, filename] of artifacts) {
  118. await rename(join(output, filename), join(artifactsRoot, filename))
  119. }
  120. await rm(appPath, { recursive: true })
  121. await rename(zipApp, appPath)
  122. } finally {
  123. await rm(root, { recursive: true, force: true })
  124. }
  125. }