// Upload an IPA to App Store Connect and wait until Apple has processed the build. // storebound apple upload [] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--pre-check ""] [--evidence ] [--dry] [--yes] [--json] // Default IPA: the newest in /export (and its -/ folders), or with // --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist. // An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed // `apple build`, pitfall 53); only --allow-existing (flows that resume after a finished upload) re-checks it instead. Before any network call every PrivacyInfo.xcprivacy in the IPA // (app root, Frameworks/*.framework, bundles; XML or binary) is checked: NSPrivacyTracking and NSPrivacyTrackingDomains // must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded. The IPA's version // (CFBundleShortVersionString) must be the editable App Store version's (else the build lands in the wrong train); // --allow-version-mismatch goes on anyway. // --via api (default): the App Store Connect API build upload (Build Uploads, API 4.1+): POST /v1/buildUploads, // reserve /v1/buildUploadFiles, PUT the parts, commit with the MD5, then poll the upload state. Same API key, no // Xcode needed, works on any OS. // --via altool: `xcrun altool --upload-package` with the API key (Xcode's own uploader; macOS). // Both are listed by Apple (App Store Connect Help > Upload builds); neither is deprecated. Then the build is polled // with `apple testflight wait` (waitForBuild) until processingState is VALID (exit 0), INVALID / FAILED (exit 1) or the // timeout (exit 7). // An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts). import { runPreCheck } from '../lib/pre-check'; import { pkgInfo } from '../lib/mas'; import { appleSdkProblem } from '../lib/store-minimums'; import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; import { basename, join, relative } from 'node:path'; import { ascKeyFile } from '../lib/credentials'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode'; import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { BUILD_WAIT_EXIT } from '../lib/build-ops'; import { type Asc, ascClient, type J } from './asc'; import { parsePlatform } from './platform'; import { waitForBuild, waitMessage } from './testflight'; import { describeReq } from './jsonapi'; import { createOnce } from '../lib/upsert'; import { elapsed, heartbeat, type HeartbeatOptions, mb, share, withHeartbeat } from '../lib/progress'; import { pump } from '../lib/runner'; import * as rt from '../lib/rt'; import { partHeaders } from './screenshot-health'; import { SHIP_EXIT, ShipStepError, shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step'; import { consoleUrl } from '../lib/console-links'; /** Ends the run with a legacy exit code; under the ship-step contract a ShipStepError with the uniform code instead. */ function stop(legacy: number, message: string): never { if (shipRun()) throw legacy === BUILD_WAIT_EXIT.timeout ? new ShipStepError(message, SHIP_EXIT.failure, 'TIMEOUT') : new ShipStepError(message, SHIP_EXIT.refused, 'BUILD_INVALID', consoleUrl('asc.testflight')); process.exit(legacy); } const TAG = 'upload'; function md5File(path: string): string { const h = rt.hasher('md5'); const fd = openSync(path, 'r'); try { const buf = Buffer.alloc(8 * 1048576); for (let n; (n = readSync(fd, buf, 0, buf.length, null)) > 0;) h.update(buf.subarray(0, n)); } finally { closeSync(fd); } return h.digest('hex'); } function slice(path: string, offset: number, length: number): Buffer { const fd = openSync(path, 'r'); try { const b = Buffer.alloc(length); readSync(fd, b, 0, length, offset); return b; } finally { closeSync(fd); } } /** One part of a build upload file, as App Store Connect reserves it (uploadOperations). */ export interface UploadOp { method: string; url: string; offset: number; length: number; requestHeaders?: { name: string; value: string }[] } export interface SendPartsOptions extends HeartbeatOptions { file: string; size: number; ops: UploadOp[]; /** Epoch ms: past it the upload stops with a timeout error (exit 7). */ deadline: number; /** One part may take at most this long before it is retried (default 15 min). */ partTimeoutMs?: number; fetch?: (url: string, init: RequestInit) => Promise; sleep?: (ms: number) => Promise; say?: (msg: string) => void; } const PART_TIMEOUT_MS = 15 * 60_000; /** The timeout error of an upload: what was sent, and how to go on. Exit 7 like every storebound wait. */ export function uploadTimeout(sent: number, size: number, partsDone: number, parts: number, ms: number): Error { return Object.assign(new Error(`upload stopped at the timeout after ${elapsed(ms)}: ${share(sent, size)} sent, ${partsDone}/${parts} parts. ` + 'Run the same command again with a longer --timeout (e.g. --timeout 120m): it resumes this build upload instead of making a new one. ' + 'On a slow or unstable connection, --via altool (macOS) is an alternative.'), { exit: WAIT_EXIT.timeout }); } /** * PUTs the reserved parts of a build upload file. Never silent: the size at the start, a line per part (every 10th * from 20 parts up), and a heartbeat ("still uploading, 4m30s, 312/890 MB (35%), part 4/12") while a part is in flight. * Every part has its own timeout and is retried (5xx, 429, network, part timeout) up to 4 times; past the deadline it * stops with exit 7 and the hint to resume. */ export async function sendParts(o: SendPartsOptions): Promise { const say = o.say ?? ((m: string) => log(TAG, m)); const doFetch = o.fetch ?? ((u: string, i: RequestInit) => fetch(u, i)); const sleep = o.sleep ?? rt.sleep; const now = o.now ?? Date.now; const started = now(); const n = o.ops.length; let sent = 0, current = 0; say(`uploading ${mb(o.size)} in ${n} part(s)`); const beat = heartbeat(ms => say(`still uploading, ${elapsed(ms)}, ${share(sent, o.size)}${n > 1 ? `, part ${current}/${n}` : ''}`), o); try { for (const [i, op] of o.ops.entries()) { current = i + 1; const headers = partHeaders(op); const body = new Uint8Array(slice(o.file, op.offset, op.length)); for (let attempt = 1; ; attempt++) { const left = o.deadline - now(); if (left <= 0) throw uploadTimeout(sent, o.size, i, n, now() - started); const partLimit = o.partTimeoutMs ?? PART_TIMEOUT_MS; const partMs = Math.min(partLimit, left); let r: Response | null = null, problem = ''; try { r = await doFetch(op.url, { method: op.method, headers, body, signal: AbortSignal.timeout(partMs) }); } catch (e) { const name = (e as Error).name; if (name !== 'TimeoutError' && name !== 'AbortError') problem = (e as Error).message; else if (partMs < partLimit) throw uploadTimeout(sent, o.size, i, n, now() - started); // cut by the deadline else problem = `no answer in ${elapsed(partMs)}`; } if (r) { await r.body?.cancel().catch(() => {}); if (r.ok) break; problem = `HTTP ${r.status}`; if (r.status < 500 && r.status !== 429) throw new Error(`part ${i + 1}/${n} -> ${r.status}`); } if (attempt >= 4) throw new Error(`part ${i + 1}/${n} failed 4 times (last: ${problem})`); say(`part ${i + 1}/${n}: ${problem}, retry ${attempt}/3`); await sleep(2000 * attempt); } sent += op.length; shipPhase('upload', `${i + 1}/${n} parts`, (sent / o.size) * 100); if (n < 20 || (i + 1) % 10 === 0 || i === n - 1) say(`${i + 1}/${n} parts, ${share(sent, o.size)}, ${elapsed(now() - started)}`); } } finally { beat.stop(); } } async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType, deadline: number, o: HeartbeatOptions = {}, platform: 'IOS' | 'MAC_OS' = 'IOS'): Promise { const size = statSync(ipa).size; const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); // one build upload per version + build number: an earlier one that did not fail is resumed, never doubled const prior = (await uploadsOf(c, appId, info, platform)).find((u: J) => u.attributes?.state?.state !== 'FAILED'); const made = prior ? { value: prior, created: false } : await createOnce({ what: `build upload ${info.version} (${info.build})`, tag: TAG, find: async () => (await uploadsOf(c, appId, info, platform)).find((u: J) => u.attributes?.state?.state !== 'FAILED') ?? null, create: async () => (await c.asc('POST', reqs.upload.path, reqs.upload.body)).data }); const up = made.value; log(TAG, made.created ? `build upload ${up.id} created` : `build upload ${up.id} exists (${up.attributes?.state?.state ?? '?'}): resuming it`); const filesOf = async () => (await c.all(`/v1/buildUploads/${up.id}/buildUploadFiles?limit=10`)).filter((f: J) => f.attributes?.fileName === basename(ipa)); const fileNow = made.created ? undefined : (await filesOf())[0]; const file = fileNow ?? (await createOnce({ what: `file reservation of build upload ${up.id}`, tag: TAG, find: async () => (await filesOf())[0] ?? null, create: async () => (await c.asc('POST', reqs.file(up.id).path, reqs.file(up.id).body)).data })).value; if (fileNow && !(fileNow.attributes?.uploadOperations ?? []).length) throw new Error(`build upload ${up.id} already has ${basename(ipa)} (${fileNow.attributes?.assetDeliveryState?.state ?? 'state unknown'}) and App Store Connect gives no upload parts for it: wait for it (storebound apple testflight wait --build ${info.build}), or raise the build number (storebound apple build)`); const ops = (file.attributes.uploadOperations ?? []) as UploadOp[]; await sendParts({ file: ipa, size, ops, deadline, ...o }); const commit = reqs.commit(file.id, md5File(ipa)); await c.asc('PATCH', commit.path, commit.body); log(TAG, 'file committed; waiting for App Store Connect to accept the upload'); for (let delay: number | null = null; ;) { const u = (await c.asc('GET', `/v1/buildUploads/${up.id}`)).data; const st = u.attributes?.state ?? {}; const p = uploadProblems(st); for (const w of p.warnings) warn(TAG, w); if (UPLOAD_DONE.has(st.state)) { if (st.state === 'FAILED') throw new Error(`upload rejected: ${p.errors.join('; ') || 'no reason given'}`); log(TAG, 'upload accepted'); return; } if (Date.now() > deadline) throw Object.assign(new Error(`upload still ${st.state ?? 'pending'} at the timeout; the file is sent, App Store Connect is still checking it: storebound apple testflight wait --build ${info.build} --version ${info.version} goes on waiting`), { exit: WAIT_EXIT.timeout }); delay = nextPollDelay(delay, { first: 15_000, max: 60_000 }); log(TAG, `upload ${st.state ?? 'pending'}, next check in ${Math.round(delay / 1000)} s`); await rt.sleep(delay); } } /** Build uploads of this version + build number (any state), newest first. */ const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }, platform = 'IOS'): Promise => c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=${platform}&limit=20`); /** * `xcrun altool --upload-package`: runs in the background with a heartbeat (it prints nothing until it is done) and is * stopped at the deadline (exit 7 with the hint). */ async function altoolUpload(root: string, ipa: string, deadline: number, o: HeartbeatOptions = {}): Promise { if (process.platform !== 'darwin' || !rt.which('xcrun')) throw new Error('--via altool needs macOS with Xcode; use --via api elsewhere'); const k = ascKeyFile(root); try { const argv = altoolArgs(ipa, k); const size = statSync(ipa).size; log(TAG, `$ ${showCmd(argv, k)}`); log(TAG, `uploading ${mb(size)} with altool (it reports only at the end)`); const p = rt.spawn(argv, { stdout: 'pipe', stderr: 'pipe' }); let timedOut = false; const kill = setTimeout(() => { timedOut = true; p.kill(); }, Math.max(0, deadline - Date.now())); const lines: string[] = []; const out = await withHeartbeat(ms => log(TAG, `still uploading with altool, ${elapsed(ms)}, ${mb(size)}`), async () => { await Promise.all([pump(p.stdout, 'stdout', l => lines.push(l)), pump(p.stderr, 'stderr', l => lines.push(l))]); return p.exited; }, o).finally(() => clearTimeout(kill)); const outText = lines.join('\n').trim(); if (timedOut) throw Object.assign(new Error('altool stopped at the timeout: run the same command again with a longer --timeout (e.g. --timeout 120m), or use --via api (sent in parts, with progress)'), { exit: WAIT_EXIT.timeout }); if (out !== 0) throw new Error(`altool failed (exit ${out}): ${outText.split('\n').filter(l => /error|fail/i.test(l)).slice(0, 8).join('\n') || outText.slice(-800)}`); const id = /"?delivery-uuid"?\s*[:=]\s*"?([0-9a-f-]{36})/i.exec(outText)?.[1]; log(TAG, `altool: upload done${id ? ` (delivery ${id})` : ''}`); } finally { k.cleanup(); } } export { exportedIpas }; /** * An IPA whose build number App Store Connect already has (a build, or a finished build upload): an Error (exit 1) * unless --allow-existing, then 'recheck' (wait for it, send nothing). A stale IPA from a failed `apple build` must * never pass as "uploaded" (pitfall 53). */ export function existingBuild(o: { ipa: string; build: string; onAsc: boolean; state?: string; uploadComplete: boolean; allowExisting: boolean; next: string }): 'upload' | 'recheck' | Error { if (!o.onAsc && !o.uploadComplete) return 'upload'; if (o.allowExisting) return 'recheck'; return new Error(`${o.ipa} is build ${o.build}, which is already on App Store Connect${o.state ? ` (${o.state})` : ''}: nothing uploaded. ` + `Did the last build fail? Run \`storebound apple build\` again (next free build number: ${o.next}) or pass the new ipa. ` + 'To only re-check a build this command uploaded before, add --allow-existing.'); } export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const via = a.str('via', 'api'); if (via !== 'api' && via !== 'altool') throw new Error('--via api | altool'); const want = a.get('build'); const macos = parsePlatform(a.get('platform')) === 'MAC_OS'; let ipa = a._.find(x => x.endsWith('.ipa') || x.endsWith('.pkg')); if (!ipa) { const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export'); let found = exportedIpas(dir); if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } }); if (!found[0] && macos) throw new Error('usage: storebound apple upload --platform macos (storebound apple build --platform macos --tauri makes one)'); if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); ipa = found[0]; } else ipa = resolve(ipa); shipPhase('verify', `checking ${relative(root, ipa)}`); const isPkg = ipa.endsWith('.pkg'); const platform: 'IOS' | 'MAC_OS' = isPkg ? 'MAC_OS' : 'IOS'; if (macos && !isPkg) throw new Error('--platform macos uploads a signed .pkg (storebound apple build --platform macos --tauri ), not an .ipa'); const info: ReturnType = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa); shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') }); if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`); if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`); // App Store Connect refuses IPAs from an Xcode / SDK older than its current minimum (cli/lib/store-minimums.ts) const sdkWhy = isPkg ? null : appleSdkProblem(info); if (sdkWhy) throw new Error(`${relative(root, ipa)}: not uploaded, ${sdkWhy}`); // every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the // upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call const privacy = isPkg ? [] : bundleTrackingIssues(ipaManifests(ipa)); if (privacy.length) throw new Error(`${relative(root, ipa)}: not uploaded, Apple would refuse it as Invalid Binary:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest, rebuild (storebound apple build) and upload the new IPA.`); const pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG }); const size = statSync(ipa).size; const dry = a.has('dry'); const json = a.has('json'); const wait = !a.has('no-wait'); const timeout = parseDuration(a.str('timeout', '60m')); const c = await ascClient(root, bundleId, config.apple?.appId); const appId = await c.app(); // the IPA must belong to the version train being prepared (the editable App Store version): MARKETING_VERSION 1.0 // from a project that ignored the intended version otherwise lands in the wrong train const editable = await c.editableVersion(platform).catch(() => null); const trainWhy = ipaVersionProblem(info.version, editable?.attributes?.versionString as string | undefined, 'the editable App Store version'); if (trainWhy) { if (a.has('allow-version-mismatch')) warn(TAG, trainWhy); else throw new Error(`${relative(root, ipa)}: not uploaded, ${trainWhy}`); } const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&limit=200&fields[builds]=version,processingState`); const numbers = builds.map(b => String(b.attributes.version)); const next = nextBuildNumber(numbers); // the same IPA uploaded by an earlier run: nothing to send again, only the wait const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info, platform).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; const onAsc = builds.find(b => String(b.attributes.version) === info.build); const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next }); // the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload if (shipRun() && (onAsc || already)) { const state = String(onAsc?.attributes?.processingState ?? 'UPLOADED'); shipNoop({ state, url: consoleUrl('asc.testflight') }); if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: state }); done(TAG, `build ${info.version} (${info.build}) is on App Store Connect already (${state}): nothing uploaded`); return; } if (decision instanceof Error) throw decision; if (decision === 'recheck') { log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`); if (!wait) { if (json) printJson({ ...{ ipa: relative(root, ipa), version: info.version, build: info.build }, uploaded: true, alreadyUploaded: true }); done(TAG, `build ${info.build} already uploaded`); return; } const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG }); if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null }); if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; } stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r)); } if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`); const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform); const plan = via === 'api' ? [describeReq(reqs.upload), describeReq(reqs.file('')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('', '')), 'GET /v1/buildUploads/ until COMPLETE'] : [showCmd(altoolArgs(ipa, { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' }), null)]; if (wait) plan.push(`GET /v1/builds?filter[version]=${info.build} until processingState is VALID (timeout ${a.str('timeout', '60m')})`); const summary = { ipa: relative(root, ipa), bundleId: info.bundleId, version: info.version, build: info.build, sizeMB: Math.round(size / 104857.6) / 10, via, plan, ...(pre ? { preCheck: pre } : {}) }; if (dry) { if (json) { printJson(summary); return; } log(TAG, `${summary.ipa}: ${info.bundleId} ${info.version} (${info.build}), ${summary.sizeMB} MB, build number free`); for (const p of plan) log(TAG, `would ${p}`); done(TAG, 'upload planned (dry run)'); return; } if (!(await confirm(ctx, `Upload ${info.bundleId} ${info.version} (${info.build}) to App Store Connect? It shows up in TestFlight once processed.`))) return; const deadline = Date.now() + timeout; shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0); log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`); if (via === 'api') { try { await apiUpload(c, appId, ipa, info, deadline, {}, platform); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } else { try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } if (!wait) { if (json) printJson({ ...summary, uploaded: true }); done(TAG, `build ${info.build} uploaded; storebound apple status shows when it is VALID`); return; } // the same poll as `storebound apple testflight wait`, within what is left of --timeout shipPhase('processing', `waiting for App Store Connect to process build ${info.build}`); const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG }); const state = r.timedOut || (r.outcome !== 'valid' && r.outcome !== 'failed') ? 'TIMEOUT' : r.seen.processingState ?? 'UNKNOWN'; if (json) printJson({ ...summary, uploaded: true, processingState: state }); if (r.outcome === 'valid') { done(TAG, `${waitMessage(info.build, r)}. Then: apple testflight distribute --build ${info.build} --group --dry`); return; } if (state === 'TIMEOUT') { warn(TAG, `build ${info.build} not processed after ${a.str('timeout', '60m')}; keep waiting with storebound apple testflight wait --build ${info.build} --version ${info.version}`); stop(BUILD_WAIT_EXIT.timeout, `build ${info.build} not processed after ${a.str('timeout', '60m')}`); } warn(TAG, `${waitMessage(info.build, r)} (Apple emails the ITMS-xxxxx reason to the team)`); stop(BUILD_WAIT_EXIT.failed, `${waitMessage(info.build, r)} (Apple emails the ITMS-xxxxx reason to the team)`); }