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:
CRTkafa 2026-10-24 12:04:26 +08:00
parent 2e8593ed5c
commit 9a937b5512
8 changed files with 726 additions and 22 deletions

View file

@ -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; }
}