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

201
cli/apple/build-mas.ts Normal file
View file

@ -0,0 +1,201 @@
// Mac App Store build of a Tauri app: `tauri build --bundles app`, signed for the Mac App Store, then productbuild to a .pkg.
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--target universal-apple-darwin]
// [--version x.y.z] [--build-number N] [--profile <file.provisionprofile>] [--out build/macos] [--unsigned] [--dry] [--json]
// Steps: Tauri project -> version / build number (next free one on App Store Connect, platform MAC_OS) -> signing
// identities from the keychain ("Apple Distribution" or "3rd Party Mac Developer Application"; "Mac Installer
// Distribution" or "3rd Party Mac Developer Installer") -> Mac App Store provisioning profile (--profile, installed, or
// from the API) -> entitlements (app sandbox + application / team identifier, the project's own keys kept) -> a
// tauri.conf overlay (signingIdentity, entitlements, embedded.provisionprofile, bundleVersion) -> `tauri build` ->
// checks of the .app (sandbox entitlement, category, minimum OS, version / build, profile inside, no local paths) ->
// productbuild --sign -> pkgutil --check-signature. --path-free remaps the checkout, CARGO_HOME, RUSTUP_HOME and the home
// folder in rustc, strips debug info and symbols, and fails when the home folder or user name is still in the app.
// --unsigned builds without identities, profile and installer signature (a pipeline check on any Mac; not uploadable).
// Result: <out>/export/<version>-<build>/<Product>.pkg + <out>/latest.json; then `storebound apple upload <pkg> --platform macos`.
// codesign needs the Mac's logged-in GUI session (SSH: errSecInternalComponent), same as apple build.
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, userInfo } from 'node:os';
import { dirname, join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist';
import { nextBuildNumber } from '../lib/release';
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
import { remoteBuildSession, showCmd } from '../lib/xcode';
import {
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
pickMasProfiles, pkgProblems, parsePkgSignature, readAppInfo, scanPathLeaks, tauriOverlay, categoryId,
} from '../lib/mas';
import { readdirSync } from 'node:fs';
import { ascClient, type Asc } from './asc';
import { findBundleId } from './signing';
import { runLogged } from './build';
import * as rt from '../lib/rt';
import { shipPhase } from '../lib/ship-step';
const TAG = 'build';
const run1 = (argv: string[], timeout = 120_000): { ok: boolean; out: string } => {
const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout });
return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}` };
};
/** The Tauri CLI: the project's node_modules/.bin/tauri (walking up), `cargo tauri`, else bunx. */
export function tauriCommand(cwd: string, exists: (p: string) => boolean = existsSync, which: (c: string) => string | null = rt.which): string[] {
for (let d = cwd, prev = ''; d !== prev; prev = d, d = dirname(d)) { const p = join(d, 'node_modules', '.bin', 'tauri'); if (exists(p)) return [p]; }
if (which('cargo-tauri')) return ['cargo', 'tauri'];
return ['bunx', '--bun', '@tauri-apps/cli'];
}
/** Mac App Store profiles installed for Xcode (both folders, .provisionprofile and .mobileprovision). */
export function installedMasProfiles(bundleId: string, teamId: string | null, home = homedir()): MasProfile[] {
const files: { path: string; profile: Record<string, PlistValue> }[] = [];
for (const d of profileDirs(home)) {
let names: string[] = []; try { names = readdirSync(d).filter(f => /\.(provisionprofile|mobileprovision)$/.test(f)); } catch { continue; }
for (const f of names) { try { files.push({ path: join(d, f), profile: parseMobileprovision(readFileSync(join(d, f))) }); } catch { /* not a profile */ } }
}
return pickMasProfiles(files, bundleId, teamId);
}
/** The ACTIVE MAC_APP_STORE profile of the bundle id from the API, written to `dest`. Null when there is none. */
async function fetchMasProfile(c: Asc, bundleId: string, dest: string): Promise<string | null> {
const b = await findBundleId(c, bundleId);
if (!b) return null;
const list: ApiProfile[] = await readProfiles(p => c.all(p), b.id, 'MAC_APP_STORE');
const ok = list.filter(p => p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - Date.now() > 86_400_000)
.sort((x, y) => Date.parse(y.attributes.expirationDate!) - Date.parse(x.attributes.expirationDate!))[0];
if (!ok) return null;
const content = ok.attributes.profileContent ?? (await c.asc('GET', `/v1/profiles/${ok.id}`)).data.attributes.profileContent;
if (!content) return null;
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, Buffer.from(content, 'base64'));
return dest;
}
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 dry = a.has('dry'), json = a.has('json');
const unsigned = a.has('unsigned');
const pathFree = a.has('path-free');
const tauri = findTauri(root, a.get('tauri'));
if (tauri.identifier !== bundleId) throw new Error(`${relative(root, tauri.confPath)}: identifier is "${tauri.identifier}", the config's apple.bundleId is "${bundleId}": they must be the same`);
const target = a.str('target', 'universal-apple-darwin');
const out = resolve(a.get('out') ?? (config.apple?.build?.out ? `${config.apple.build.out.replace(/\/ios$/, '')}/macos` : 'build/macos'));
const version = a.get('version') ?? tauri.version;
if (!version || !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('version: x.y.z from --version or tauri.conf.json "version"');
let build = a.get('build-number');
let teamId = config.apple?.signing?.teamId ?? null;
let client: Asc | null = null;
if (!unsigned || !build) {
try {
client = await ascClient(root, bundleId, config.apple?.appId);
if (!build) {
const builds = await client.all(`/v1/builds?filter[app]=${await client.app()}&filter[preReleaseVersion.platform]=MAC_OS&limit=200&fields[builds]=version`);
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
log(TAG, `${builds.length} macOS builds on App Store Connect: next build number ${build} (CFBundleVersion must increase)`);
}
if (!teamId) teamId = (await findBundleId(client, bundleId))?.attributes?.seedId ?? null;
} catch (e) {
if (!dry) throw new Error(`${(e as Error).message}\n(pass --build-number N and set apple.signing.teamId to build without App Store Connect reads)`);
warn(TAG, `App Store Connect not readable (${(e as Error).message.split('\n')[0]?.slice(0, 120)}): dry run continues with placeholders`);
}
}
if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42');
const buildNo = build ?? '<next build number>';
const team = teamId ?? '<TEAM_ID>';
// identities and profile (a real signed run needs them; a dry run shows placeholders)
const mac = process.platform === 'darwin';
let appIdentity: string | null = null, installerIdentity: string | null = null;
if (!unsigned && mac) {
const picked = pickMasIdentities(parseIdentities(run1(['security', 'find-identity', '-v']).out), teamId);
appIdentity = picked.app?.name ?? null; installerIdentity = picked.installer?.name ?? null;
const missing = [!appIdentity && 'an "Apple Distribution" / "3rd Party Mac Developer Application" identity', !installerIdentity && 'a "Mac Installer Distribution" / "3rd Party Mac Developer Installer" identity'].filter(Boolean);
if (missing.length && !dry) throw new Error(`no ${missing.join(' and no ')} for team ${team} in the keychain. Create them with apple.signing.certificateType DISTRIBUTION / MAC_INSTALLER_DISTRIBUTION and \`storebound apple signing ensure\`, then import the certificate into the login keychain.`);
if (missing.length) warn(TAG, `missing in the keychain: ${missing.join(', ')}`);
}
const profileFile = join(out, 'embedded.provisionprofile');
let profile: string | null = null;
if (!unsigned) {
const given = a.get('profile');
if (given) { profile = resolve(given); if (!existsSync(profile)) throw new Error(`--profile ${given}: no such file`); }
else {
const have = installedMasProfiles(bundleId, teamId)[0];
if (have) { profile = have.path; log(TAG, `Mac App Store profile "${have.name}" (installed, expires ${have.expires.slice(0, 10)})`); }
else if (client && !dry) profile = await fetchMasProfile(client, bundleId, profileFile);
else if (client) { try { profile = await fetchMasProfile(client, bundleId, profileFile); } catch (e) { warn(TAG, `profile not readable: ${(e as Error).message.split('\n')[0]}`); } }
if (!profile && !dry) throw new Error(`no active MAC_APP_STORE provisioning profile for ${bundleId}: set apple.signing.profileType to MAC_APP_STORE and run \`storebound apple signing ensure\`, or pass --profile <file.provisionprofile>`);
if (!profile) warn(TAG, `no MAC_APP_STORE profile for ${bundleId} yet (storebound apple signing ensure with apple.signing.profileType MAC_APP_STORE)`);
}
}
const home = homedir(), user = userInfo().username;
const env = pathFree ? pathFreeEnv({ home, user, root: process.env.STOREBOUND_PATHFREE_ROOT ?? root, cargoHome: process.env.CARGO_HOME, rustupHome: process.env.RUSTUP_HOME, rustflags: process.env.RUSTFLAGS }) : {};
const entFile = join(out, 'mas.entitlements');
const configFile = join(out, 'tauri.mas.conf.json');
const exportPath = join(out, 'export', `${version}-${buildNo}`);
const pkg = join(exportPath, `${tauri.productName}.pkg`);
const tauriCmd = tauriCommand(tauri.cwd);
const category = tauri.category && !categoryId(tauri.category) ? undefined : tauri.category;
const overlay = tauriOverlay({ appIdentity, entitlementsFile: entFile, profile, version, build: buildNo, unsigned, minOs: tauri.minOs, category });
const input = { tauri, tauriCmd, target, out, configFile, entitlementsFile: entFile, profile, appIdentity, installerIdentity: installerIdentity ?? '<Mac Installer Distribution identity>', version, build: buildNo, unsigned, pathFree, pkg };
const steps = masSteps(input);
const existingEnt = tauri.entitlements && existsSync(tauri.entitlements) ? (parsePlist(readFileSync(tauri.entitlements)) as Record<string, PlistValue>) : undefined;
const ent = masEntitlements({ teamId: team, bundleId, existing: existingEnt });
const plan = { platform: 'MAC_OS', tauri: relative(root, tauri.confPath), target, version, build: buildNo, teamId: teamId, unsigned, pathFree, appIdentity, installerIdentity,
profile: profile ? basenameOf(profile) : null, entitlements: Object.keys(ent), tauriOverlay: overlay, env: Object.fromEntries(Object.entries(env).map(([k, v]) => [k, k === 'RUSTFLAGS' ? v.replaceAll(home, '~') : v])),
steps: [showCmd(steps.tauri, null), showCmd(steps.codesignVerify, null), showCmd(steps.productbuild, null), showCmd(steps.pkgCheck, null)], out: relative(root, out) || '.', pkg: relative(root, pkg) };
if (dry) {
if (json) { printJson(plan); return; }
log(TAG, `Tauri ${tauri.productName} (${bundleId}) ${version} (${buildNo}) for ${target}, ${unsigned ? 'UNSIGNED' : 'Mac App Store signing'}${pathFree ? ', path-free' : ''}`);
log(TAG, `app identity: ${appIdentity ?? '(none found)'}; installer identity: ${installerIdentity ?? '(none found)'}; profile: ${plan.profile ?? '(none)'}`);
log(TAG, `entitlements: ${plan.entitlements.join(', ')}`);
if (pathFree) log(TAG, `RUSTFLAGS: ${plan.env.RUSTFLAGS}`);
for (const s of plan.steps) log(TAG, `would run: ${s}`);
done(TAG, 'macOS build planned (dry run)');
return;
}
if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)');
const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim());
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal <file>\`), or add --unsigned for a pipeline check.`);
mkdirSync(out, { recursive: true });
writeFileSync(entFile, entitlementsXml(ent));
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);
shipPhase('build', `tauri build ${target}, build ${buildNo}`);
await runLogged(steps.tauri, tauri.cwd, join(out, 'tauri-build.log'), showCmd(steps.tauri, null), env);
const appDir = findBuiltApp(tauri.cwd, root, target, tauri.productName);
if (!appDir) throw new Error(`tauri build exited 0 but no ${tauri.productName}.app is under ${join(tauri.cwd, 'target', target, 'release/bundle/macos')} (see ${join(out, 'tauri-build.log')})`);
shipPhase('verify', `checking ${basenameOf(appDir)}`);
if (!unsigned) {
const v = run1(steps.codesignVerify.map(x => (x.endsWith('.app') ? appDir : x)));
if (!v.ok) throw new Error(`codesign --verify failed:\n${v.out.slice(-600)}`);
}
const entOut = unsigned ? null : entitlementKeysOf(run1(['codesign', '-d', '--entitlements', ':-', appDir]).out);
const leaks = pathFree || a.has('scan-paths') ? scanPathLeaks(appDir, leakNeedles(home, user, root)) : [];
const checks = appProblems({ info: readAppInfo(appDir), entitlements: entOut, hasProfile: existsSync(join(appDir, 'Contents', 'embedded.provisionprofile')), leaks },
{ bundleId, version, build: buildNo, signed: !unsigned, pathFree });
for (const w of checks.warnings) warn(TAG, w);
if (checks.errors.length) throw new Error(`${basenameOf(appDir)} was built, but the Mac App Store would refuse it:\n${checks.errors.map(x => ` ${x}`).join('\n')}`);
shipPhase('sign', 'productbuild');
rmSync(exportPath, { recursive: true, force: true });
mkdirSync(exportPath, { recursive: true });
const pbArgv = masSteps({ ...input, installerIdentity }).productbuild.map(x => (x.endsWith('.app') ? appDir : x));
await runLogged(pbArgv, tauri.cwd, join(out, 'productbuild.log'), showCmd(pbArgv, null));
if (!existsSync(pkg)) throw new Error(`productbuild exited 0 but left no ${pkg}`);
if (!unsigned) {
const sig = parsePkgSignature(run1(steps.pkgCheck).out);
const bad = pkgProblems(sig);
if (bad.length) throw new Error(`${relative(root, pkg)}:\n${bad.map(x => ` ${x}`).join('\n')}`);
log(TAG, `pkg signed with "${sig.certificate}"`);
}
copyIfAny(profile, profileFile);
const result = { pkg, platform: 'MAC_OS', bundleId, version, build: buildNo, unsigned, pathFree, leaks: leaks.length };
writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}\n`);
writeFileSync(`${pkg}.json`, `${JSON.stringify(result, null, 2)}\n`);
if (json) printJson(result);
done(TAG, `${relative(root, pkg)}: ${bundleId} ${version} (${buildNo})${unsigned ? ' UNSIGNED, not uploadable' : `. Next: storebound apple upload ${relative(root, pkg)} --platform macos --dry`}`);
}
const basenameOf = (p: string): string => p.split(/[\\/]/).pop() ?? p;
function copyIfAny(src: string | null, dest: string): void { if (src && src !== dest && existsSync(src) && !existsSync(dest)) { try { copyFileSync(src, dest); } catch { /* optional */ } } }

View file

@ -26,6 +26,7 @@ import { capacitorProject, webStep } from '../lib/web-assets';
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode';
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
import { ascClient } from './asc';
import { parsePlatform } from './platform';
import { findBundleId } from './signing';
import * as rt from '../lib/rt';
import { shipPhase } from '../lib/ship-step';
@ -98,6 +99,8 @@ export async function exportIpa(o: { root: string; out: string; archivePath: str
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
// Mac App Store build of a Tauri app (cli/apple/build-mas.ts)
if (a.has('tauri') || parsePlatform(a.get('platform')) === 'MAC_OS') return (await import('./build-mas')).run(ctx);
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const cfg = config.apple?.build ?? {};
const signing = config.apple?.signing ?? {};

View file

@ -55,7 +55,7 @@ function localKeys(root: string): Map<string, string> {
/** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */
function keychainIdentities(): Set<string> {
if (process.platform !== 'darwin' || !rt.which('security')) return new Set();
const r = rt.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' });
const r = rt.spawnSync(['security', 'find-identity', '-v'], { stdout: 'pipe', stderr: 'pipe' });
return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1));
}

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