play build: find the Android SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir, default folder), pass it in the env, stop before Gradle when missing; doctor shows it. apple upload: size, per-part lines, 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, --timeout exits 7 with the resume hint. Pitfall 25
This commit is contained in:
parent
366362d62b
commit
fc524cd2d4
15 changed files with 460 additions and 34 deletions
|
|
@ -24,6 +24,8 @@ 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';
|
||||
|
||||
const TAG = 'upload';
|
||||
|
||||
|
|
@ -42,7 +44,84 @@ function slice(path: string, offset: number, length: number): Buffer {
|
|||
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> {
|
||||
/** 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 ?? Bun.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
|
||||
|
|
@ -59,21 +138,8 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
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 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');
|
||||
|
|
@ -87,7 +153,7 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
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 });
|
||||
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 Bun.sleep(delay);
|
||||
|
|
@ -98,15 +164,29 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
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> {
|
||||
/**
|
||||
* `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' || !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);
|
||||
const size = statSync(ipa).size;
|
||||
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)}`);
|
||||
log(TAG, `uploading ${mb(size)} with altool (it reports only at the end)`);
|
||||
const p = Bun.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(); }
|
||||
|
|
@ -165,9 +245,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
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 await altoolUpload(root, ipa);
|
||||
} 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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue