180 lines
13 KiB
TypeScript
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);
|
|
}
|