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, 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 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. 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 - **`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, ...). 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 - **`--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. // 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 // 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. // 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 { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir, tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path'; import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr';
import { done, log, printJson, warn } from '../lib/log'; import { done, log, printJson, warn } from '../lib/log';
import { 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, compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs,
type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities, type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities,
} from '../lib/signing'; } from '../lib/signing';
import { isBuildCopyPath } from '../lib/privacy-manifest'; 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 type { ShipConfig } from '../lib/config';
import { textTable } from '../lib/table'; import { textTable } from '../lib/table';
import { type Asc, ascClient, type J } from './asc'; import { type Asc, ascClient, type J } from './asc';
@ -92,21 +95,90 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n
return out; 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> { export async function capabilityReport(c: Asc, config: ShipConfig, root: string): Promise<CapabilityReport> {
const bundleId = config.apple!.bundleId; const bundleId = config.apple!.bundleId;
const b = await findBundleId(c, bundleId); const b = await findBundleId(c, bundleId);
const appId = b ? (b.capabilities as string[]) : null; const appId = b ? (b.capabilities as string[]) : null;
const cfg = capabilitySpecs(config.apple?.signing).map(x => x.type); const cfg = capabilitySpecs(config.apple?.signing).map(x => x.type);
const project = readProjectEntitlements(root); 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 { 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 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}`); 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)'); 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 { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log'; import { done, log, printJson, warn } from '../lib/log';
import { compareBuild, nextBuildNumber, nextPollDelay, parseDuration, WAIT_EXIT } from '../lib/release'; 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 { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
import { BUILD_WAIT_EXIT } from '../lib/build-ops'; import { BUILD_WAIT_EXIT } from '../lib/build-ops';
import { type Asc, ascClient, type J } from './asc'; 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(); } } finally { k.cleanup(); }
} }
/** Every .ipa in the export folder and its per-build subfolders (<version>-<build>/), newest first. */ export { exportedIpas };
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);
}
/** /**
* An IPA whose build number App Store Connect already has (a build, or a finished build upload): an Error (exit 1) * 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 }; 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 /** 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 * 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. */ * 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 out: CapMismatch[] = [];
const has = new Set(i.appId ?? []); const has = new Set(i.appId ?? []);
const projCaps = new Map<string, { file: string; key: string }>(); 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) { if (i.project) {
for (const c of i.config) if (needsEntitlement.has(c) && !projCaps.has(c)) { for (const c of i.config) if (needsEntitlement.has(c) && !projCaps.has(c)) {
const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0]; 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`, // A warning about this checkout only: native changes often live on a release branch (OTA fingerprint), so it
fix: `add ${key} to the app target's .entitlements (Xcode > Signing & Capabilities > + ${c.toLowerCase().replace(/_/g, ' ')}) and set CODE_SIGN_ENTITLEMENTS` }); // 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) 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)` }); 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; 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 } 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. */ /** 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 { export function ipaInfo(ipa: string | Buffer): IpaInfo {
const e = zipEntries(ipa).find(x => /^Payload\/[^/]+\.app\/Info\.plist$/.test(x.name)); 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)'); 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: 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. `apple listing`, ...), which runs anywhere with its own dry run and read-back.
- **Guard:** `dedicatedCommand` (cli/api/gate.ts), `tests/signing-capabilities.test.ts`. - **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 // the live bundleIds?include=bundleIdCapabilities shape (capability id = <bundleIdId>_<TYPE>, settings null); every
// id is a placeholder. // id is a placeholder.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { run as signing } from '../cli/apple/signing'; import { writeFileSync } from 'node:fs';
import { compareCapabilities, entitlementKeys, ensureScope, projectEntitlements, unusedStoreboundProfiles, verifyCapabilities } from '../cli/lib/signing'; 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 { dedicatedCommand, decide } from '../cli/api/gate';
import { upsertTiming } from '../cli/lib/upsert'; import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock'; 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(); 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');
});
});