Files
hermes-agent/apps/desktop/scripts/notarize.mjs
ethernet 15eb3be676 fix(notarize): resume timed-out waits without resubmitting
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.
2026-09-08 16:25:42 -04:00

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()
}
}