296 lines
20 KiB
TypeScript
296 lines
20 KiB
TypeScript
// Upload an IPA to App Store Connect and wait until Apple has processed the build.
|
|
// storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
|
|
// Default IPA: the newest in <apple.build.out or build/ios>/export (and its <version>-<build>/ 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.
|
|
// --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 { 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, 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 { 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';
|
|
|
|
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<Response>;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
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<void> {
|
|
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: Record<string, string> = {};
|
|
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value;
|
|
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;
|
|
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<typeof ipaInfo>, deadline: number, o: HeartbeatOptions = {}): Promise<void> {
|
|
const size = statSync(ipa).size;
|
|
const reqs = buildUploadReqs(appId, info, basename(ipa), size);
|
|
// 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)).find((u: J) => u.attributes?.state?.state !== 'FAILED');
|
|
const made = prior ? { value: prior, created: false } : await createOnce<J>({ what: `build upload ${info.version} (${info.build})`, tag: TAG,
|
|
find: async () => (await uploadsOf(c, appId, info)).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<J>({ 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 }): Promise<J[]> =>
|
|
c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=IOS&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<void> {
|
|
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<void> {
|
|
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');
|
|
let ipa = a._.find(x => x.endsWith('.ipa'));
|
|
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]) throw new Error(`usage: storebound apple upload <app.ipa> (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`);
|
|
ipa = found[0];
|
|
} else ipa = resolve(ipa);
|
|
const info = ipaInfo(ipa);
|
|
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 = 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 = 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 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();
|
|
const builds = await c.all(`/v1/builds?filter[app]=${appId}&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).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 });
|
|
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; }
|
|
process.exit(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout);
|
|
}
|
|
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);
|
|
const plan = via === 'api'
|
|
? [describeReq(reqs.upload), describeReq(reqs.file('<upload id>')), `PUT the file parts to the upload URLs Apple returns`, describeReq(reqs.commit('<file id>', '<md5>')), 'GET /v1/buildUploads/<id> 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 };
|
|
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;
|
|
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); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); process.exit((e as { exit: number }).exit); } throw e; }
|
|
} else {
|
|
try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); process.exit((e as { exit: number }).exit); } 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
|
|
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 <name> --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}`); process.exit(BUILD_WAIT_EXIT.timeout); }
|
|
warn(TAG, `${waitMessage(info.build, r)} (Apple emails the ITMS-xxxxx reason to the team)`);
|
|
process.exit(BUILD_WAIT_EXIT.failed);
|
|
}
|