package-macos.ts 5.3 KB

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