apple build --platform macos --tauri: Mac App Store build of a Tauri app (sandbox entitlements, profile, productbuild pkg, path-free, checks); apple upload accepts the pkg
This commit is contained in:
parent
2e8593ed5c
commit
9a937b5512
8 changed files with 726 additions and 22 deletions
|
|
@ -17,6 +17,7 @@
|
|||
// timeout (exit 7).
|
||||
// An upload is a write: --dry shows the plan; a real run asks first (--yes in scripts).
|
||||
import { runPreCheck } from '../lib/pre-check';
|
||||
import { pkgInfo } from '../lib/mas';
|
||||
import { appleSdkProblem } from '../lib/store-minimums';
|
||||
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
|
||||
import { basename, join, relative } from 'node:path';
|
||||
|
|
@ -29,6 +30,7 @@ import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, ipaVersionProblem,
|
|||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { parsePlatform } from './platform';
|
||||
import { waitForBuild, waitMessage } from './testflight';
|
||||
import { describeReq } from './jsonapi';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
|
|
@ -139,13 +141,13 @@ export async function sendParts(o: SendPartsOptions): Promise<void> {
|
|||
} finally { beat.stop(); }
|
||||
}
|
||||
|
||||
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number, o: HeartbeatOptions = {}): Promise<void> {
|
||||
async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<typeof ipaInfo>, deadline: number, o: HeartbeatOptions = {}, platform: 'IOS' | 'MAC_OS' = 'IOS'): Promise<void> {
|
||||
const size = statSync(ipa).size;
|
||||
const reqs = buildUploadReqs(appId, info, basename(ipa), size);
|
||||
const reqs = buildUploadReqs(appId, info, basename(ipa), size, platform);
|
||||
// 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 prior = (await uploadsOf(c, appId, info, platform)).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,
|
||||
find: async () => (await uploadsOf(c, appId, info, platform)).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`);
|
||||
|
|
@ -179,8 +181,8 @@ async function apiUpload(c: Asc, appId: string, ipa: string, info: ReturnType<ty
|
|||
}
|
||||
|
||||
/** 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`);
|
||||
const uploadsOf = (c: Asc, appId: string, info: { version: string; build: string }, platform = 'IOS'): Promise<J[]> =>
|
||||
c.all(`/v1/apps/${appId}/buildUploads?filter[cfBundleShortVersionString]=${encodeURIComponent(info.version)}&filter[cfBundleVersion]=${encodeURIComponent(info.build)}&filter[platform]=${platform}&limit=20`);
|
||||
|
||||
/**
|
||||
* `xcrun altool --upload-package`: runs in the background with a heartbeat (it prints nothing until it is done) and is
|
||||
|
|
@ -232,25 +234,30 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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'));
|
||||
const macos = parsePlatform(a.get('platform')) === 'MAC_OS';
|
||||
let ipa = a._.find(x => x.endsWith('.ipa') || x.endsWith('.pkg'));
|
||||
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] && macos) throw new Error('usage: storebound apple upload <app.pkg> --platform macos (storebound apple build --platform macos --tauri <src-tauri> makes one)');
|
||||
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);
|
||||
shipPhase('verify', `checking ${relative(root, ipa)}`);
|
||||
const info = ipaInfo(ipa);
|
||||
const isPkg = ipa.endsWith('.pkg');
|
||||
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);
|
||||
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}`);
|
||||
// App Store Connect refuses IPAs from an Xcode / SDK older than its current minimum (cli/lib/store-minimums.ts)
|
||||
const sdkWhy = appleSdkProblem(info);
|
||||
const sdkWhy = isPkg ? null : 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));
|
||||
const privacy = isPkg ? [] : 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 pre = await runPreCheck({ args: a, config, root, resolve, file: ipa, shown: relative(root, ipa), tag: TAG });
|
||||
const size = statSync(ipa).size;
|
||||
|
|
@ -263,14 +270,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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().catch(() => null);
|
||||
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`);
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}${isPkg ? '&filter[preReleaseVersion.platform]=MAC_OS' : ''}&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 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
|
||||
|
|
@ -291,7 +298,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r));
|
||||
}
|
||||
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 reqs = buildUploadReqs(appId, info, basename(ipa), size, platform);
|
||||
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)];
|
||||
|
|
@ -309,7 +316,7 @@ 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); } 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, {}, 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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue