storebound/cli/apple/signing.ts

420 lines
28 KiB
TypeScript

// Code signing through the App Store Connect API: bundle id capabilities, the distribution certificate and the
// provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings.
// storebound apple signing status [--json] read-only: capabilities, certificates (with / without a
// private key on this machine), profiles, what is installed
// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]
// adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick).
// style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also
// creates a distribution certificate when this machine
// holds no key for any valid one (RSA 2048 key generated here, kept 0600 in .storebound/signing/, never printed;
// --keychain also imports key + certificate into the login keychain or the given one), creates or reuses an
// App Store profile per bundle id (apple.bundleId + apple.signing.extraBundleIds) and installs it for Xcode.
// Every write is shown first (--dry shows only that) and asks once before anything is created. Developer ID
// certificates need the Account Holder: the API's 403 is explained. Pure planning: cli/lib/signing.ts.
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr';
import { done, log, printJson, warn } from '../lib/log';
import {
type ApiCert, type ApiProfile, type AppleSigning, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo,
ENTITLEMENT_CAPABILITIES, machoEntitlements, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs,
type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities,
} from '../lib/signing';
import { isBuildCopyPath } from '../lib/privacy-manifest';
import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode';
import { parsePlist } from '../lib/plist';
import { zipEntries, zipRead } from '../lib/zip';
import type { ShipConfig } from '../lib/config';
import { textTable } from '../lib/table';
import { type Asc, ascClient, type J } from './asc';
import { describeReq, execute, type Req } from './jsonapi';
import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert';
import * as rt from '../lib/rt';
const TAG = 'signing';
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]';
export const signingDir = (root: string): string => join(root, '.storebound', 'signing');
/** SPKIs of the private keys in .storebound/signing (file name = certificate id once created). */
function localKeys(root: string): Map<string, string> {
const dir = signingDir(root);
const out = new Map<string, string>();
if (!existsSync(dir)) return out;
for (const f of readdirSync(dir).filter(f => f.endsWith('.key'))) {
try { out.set(spkiOfKey(readFileSync(join(dir, f), 'utf8')), f); } catch { warn(TAG, `${f}: not a private key, ignored`); }
}
return out;
}
/** 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' });
return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1));
}
export async function findBundleId(c: Asc, identifier: string): Promise<J | null> {
const r = await c.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(identifier)}&include=bundleIdCapabilities&limit=200`);
const hit = (r.data as J[]).find(b => b.attributes.identifier === identifier) ?? null;
if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id))
.map((x: J) => x.attributes.capabilityType as string);
return hit;
}
/** The iOS project's .entitlements files (next to each .xcodeproj, build copies skipped) with the capabilities they
* need and whether the project references them (CODE_SIGN_ENTITLEMENTS). null when there is no Xcode project. */
export function readProjectEntitlements(root: string): ProjectEntitlements[] | null {
const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p));
if (!projects.length) return null;
const out: ProjectEntitlements[] = [];
for (const proj of projects) {
let pbx = '';
try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { /* unreadable */ }
const refs = [...pbx.matchAll(/CODE_SIGN_ENTITLEMENTS\s*=\s*"?([^";]+)"?;/g)].map(m => m[1]!.trim().replace(/^\$\(SRCROOT\)\//, ''));
const walk = (dir: string, d: number): void => {
let names: string[] = [];
try { names = readdirSync(dir); } catch { return; }
for (const n of names) {
const f = join(dir, n);
if (n.endsWith('.entitlements')) {
const rel = relative(root, f);
if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue;
out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => f.endsWith('/' + r)) : null));
} else if (d > 0 && !n.startsWith('.') && !['node_modules', 'Pods', 'build', 'DerivedData'].includes(n) && !n.endsWith('.xcodeproj')) {
try { if (statSync(f).isDirectory()) walk(f, d - 1); } catch { /* unreadable */ }
}
}
};
walk(dirname(join(root, proj)), 3);
}
return out;
}
const git = (root: string, args: string[]): string | null => {
try {
const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' });
return r.success ? r.stdout.toString() : null;
} catch { return null; }
};
/** The current git branch and the other local branches (and the worktrees that have them out) whose tree has an
* .entitlements file (build copies skipped). null outside a git repository. */
export function checkoutInfo(root: string): CheckoutInfo | null {
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
if (head === null) return null;
const branch = head.trim() === 'HEAD' ? null : head.trim();
const worktrees = new Map<string, string>();
let wt = '';
for (const l of (git(root, ['worktree', 'list', '--porcelain']) ?? '').split('\n')) {
if (l.startsWith('worktree ')) wt = l.slice(9);
else if (l.startsWith('branch refs/heads/')) worktrees.set(l.slice(18), wt);
}
const elsewhere: string[] = [];
const branches = (git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']) ?? '').split('\n').filter(Boolean).filter(b => b !== branch).slice(0, 100);
for (const b of branches) {
const files = (git(root, ['ls-tree', '-r', '--name-only', b]) ?? '').split('\n').filter(f => f.endsWith('.entitlements') && !isBuildCopyPath(f));
if (!files.length) continue;
const w = worktrees.get(b);
elsewhere.push(`branch ${b} (${files.slice(0, 2).join(', ')}${files.length > 2 ? ', ...' : ''}${w ? `; worktree ${w}` : ''})`);
}
return { branch, elsewhere };
}
/** The real entitlements of the newest built IPA (highest build number, newest file per number): codesign on macOS,
* else the Mach-O signature's entitlements blob. null when there is no IPA or it cannot be read. */
export function builtIpaEntitlements(root: string, config: ShipConfig): BuildEntitlements | null {
const dir = join(resolvePath(root, config.apple?.build?.out ?? 'build/ios'), 'export');
const ipas: { file: string; build: string; exe: string }[] = [];
for (const f of exportedIpas(dir)) {
try {
const e = zipEntries(f).find(x => /^Payload\/[^/]+\.app\/Info\.plist$/.test(x.name))!;
const p = parsePlist(zipRead(f, e)) as Record<string, unknown>;
ipas.push({ file: f, build: ipaInfo(f).build, exe: `${e.name.slice(0, -'Info.plist'.length)}${String(p.CFBundleExecutable ?? '')}` });
} catch { /* not an app archive */ }
}
// exportedIpas is newest first; a stable sort by build number keeps the newest file of each number first
ipas.sort((x, y) => y.build.localeCompare(x.build, 'en', { numeric: true }));
const pick = ipas[0];
if (!pick) return null;
const entry = zipEntries(pick.file).find(x => x.name === pick.exe);
if (!entry) return null;
const bin = zipRead(pick.file, entry);
const rel = relative(root, pick.file);
if (process.platform === 'darwin' && rt.which('codesign')) {
const tmp = mkdtempSync(join(tmpdir(), 'storebound-ent-'));
try {
const f = join(tmp, basename(pick.exe));
writeFileSync(f, bin);
const r = rt.spawnSync(['codesign', '-d', '--entitlements', ':-', f], { stdout: 'pipe', stderr: 'pipe' });
const xml = r.stdout.toString();
if (r.success && /<plist/.test(xml)) return buildEntitlements(pick.build, rel, xml, 'codesign');
} catch { /* fall back to the Mach-O blob */ } finally { rmSync(tmp, { recursive: true, force: true }); }
}
const xml = machoEntitlements(bin)[0];
return xml === undefined ? null : buildEntitlements(pick.build, rel, xml, 'mach-o');
}
export interface CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; checkout: CheckoutInfo | null; build: BuildEntitlements | null; mismatches: CapMismatch[] }
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements, and the
* entitlements of the newest local IPA when there is one. */
export async function capabilityReport(c: Asc, config: ShipConfig, root: string): Promise<CapabilityReport> {
const bundleId = config.apple!.bundleId;
const b = await findBundleId(c, bundleId);
const appId = b ? (b.capabilities as string[]) : null;
const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type);
const project = readProjectEntitlements(root);
let build: BuildEntitlements | null = null;
try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ }
const checkout = project && cfg.some(x => Object.values(ENTITLEMENT_CAPABILITIES).includes(x as CapabilityType)) && !project.some(p => p.caps.length) ? checkoutInfo(root) : null;
return { bundleId, appId, config: cfg, project, checkout, build, mismatches: compareCapabilities({ appId, config: cfg, project, checkout, build }) };
}
export function printCapabilityReport(r: CapabilityReport, tag: string): void {
const proj = r.project === null ? 'no Xcode project found' : r.project.length ? r.project.map(p => `${p.file}: ${p.caps.map(c => c.cap).join(', ') || 'none'}${p.referenced === false ? ' (NOT referenced by CODE_SIGN_ENTITLEMENTS)' : ''}`).join('; ') : 'no .entitlements file';
const built = r.build ? `uploaded build ${r.build.number} (${r.build.file}, via ${r.build.via}): ${r.build.caps.map(c => c.cap).join(', ') || 'none'}` : 'no local IPA (the App Store Connect API does not expose a build\'s entitlements)';
log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj} | ${built}`);
for (const m of r.mismatches) warn(tag, `${m.msg}. Fix: ${m.fix}`);
if (!r.mismatches.length) log(tag, 'capabilities match (App ID, config, project)');
}
const profilesOf = (c: Asc, bundleIdId: string, type: ProfileType): Promise<ApiProfile[]> => readProfiles(p => c.all(p), bundleIdId, type);
const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []);
function install(p: ApiProfile): string[] {
const content = p.attributes.profileContent;
if (!content || !p.attributes.uuid) throw new Error(`profile ${p.attributes.name}: no content to install`);
const out: string[] = [];
for (const d of profileDirs(homedir())) {
mkdirSync(d, { recursive: true });
const f = join(d, `${p.attributes.uuid}.mobileprovision`);
writeFileSync(f, Buffer.from(content, 'base64'));
out.push(f);
}
return out;
}
function keychainImport(keyFile: string, cerFile: string, keychain: string | null): void {
if (process.platform !== 'darwin') throw new Error('--keychain: macOS only (on other systems keep the key in .storebound/signing)');
const kc = keychain ? ['-k', keychain] : [];
for (const cmd of [['security', 'import', keyFile, ...kc, '-t', 'priv', '-f', 'openssl', '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'], ['security', 'import', cerFile, ...kc, '-t', 'cert']]) {
const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' });
const msg = (r.stdout.toString() + r.stderr.toString()).trim();
if (r.exitCode !== 0 && !/already exists/i.test(msg)) throw new Error(`security import failed: ${msg.slice(0, 300)}`);
}
log(TAG, `key and certificate imported into ${keychain ?? 'the default keychain'}`);
}
export interface SigningState {
bundleId: string; teamId: string | null; registered: boolean;
capabilities: { present: string[]; missing: string[]; extra: string[] };
certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null };
profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[];
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
const sub = a._[0];
if (sub !== 'status' && sub !== 'ensure') throw new Error(USAGE);
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
const s: AppleSigning = config.apple?.signing ?? {};
const certType: CertType = s.certificateType ?? 'DISTRIBUTION';
const profileType: ProfileType = s.profileType ?? 'IOS_APP_STORE';
const dry = a.has('dry');
const json = a.has('json');
const style = s.style ?? 'automatic';
const scope = ensureScope(style);
const c = await ascClient(root, bundleId, config.apple?.appId);
// --- read everything first (read-only)
const ids = [bundleId, ...(s.extraBundleIds ?? [])];
const bundles = new Map<string, J | null>();
for (const id of ids) bundles.set(id, await findBundleId(c, id));
const main = bundles.get(bundleId);
const capPlan = main ? planCapabilities(main.id, main.capabilities ?? [], capabilitySpecs(s, config.games?.gameCenter === true)) : { create: [], present: [], extra: [] };
const certs: ApiCert[] = await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`);
const keys = localKeys(root);
const identities = keychainIdentities();
const hasKey = (x: ApiCert): boolean => {
const content = x.attributes.certificateContent;
if (!content) return false;
const spki = certSpki(content);
return (!!spki && keys.has(spki)) || identities.has(certSha1(content));
};
const choice = pickCertificate(certs, certType, hasKey);
const profiles = new Map<string, { list: ApiProfile[]; plan: ReturnType<typeof planProfile> | null }>();
for (const id of ids) {
const b = bundles.get(id);
if (!b) { profiles.set(id, { list: [], plan: null }); continue; }
const list = await profilesOf(c, b.id, profileType);
profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null });
}
const state: SigningState = {
bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main,
capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra },
certificate: { type: certType, chosen: choice.cert?.attributes.name ?? choice.cert?.attributes.displayName ?? null, why: choice.why,
onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null },
profiles: ids.map(id => {
const p = profiles.get(id)!;
if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' };
const r = p.plan?.reuse;
const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE');
if (style === 'automatic')
return managed
? { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0,
why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'}; renewed with the App ID's current capabilities when they change)` }
: { bundleId: id, name: null, uuid: null, state: 'Xcode-managed', installed: false, why: 'automatic signing: xcodebuild -allowProvisioningUpdates makes it at archive time' };
return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile',
installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' };
}),
};
// storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one)
const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []);
const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse));
const cleanup = (): void => {
if (!unused.length) return;
log(TAG, `${unused.length} provisioning profile(s) storebound made that nothing uses${style === 'automatic' ? ' (automatic signing never needs them)' : ''}: ${unused.map(p => `"${p.attributes.name}"`).join(', ')}`);
log(TAG, 'deleting them is your call; not run by storebound:');
for (const p of unused) log(TAG, ` ${deleteProfileCmd(p)}`);
};
const report = await capabilityReport(c, config, root);
if (sub === 'status') {
if (json) { printJson({ ...state, style, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
log(TAG, `${bundleId}: ${main ? `registered, team ${state.teamId ?? '?'}` : 'NOT registered (storebound apple bundle)'}`);
log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`);
log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`);
for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`);
console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why])));
printCapabilityReport(report, TAG);
cleanup();
done(TAG, 'status read');
return;
}
// --- ensure: plan, show, ask once, then write
if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`);
const missingExtra = ids.filter(id => !bundles.get(id));
if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`);
const needCert = scope.certificates && !choice.cert;
const plan: string[] = [
...capPlan.create.map(r => describeReq(r)),
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
...(!scope.profiles ? [] : ids.flatMap(id => {
const p = profiles.get(id)!.plan;
return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : [];
})),
];
log(TAG, scope.why);
if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit)
warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`);
if (json && dry) { printJson({ ...state, style, scope, plan, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
for (const line of plan) log(TAG, `${dry ? 'would ' : ''}${line}`);
if (!plan.length) log(TAG, 'nothing to create on App Store Connect');
if (dry) {
if (scope.profiles) for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`);
if (capPlan.create.length) log(TAG, `would read the App ID's capabilities back after the write and fail if ${capPlan.create.map(r => (r.body as J).data.attributes.capabilityType).join(' / ')} is not there`);
printCapabilityReport(report, TAG);
cleanup();
done(TAG, `signing checked (dry run): ${plan.length} write(s)`);
return;
}
if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return;
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses);
if (!scope.profiles) {
printCapabilityReport(await capabilityReport(c, config, root), TAG);
cleanup();
if (json) printJson({ ...state, style, scope, plan, done: true });
done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`);
return;
}
let cert = choice.cert;
if (needCert) {
const dir = signingDir(root);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const { key } = newSigningKey();
const pending = join(dir, `pending-${Date.now()}.key`);
writeFileSync(pending, key.export({ type: 'pkcs1', format: 'pem' }).toString(), { mode: 0o600 });
chmodSync(pending, 0o600);
let created: J;
try {
const csr = buildCsr(key, { commonName: `storebound ${config.app.name}`.slice(0, 64) });
created = (await c.asc('POST', '/v1/certificates', certificateReq(certType, csrContent(csr)).body)).data;
} catch (e) {
if (isAmbiguous(e)) {
// the certificate may exist: it is the one whose public key is this pending key's. The key stays on disk either
// way (a later run finds the certificate through it), so no certificate is ever orphaned or made twice.
const mine = spkiOfKey(readFileSync(pending, 'utf8'));
let found: J | undefined;
for (let i = 0; i < 4 && !found; i++) {
if (i) await upsertTiming.sleep(upsertTiming.waitMs * i);
found = (await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`)).find((x: ApiCert) => !!x.attributes.certificateContent && certSpki(x.attributes.certificateContent) === mine);
}
if (!found) throw new Error(`${(e as Error).message}\n\nThe certificate request got no clear answer and App Store Connect does not list a certificate for the new key (yet). The key is kept in ${pending.replace(root + '/', '')}; run \`storebound apple signing ensure\` again in a minute: it finds the certificate through that key instead of making another one.`);
created = found;
log(TAG, 'the certificate request went through (found by its public key)');
} else {
rmSync(pending, { force: true });
const why = explainCertError(e, certType);
throw new Error(why ? `${(e as Error).message}\n\n${why}` : (e as Error).message);
}
}
const keyFile = join(dir, `${created.id}.key`), cerFile = join(dir, `${created.id}.cer`);
renameSync(pending, keyFile);
writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 });
log(TAG, `certificate ${created.attributes.name ?? created.id} created (expires ${String(created.attributes.expirationDate).slice(0, 10)}); key and certificate in .storebound/signing/ (gitignored, back them up)`);
if (a.has('keychain')) keychainImport(keyFile, cerFile, a.get('keychain') === 'true' ? null : a.get('keychain')!);
else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)');
cert = created;
}
for (const id of ids) {
const b = bundles.get(id)!;
const list = profiles.get(id)!.list;
const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
let prof: ApiProfile;
if (p.create) {
// an unclear answer is checked by reading the bundle id's profiles again (one active profile with this
// certificate is reused), never re-sent blindly
const r = (await createOnce<J>({ what: `${id} profile`, tag: TAG,
find: async () => {
const again = planProfile(await profilesOf(c, b.id, profileType), { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
return again.reuse ? { id: again.reuse.id, attributes: (await c.asc('GET', `/v1/profiles/${again.reuse.id}`)).data.attributes } : null;
},
create: async () => (await c.asc('POST', p.create!.path, p.create!.body)).data })).value;
prof = { id: r.id, attributes: r.attributes };
log(TAG, `${id}: profile "${prof.attributes.name}" created (${prof.attributes.uuid})`);
} else {
prof = p.reuse!;
if (!prof.attributes.profileContent) prof = { ...prof, attributes: (await c.asc('GET', `/v1/profiles/${prof.id}`)).data.attributes };
log(TAG, `${id}: ${p.why}`);
}
if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`);
}
cleanup();
if (json) printJson({ ...state, plan, done: true });
done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)');
}
/** After a capability write: read the App ID back (App Store Connect lags a little) and fail, with the API's answers,
* when a wanted capability is still not there. Seen live: an add that "succeeded" while the App ID kept only
* IN_APP_PURCHASE. */
export async function checkCapabilitiesStuck(c: Asc, bundleId: string, want: string[], responses: unknown[], o: { waitMs?: number; timeoutMs?: number } = {}): Promise<void> {
const r = await verifyCapabilities(async () => ((await findBundleId(c, bundleId))?.capabilities as string[] | undefined) ?? [], want,
{ waitMs: o.waitMs ?? upsertTiming.waitMs, timeoutMs: o.timeoutMs, sleep: upsertTiming.sleep, onWait: (m, n) => log(TAG, `read-back ${n}: ${m.join(', ')} not on the App ID yet, checking again`) });
if (!r.missing.length) { log(TAG, `read back: the App ID has ${want.join(', ')}`); return; }
throw new Error(`capability write did not stick: ${bundleId} still lacks ${r.missing.join(', ')} after ${r.reads} read(s) (it has: ${r.have.join(', ') || 'none'}).\n`
+ `API answers to the writes: ${JSON.stringify(responses).slice(0, 1500)}\n`
+ 'Check the bundle id in the config (apple.bundleId), that the API key has the Admin role, and add the capability in '
+ 'developer.apple.com > Certificates, Identifiers & Profiles > Identifiers if the API keeps refusing it; then run storebound apple signing status.');
}