|
| 1 | +/** Notarize and staple built DMGs, then repair their update metadata. */ |
| 2 | + |
| 3 | +import { spawnSync } from 'node:child_process' |
| 4 | +import { createHash } from 'node:crypto' |
| 5 | +import { |
| 6 | + existsSync, |
| 7 | + readFileSync, |
| 8 | + readdirSync, |
| 9 | + statSync, |
| 10 | + unlinkSync, |
| 11 | + writeFileSync, |
| 12 | +} from 'node:fs' |
| 13 | +import { basename, join } from 'node:path' |
| 14 | +import { resolveNotarizationCredentials } from './release-preflight' |
| 15 | + |
| 16 | +export interface CommandResult { |
| 17 | + readonly status: number | null |
| 18 | + readonly stderr: string |
| 19 | + readonly stdout: string |
| 20 | +} |
| 21 | + |
| 22 | +export type CommandRunner = (command: string, args: readonly string[]) => CommandResult |
| 23 | + |
| 24 | +export interface FinalizeMacArtifactsOptions { |
| 25 | + readonly distDir: string |
| 26 | + readonly env: NodeJS.ProcessEnv |
| 27 | + readonly log?: (message: string) => void |
| 28 | + readonly runCommand?: CommandRunner |
| 29 | +} |
| 30 | + |
| 31 | +function requiredValue(env: NodeJS.ProcessEnv, name: string): string { |
| 32 | + return env[name]!.trim() |
| 33 | +} |
| 34 | + |
| 35 | +/** Build the notarytool credential arguments selected by the release preflight. */ |
| 36 | +export function buildNotarytoolArguments(env: NodeJS.ProcessEnv): readonly string[] { |
| 37 | + switch (resolveNotarizationCredentials(env)) { |
| 38 | + case 'api-key': |
| 39 | + return [ |
| 40 | + '--key', requiredValue(env, 'APPLE_API_KEY'), |
| 41 | + '--key-id', requiredValue(env, 'APPLE_API_KEY_ID'), |
| 42 | + '--issuer', requiredValue(env, 'APPLE_API_ISSUER'), |
| 43 | + ] |
| 44 | + case 'apple-id': |
| 45 | + return [ |
| 46 | + '--apple-id', requiredValue(env, 'APPLE_ID'), |
| 47 | + '--password', requiredValue(env, 'APPLE_APP_SPECIFIC_PASSWORD'), |
| 48 | + '--team-id', requiredValue(env, 'APPLE_TEAM_ID'), |
| 49 | + ] |
| 50 | + case 'keychain-profile': { |
| 51 | + const args = ['--keychain-profile', requiredValue(env, 'APPLE_KEYCHAIN_PROFILE')] |
| 52 | + const keychain = env['APPLE_KEYCHAIN']?.trim() |
| 53 | + if (keychain !== undefined && keychain !== '') args.push('--keychain', keychain) |
| 54 | + return args |
| 55 | + } |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +function yamlScalar(value: string): string { |
| 60 | + const trimmed = value.trim() |
| 61 | + if ( |
| 62 | + (trimmed.startsWith("'") && trimmed.endsWith("'")) |
| 63 | + || (trimmed.startsWith('"') && trimmed.endsWith('"')) |
| 64 | + ) return trimmed.slice(1, -1) |
| 65 | + return trimmed |
| 66 | +} |
| 67 | + |
| 68 | +/** Update all checksum and size fields associated with one DMG. */ |
| 69 | +export function rewriteLatestMacYaml( |
| 70 | + yaml: string, |
| 71 | + filename: string, |
| 72 | + sha512: string, |
| 73 | + size: number, |
| 74 | +): string { |
| 75 | + const lines = yaml.split('\n') |
| 76 | + let fileEntryIndent: number | undefined |
| 77 | + let topLevelPathMatches = false |
| 78 | + let checksumUpdates = 0 |
| 79 | + let sizeUpdates = 0 |
| 80 | + |
| 81 | + for (let index = 0; index < lines.length; index += 1) { |
| 82 | + const line = lines[index]! |
| 83 | + const indentation = line.search(/\S|$/) |
| 84 | + const url = line.match(/^(\s*)-\s+url:\s*(.+?)\s*$/) |
| 85 | + if (url !== null) { |
| 86 | + fileEntryIndent = yamlScalar(url[2]!) === filename ? url[1]!.length : undefined |
| 87 | + continue |
| 88 | + } |
| 89 | + |
| 90 | + if (fileEntryIndent !== undefined) { |
| 91 | + if (line.trim() !== '' && indentation <= fileEntryIndent) { |
| 92 | + fileEntryIndent = undefined |
| 93 | + } else { |
| 94 | + const checksum = line.match(/^(\s*)sha512:\s*.*$/) |
| 95 | + if (checksum !== null) { |
| 96 | + lines[index] = `${checksum[1]}sha512: ${sha512}` |
| 97 | + checksumUpdates += 1 |
| 98 | + continue |
| 99 | + } |
| 100 | + const artifactSize = line.match(/^(\s*)size:\s*.*$/) |
| 101 | + if (artifactSize !== null) { |
| 102 | + lines[index] = `${artifactSize[1]}size: ${String(size)}` |
| 103 | + sizeUpdates += 1 |
| 104 | + continue |
| 105 | + } |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + if (topLevelPathMatches) { |
| 110 | + if (line.startsWith('sha512:')) { |
| 111 | + lines[index] = `sha512: ${sha512}` |
| 112 | + checksumUpdates += 1 |
| 113 | + continue |
| 114 | + } |
| 115 | + if (line.startsWith('size:')) { |
| 116 | + lines[index] = `size: ${String(size)}` |
| 117 | + sizeUpdates += 1 |
| 118 | + continue |
| 119 | + } |
| 120 | + if (line.trim() !== '' && indentation === 0) topLevelPathMatches = false |
| 121 | + } |
| 122 | + |
| 123 | + const path = line.match(/^path:\s*(.+?)\s*$/) |
| 124 | + if (path !== null) topLevelPathMatches = yamlScalar(path[1]!) === filename |
| 125 | + } |
| 126 | + |
| 127 | + if (checksumUpdates === 0 || sizeUpdates === 0) { |
| 128 | + throw new Error(`latest-mac.yml does not contain complete metadata for ${filename}`) |
| 129 | + } |
| 130 | + return lines.join('\n') |
| 131 | +} |
| 132 | + |
| 133 | +function defaultCommandRunner(command: string, args: readonly string[]): CommandResult { |
| 134 | + const result = spawnSync(command, args, { encoding: 'utf8' }) |
| 135 | + if (result.error !== undefined) throw result.error |
| 136 | + return { status: result.status, stderr: result.stderr, stdout: result.stdout } |
| 137 | +} |
| 138 | + |
| 139 | +/** Finalize every DMG in the supplied desktop distribution directory. */ |
| 140 | +export function finalizeMacArtifacts(options: FinalizeMacArtifactsOptions): void { |
| 141 | + const runCommand = options.runCommand ?? defaultCommandRunner |
| 142 | + const log = options.log ?? console.log |
| 143 | + const dmgs = readdirSync(options.distDir, { withFileTypes: true }) |
| 144 | + .filter(entry => entry.isFile() && entry.name.endsWith('.dmg')) |
| 145 | + .map(entry => entry.name) |
| 146 | + .sort() |
| 147 | + if (dmgs.length === 0) throw new Error(`No DMG artifacts found in ${options.distDir}`) |
| 148 | + |
| 149 | + const metadataPath = join(options.distDir, 'latest-mac.yml') |
| 150 | + let metadata = readFileSync(metadataPath, 'utf8') |
| 151 | + const credentialArgs = buildNotarytoolArguments(options.env) |
| 152 | + |
| 153 | + for (const filename of dmgs) { |
| 154 | + const dmgPath = join(options.distDir, filename) |
| 155 | + const notarization = runCommand('xcrun', [ |
| 156 | + 'notarytool', 'submit', dmgPath, '--wait', '--output-format', 'json', ...credentialArgs, |
| 157 | + ]) |
| 158 | + const notaryOutput = [notarization.stdout.trim(), notarization.stderr.trim()].filter(Boolean).join('\n') |
| 159 | + if (notaryOutput !== '') log(notaryOutput) |
| 160 | + if (notarization.status !== 0) { |
| 161 | + throw new Error(`notarytool failed for ${filename} with status ${String(notarization.status)}:\n${notaryOutput}`) |
| 162 | + } |
| 163 | + |
| 164 | + let status: unknown |
| 165 | + try { |
| 166 | + status = (JSON.parse(notarization.stdout) as { readonly status?: unknown }).status |
| 167 | + } catch { |
| 168 | + throw new Error(`notarytool returned invalid JSON for ${filename}:\n${notaryOutput}`) |
| 169 | + } |
| 170 | + if (status !== 'Accepted') { |
| 171 | + throw new Error(`notarytool did not accept ${filename} (status: ${String(status)}):\n${notaryOutput}`) |
| 172 | + } |
| 173 | + |
| 174 | + const stapling = runCommand('xcrun', ['stapler', 'staple', dmgPath]) |
| 175 | + const staplerOutput = [stapling.stdout.trim(), stapling.stderr.trim()].filter(Boolean).join('\n') |
| 176 | + if (staplerOutput !== '') log(staplerOutput) |
| 177 | + if (stapling.status !== 0) { |
| 178 | + throw new Error(`stapler failed for ${filename} with status ${String(stapling.status)}:\n${staplerOutput}`) |
| 179 | + } |
| 180 | + |
| 181 | + const size = statSync(dmgPath).size |
| 182 | + const sha512 = createHash('sha512').update(readFileSync(dmgPath)).digest('base64') |
| 183 | + metadata = rewriteLatestMacYaml(metadata, filename, sha512, size) |
| 184 | + |
| 185 | + const blockmapPath = `${dmgPath}.blockmap` |
| 186 | + if (existsSync(blockmapPath)) { |
| 187 | + unlinkSync(blockmapPath) |
| 188 | + log(`Removed stale ${basename(blockmapPath)} because stapling changed the DMG; electron-updater will use a full download.`) |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + writeFileSync(metadataPath, metadata) |
| 193 | +} |
0 commit comments