An Apple status request can time out while notarization continues. Keep submit --wait as the normal path. Resume the same submission with notarytool wait only after the observed HTTP timeout. Bound retries by one shared deadline and increase the existing macOS job and build-step limits. Unknown submission IDs and permanent failures still fail the build. Focused notarization and macOS packaging tests pass, with lint, syntax, and workflow checks. Live Apple notarization remains unverified.
175 lines
6.3 KiB
JavaScript
175 lines
6.3 KiB
JavaScript
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { execFile } from 'node:child_process'
|
|
import { setTimeout } from 'node:timers/promises'
|
|
|
|
export function runCommand(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command, args, options, (error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(
|
|
Object.assign(new Error(
|
|
`${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}`,
|
|
{ cause: error }
|
|
), { code: error.code, stdout, stderr })
|
|
)
|
|
return
|
|
}
|
|
resolve({ stdout, stderr })
|
|
})
|
|
})
|
|
}
|
|
|
|
function inlineKeyLooksValid(value) {
|
|
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
|
|
}
|
|
|
|
function resolveApiKeyPath(rawValue) {
|
|
const value = String(rawValue || '').trim()
|
|
if (!value) return { keyPath: '', cleanup: () => {} }
|
|
|
|
if (fs.existsSync(value)) {
|
|
return { keyPath: value, cleanup: () => {} }
|
|
}
|
|
|
|
if (!inlineKeyLooksValid(value)) {
|
|
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
|
|
}
|
|
|
|
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
|
|
fs.writeFileSync(tempPath, value, 'utf8')
|
|
return {
|
|
keyPath: tempPath,
|
|
cleanup: () => {
|
|
try {
|
|
fs.rmSync(tempPath, { force: true })
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function submitAndStaple(zipPath, appPath, auth, { run, sleep, log, now }) {
|
|
log(`[notarize] submitting ${zipPath}. Waiting for Apple's decision.`)
|
|
const deadline = now() + 4 * 60 * 60 * 1000
|
|
const waitDelays = [15000, 30000, 60000]
|
|
let resumeId
|
|
let result
|
|
let submitError
|
|
for (let attempt = 0; ; attempt++) {
|
|
const remaining = Math.ceil(deadline - now())
|
|
if (remaining <= 0) {
|
|
throw new Error(`Notarization ${resumeId ?? zipPath} exceeded the wait budget`, { cause: submitError })
|
|
}
|
|
const action = resumeId ? ['wait', resumeId] : ['submit', zipPath, '--wait']
|
|
try {
|
|
result = await run('xcrun', [
|
|
'notarytool', ...action, ...auth, '--timeout', `${Math.ceil(remaining / 1000)}s`, '--output-format', 'json'
|
|
], { timeout: remaining + 60000 })
|
|
submitError = undefined
|
|
break
|
|
} catch (error) {
|
|
// A rejected submission can still return a JSON result and a log ID.
|
|
result = error
|
|
submitError = error
|
|
const output = `${error.stdout ?? ''}\n${error.stderr ?? ''}\n${error.message}`
|
|
if (!/NSURLErrorDomain\s+Code=-1001\b/.test(output)) break
|
|
// The status-request URL identifies the uploaded app, unlike other UUIDs in the error.
|
|
resumeId ??= output.match(/https:\/\/appstoreconnect\.apple\.com\/notary\/v2\/submissions\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?=[?\s,}]|$)/i)?.[1]
|
|
const delay = waitDelays[attempt]
|
|
if (!resumeId || delay === undefined || now() + delay >= deadline) break
|
|
log(`[notarize] request timed out for ${resumeId}. Resuming wait in ${delay / 1000}s.`)
|
|
await sleep(delay)
|
|
}
|
|
}
|
|
let submission
|
|
try {
|
|
submission = JSON.parse(result.stdout)
|
|
} catch {
|
|
throw submitError ?? new Error(`Invalid notarytool response: ${result.stdout}`)
|
|
}
|
|
const { id, status } = submission ?? {}
|
|
if (typeof id !== 'string' || !id || typeof status !== 'string') {
|
|
throw submitError ?? new Error(`Incomplete notarytool response: ${result.stdout}`)
|
|
}
|
|
if (resumeId && id !== resumeId) {
|
|
throw new Error(`notarytool returned submission ${id} while waiting for ${resumeId}`)
|
|
}
|
|
log(`[notarize] submission ${id}: ${status}`)
|
|
if (submitError || status !== 'Accepted') {
|
|
let diagnostics
|
|
try {
|
|
const result = await run('xcrun', ['notarytool', 'log', id, ...auth], { timeout: 120000 })
|
|
diagnostics = result.stdout
|
|
} catch (error) {
|
|
diagnostics = `Could not retrieve Apple's diagnostic log: ${error.message}`
|
|
}
|
|
throw new Error(`Notarization ${id}: ${status}\n${diagnostics}`, { cause: submitError })
|
|
}
|
|
|
|
// Accepted tickets can take time to reach Apple's CloudKit lookup service.
|
|
const delays = [15000, 30000, 60000, 120000, 120000]
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
log(`[notarize] stapling attempt ${attempt + 1}/${delays.length + 1}`)
|
|
await run('xcrun', ['stapler', 'staple', '-v', appPath], { timeout: 120000 })
|
|
log(`[notarize] ticket stapled for submission ${id}`)
|
|
return
|
|
} catch (error) {
|
|
const output = `${error.stdout ?? ''}\n${error.stderr ?? ''}\n${error.message}`
|
|
const missingTicket = error.code === 65 && /CloudKit query[^\n]*Record not found/i.test(output)
|
|
if (!missingTicket || attempt === delays.length) throw error
|
|
log(`[notarize] accepted ticket not available. Retrying in ${delays[attempt] / 1000}s.`)
|
|
await sleep(delays[attempt])
|
|
}
|
|
}
|
|
}
|
|
|
|
export default async function notarize(context, {
|
|
run = runCommand, env = process.env, sleep = setTimeout, log = console.log, now = () => performance.now()
|
|
} = {}) {
|
|
const { electronPlatformName, appOutDir, packager } = context
|
|
if (electronPlatformName !== 'darwin') return
|
|
|
|
const appName = packager.appInfo.productFilename
|
|
const appPath = path.join(appOutDir, `${appName}.app`)
|
|
if (!fs.existsSync(appPath)) {
|
|
throw new Error(`Cannot notarize missing app bundle: ${appPath}`)
|
|
}
|
|
|
|
const profile = String(env.APPLE_NOTARY_PROFILE || '').trim()
|
|
let auth
|
|
let cleanup = () => {}
|
|
if (profile) {
|
|
auth = ['--keychain-profile', profile]
|
|
} else {
|
|
const keyId = String(env.APPLE_API_KEY_ID || '').trim()
|
|
const issuer = String(env.APPLE_API_ISSUER || '').trim()
|
|
const rawApiKey = env.APPLE_API_KEY
|
|
if (!rawApiKey || !keyId || !issuer) {
|
|
log(
|
|
'Skipping notarization: APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are not fully configured.'
|
|
)
|
|
return
|
|
}
|
|
const key = resolveApiKeyPath(rawApiKey)
|
|
auth = ['--key', key.keyPath, '--key-id', keyId, '--issuer', issuer]
|
|
cleanup = key.cleanup
|
|
}
|
|
|
|
const zipPath = path.join(appOutDir, `${appName}.zip`)
|
|
try {
|
|
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
|
|
await submitAndStaple(zipPath, appPath, auth, { run, sleep, log, now })
|
|
} finally {
|
|
try {
|
|
fs.rmSync(zipPath, { force: true })
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
cleanup()
|
|
}
|
|
}
|