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
201
cli/apple/build-mas.ts
Normal file
201
cli/apple/build-mas.ts
Normal 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 */ } } }
|
||||
|
|
@ -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 ?? {};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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