storebound/cli/apple/upload.ts
2026-10-12 04:00:58 +08:00

180 lines
13 KiB
TypeScript

// Upload an IPA to App Store Connect and wait until Apple has processed the build.
// storebound apple upload [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
// Default IPA: the newest in <apple.build.out or build/ios>/export. The bundle id, version and build number come from
// the IPA's Info.plist; the upload is refused when that build number is already on App Store Connect for the app
// (`apple next-build` / `apple build` pick a free one).
// --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 { 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, ipaInfo, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
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';
const TAG = 'upload';
function md5File(path: string): string {
const h = new Bun.CryptoHasher('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); }
}
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number): 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 J[];
log(TAG, `uploading ${(size / 1048576).toFixed(1)} MB in ${ops.length} part(s)`);
let sent = 0;
for (const [i, op] of ops.entries()) {
const headers: Record<string, string> = {};
for (const h of op.requestHeaders ?? []) headers[h.name] = h.value;
for (let attempt = 1; ; attempt++) {
const r = await fetch(op.url, { method: op.method, headers, body: new Uint8Array(slice(ipa, op.offset, op.length)) });
if (r.ok) break;
if (attempt >= 4 || (r.status < 500 && r.status !== 429)) throw new Error(`part ${i + 1}/${ops.length} -> ${r.status}`);
await Bun.sleep(2000 * attempt);
}
sent += op.length;
if ((i + 1) % 10 === 0 || i === ops.length - 1) log(TAG, `${i + 1}/${ops.length} parts (${Math.round((sent / size) * 100)}%)`);
}
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`), { 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 Bun.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`);
async function altoolUpload(root: string, ipa: string): Promise<void> {
if (process.platform !== 'darwin' || !Bun.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);
log(TAG, `$ ${showCmd(argv, k)}`);
const r = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe' });
const outText = (r.stdout.toString() + '\n' + r.stderr.toString()).trim();
if (r.exitCode !== 0) throw new Error(`altool failed (exit ${r.exitCode}): ${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 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');
let ipa = a._.find(x => x.endsWith('.ipa'));
if (!ipa) {
const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export');
const found = existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs) : [];
if (!found[0]) throw new Error(`usage: storebound apple upload <app.ipa> (nothing in ${relative(root, dir)}; storebound apple build makes one)`);
ipa = found[0];
} else ipa = resolve(ipa);
const info = ipaInfo(ipa);
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
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`);
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;
if (already) {
log(TAG, `build ${info.version} (${info.build}) was uploaded already (build upload ${already.id}); 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.includes(info.build)) throw new Error(`build ${info.build} is already on App Store Connect: rebuild with --build-number ${next} (storebound apple build)`);
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;
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 await altoolUpload(root, ipa);
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);
}