capability check: a checkout without .entitlements is a warning about this checkout only (built from another branch?), naming the current branch and the local branches / worktrees that have an .entitlements file, never a claim about the build; the newest built IPA (highest build number) is read (codesign -d --entitlements on macOS, else the Mach-O signature's entitlements blob) and compared as 'uploaded build N'; without one the output says the App Store Connect API does not expose a build's entitlements; exportedIpas moved to cli/lib/xcode.ts; pitfall 67; tests

This commit is contained in:
CRTkafa 2026-10-14 19:17:45 +08:00
parent daae3715bf
commit 2b07f56673
7 changed files with 230 additions and 27 deletions

View file

@ -12,6 +12,11 @@
compare the App ID, `apple.signing.capabilities` and the iOS project's `.entitlements` (push, Game Center,
associated domains, Sign in with Apple, iCloud, app groups, ...; also an entitlements file not set in
CODE_SIGN_ENTITLEMENTS) and name what is missing where.
- **No false alarm for entitlements on another branch.** A checkout without `.entitlements` is a warning about this
checkout only ("built from another branch?"), naming the current branch and the local branches / worktrees that
have one; it never claims the build lacks them. With a built IPA in build/ios/export, its real entitlements
(codesign, or the Mach-O signature when codesign is missing) are compared as "uploaded build N"; without one the
output says the App Store Connect API does not expose a build's entitlements.
- **`storebound api` writes outside a terminal** say that they need an interactive terminal by design and name the
dedicated command for common operations (bundle id capabilities: `apple signing ensure`, IAPs, listing, ...).
- **`--json` prints only JSON on stdout.** Every command run with `--json` sends its log, step and table lines to

View file

