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
|
|
@ -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