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.
223 lines
11 KiB
JavaScript
223 lines
11 KiB
JavaScript
import { mkdtempSync, mkdirSync, existsSync, rmSync, writeFileSync } from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import { expect, test, vi } from 'vitest'
|
|
import notarize, { runCommand } from '../apps/desktop/scripts/notarize.mjs'
|
|
|
|
const submissionId = '00000000-0000-4000-8000-000000000001'
|
|
const missingTicket = () => Object.assign(new Error('CloudKit query for Hermes.app failed due to "Record not found".\nThe staple and validate action failed! Error 65.'), { code: 65 })
|
|
|
|
function fixture() {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'hermes-notary-test-'))
|
|
mkdirSync(path.join(root, 'Hermes.app'))
|
|
const key = path.join(root, 'api-key.p8')
|
|
writeFileSync(key, 'test credential path only')
|
|
return {
|
|
root,
|
|
context: { electronPlatformName: 'darwin', appOutDir: root, packager: { appInfo: { productFilename: 'Hermes' } } },
|
|
environments: [
|
|
{ APPLE_NOTARY_PROFILE: 'test-profile' },
|
|
{ APPLE_API_KEY: key, APPLE_API_KEY_ID: 'test-key', APPLE_API_ISSUER: 'test-issuer' }
|
|
],
|
|
cleanup: () => rmSync(root, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
test('accepted submissions retry only ticket propagation, without resubmitting or rebuilding', async () => {
|
|
const f = fixture()
|
|
try {
|
|
for (const env of f.environments) {
|
|
const calls = []
|
|
let attempts = 0
|
|
const run = vi.fn(async (command, args) => {
|
|
calls.push([command, ...args])
|
|
if (command === 'ditto') writeFileSync(args.at(-1), 'archive fixture')
|
|
if (args[0] === 'notarytool') return { stdout: JSON.stringify({ id: submissionId, status: 'Accepted' }) }
|
|
if (args[0] === 'stapler' && ++attempts <= 2) {
|
|
// Exercise exit-code and output capture with a real child process.
|
|
return runCommand(process.execPath, ['-e',
|
|
'process.stdout.write(process.argv[1]); process.exitCode = 65', missingTicket().message])
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
const sleep = vi.fn(async () => {})
|
|
const log = vi.fn()
|
|
await notarize(f.context, { run, sleep, log, env })
|
|
expect(calls.filter(c => c[0] === 'ditto')).toHaveLength(1)
|
|
const submissions = calls.filter(c => c[1] === 'notarytool' && c[2] === 'submit')
|
|
expect(submissions).toHaveLength(1)
|
|
expect(calls.filter(c => c[1] === 'notarytool' && c[2] === 'wait')).toHaveLength(0)
|
|
expect(submissions[0]).toEqual(expect.arrayContaining(['--wait', '--output-format', 'json']))
|
|
expect(submissions[0]).toEqual(expect.arrayContaining(env.APPLE_NOTARY_PROFILE
|
|
? ['--keychain-profile', env.APPLE_NOTARY_PROFILE]
|
|
: ['--key', env.APPLE_API_KEY, '--key-id', env.APPLE_API_KEY_ID, '--issuer', env.APPLE_API_ISSUER]))
|
|
expect(attempts).toBe(3)
|
|
expect(sleep.mock.calls.map(([ms]) => ms)).toEqual([15000, 30000])
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining(`${submissionId}: Accepted`))
|
|
expect(existsSync(path.join(f.root, 'Hermes.zip'))).toBe(false)
|
|
}
|
|
} finally {
|
|
f.cleanup()
|
|
}
|
|
})
|
|
|
|
test('HTTP timeouts resume the same submission with notarytool wait and a shrinking budget', async () => {
|
|
const f = fixture()
|
|
// Replay the status-request failure from the macOS release log.
|
|
const timeoutOutput = `Error: HTTPError(statusCode: nil, error: Error Domain=NSURLErrorDomain Code=-1001 "The request timed out." UserInfo={NSErrorFailingURLStringKey=https://appstoreconnect.apple.com/notary/v2/submissions/${submissionId}?, _kCFStreamErrorDomainKey=1})`
|
|
try {
|
|
for (const env of f.environments) {
|
|
const calls = []
|
|
let elapsed = 0
|
|
let waits = 0
|
|
const log = vi.fn()
|
|
const run = async (command, args, options = {}) => {
|
|
calls.push({ command, args, options })
|
|
if (command === 'ditto') writeFileSync(args.at(-1), 'archive fixture')
|
|
if (args[0] === 'notarytool' && args[1] === 'submit') {
|
|
elapsed += 10 * 60 * 1000
|
|
return runCommand(process.execPath, ['-e',
|
|
'process.stderr.write(process.argv[1]); process.exitCode = 1', timeoutOutput])
|
|
}
|
|
if (args[0] === 'notarytool' && args[1] === 'wait') {
|
|
if (++waits === 1) {
|
|
elapsed += 20 * 60 * 1000
|
|
// The resumed command already owns the ID even if this error omits it.
|
|
throw new Error('Error Domain=NSURLErrorDomain Code=-1001 "The request timed out."')
|
|
}
|
|
return { stdout: JSON.stringify({ id: submissionId, status: 'Accepted' }) }
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
}
|
|
await notarize(f.context, {
|
|
run, env, log, now: () => elapsed,
|
|
sleep: async ms => { elapsed += ms },
|
|
})
|
|
expect(calls.filter(c => c.command === 'ditto')).toHaveLength(1)
|
|
const notary = calls.filter(c => c.args[0] === 'notarytool')
|
|
expect(notary.map(c => c.args[1])).toEqual(['submit', 'wait', 'wait'])
|
|
expect(notary[0].args).toContain('--wait')
|
|
const auth = env.APPLE_NOTARY_PROFILE
|
|
? ['--keychain-profile', env.APPLE_NOTARY_PROFILE]
|
|
: ['--key', env.APPLE_API_KEY, '--key-id', env.APPLE_API_KEY_ID, '--issuer', env.APPLE_API_ISSUER]
|
|
for (const call of notary) {
|
|
expect(call.args).toEqual(expect.arrayContaining([...auth, '--output-format', 'json', '--timeout']))
|
|
const waitTimeout = call.args[call.args.indexOf('--timeout') + 1]
|
|
expect(waitTimeout).toMatch(/^[1-9][0-9]*s$/)
|
|
expect(call.options.timeout).toBeGreaterThan(Number.parseInt(waitTimeout, 10) * 1000)
|
|
}
|
|
for (let i = 1; i < notary.length; i++) {
|
|
expect(notary[i].args[2]).toBe(submissionId)
|
|
const timeoutSeconds = call => Number.parseInt(call.args[call.args.indexOf('--timeout') + 1], 10)
|
|
expect(timeoutSeconds(notary[i])).toBeLessThan(timeoutSeconds(notary[i - 1]))
|
|
expect(notary[i].options.timeout).toBeLessThan(notary[i - 1].options.timeout)
|
|
}
|
|
expect(calls.filter(c => c.args[0] === 'stapler')).toHaveLength(1)
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining(submissionId))
|
|
expect(existsSync(path.join(f.root, 'Hermes.zip'))).toBe(false)
|
|
}
|
|
} finally { f.cleanup() }
|
|
})
|
|
|
|
test('wait recovery requires a known submission and stops at failures or its limits', async () => {
|
|
const f = fixture()
|
|
const timeout = 'Error Domain=NSURLErrorDomain Code=-1001 "The request timed out."'
|
|
const submissionUrl = `https://appstoreconnect.apple.com/notary/v2/submissions/${submissionId}?`
|
|
const scenarios = ['missing-id', 'malformed-id', 'auth', 'exhausted', 'budget', 'sleep-budget', 'invalid', 'wrong-id']
|
|
try {
|
|
for (const scenario of scenarios) {
|
|
const calls = []
|
|
let elapsed = 0
|
|
const run = async (command, args, options = {}) => {
|
|
calls.push([command, ...args])
|
|
if (command === 'ditto') writeFileSync(args.at(-1), 'archive fixture')
|
|
if (args[0] === 'notarytool' && args[1] === 'log') return { stdout: 'signature rejected' }
|
|
if (args[0] === 'notarytool') {
|
|
if (scenario === 'auth') throw new Error(`authentication failed for ${submissionUrl}`)
|
|
if (scenario === 'missing-id') throw new Error(`${timeout} LocalDataTask <${submissionId}>`)
|
|
if (scenario === 'malformed-id') throw new Error(`${timeout} ${submissionUrl.replace('?', 'garbage?')}`)
|
|
if (scenario === 'budget') elapsed += options.timeout
|
|
if (args[1] === 'wait' && scenario === 'invalid') {
|
|
throw Object.assign(new Error('notarization rejected'), {
|
|
stdout: JSON.stringify({ id: submissionId, status: 'Invalid' }),
|
|
})
|
|
}
|
|
if (args[1] === 'wait' && scenario === 'wrong-id') {
|
|
return { stdout: JSON.stringify({ id: '00000000-0000-4000-8000-000000000002', status: 'Accepted' }) }
|
|
}
|
|
throw new Error(`${timeout} ${submissionUrl}`)
|
|
}
|
|
return { stdout: '' }
|
|
}
|
|
const sleep = vi.fn(async ms => { elapsed += scenario === 'sleep-budget' ? 24 * 60 * 60 * 1000 : ms })
|
|
const failure = await notarize(f.context, {
|
|
run, env: f.environments[0], sleep, log: vi.fn(), now: () => elapsed,
|
|
}).catch(error => error)
|
|
expect(failure).toBeInstanceOf(Error)
|
|
expect(calls.filter(c => c[2] === 'submit')).toHaveLength(1)
|
|
expect(calls.filter(c => c[1] === 'stapler')).toHaveLength(0)
|
|
const waits = calls.filter(c => c[2] === 'wait')
|
|
if (scenario === 'exhausted') {
|
|
expect(waits).toHaveLength(3)
|
|
expect(sleep).toHaveBeenCalledTimes(waits.length)
|
|
} else if (scenario === 'invalid' || scenario === 'wrong-id') {
|
|
expect(waits).toHaveLength(1)
|
|
expect(failure.message).toContain(scenario === 'invalid' ? 'signature rejected' : 'while waiting for')
|
|
} else {
|
|
expect(waits).toHaveLength(0)
|
|
expect(sleep).toHaveBeenCalledTimes(scenario === 'sleep-budget' ? 1 : 0)
|
|
}
|
|
expect(calls.filter(c => c[2] === 'log')).toHaveLength(scenario === 'invalid' ? 1 : 0)
|
|
expect(existsSync(path.join(f.root, 'Hermes.zip'))).toBe(false)
|
|
}
|
|
} finally { f.cleanup() }
|
|
})
|
|
|
|
test('rejections, unknown failures and exhausted propagation retries remain build failures', async () => {
|
|
const f = fixture()
|
|
try {
|
|
for (const scenario of ['invalid-zero', 'invalid-nonzero', 'malformed', 'auth', 'permanent-staple', 'exhausted']) {
|
|
const calls = []
|
|
const run = vi.fn(async (command, args) => {
|
|
calls.push([command, ...args])
|
|
if (command === 'ditto') writeFileSync(args.at(-1), 'archive fixture')
|
|
if (args[0] === 'notarytool' && args[1] === 'log') return { stdout: '{"issues":[{"message":"signature rejected"}]}' }
|
|
if (args[0] === 'notarytool') {
|
|
if (scenario === 'auth') throw new Error('authentication failed')
|
|
if (scenario === 'malformed') return { stdout: 'not JSON' }
|
|
const stdout = JSON.stringify({ id: submissionId, status: scenario.startsWith('invalid') ? 'Invalid' : 'Accepted' })
|
|
if (scenario === 'invalid-nonzero') throw Object.assign(new Error('submit failed'), { code: 1, stdout })
|
|
return { stdout }
|
|
}
|
|
if (args[0] === 'stapler') {
|
|
if (scenario === 'permanent-staple') throw Object.assign(new Error('invalid bundle format'), { code: 65 })
|
|
throw missingTicket()
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
const sleep = vi.fn(async () => {})
|
|
const failure = await notarize(f.context, { run, sleep, log: vi.fn(), env: f.environments[0] }).catch(e => e)
|
|
expect(failure).toBeInstanceOf(Error)
|
|
expect(calls.filter(c => c[2] === 'submit')).toHaveLength(1)
|
|
const staples = calls.filter(c => c[1] === 'stapler')
|
|
if (scenario.startsWith('invalid')) {
|
|
expect(failure.message).toContain('Invalid')
|
|
expect(failure.message).toContain('signature rejected')
|
|
expect(calls.filter(c => c[2] === 'log')).toHaveLength(1)
|
|
expect(staples).toHaveLength(0)
|
|
} else if (scenario === 'exhausted') {
|
|
expect(failure.message).toContain('Record not found')
|
|
expect(staples.length).toBe(sleep.mock.calls.length + 1)
|
|
expect(staples.length).toBeGreaterThan(1)
|
|
expect(staples.length).toBeLessThanOrEqual(6)
|
|
} else {
|
|
expect(staples).toHaveLength(scenario === 'permanent-staple' ? 1 : 0)
|
|
}
|
|
if (scenario !== 'exhausted') expect(sleep).not.toHaveBeenCalled()
|
|
expect(existsSync(path.join(f.root, 'Hermes.zip'))).toBe(false)
|
|
}
|
|
} finally {
|
|
f.cleanup()
|
|
}
|
|
})
|