storebound/cli/apple/build.ts

258 lines
19 KiB
TypeScript

// Archive and export an App Store IPA with xcodebuild, no Xcode UI.
// storebound apple build --platform macos --tauri <src-tauri> [--path-free] [--unsigned] [--target T] [--profile F] [--version x.y.z] [--build-number N] [--out D]
// (Mac App Store build of a Tauri app: cli/apple/build-mas.ts)
// storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]
// [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json]
// [--keychain <path>] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap]
// Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in
// STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain <path> signs with a build
// keychain instead of the login keychain. Manual signing: stops before the build when a target's App Group is not in its
// installed profile (the developer portal assigns groups; storebound apple signing status/ensure names the steps).
// First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a
// Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops.
// Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/<Name>, Flutter ios/Runner,
// Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless
// --build-number is given, writes <out>/ExportOptions.plist (method app-store-connect, team, signing style and the
// profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<version>-<build>/<App>.ipa for `storebound apple upload`. Each build exports into its own
// folder, cleaned first: xcodebuild refuses to overwrite an earlier App.ipa ("exportArchive Copy failed", pitfall 53).
// Full xcodebuild output goes to <out>/xcodebuild-*.log; the terminal gets the result lines.
// Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles).
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { ascKeyFile } from '../lib/credentials';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { pump } from '../lib/runner';
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
import { nextBuildNumber } from '../lib/release';
import { exportOptions, profileDirs } from '../lib/signing';
import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain';
import { appGroupGapsFor, appGroupSteps } from '../lib/targets';
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 { discoverTargets, findBundleId } from './signing';
import * as rt from '../lib/rt';
import { shipPhase } from '../lib/ship-step';
const TAG = 'build';
/** Installed App Store profiles for a bundle id, newest expiry first: { uuid, name, team }. */
export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string; entitlements: Record<string, unknown> }[] {
const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record<string, unknown> }[] = [];
for (const d of profileDirs(home)) {
let files: string[] = [];
try { files = readdirSync(d).filter(f => f.endsWith('.mobileprovision')); } catch { continue; }
for (const f of files) {
try {
const p = parseMobileprovision(readFileSync(join(d, f)));
const ent = p.Entitlements as Record<string, unknown> | undefined;
const appId = String(ent?.['application-identifier'] ?? '');
const team = String((p.TeamIdentifier as string[] | undefined)?.[0] ?? '');
const store = !p.ProvisionedDevices && !p.ProvisionsAllDevices && ent?.['get-task-allow'] !== true;
if (!store || appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue;
if (Date.parse(String(p.ExpirationDate)) < Date.now()) continue;
if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate), entitlements: ent ?? {} });
} catch { /* not a profile */ }
}
}
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
}
const security = (argv: string[]): { ok: boolean; out: string } => { const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { ok: r.success, out: (r.stdout.toString() + r.stderr.toString()).trim() }; };
/** Puts a build keychain on the user's keychain search list (xcodebuild -exportArchive finds identities there). */
function addToSearchList(keychain: string): void {
const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out);
const step = unlockSteps(keychain, list).find(x => x[1] === 'list-keychains');
if (step) { const r = security(step); if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.slice(0, 200)}`); log(TAG, `${keychain} added to the keychain search list`); }
}
/** --keychain-unlock: unlocks the keychain (the login keychain, or --keychain) with STOREBOUND_KEYCHAIN_PASSWORD and
* sets the key partition list so codesign uses the keys without a prompt. The password is never printed. */
function unlockKeychain(keychain: string | null): void {
const pw = keychainPassword(process.env);
const kc = keychain ?? parseKeychainList(security(['security', 'default-keychain', '-d', 'user']).out)[0] ?? join(homedir(), 'Library/Keychains/login.keychain-db');
const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out);
for (const step of unlockSteps(kc, list)) {
const r = security(step.map(x => (x === 'PASSWORD' ? pw : x)));
if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.split(pw).join('***').slice(0, 300)}`);
}
log(TAG, `keychain ${kc} unlocked, key partition list set (password from ${'STOREBOUND_KEYCHAIN_PASSWORD'})`);
}
export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
log(TAG, `$ ${show}`);
log(TAG, `full output: ${logFile}`);
// Apple's tool folders first: Xcode's IPA step runs `rsync` from PATH, and Homebrew's rsync 3.x breaks it (pitfall 54)
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PATH: xcodePath(process.env.PATH, process.env.DEVELOPER_DIR), LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
const chunks: string[] = [];
const keep = /(\berror:|\*\* (ARCHIVE|EXPORT|BUILD) (SUCCEEDED|FAILED) \*\*|requires a provisioning profile|No signing certificate|Signing for .* requires|Exported .* to)/;
const each = (line: string): void => { chunks.push(line); if (keep.test(line)) log(TAG, line.trim().slice(0, 300)); };
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
const code = await p.exited;
writeFileSync(logFile, chunks.join('\n'));
if (code !== 0) throw Object.assign(new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`), { output: chunks.join('\n') });
}
/** The export folder of one build: <out>/export/<version>-<build> (build-<build> when the project sets the version). */
export function exportDir(out: string, version: string | undefined, build: string): string {
return join(out, 'export', version ? `${version}-${build}` : `build-${build}`);
}
/**
* xcodebuild -exportArchive into a fresh folder (removed first, so an IPA left by an earlier export never makes it
* fail with "Copy failed" or pass as this build). Throws on a non-zero exit or when no .ipa came out, so
* `apple build && apple upload` and `flow run` stop there.
*/
export async function exportIpa(o: { root: string; out: string; archivePath: string; exportPath: string; auth: AscAuth | null; argv?: string[]; tmp?: string }): Promise<string> {
rmSync(o.exportPath, { recursive: true, force: true });
const exp = o.argv ?? exportArgs(o.archivePath, join(o.out, 'ExportOptions.plist'), o.exportPath, o.auth);
try {
await runLogged(exp, o.root, join(o.out, 'xcodebuild-export.log'), showCmd(exp, o.auth));
} catch (e) {
// "Copy failed" says nothing: the IDEDistribution log has the real line (e.g. "rsync error: syntax or usage error")
const d = distributionErrors((e as { output?: string }).output ?? '', o.tmp);
if (d?.lines.length) (e as Error).message += `\nwhy (${d.log}):\n${d.lines.map(l => ` ${l}`).join('\n')}`
+ (d.lines.some(l => /rsync/i.test(l)) ? '\nA non-Apple rsync (Homebrew) breaks Xcode\'s IPA step: storebound doctor shows which one is first on PATH.' : '');
throw e;
}
const ipa = existsSync(o.exportPath) ? readdirSync(o.exportPath).find(f => f.endsWith('.ipa')) : undefined;
if (!ipa) throw new Error(`xcodebuild -exportArchive exited 0 but left no .ipa in ${o.exportPath}: see ${join(o.out, 'xcodebuild-export.log')}`);
return join(o.exportPath, ipa);
}
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 ?? {};
const style = signing.style ?? 'automatic';
const dry = a.has('dry');
const json = a.has('json');
const workspace = a.get('workspace') ?? cfg.workspace, project = a.get('project') ?? cfg.project;
// Capacitor in a monorepo (apps/native/ios): the config's workspace / project wins, else the one Capacitor project found
const explicit = workspace ?? project;
const cap = capacitorProject(root, explicit ? dirname(explicit) : undefined);
const sub = !explicit && cap?.dir ? cap.dir : '';
const found = pickXcode(scanXcode(sub ? join(root, sub) : root), { ...cfg, workspace, project, scheme: a.get('scheme') ?? cfg.scheme });
const target = sub ? { ...found, ...(found.workspace ? { workspace: `${sub}/${found.workspace}` } : {}), ...(found.project ? { project: `${sub}/${found.project}` } : {}) } : found;
if (sub) target.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set apple.build.workspace to use another)`);
const configuration = a.str('configuration', cfg.configuration ?? 'Release');
const out = resolve(a.get('out') ?? cfg.out ?? 'build/ios');
const version = a.get('version');
if (version && !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('--version: x.y.z');
// reads: build number and team id from App Store Connect (only if not given)
let build = a.get('build-number');
let teamId = signing.teamId ?? null;
if (!build || !teamId) {
try {
const c = await ascClient(root, bundleId, config.apple?.appId);
if (!build) {
const builds = await c.all(`/v1/builds?filter[app]=${await c.app()}&limit=200&fields[builds]=version`);
build = nextBuildNumber(builds.map(b => String(b.attributes.version)));
log(TAG, `${builds.length} builds on App Store Connect: next build number ${build}`);
}
if (!teamId) teamId = (await findBundleId(c, 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 scan = discoverTargets(root, config);
const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)];
const profiles: Record<string, string> = {};
if (style === 'manual') for (const id of ids) {
const p = installedProfiles(id, teamId)[0];
if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`);
else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`);
}
// App Groups: a manual-signing profile carries a group only when the portal assigned it to the bundle id
const groupGaps = style === 'manual' ? appGroupGapsFor([...(scan.main ? [scan.main] : []), ...scan.extensions], id => { const p = installedProfiles(id, teamId)[0]; return p ? p.entitlements : null; }, () => null) : [];
for (const g of groupGaps) {
const msg = `${g.bundleId}: the installed profile lacks App Group(s) ${g.groups.join(', ')}`;
if (a.has('allow-app-group-gap') || dry) warn(TAG, msg);
else throw new Error(`${msg}; codesign would refuse the build.\n${appGroupSteps(g).slice(1).join('\n')}\n(--allow-app-group-gap builds anyway)`);
}
const opts = exportOptions({ teamId: teamId ?? '<TEAM_ID>', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '<profile uuid>' }) : undefined });
const plist = toXmlPlist(opts);
const keychainFlag = a.get('keychain');
const keychain = keychainFlag && keychainFlag !== 'true' ? resolve(keychainFlag) : null;
const unlock = a.has('keychain-unlock');
const archivePath = join(out, `${target.scheme}.xcarchive`);
const exportPath = exportDir(out, version, buildNo);
const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' };
const auth = style === 'automatic' ? authPreview : null;
const steps = [
...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []),
showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth),
showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth),
];
const unlockPreview = unlock ? unlockSteps(keychain ?? '<login keychain>', []).map(showSecurity) : [];
const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration,
version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
if (dry) {
const web = await webStep({ ...webOpts, dry: true, quiet: json });
if (json) { printJson({ ...plan, web }); return; }
log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`);
for (const n of target.notes) log(TAG, n);
for (const s of plan.steps) log(TAG, `would run: ${s}`);
console.log(`\n${relative(root, join(out, 'ExportOptions.plist'))}:\n${plist}`);
done(TAG, 'build planned (dry run)');
return;
}
if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
// codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent
// (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101).
const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim());
if (remote && !a.has('allow-ssh') && !unlock && !keychain) throw new Error(sshBuildRefusal(remote));
if (unlock || keychain) {
if (unlock) unlockKeychain(keychain);
else addToSearchList(keychain!);
}
shipPhase('prebuild', 'web build / prebuild');
const web = await webStep({ ...webOpts, dry: false });
for (const n of target.notes) log(TAG, n);
const rsync = foreignRsync(process.env.PATH);
if (rsync) log(TAG, `note: ${rsync} is first on your PATH; xcodebuild runs with /usr/bin first (a non-Apple rsync breaks Xcode's IPA export)`);
mkdirSync(out, { recursive: true });
writeFileSync(join(out, 'ExportOptions.plist'), plist);
const key = style === 'automatic' ? ascKeyFile(root) : null;
const realAuth: AscAuth | null = key ? { keyPath: key.keyPath, keyId: key.keyId, issuerId: key.issuerId } : null;
try {
if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null));
shipPhase('build', `xcodebuild archive, build ${build}`);
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: keychain ? keychainSigningArgs(keychain) : undefined });
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
shipPhase('sign', 'xcodebuild -exportArchive (signing)');
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });
} finally { key?.cleanup(); }
const ipa = readdirSync(exportPath).find(f => f.endsWith('.ipa'))!;
const info = ipaInfo(join(exportPath, ipa));
shipPhase('verify', `${info.version} (${info.build})`);
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
const vwhy = ipaVersionProblem(info.version, version, 'the requested --version');
if (vwhy) { if (a.has('allow-version-mismatch')) warn(TAG, vwhy); else throw new Error(`${relative(root, join(exportPath, ipa))}: ${vwhy}`); }
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
// ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA
const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa)));
if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`);
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle };
writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}
`);
if (json) printJson(result);
done(TAG, `${relative(root, result.ipa)}: ${info.bundleId} ${info.version} (${info.build}). Next: storebound apple upload ${relative(root, result.ipa)} --dry`);
}