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

@ -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)');