storebound/cli/lib/signing.ts

378 lines
27 KiB
TypeScript

// Code signing through the App Store Connect API (Certificates, Identifiers & Profiles), pure planning:
// bundle id capabilities, which distribution certificate to use, which provisioning profile to (re)create, where
// profiles are installed, and the ExportOptions.plist for `xcodebuild -exportArchive`. The command is
// cli/apple/signing.ts; requests are JSON:API `Req`s (printed on --dry, sent otherwise).
// API reference: https://developer.apple.com/documentation/appstoreconnectapi (Bundle IDs, Bundle ID Capabilities,
// Certificates, Profiles).
import { createHash, X509Certificate } from 'node:crypto';
import { join } from 'node:path';
import type { Req } from '../apple/jsonapi';
import { one, many } from '../apple/jsonapi';
import type { PlistValue } from './plist';
/** CapabilityType values of the App Store Connect API (BundleIdCapability.capabilityType). */
export const CAPABILITY_TYPES = ['ICLOUD', 'IN_APP_PURCHASE', 'GAME_CENTER', 'PUSH_NOTIFICATIONS', 'WALLET', 'INTER_APP_AUDIO', 'MAPS',
'ASSOCIATED_DOMAINS', 'PERSONAL_VPN', 'APP_GROUPS', 'HEALTHKIT', 'HOMEKIT', 'WIRELESS_ACCESSORY_CONFIGURATION', 'APPLE_PAY', 'DATA_PROTECTION',
'SIRIKIT', 'NETWORK_EXTENSIONS', 'MULTIPATH', 'HOT_SPOT', 'NFC_TAG_READING', 'CLASSKIT', 'AUTOFILL_CREDENTIAL_PROVIDER',
'ACCESS_WIFI_INFORMATION', 'NETWORK_CUSTOM_PROTOCOL', 'COREMEDIA_HLS_LOW_LATENCY', 'SYSTEM_EXTENSION_INSTALL', 'USER_MANAGEMENT',
'APPLE_ID_AUTH'] as const;
export type CapabilityType = typeof CAPABILITY_TYPES[number];
/** Certificate types storebound creates: Apple Distribution (DISTRIBUTION, all platforms), the older iOS Distribution,
* and Developer ID Application (Mac apps outside the App Store; Account Holder only). */
export const CERT_TYPES = ['DISTRIBUTION', 'IOS_DISTRIBUTION', 'DEVELOPER_ID_APPLICATION', 'DEVELOPER_ID_APPLICATION_G2', 'MAC_APP_DISTRIBUTION', 'MAC_INSTALLER_DISTRIBUTION'] as const;
export type CertType = typeof CERT_TYPES[number];
/** Profile types for store builds (Profile.profileType). */
export const PROFILE_TYPES = ['IOS_APP_STORE', 'IOS_APP_ADHOC', 'TVOS_APP_STORE', 'MAC_APP_STORE', 'MAC_APP_DIRECT', 'MAC_CATALYST_APP_STORE'] as const;
export type ProfileType = typeof PROFILE_TYPES[number];
export interface CapabilitySpec { type: CapabilityType; settings?: unknown[] }
export interface AppleSigning {
/** automatic (default): xcodebuild -allowProvisioningUpdates with the API key creates what it needs.
* manual: the certificate and profiles from `storebound apple signing ensure`, named in ExportOptions.plist. */
style?: 'automatic' | 'manual';
/** Team ID (10 characters). Default: the bundle id's seedId from the API. */
teamId?: string;
/** Default DISTRIBUTION (Apple Distribution). */
certificateType?: CertType;
/** Default IOS_APP_STORE. */
profileType?: ProfileType;
/** Capabilities the bundle id must have (Certificates, Identifiers & Profiles), e.g. ['IN_APP_PURCHASE', 'GAME_CENTER']. */
capabilities?: (CapabilityType | CapabilitySpec)[];
/** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */
extraBundleIds?: string[];
/** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project
* (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is
* registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */
extensions?: ExtensionSpec[];
}
export interface ExtensionSpec {
bundleId: string;
/** A label for messages and the bundle id's name on the developer portal. */
name?: string;
/** Default: the capabilities of the target's entitlements file (none when there is none). */
capabilities?: (CapabilityType | CapabilitySpec)[];
/** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */
appGroups?: string[];
}
export function validateSigning(s: AppleSigning | undefined): string[] {
if (!s) return [];
const e: string[] = [];
if (s.style && !['automatic', 'manual'].includes(s.style)) e.push('apple.signing.style: automatic | manual');
if (s.teamId && !/^[A-Z0-9]{10}$/.test(s.teamId)) e.push('apple.signing.teamId: the 10-character Team ID');
if (s.certificateType && !CERT_TYPES.includes(s.certificateType)) e.push(`apple.signing.certificateType: one of ${CERT_TYPES.join(', ')}`);
if (s.profileType && !PROFILE_TYPES.includes(s.profileType)) e.push(`apple.signing.profileType: one of ${PROFILE_TYPES.join(', ')}`);
for (const c of s.capabilities ?? []) {
const t = typeof c === 'string' ? c : c?.type;
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`);
}
for (const x of s.extensions ?? []) {
if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; }
for (const c of x.capabilities ?? []) {
const t = typeof c === 'string' ? c : c?.type;
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`);
}
if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`);
}
for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`);
return e;
}
/** The capabilities the App ID must have: apple.signing.capabilities, plus GAME_CENTER when games.gameCenter is true. */
export const capabilitySpecs = (s: AppleSigning | undefined, gameCenter = false): CapabilitySpec[] => {
const out = (s?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c));
if (gameCenter && !out.some(c => c.type === 'GAME_CENTER')) out.push({ type: 'GAME_CENTER' });
return out;
};
/** Capabilities to add (never removes one: removing can break a shipped feature; extra ones are reported). */
export function planCapabilities(bundleIdId: string, existing: string[], want: CapabilitySpec[]): { create: Req[]; present: string[]; extra: string[] } {
const have = new Set(existing);
const create = want.filter(w => !have.has(w.type)).map((w): Req => ({
method: 'POST', path: '/v1/bundleIdCapabilities', note: `capability ${w.type}`,
body: { data: { type: 'bundleIdCapabilities', attributes: { capabilityType: w.type, ...(w.settings ? { settings: w.settings } : {}) }, relationships: { bundleId: one('bundleIds', bundleIdId) } } },
}));
return { create, present: want.filter(w => have.has(w.type)).map(w => w.type), extra: existing.filter(t => !want.some(w => w.type === t)) };
}
export interface ApiCert { id: string; attributes: { certificateType?: string; certificateContent?: string; expirationDate?: string; displayName?: string; name?: string; serialNumber?: string; activated?: boolean } }
/** SHA-1 of the certificate DER, upper-case hex: what `security find-identity` prints. */
export const certSha1 = (contentB64: string): string => createHash('sha1').update(Buffer.from(contentB64, 'base64')).digest('hex').toUpperCase();
/** SPKI (DER, base64) of a certificate's public key, or null when it does not parse. */
export function certSpki(contentB64: string): string | null {
try { return new X509Certificate(Buffer.from(contentB64, 'base64')).publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); } catch { return null; }
}
export interface CertChoice { cert: ApiCert | null; why: string; usable: ApiCert[]; withoutKey: ApiCert[]; expiring: ApiCert[] }
/** A certificate is usable when it is of the wanted type, active, valid for 30 more days and this machine holds its
* private key (a key file in .storebound/signing or a code signing identity in the keychain). Newest first. */
export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now()): CertChoice {
const ofType = certs.filter(c => c.attributes.certificateType === type && c.attributes.activated !== false);
const valid = ofType.filter(c => Date.parse(c.attributes.expirationDate ?? '') > now);
const expiring = valid.filter(c => Date.parse(c.attributes.expirationDate!) - now < 30 * 86_400_000);
const usable = valid.filter(c => hasKey(c) && !expiring.includes(c)).sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!));
const withoutKey = valid.filter(c => !hasKey(c));
const cert = usable[0] ?? null;
const why = cert ? `using ${cert.attributes.name ?? cert.attributes.displayName ?? type} (expires ${cert.attributes.expirationDate?.slice(0, 10)})`
: withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: a new one is needed (or import the .p12 from the Mac that made it)`
: `no valid ${type} certificate`;
return { cert, why, usable, withoutKey, expiring };
}
/** Apple caps distribution certificates per team (Apple Distribution: 3 per team). */
export const CERT_LIMIT: Partial<Record<CertType, number>> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5, MAC_APP_DISTRIBUTION: 3, MAC_INSTALLER_DISTRIBUTION: 3 };
export function certificateReq(type: CertType, csr: string): Req {
return { method: 'POST', path: '/v1/certificates', note: `create ${type} certificate (key in .storebound/signing, never printed)`,
body: { data: { type: 'certificates', attributes: { certificateType: type, csrContent: csr } } } };
}
/** A 403 when creating a Developer ID certificate: only the Account Holder may create them. Explanation or null. */
export function explainCertError(err: unknown, type: CertType): string | null {
const s = String(err);
if (/DEVELOPER_ID/.test(type) && /-> 403/.test(s))
return 'Developer ID certificates can only be created by the Account Holder of the team. An API key (even Admin) gets 403 here. '
+ 'The Account Holder creates it once in Xcode (Settings > Accounts > Manage Certificates > + > Developer ID Application) or at '
+ 'developer.apple.com > Certificates, then exports the .p12 for this machine. App Store builds need DISTRIBUTION instead.';
if (/-> 409/.test(s) && /maximum|limit|already/i.test(s))
return 'The team has reached its limit of this certificate type. Revoke an unused one at developer.apple.com > Certificates (owner decision: '
+ 'builds signed with it keep working, but its profiles become invalid), or use a certificate whose key is on another Mac (export a .p12 there).';
if (/-> 403/.test(s)) return 'The API key may not manage certificates: it needs the Admin role (Users and Access > Integrations).';
return null;
}
export interface ApiProfile {
id: string; attributes: { name?: string; profileType?: string; profileState?: string; expirationDate?: string; uuid?: string; profileContent?: string };
certIds?: string[];
/** Xcode-managed profile (automatic signing, "iOS Team Store Provisioning Profile: ..."): listed under the bundle id,
* but /v1/profiles/{id} answers 404 for it, so its certificates are unknown and storebound never reuses it. */
managed?: boolean;
}
/** The profiles of one bundle id of `type`, each with its certificate ids. A profile the profiles API does not serve
* (404: Xcode-managed, seen live October 2026) is kept with `managed: true` instead of failing the whole read. */
export async function readProfiles(all: (path: string) => Promise<{ id: string; attributes?: Record<string, unknown> }[]>, bundleIdId: string, type: ProfileType): Promise<ApiProfile[]> {
const ps = await all(`/v1/bundleIds/${bundleIdId}/profiles?limit=200`);
const out: ApiProfile[] = [];
for (const p of ps.filter(p => p.attributes?.profileType === type)) {
const attributes = p.attributes as ApiProfile['attributes'];
try {
const certs = await all(`/v1/profiles/${p.id}/relationships/certificates?limit=200`);
out.push({ id: p.id, attributes, certIds: certs.map(x => x.id) });
} catch (e) {
if (!/-> 404\b/.test((e as Error).message)) throw e;
out.push({ id: p.id, attributes, managed: true });
}
}
return out;
}
export const profileName = (bundleId: string, type: ProfileType, date = new Date()): string =>
`storebound ${bundleId} ${type === 'IOS_APP_STORE' ? 'App Store' : type} ${date.toISOString().slice(0, 16).replace('T', ' ')}`;
export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string }
/** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan {
const now = o.now ?? Date.now();
const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE'
&& Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId))
.sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!));
if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed);
const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed);
const why = [
stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '',
managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '',
].filter(Boolean).join('; ') || `no ${o.type} profile`;
const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why;
return { reuse: null, why: why2, create: {
method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`,
body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type },
relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } },
} };
}
/** Where provisioning profiles are installed: Xcode 16 and later read ~/Library/Developer/Xcode/UserData/Provisioning
* Profiles; older Xcode (and some tools) ~/Library/MobileDevice/Provisioning Profiles. storebound writes both. */
export const profileDirs = (home: string): string[] => [join(home, 'Library/Developer/Xcode/UserData/Provisioning Profiles'), join(home, 'Library/MobileDevice/Provisioning Profiles')];
/** Lines of `security find-identity -v -p codesigning` -> SHA-1 hashes of identities with a private key. */
export function parseIdentities(out: string): { sha1: string; name: string }[] {
return [...out.matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s+"([^"]+)"/gm)].map(m => ({ sha1: m[1]!, name: m[2]! }));
}
export interface ExportOptionsInput { teamId: string; style: 'automatic' | 'manual'; profiles?: Record<string, string>; method?: string; uploadSymbols?: boolean; certificate?: string }
/** ExportOptions.plist for an App Store Connect export (`method` app-store-connect, Xcode 15.3+; "app-store" is the
* deprecated name). destination export: storebound uploads the IPA itself (`apple upload`), with the build number it
* chose, so Xcode must not change it (manageAppVersionAndBuildNumber false). */
export function exportOptions(i: ExportOptionsInput): Record<string, PlistValue> {
const o: Record<string, PlistValue> = {
method: i.method ?? 'app-store-connect', teamID: i.teamId, signingStyle: i.style, destination: 'export',
uploadSymbols: i.uploadSymbols ?? true, manageAppVersionAndBuildNumber: false,
};
if (i.style === 'manual') {
if (!i.profiles || !Object.keys(i.profiles).length) throw new Error('manual signing needs a provisioning profile per bundle id (storebound apple signing ensure)');
o.provisioningProfiles = { ...i.profiles };
o.signingCertificate = i.certificate ?? 'Apple Distribution';
}
return o;
}
// --- capabilities: App ID vs config vs the iOS project's entitlements
/** Entitlement keys (*.entitlements) -> the App ID capability they need. In-App Purchase has no entitlement. */
export const ENTITLEMENT_CAPABILITIES: Record<string, CapabilityType> = {
'aps-environment': 'PUSH_NOTIFICATIONS',
'com.apple.developer.aps-environment': 'PUSH_NOTIFICATIONS',
'com.apple.developer.game-center': 'GAME_CENTER',
'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS',
'com.apple.developer.applesignin': 'APPLE_ID_AUTH',
'com.apple.developer.icloud-container-identifiers': 'ICLOUD',
'com.apple.developer.icloud-services': 'ICLOUD',
'com.apple.developer.ubiquity-kvstore-identifier': 'ICLOUD',
'com.apple.developer.ubiquity-container-identifiers': 'ICLOUD',
'com.apple.security.application-groups': 'APP_GROUPS',
'com.apple.developer.healthkit': 'HEALTHKIT',
'com.apple.developer.homekit': 'HOMEKIT',
'com.apple.developer.in-app-payments': 'APPLE_PAY',
'com.apple.developer.pass-type-identifiers': 'WALLET',
'com.apple.developer.siri': 'SIRIKIT',
'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS',
'com.apple.developer.networking.vpn.api': 'PERSONAL_VPN',
'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING',
'com.apple.developer.networking.wifi-info': 'ACCESS_WIFI_INFORMATION',
'com.apple.developer.default-data-protection': 'DATA_PROTECTION',
'com.apple.developer.ClassKit-environment': 'CLASSKIT',
'com.apple.developer.authentication-services.autofill-credential-provider': 'AUTOFILL_CREDENTIAL_PROVIDER',
'com.apple.developer.networking.multipath': 'MULTIPATH',
'com.apple.developer.networking.HotspotConfiguration': 'HOT_SPOT',
'inter-app-audio': 'INTER_APP_AUDIO',
'com.apple.external-accessory.wireless-configuration': 'WIRELESS_ACCESSORY_CONFIGURATION',
'com.apple.developer.coremedia.hls.low-latency': 'COREMEDIA_HLS_LOW_LATENCY',
'com.apple.developer.user-management': 'USER_MANAGEMENT',
};
/** Top-level entitlement keys switched on in an .entitlements plist (a key whose value is <false/> is off). */
export function entitlementKeys(xml: string): string[] {
const body = xml.replace(/<!--[\s\S]*?-->/g, '');
return [...body.matchAll(/<key>\s*([^<]+?)\s*<\/key>\s*(<false\s*\/>)?/g)].filter(m => !m[2]).map(m => m[1]!);
}
export interface ProjectEntitlements { file: string; caps: { cap: CapabilityType; key: string }[]; referenced: boolean | null }
export function projectEntitlements(file: string, xml: string, referenced: boolean | null): ProjectEntitlements {
const caps: { cap: CapabilityType; key: string }[] = [];
for (const key of entitlementKeys(xml)) {
const cap = ENTITLEMENT_CAPABILITIES[key];
if (cap && !caps.some(c => c.cap === cap)) caps.push({ cap, key });
}
return { file, caps, referenced };
}
export interface CapMismatch { cap: string; missingIn: 'app-id' | 'config' | 'project' | 'build'; msg: string; fix: string }
/** Compares the three places a capability has to be: the App ID (Certificates, Identifiers & Profiles), the config's
* apple.signing.capabilities and the iOS project's entitlements. `appId` null = not read; `project` null = no iOS
* project found (not compared). Names exactly what is missing where. */
export interface CheckoutInfo { branch: string | null; /** other local branches / worktrees with an .entitlements file */ elsewhere: string[] }
export interface BuildEntitlements { number: string; file: string; caps: { cap: CapabilityType; key: string }[]; via: 'codesign' | 'mach-o' }
/** The capability-relevant entitlement keys of a built app, as projectEntitlements. */
export const buildEntitlements = (number: string, file: string, xml: string, via: BuildEntitlements['via']): BuildEntitlements =>
({ number, file, caps: projectEntitlements(file, xml, null).caps, via });
/** The embedded entitlements plists of a Mach-O (thin or fat): the code signature's entitlements blob (magic
* 0xfade7171, big-endian length, XML plist). Used when codesign is not available. */
export function machoEntitlements(bin: Buffer): string[] {
const out: string[] = [];
const magic = Buffer.from([0xfa, 0xde, 0x71, 0x71]);
for (let i = bin.indexOf(magic); i >= 0; i = bin.indexOf(magic, i + 4)) {
if (i + 8 > bin.length) break;
const len = bin.readUInt32BE(i + 4);
if (len < 8 || i + len > bin.length) continue;
const xml = bin.subarray(i + 8, i + len).toString('utf8');
if (/^\s*(<\?xml|<plist|<!DOCTYPE)/.test(xml) && !out.includes(xml)) out.push(xml);
}
return out;
}
export const NO_BUILD_API = 'the App Store Connect API does not expose a build\'s entitlements, and no local IPA (build/ios/export) was found to read them from';
export function compareCapabilities(i: { appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; checkout?: CheckoutInfo | null; build?: BuildEntitlements | null }): CapMismatch[] {
const out: CapMismatch[] = [];
const has = new Set(i.appId ?? []);
const projCaps = new Map<string, { file: string; key: string }>();
for (const p of i.project ?? []) for (const c of p.caps) if (!projCaps.has(c.cap)) projCaps.set(c.cap, { file: p.file, key: c.key });
const needsEntitlement = new Set<string>(Object.values(ENTITLEMENT_CAPABILITIES));
if (i.appId) {
for (const c of i.config) if (!has.has(c))
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in apple.signing.capabilities but not on the App ID`, fix: 'storebound apple signing ensure --dry, then without --dry' });
for (const [c, w] of projCaps) if (!has.has(c) && !i.config.includes(c))
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in ${w.file} (${w.key}) but not on the App ID: the archive's profile will not allow it`, fix: `add '${c}' to apple.signing.capabilities, then storebound apple signing ensure` });
}
for (const [c, w] of projCaps) if (!i.config.includes(c))
out.push({ cap: c, missingIn: 'config', msg: `${c} is in ${w.file} (${w.key}) but not in apple.signing.capabilities`, fix: `add '${c}' to apple.signing.capabilities so signing ensure keeps the App ID in step` });
if (i.project) {
for (const c of i.config) if (needsEntitlement.has(c) && !projCaps.has(c)) {
const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0];
// A warning about this checkout only: native changes often live on a release branch (OTA fingerprint), so it
// never claims the build lacks them. The built IPA, when there is one, is checked below.
const where = i.checkout?.branch ? ` on branch ${i.checkout.branch}` : '';
const other = i.checkout?.elsewhere.length ? `; an .entitlements file is on ${i.checkout.elsewhere.join(', ')}` : '';
const built = i.build ? (i.build.caps.some(x => x.cap === c) ? `; uploaded build ${i.build.number} has it` : '') : `; ${NO_BUILD_API}`;
out.push({ cap: c, missingIn: 'project', msg: `${c}: this checkout's iOS project${where} has no entitlements for ${key} (built from another branch?)${other}${built}`,
fix: i.checkout?.elsewhere.length ? `nothing if you build from ${i.checkout.elsewhere[0]}; otherwise add ${key} to the app target's .entitlements and set CODE_SIGN_ENTITLEMENTS`
: `if this branch is the one you build: add ${key} to the app target's .entitlements (Xcode > Signing & Capabilities > + ${c.toLowerCase().replace(/_/g, ' ')}) and set CODE_SIGN_ENTITLEMENTS` });
}
for (const p of i.project) if (p.referenced === false && p.caps.length)
out.push({ cap: p.caps.map(c => c.cap).join(', '), missingIn: 'project', msg: `${p.file} is not referenced by CODE_SIGN_ENTITLEMENTS in the Xcode project: builds ignore it`, fix: `set CODE_SIGN_ENTITLEMENTS = ${p.file.split('/').slice(-2).join('/')} for the app target (Release and Debug)` });
}
if (i.build) {
const b = i.build, bc = new Set(b.caps.map(x => x.cap as string));
for (const c of i.config) if (needsEntitlement.has(c) && !bc.has(c)) {
const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0];
out.push({ cap: c, missingIn: 'build', msg: `${c} is in apple.signing.capabilities but uploaded build ${b.number} (${b.file}) has no ${key} entitlement`, fix: `build from the branch that has the entitlements, or add ${key} to the app target's .entitlements, then a new build` });
}
if (i.appId) for (const x of b.caps) if (!has.has(x.cap))
out.push({ cap: x.cap, missingIn: 'app-id', msg: `uploaded build ${b.number} has ${x.key} but ${x.cap} is not on the App ID`, fix: `add '${x.cap}' to apple.signing.capabilities, then storebound apple signing ensure` });
}
return out;
}
/** Reads the App ID's capabilities until every wanted one is there (App Store Connect lags right after a write) or the
* time is up. `missing` empty = all there. */
export async function verifyCapabilities(read: () => Promise<string[]>, want: string[], o: { tries?: number; waitMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise<void>; onWait?: (missing: string[], n: number) => void } = {}): Promise<{ have: string[]; missing: string[]; reads: number }> {
const tries = o.tries ?? 5, waitMs = o.waitMs ?? 3000, deadline = Date.now() + (o.timeoutMs ?? 45_000);
const sleep = o.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
let have: string[] = [], missing = want, reads = 0;
for (let n = 0; n < tries; n++) {
if (n) { o.onWait?.(missing, n); if (Date.now() + waitMs * n > deadline) break; await sleep(waitMs * n); }
have = await read(); reads++;
missing = want.filter(w => !have.includes(w));
if (!missing.length) break;
}
return { have, missing, reads };
}
/** Provisioning profiles storebound made ("storebound <bundle id> ..."), not in `inUse`: unused leftovers whose
* deletion is the owner's call. */
export const unusedStoreboundProfiles = (list: ApiProfile[], inUse: Set<string>): ApiProfile[] =>
list.filter(p => /^storebound /.test(p.attributes.name ?? '') && !inUse.has(p.id));
export const deleteProfileCmd = (p: ApiProfile): string => `storebound api apple profiles_deleteInstance --param id=${p.id} --yes # "${p.attributes.name}" (in a terminal; asks you to type the operation id)`;
/** What `signing ensure` writes: automatic signing only adds capabilities (Xcode owns certificates and profiles). */
export function ensureScope(style: 'automatic' | 'manual' | undefined): { certificates: boolean; profiles: boolean; why: string } {
return (style ?? 'automatic') === 'automatic'
? { certificates: false, profiles: false, why: 'automatic signing: ensure only keeps the App ID and its capabilities in step; certificates and provisioning profiles are Xcode\'s (xcodebuild -allowProvisioningUpdates with the API key makes them at archive time)' }
: { certificates: true, profiles: true, why: 'manual signing: ensure also keeps the distribution certificate and an App Store profile per bundle id' };
}