apple.macos listing / review notes per platform + iOS-only claim warnings; apple upload of a .pkg: bounded pkgutil, macOS processing wait, heartbeats; release 409 pattern
This commit is contained in:
parent
53d57544bf
commit
1e47c2ee60
18 changed files with 417 additions and 63 deletions
|
|
@ -28,7 +28,7 @@ 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, readLastBuild, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } from '../lib/xcode';
|
||||
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem, readLastBuild, remoteBuildSession, showCmd, UPLOAD_DONE, uploadProblems, uploadSearchDirs } 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';
|
||||
|
|
@ -51,6 +51,9 @@ function stop(legacy: number, message: string): never {
|
|||
|
||||
const TAG = 'upload';
|
||||
|
||||
/** Test hook: heartbeat options (short interval, manual ticker) for the reads, the upload and the processing wait. */
|
||||
export const hooks: { heartbeat?: HeartbeatOptions } = {};
|
||||
|
||||
function md5File(path: string): string {
|
||||
const h = rt.hasher('md5');
|
||||
const fd = openSync(path, 'r');
|
||||
|
|
@ -257,9 +260,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
} else ipa = resolve(ipa);
|
||||
shipPhase('verify', `checking ${relative(root, ipa)}`);
|
||||
const isPkg = ipa.endsWith('.pkg');
|
||||
log(TAG, `${relative(root, ipa)}: reading ${isPkg ? 'the package (pkgutil)' : 'the IPA'} (${mb(statSync(ipa).size)})`);
|
||||
// over SSH (no GUI session) anything that touches the login keychain can wait for a prompt nobody sees: say so up front
|
||||
const remote = process.platform === 'darwin' ? remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()) : null;
|
||||
if (remote) warn(TAG, `${remote}: the upload itself uses the App Store Connect API key (no keychain, no prompt), but pkgutil / altool can stall on a keychain or GUI prompt nobody sees here; every step has a timeout. If it stalls, run it in the Mac's logged-in GUI session (see storebound apple build --help, --allow-ssh)`);
|
||||
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 <src-tauri>), not an .ipa');
|
||||
const info: ReturnType<typeof ipaInfo> = isPkg ? pkgInfo(ipa, bundleId) : ipaInfo(ipa);
|
||||
const info: ReturnType<typeof ipaInfo> = isPkg ? pkgInfo(ipa, bundleId, { say: m => log(TAG, m) }) : ipaInfo(ipa);
|
||||
log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build})`);
|
||||
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}`);
|
||||
|
|
@ -277,18 +285,21 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const wait = !a.has('no-wait');
|
||||
const timeout = parseDuration(a.str('timeout', '60m'));
|
||||
|
||||
log(TAG, `asking App Store Connect about ${bundleId}: the app, the editable version, the builds, earlier uploads of build ${info.build}`);
|
||||
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}&limit=200&fields[builds]=version,processingState`);
|
||||
// every read has its own timeout and retries; the heartbeat says the run is alive while one of them waits
|
||||
const { appId, editable, builds, already } = await withHeartbeat(ms => log(TAG, `still reading App Store Connect, ${elapsed(ms)}`), async () => {
|
||||
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 builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
// 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;
|
||||
return { appId, editable, builds, already };
|
||||
}, hooks.heartbeat);
|
||||
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
|
||||
|
|
@ -300,14 +311,18 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
return;
|
||||
}
|
||||
if (decision instanceof Error) throw decision;
|
||||
log(TAG, `build ${info.build} is not on App Store Connect (next free: ${next})`);
|
||||
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 });
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: timeout, tag: TAG, ...hooks.heartbeat });
|
||||
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));
|
||||
}
|
||||
// (a build number App Store Connect has already ends above, before this: the clearer message)
|
||||
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}`); }
|
||||
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'
|
||||
|
|
@ -327,14 +342,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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; }
|
||||
try { await apiUpload(c, appId, ipa, info, deadline, hooks.heartbeat ?? {}, 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; }
|
||||
try { await altoolUpload(root, ipa, deadline, hooks.heartbeat); } 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 r = await waitForBuild(c, appId, info.build, { marketing: info.version, platform, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG, ...hooks.heartbeat });
|
||||
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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue