macos-signing-keychain.mjs 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
  1. /** Own a temporary PKCS#12 signing identity for one macOS packaging invocation. */
  2. import { execFileSync } from 'node:child_process'
  3. import { randomBytes } from 'node:crypto'
  4. import { mkdtempSync, rmSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. /**
  8. * Execute a credential-bearing Apple command without exposing arguments or tool output on failure.
  9. * @param {string} command Absolute executable path.
  10. * @param {string[]} args Command arguments, potentially containing secrets.
  11. * @returns {void}
  12. */
  13. function execute(command, args) {
  14. try { execFileSync(command, args, { stdio: 'pipe', timeout: 120_000 }) }
  15. catch (error) {
  16. // execFile errors contain the command line, including private-key passwords.
  17. throw new Error(`desktop macOS signing: ${command} ${args[0]} failed; check certificate, password, and signing access`)
  18. }
  19. }
  20. /**
  21. * Import and authorize the required p12 before work; delete the owned keychain after work settles.
  22. * Children receive only its path, never the p12 password. Existing login keychains are not unlocked.
  23. * Abrupt process termination requires the CI runner to clean its temporary directory.
  24. * @param {NodeJS.ProcessEnv} environment Validated platform configuration with local CSC_LINK and CSC_KEY_PASSWORD.
  25. * @param {(environment: NodeJS.ProcessEnv) => Promise<void>} action All signing work, settled before cleanup.
  26. * @param {(command: string, args: string[]) => void} run Apple command executor.
  27. * @returns {Promise<void>} Resolves after work and cleanup; rejects on setup, work, or cleanup failure.
  28. */
  29. export async function withMacOSSigningKeychain(environment, action, run = execute) {
  30. const certificate = environment.CSC_LINK
  31. const exportPassword = environment.CSC_KEY_PASSWORD
  32. if (!certificate || exportPassword === undefined) throw new Error('desktop macOS signing: CSC_LINK and CSC_KEY_PASSWORD are required')
  33. const directory = mkdtempSync(join(tmpdir(), 'dsh-macos-signing-'))
  34. const keychain = join(directory, 'signing.keychain-db')
  35. const password = randomBytes(32).toString('base64')
  36. /** @param {string[]} args Security command arguments. */
  37. const security = args => run('/usr/bin/security', args)
  38. let created = false
  39. try {
  40. security(['create-keychain', '-p', password, keychain])
  41. created = true
  42. security(['unlock-keychain', '-p', password, keychain])
  43. security(['set-keychain-settings', keychain])
  44. security(['import', certificate, '-k', keychain, '-P', exportPassword, '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'])
  45. security(['set-key-partition-list', '-S', 'apple-tool:,apple:', '-s', '-k', password, keychain])
  46. const probe = join(directory, 'probe')
  47. run('/bin/cp', ['/usr/bin/true', probe])
  48. run('/usr/bin/codesign', ['--force', '--sign', `Developer ID Application: ${environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY}`, '--keychain', keychain, '--timestamp', '--options', 'runtime', probe])
  49. run('/usr/bin/codesign', ['--verify', '--strict', probe])
  50. const childEnvironment = { ...environment, CSC_KEYCHAIN: keychain }
  51. delete childEnvironment.CSC_LINK
  52. delete childEnvironment.CSC_KEY_PASSWORD
  53. await action(childEnvironment)
  54. } finally {
  55. try { if (created) security(['delete-keychain', keychain]) }
  56. finally { rmSync(directory, { recursive: true, force: true }) }
  57. }
  58. }