@ -11,20 +11,23 @@
// 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, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, relative } from 'node:path';
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 CapMismatch, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
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 { scanXcode } from '../lib/xcode';
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';
@ -92,21 +95,90 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n
return out;
}
export interface CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; mismatches: CapMismatch[] }
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; }
};
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements. */
/** 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).map(x => x.type);
const project = readProjectEntitlements(root);
return { bundleId, appId, config: cfg, project, mismatches: compareCapabilities({ appId, config: cfg, project }) };
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';
log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj}`);
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)');
}

View file

@ -21,7 +21,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release';
import { altoolArgs, buildUploadReqs, ipaInfo, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
import { altoolArgs, buildUploadReqs, exportedIpas, ipaInfo, showCmd, UPLOAD_DONE, uploadProblems } from '../lib/xcode';
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
import { BUILD_WAIT_EXIT } from '../lib/build-ops';
import { type Asc, ascClient, type J } from './asc';
@ -197,17 +197,7 @@ async function altoolUpload(root: string, ipa: string, deadline: number, o: Hear
} finally { k.cleanup(); }
}
/** Every .ipa in the export folder and its per-build subfolders (<version>-<build>/), newest first. */
export function exportedIpas(dir: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const f of readdirSync(dir)) {
const p = join(dir, f);
if (f.endsWith('.ipa')) out.push(p);
else if (statSync(p).isDirectory()) for (const g of readdirSync(p)) if (g.endsWith('.ipa')) out.push(join(p, g));
}
return out.sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs);
}
export { exportedIpas };
/**
* An IPA whose build number App Store Connect already has (a build, or a finished build upload): an Error (exit 1)

View file

@ -251,12 +251,36 @@ export function projectEntitlements(file: string, xml: string, referenced: boole
return { file, caps, referenced };
}
export interface CapMismatch { cap: string; missingIn: 'app-id' | 'config' | 'project'; msg: string; fix: string }
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 function compareCapabilities(i: { appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null }): CapMismatch[] {
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 }>();
@ -273,12 +297,27 @@ export function compareCapabilities(i: { appId: string[] | null; config: string[
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` });
// 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;
}

View file

@ -150,6 +150,18 @@ export const showCmd = (argv: string[], auth: AscAuth | null): string =>
export interface IpaInfo { bundleId: string; version: string; build: string; minOs?: string; name?: string; platform?: string }
/** Bundle id, CFBundleShortVersionString and CFBundleVersion from Payload/<App>.app/Info.plist of an IPA. */
/** Every .ipa in the export folder and its per-build subfolders (<version>-<build>/), newest first. */
export function exportedIpas(dir: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const f of readdirSync(dir)) {
const p = join(dir, f);
if (f.endsWith('.ipa')) out.push(p);
else if (statSync(p).isDirectory()) for (const g of readdirSync(p)) if (g.endsWith('.ipa')) out.push(join(p, g));
}
return out.sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs);
}
export function ipaInfo(ipa: string | Buffer): IpaInfo {
const e = zipEntries(ipa).find(x => /^Payload\/[^/]+\.app\/Info\.plist$/.test(x.name));
if (!e) throw new Error('not an iOS app archive (no Payload/<App>.app/Info.plist)');

View file

@ -767,3 +767,15 @@ that fails without the fix (see CONTRIBUTING.md).
dedicated command the message names (bundle id capabilities: `apple signing ensure`; IAPs: `apple iap`; listing:
`apple listing`, ...), which runs anywhere with its own dry run and read-back.
- **Guard:** `dedicatedCommand` (cli/api/gate.ts), `tests/signing-capabilities.test.ts`.
## 67. "builds ship without push / Game Center" while the uploaded build had both
- **Do:** a checkout without `.entitlements` says nothing about the build: native changes may live on a release
branch on purpose (OTA fingerprint on main). The comparison warns only about this checkout ("built from another
branch?"), names the current branch and the local branches / worktrees that have an `.entitlements` file, and is
never a blocker. The real answer is the built IPA: with one in build/ios/export (highest build number, newest file)
storebound reads its entitlements (`codesign -d --entitlements :-` on macOS, else the Mach-O signature blob) and
compares those, labelled "uploaded build N". The App Store Connect API does not expose a build's entitlements, so
without a local IPA it says that instead of guessing.
- **Guard:** `checkoutInfo`, `builtIpaEntitlements` (cli/apple/signing.ts), `machoEntitlements`,
`compareCapabilities` (cli/lib/signing.ts), `tests/signing-capabilities.test.ts` (entitlements only on another
branch; IPAs with entitlements).

View file

@ -4,8 +4,13 @@
// the live bundleIds?include=bundleIdCapabilities shape (capability id = <bundleIdId>_<TYPE>, settings null); every
// id is a placeholder.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { run as signing } from '../cli/apple/signing';
import { compareCapabilities, entitlementKeys, ensureScope, projectEntitlements, unusedStoreboundProfiles, verifyCapabilities } from '../cli/lib/signing';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { ascClient } from '../cli/apple/asc';
import { capabilityReport, printCapabilityReport, run as signing } from '../cli/apple/signing';
import { buildEntitlements, compareCapabilities, entitlementKeys, ensureScope, machoEntitlements, projectEntitlements, unusedStoreboundProfiles, verifyCapabilities } from '../cli/lib/signing';
import { toXmlPlist } from '../cli/lib/plist';
import { makeZip } from './build-fixtures';
import { dedicatedCommand, decide } from '../cli/api/gate';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
@ -141,3 +146,71 @@ describe('storebound api writes outside a terminal', () => {
expect(dedicatedCommand('apple', 'apps_getInstance')).toBeNull();
});
});
// STAR DASH (October 2026): main keeps native changes off on purpose (OTA fingerprint); the entitlements live on a
// release branch and the uploaded build has them. The checkout warning must say so, never claim the build lacks them,
// and the built IPA's real entitlements are compared when one exists.
describe('entitlements on another branch, and the built IPA', () => {
const git = (root: string, ...args: string[]) => {
const r = Bun.spawnSync(['git', '-C', root, '-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', ...args], { stdout: 'pipe', stderr: 'pipe' });
if (!r.success) throw new Error(r.stderr.toString());
};
const caps = ['IN_APP_PURCHASE' as const, 'PUSH_NOTIFICATIONS' as const, 'GAME_CENTER' as const];
const cfg = { ...BASE, apple: { ...BASE.apple!, signing: { capabilities: caps } } };
const appIdRoutes = () => [{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(caps) }];
const PROD = ENT.replace('development', 'production');
// a fake Mach-O with an embedded entitlements blob (0xfade7171, big-endian length, XML)
const machO = (xml: string) => {
const body = Buffer.from(xml), head = Buffer.alloc(8);
head.writeUInt32BE(0xfade7171, 0); head.writeUInt32BE(body.length + 8, 4);
return Buffer.concat([Buffer.from([0xcf, 0xfa, 0xed, 0xfe]), Buffer.alloc(60), head, body, Buffer.alloc(16)]);
};
const info = (build: string) => toXmlPlist({ CFBundleIdentifier: 'com.example.notes', CFBundleShortVersionString: '1.0.1', CFBundleVersion: build, CFBundleExecutable: 'App' });
const ipa = (build: string, xml: string | null) => makeZip([
{ name: 'Payload/App.app/Info.plist', data: info(build) },
...(xml === null ? [] : [{ name: 'Payload/App.app/App', data: machO(xml) }]),
]);
test('machoEntitlements reads the blob', () => {
expect(machoEntitlements(machO(PROD))).toEqual([PROD]);
expect(machoEntitlements(Buffer.from('no signature here'))).toEqual([]);
});
test('no entitlements on this branch: a checkout warning naming the branch and the one that has them, never a build claim', async () => {
ascEnv();
const p = project(cfg, { 'ios/App/App.xcodeproj/project.pbxproj': PBX(false), 'ios/App/App/AppDelegate.swift': '' });
git(p.root, 'init', '-q', '-b', 'main'); git(p.root, 'add', '-A'); git(p.root, 'commit', '-qm', 'main');
git(p.root, 'checkout', '-qb', 'release/1.0.1');
writeFileSync(join(p.root, 'ios/App/App/App.entitlements'), PROD, { flag: 'w' });
git(p.root, 'add', '-A'); git(p.root, 'commit', '-qm', 'native'); git(p.root, 'checkout', '-q', 'main');
mockFetch(appIdRoutes());
const r = await capabilityReport(await ascClient(p.root, "com.example.notes"), p.loaded.config, p.root);
expect(r.checkout).toMatchObject({ branch: 'main' });
const push = r.mismatches.find(m => m.cap === 'PUSH_NOTIFICATIONS')!;
expect(push.msg).toContain("this checkout's iOS project on branch main has no entitlements for aps-environment (built from another branch?)");
expect(push.msg).toContain('branch release/1.0.1 (ios/App/App/App.entitlements)');
expect(push.msg).toContain('does not expose a build');
expect(push.msg).not.toContain('ship without');
});
test('a built IPA: its real entitlements are compared and labelled with its build number', async () => {
ascEnv();
const p = project(cfg, {
'ios/App/App.xcodeproj/project.pbxproj': PBX(false),
'build/ios/export/old/App.ipa': ipa('6', ENT.replace(/<key>com.apple.developer.game-center[^\n]*\n/, '')),
'build/ios/export/App.ipa': ipa('7', PROD),
'build/ios/export/broken.ipa': ipa('5', null),
});
mockFetch(appIdRoutes());
const r = await capabilityReport(await ascClient(p.root, "com.example.notes"), p.loaded.config, p.root);
expect(r.build).toMatchObject({ number: '7', file: 'build/ios/export/App.ipa' });
expect(r.build!.caps.map(c => c.cap)).toEqual(['PUSH_NOTIFICATIONS', 'GAME_CENTER']);
expect(r.mismatches.filter(m => m.missingIn === 'build')).toEqual([]);
expect(r.mismatches.find(m => m.cap === 'GAME_CENTER')!.msg).toContain('uploaded build 7 has it');
// the build without game-center is flagged as the build, by number
const only6 = compareCapabilities({ appId: caps, config: caps, project: [], build: buildEntitlements('6', 'x.ipa', ENT.replace(/<key>com.apple.developer.game-center[^\n]*\n/, ''), 'mach-o') });
expect(only6.find(m => m.missingIn === 'build')!.msg).toContain('uploaded build 6 (x.ipa) has no com.apple.developer.game-center');
const out = await capture(async () => printCapabilityReport(r, 'apple'));
expect(out.out + out.err).toContain('uploaded build 7 (build/ios/export/App.ipa');
});
});