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)