package-macos.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  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 with publishing disabled; resolves only after its DMG
  55. * notarization and verification hook succeeds, and rejects on build or hook failure.
  56. * @param apple - Apple signing, copying, and notarization operations.
  57. * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory.
  58. */
  59. export async function packageMacOSArtifacts(
  60. request: MacOSArtifactRequest,
  61. build: (artifact: DesktopPrepackagedArtifact) => Promise<void>,
  62. apple: MacOSArtifactOperations = operations,
  63. ): Promise<void> {
  64. const { arch, version, artifactsRoot, environment } = request
  65. const expected = resolveMacOSSigningEnvironment(environment)
  66. const credentials = resolveMacOSNotarizationEnvironment(environment)
  67. const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app')
  68. const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-'))
  69. const zipApp = join(root, 'zip', basename(appPath))
  70. const dmgApp = join(root, 'dmg', basename(appPath))
  71. const zipOutput = join(root, 'zip-artifacts')
  72. const dmgOutput = join(root, 'dmg-artifacts')
  73. try {
  74. await apple.copyApp(appPath, zipApp)
  75. await apple.copyApp(appPath, dmgApp)
  76. apple.verifySignature(zipApp, expected)
  77. apple.verifySignature(dmgApp, expected)
  78. const results = await Promise.allSettled([
  79. timed('App notarization and ZIP', async () => {
  80. await apple.notarize({ appPath: zipApp, ...credentials })
  81. apple.verifyNotarization(zipApp, expected)
  82. await build({ format: 'zip', appPath: zipApp, output: zipOutput })
  83. }),
  84. timed('DMG creation and notarization', async () => {
  85. await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput })
  86. }),
  87. ])
  88. const failures = results.filter(result => result.status === 'rejected')
  89. if (failures.length > 0) {
  90. throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed')
  91. }
  92. const base = `deepseek-harness-${version}-mac-${arch}`
  93. const artifacts = [
  94. [dmgOutput, `${base}.dmg`],
  95. [zipOutput, `${base}.zip`],
  96. [zipOutput, `${base}.zip.blockmap`],
  97. [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')],
  98. ] as const
  99. for (const [output, filename] of artifacts) {
  100. const file = join(output, filename)
  101. const details = await stat(file)
  102. if (!details.isFile() || details.size === 0) {
  103. throw new Error(`desktop macOS packaging: missing or empty artifact ${file}`)
  104. }
  105. }
  106. for (const [output, filename] of artifacts) {
  107. await rename(join(output, filename), join(artifactsRoot, filename))
  108. }
  109. await rm(appPath, { recursive: true })
  110. await rename(zipApp, appPath)
  111. } finally {
  112. await rm(root, { recursive: true, force: true })
  113. }
  114. }