apple signing status|ensure: bundle id capabilities, a distribution certificate from a key made locally (kept in .storebound/signing, never printed), App Store profiles installed for Xcode; Developer ID 403 explained. apple build / apple upload: xcodebuild archive + export with a generated ExportOptions.plist, next build number, App Store Connect API build upload (or altool), processing wait. Native, Capacitor, React Native, Flutter, Unity projects. play build / play upload: Gradle bundleRelease with the upload key passed through the environment, next versionCode read back from the AAB, testing tracks only. originality: offline Guideline 4.3 check (name, listing text, icon and screenshot perceptual hashes, project structure). New skill build-and-sign; zero new dependencies.
148 lines
10 KiB
TypeScript
148 lines
10 KiB
TypeScript
// Archive and export an App Store IPA with xcodebuild, no Xcode UI.
|
|
// storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]
|
|
// [--configuration Release] [--out build/ios] [--dry] [--json]
|
|
// 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/<App>.ipa for `storebound apple upload`.
|
|
// 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, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { 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 { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
|
import { ascClient } from './asc';
|
|
import { findBundleId } from './signing';
|
|
|
|
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 }[] {
|
|
const out: { uuid: string; name: string; team: string; expires: string }[] = [];
|
|
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) });
|
|
} catch { /* not a profile */ }
|
|
}
|
|
}
|
|
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
|
|
}
|
|
|
|
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}`);
|
|
const p = Bun.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, 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 new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`);
|
|
}
|
|
|
|
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 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 target = pickXcode(scanXcode(root), { ...cfg, workspace: a.get('workspace') ?? cfg.workspace, project: a.get('project') ?? cfg.project, scheme: a.get('scheme') ?? cfg.scheme });
|
|
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 ids = [bundleId, ...(signing.extraBundleIds ?? [])];
|
|
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)`);
|
|
}
|
|
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 archivePath = join(out, `${target.scheme}.xcarchive`);
|
|
const exportPath = join(out, 'export');
|
|
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 }), auth),
|
|
showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth),
|
|
];
|
|
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, notes: target.notes };
|
|
if (dry) {
|
|
if (json) { printJson(plan); 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 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' || !Bun.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
|
|
for (const n of target.notes) log(TAG, n);
|
|
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));
|
|
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth });
|
|
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
|
|
const exp = exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, realAuth);
|
|
await runLogged(exp, root, join(out, 'xcodebuild-export.log'), showCmd(exp, realAuth));
|
|
} finally { key?.cleanup(); }
|
|
const ipa = existsSync(exportPath) ? readdirSync(exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
|
if (!ipa) throw new Error(`export finished but no .ipa in ${exportPath}`);
|
|
const info = ipaInfo(join(exportPath, ipa));
|
|
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
|
|
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)`);
|
|
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build };
|
|
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`);
|
|
}
|