apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests

This commit is contained in:
CRTkafa 2026-10-14 19:10:01 +08:00
parent 357319a775
commit daae3715bf
12 changed files with 463 additions and 23 deletions

View file

@ -199,3 +199,114 @@ export function exportOptions(i: ExportOptionsInput): Record<string, PlistValue>
}
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'; 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 function compareCapabilities(i: { appId: string[] | null; config: string[]; project: ProjectEntitlements[] | 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];
out.push({ cap: c, missingIn: 'project', msg: `${c} is in apple.signing.capabilities but no .entitlements file of the iOS project has ${key}: builds ship without it`,
fix: `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)` });
}
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' };
}

View file

@ -59,6 +59,8 @@ export interface AppleSnapshot {
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
expectedProducts: string[];
privacyUrl: UrlCheck;
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
capabilityMismatches?: { msg: string; fix: string }[];
/** Local renders (Apple sizes only), checked before upload. Optional: validate fills it when a renders dir exists. */
renders?: RendersCheck;
/** Auto-renewable subscriptions on App Store Connect (validate reads them when the config has subscriptions). */
@ -218,6 +220,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
f.push(...appleSubscriptionRules(s));
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);
if (s.capabilityMismatches && !s.capabilityMismatches.length) add('ok', 'capabilities', 'capabilities match (App ID, config, project entitlements)');
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
f.push(...rendersFindings('apple', s.renders));