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:
parent
daae3715bf
commit
2b07f56673
7 changed files with 230 additions and 27 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue