From 2b07f56673380d76a74d44e41e86ef4ff05eb58c Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 19:17:45 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 5 ++ cli/apple/signing.ts | 90 +++++++++++++++++++++++++++--- cli/apple/upload.ts | 14 +---- cli/lib/signing.ts | 47 ++++++++++++++-- cli/lib/xcode.ts | 12 ++++ skills/store-pitfalls/SKILL.md | 12 ++++ tests/signing-capabilities.test.ts | 77 ++++++++++++++++++++++++- 7 files changed, 230 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac3021b..9358c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index 90fa54a..63ca35d 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -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(); + 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; + 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 && / { 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)'); } diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 7d1b10b..72ff2f7 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -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 (-/), 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) diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 2bd23d1..6e598a1 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -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|(); @@ -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; } diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index 6fcde14..c37e47f 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -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/Info.plist of an IPA. */ +/** Every .ipa in the export folder and its per-build subfolders (-/), 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/Info.plist)'); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 5366ce6..10424ae 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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). diff --git a/tests/signing-capabilities.test.ts b/tests/signing-capabilities.test.ts index 868a545..1956270 100644 --- a/tests/signing-capabilities.test.ts +++ b/tests/signing-capabilities.test.ts @@ -4,8 +4,13 @@ // the live bundleIds?include=bundleIdCapabilities shape (capability id = _, 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(/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(/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'); + }); +});