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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@
|
|||
// the live bundleIds?include=bundleIdCapabilities shape (capability id = <bundleIdId>_<TYPE>, 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(/<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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue