apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests
This commit is contained in:
parent
357319a775
commit
daae3715bf
12 changed files with 463 additions and 23 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **`apple signing ensure` with automatic signing creates no profiles or certificates.** With `signing.style:
|
||||
automatic` (the default) it only adds missing capabilities to the App ID; Xcode owns certificates and profiles, and
|
||||
the dry run says so. Provisioning profiles storebound made that nothing uses are listed by name with the exact
|
||||
delete command (never run by storebound).
|
||||
- **Capability writes are read back.** After adding a capability, `ensure` reads the App ID again (a few tries,
|
||||
about 45 s at most) and fails with the API's answers when one is missing, instead of reporting success.
|
||||
- **Capabilities compared in three places.** `apple status`, `apple signing status` / `ensure --dry` and `validate`
|
||||
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.
|
||||
- **`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
|
||||
stderr (one place: `withJsonOutput` in cli/lib/log.ts, wrapped around every command); the last commands that printed
|
||||
JSON with console.log use `printJson`. A test runs every command that reads `--json` against the fake stores and
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|||
import { resolve } from 'node:path';
|
||||
import type { Args } from './lib/args';
|
||||
import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client';
|
||||
import { decide, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
|
||||
import { decide, dedicatedCommand, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
|
||||
import { APPLE_SPEC } from './api/apple.gen';
|
||||
import { PLAY_APIS } from './api/play.gen';
|
||||
import { WRAPPED } from './api/wrapped.gen';
|
||||
|
|
@ -147,7 +147,9 @@ export async function run(args: Args): Promise<void> {
|
|||
});
|
||||
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
|
||||
if (decision.action === 'dry') {
|
||||
if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
|
||||
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
|
||||
if (instead) console.error(`api: ${op.id} has a dedicated storebound command that runs without a terminal: ${instead}`);
|
||||
if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, ...(instead ? { instead } : {}), request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
|
||||
else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,28 @@ export function decide(g: GateInput): Decision {
|
|||
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
|
||||
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
|
||||
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
|
||||
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: writes through `storebound api` are only sent from one' };
|
||||
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: by design, writes through `storebound api` are sent only from an interactive terminal (--yes alone is not enough)' };
|
||||
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
|
||||
}
|
||||
|
||||
/** storebound commands that do the common writes of a resource the safe way (dry run, read-back, idempotent):
|
||||
* named when a raw `api` write is held, so an agent uses them instead of a terminal. First match wins. */
|
||||
const DEDICATED: [Store, RegExp, string][] = [
|
||||
['apple', /^bundleIdCapabilities_/, 'storebound apple signing ensure --dry, then without --dry (adds apple.signing.capabilities to the App ID and reads them back)'],
|
||||
['apple', /^bundleIds_/, 'storebound apple bundle --dry, then without --dry'],
|
||||
['apple', /^(certificates|profiles)_/, 'storebound apple signing ensure (manual signing; automatic signing leaves certificates and profiles to Xcode)'],
|
||||
['apple', /^devices_/, 'storebound apple adhoc register --dry (adhoc-distribution skill)'],
|
||||
['apple', /^(inAppPurchases|inAppPurchaseLocalizations|inAppPurchasePriceSchedules)/, 'storebound apple iap --dry'],
|
||||
['apple', /^(subscriptions|subscriptionGroups|subscriptionLocalizations|subscriptionPrices)/, 'storebound apple subscriptions --dry'],
|
||||
['apple', /^(appStoreVersionLocalizations|appInfoLocalizations)_/, 'storebound apple listing --dry'],
|
||||
['apple', /^(appScreenshotSets|appScreenshots)_/, 'storebound apple screenshots --dry'],
|
||||
['apple', /^betaGroups_|^betaTesters_/, 'storebound apple testflight --dry'],
|
||||
['play', /\.(listings|details)\./, 'storebound play listing --dry'],
|
||||
['play', /\.images\./, 'storebound play images --dry'],
|
||||
['play', /\.(inappproducts|onetimeproducts)\./, 'storebound play products --dry'],
|
||||
['play', /\.subscriptions\./, 'storebound play subscriptions --dry'],
|
||||
];
|
||||
|
||||
export function dedicatedCommand(store: Store, opId: string): string | null {
|
||||
return DEDICATED.find(([s, re]) => s === store && re.test(opId))?.[2] ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,29 @@
|
|||
// storebound apple signing status [--json] read-only: capabilities, certificates (with / without a
|
||||
// private key on this machine), profiles, what is installed
|
||||
// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]
|
||||
// adds missing capabilities (apple.signing.capabilities), creates a distribution certificate when this machine
|
||||
// adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick).
|
||||
// style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also
|
||||
// creates a distribution certificate when this machine
|
||||
// holds no key for any valid one (RSA 2048 key generated here, kept 0600 in .storebound/signing/, never printed;
|
||||
// --keychain also imports key + certificate into the login keychain or the given one), creates or reuses an
|
||||
// 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, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join, relative } 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, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
|
||||
explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, readProfiles,
|
||||
type ApiCert, type ApiProfile, type AppleSigning, type CapMismatch, 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 type { ShipConfig } from '../lib/config';
|
||||
import { textTable } from '../lib/table';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { describeReq, execute, type Req } from './jsonapi';
|
||||
|
|
@ -57,6 +63,54 @@ export async function findBundleId(c: Asc, identifier: string): Promise<J | null
|
|||
return hit;
|
||||
}
|
||||
|
||||
/** The iOS project's .entitlements files (next to each .xcodeproj, build copies skipped) with the capabilities they
|
||||
* need and whether the project references them (CODE_SIGN_ENTITLEMENTS). null when there is no Xcode project. */
|
||||
export function readProjectEntitlements(root: string): ProjectEntitlements[] | null {
|
||||
const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p));
|
||||
if (!projects.length) return null;
|
||||
const out: ProjectEntitlements[] = [];
|
||||
for (const proj of projects) {
|
||||
let pbx = '';
|
||||
try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { /* unreadable */ }
|
||||
const refs = [...pbx.matchAll(/CODE_SIGN_ENTITLEMENTS\s*=\s*"?([^";]+)"?;/g)].map(m => m[1]!.trim().replace(/^\$\(SRCROOT\)\//, ''));
|
||||
const walk = (dir: string, d: number): void => {
|
||||
let names: string[] = [];
|
||||
try { names = readdirSync(dir); } catch { return; }
|
||||
for (const n of names) {
|
||||
const f = join(dir, n);
|
||||
if (n.endsWith('.entitlements')) {
|
||||
const rel = relative(root, f);
|
||||
if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue;
|
||||
out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => f.endsWith('/' + r)) : null));
|
||||
} else if (d > 0 && !n.startsWith('.') && !['node_modules', 'Pods', 'build', 'DerivedData'].includes(n) && !n.endsWith('.xcodeproj')) {
|
||||
try { if (statSync(f).isDirectory()) walk(f, d - 1); } catch { /* unreadable */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dirname(join(root, proj)), 3);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; mismatches: CapMismatch[] }
|
||||
|
||||
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements. */
|
||||
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 }) };
|
||||
}
|
||||
|
||||
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}`);
|
||||
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)');
|
||||
}
|
||||
|
||||
const profilesOf = (c: Asc, bundleIdId: string, type: ProfileType): Promise<ApiProfile[]> => readProfiles(p => c.all(p), bundleIdId, type);
|
||||
|
||||
const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []);
|
||||
|
|
@ -103,6 +157,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const profileType: ProfileType = s.profileType ?? 'IOS_APP_STORE';
|
||||
const dry = a.has('dry');
|
||||
const json = a.has('json');
|
||||
const style = s.style ?? 'automatic';
|
||||
const scope = ensureScope(style);
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
|
||||
// --- read everything first (read-only)
|
||||
|
|
@ -138,21 +194,36 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' };
|
||||
const r = p.plan?.reuse;
|
||||
const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE');
|
||||
if (!r && managed && (s.style ?? 'automatic') === 'automatic')
|
||||
return { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0,
|
||||
why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'})` };
|
||||
if (style === 'automatic')
|
||||
return managed
|
||||
? { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0,
|
||||
why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'}; renewed with the App ID's current capabilities when they change)` }
|
||||
: { bundleId: id, name: null, uuid: null, state: 'Xcode-managed', installed: false, why: 'automatic signing: xcodebuild -allowProvisioningUpdates makes it at archive time' };
|
||||
return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile',
|
||||
installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' };
|
||||
}),
|
||||
};
|
||||
|
||||
// storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one)
|
||||
const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []);
|
||||
const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse));
|
||||
const cleanup = (): void => {
|
||||
if (!unused.length) return;
|
||||
log(TAG, `${unused.length} provisioning profile(s) storebound made that nothing uses${style === 'automatic' ? ' (automatic signing never needs them)' : ''}: ${unused.map(p => `"${p.attributes.name}"`).join(', ')}`);
|
||||
log(TAG, 'deleting them is your call; not run by storebound:');
|
||||
for (const p of unused) log(TAG, ` ${deleteProfileCmd(p)}`);
|
||||
};
|
||||
const report = await capabilityReport(c, config, root);
|
||||
|
||||
if (sub === 'status') {
|
||||
if (json) { printJson(state); return; }
|
||||
if (json) { printJson({ ...state, style, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
|
||||
log(TAG, `${bundleId}: ${main ? `registered, team ${state.teamId ?? '?'}` : 'NOT registered (storebound apple bundle)'}`);
|
||||
log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`);
|
||||
log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`);
|
||||
for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`);
|
||||
console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why])));
|
||||
printCapabilityReport(report, TAG);
|
||||
cleanup();
|
||||
done(TAG, 'status read');
|
||||
return;
|
||||
}
|
||||
|
|
@ -161,28 +232,40 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`);
|
||||
const missingExtra = ids.filter(id => !bundles.get(id));
|
||||
if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`);
|
||||
const needCert = !choice.cert;
|
||||
const needCert = scope.certificates && !choice.cert;
|
||||
const plan: string[] = [
|
||||
...capPlan.create.map(r => describeReq(r)),
|
||||
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
|
||||
...ids.flatMap(id => {
|
||||
...(!scope.profiles ? [] : ids.flatMap(id => {
|
||||
const p = profiles.get(id)!.plan;
|
||||
return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : [];
|
||||
}),
|
||||
})),
|
||||
];
|
||||
log(TAG, scope.why);
|
||||
if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit)
|
||||
warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`);
|
||||
if (json && dry) { printJson({ ...state, plan }); return; }
|
||||
if (json && dry) { printJson({ ...state, style, scope, plan, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
|
||||
for (const line of plan) log(TAG, `${dry ? 'would ' : ''}${line}`);
|
||||
if (!plan.length) log(TAG, 'nothing to create on App Store Connect');
|
||||
if (dry) {
|
||||
for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`);
|
||||
if (scope.profiles) for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`);
|
||||
if (capPlan.create.length) log(TAG, `would read the App ID's capabilities back after the write and fail if ${capPlan.create.map(r => (r.body as J).data.attributes.capabilityType).join(' / ')} is not there`);
|
||||
printCapabilityReport(report, TAG);
|
||||
cleanup();
|
||||
done(TAG, `signing checked (dry run): ${plan.length} write(s)`);
|
||||
return;
|
||||
}
|
||||
if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return;
|
||||
|
||||
await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
|
||||
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
|
||||
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s).map(w => w.type), responses);
|
||||
if (!scope.profiles) {
|
||||
printCapabilityReport(await capabilityReport(c, config, root), TAG);
|
||||
cleanup();
|
||||
if (json) printJson({ ...state, style, scope, plan, done: true });
|
||||
done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`);
|
||||
return;
|
||||
}
|
||||
let cert = choice.cert;
|
||||
if (needCert) {
|
||||
const dir = signingDir(root);
|
||||
|
|
@ -245,7 +328,21 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`);
|
||||
}
|
||||
cleanup();
|
||||
if (json) printJson({ ...state, plan, done: true });
|
||||
done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)');
|
||||
}
|
||||
|
||||
|
||||
/** After a capability write: read the App ID back (App Store Connect lags a little) and fail, with the API's answers,
|
||||
* when a wanted capability is still not there. Seen live: an add that "succeeded" while the App ID kept only
|
||||
* IN_APP_PURCHASE. */
|
||||
export async function checkCapabilitiesStuck(c: Asc, bundleId: string, want: string[], responses: unknown[], o: { waitMs?: number; timeoutMs?: number } = {}): Promise<void> {
|
||||
const r = await verifyCapabilities(async () => ((await findBundleId(c, bundleId))?.capabilities as string[] | undefined) ?? [], want,
|
||||
{ waitMs: o.waitMs ?? upsertTiming.waitMs, timeoutMs: o.timeoutMs, sleep: upsertTiming.sleep, onWait: (m, n) => log(TAG, `read-back ${n}: ${m.join(', ')} not on the App ID yet, checking again`) });
|
||||
if (!r.missing.length) { log(TAG, `read back: the App ID has ${want.join(', ')}`); return; }
|
||||
throw new Error(`capability write did not stick: ${bundleId} still lacks ${r.missing.join(', ')} after ${r.reads} read(s) (it has: ${r.have.join(', ') || 'none'}).\n`
|
||||
+ `API answers to the writes: ${JSON.stringify(responses).slice(0, 1500)}\n`
|
||||
+ 'Check the bundle id in the config (apple.bundleId), that the API key has the Admin role, and add the capability in '
|
||||
+ 'developer.apple.com > Certificates, Identifiers & Profiles > Identifiers if the API keeps refusing it; then run storebound apple signing status.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
|
||||
// storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]
|
||||
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
|
||||
// Also compares the App ID's capabilities, apple.signing.capabilities and the iOS project's entitlements and warns,
|
||||
// naming what is missing where.
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { confirm, need } from '../lib/ctx';
|
||||
import { log, printJson } from '../lib/log';
|
||||
|
|
@ -16,6 +18,7 @@ import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '..
|
|||
import { EMPTY_DRAFT } from '../lib/review-submit';
|
||||
import { type Asc, ascClient, type J } from './asc';
|
||||
import { notCancellable, readItems, type SubItem } from './submission';
|
||||
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
||||
|
|
@ -68,6 +71,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
|
||||
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
||||
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
|
||||
let caps: CapabilityReport | { error: string };
|
||||
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
|
||||
// a submission back from App Review: its items and their states, and how to resubmit it
|
||||
const unresolved: { id: string; items: SubItem[] }[] = [];
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
|
||||
|
|
@ -97,7 +102,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
|
||||
if (v) buildVersions[b.id] = v;
|
||||
}
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] });
|
||||
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], capabilityCheck: caps });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -113,4 +118,6 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const newest = builds.data[0]?.attributes?.version;
|
||||
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||
}
|
||||
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
|
||||
else printCapabilityReport(caps, 'apple');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@
|
|||
],
|
||||
"apple status": [
|
||||
"cancel-submission",
|
||||
"keychain",
|
||||
"no-install",
|
||||
"timeout",
|
||||
"version",
|
||||
"wait-review"
|
||||
|
|
@ -817,8 +819,10 @@
|
|||
"include-drafts",
|
||||
"issue",
|
||||
"jobs",
|
||||
"keychain",
|
||||
"lang",
|
||||
"no-health",
|
||||
"no-install",
|
||||
"no-local",
|
||||
"no-scan",
|
||||
"out",
|
||||
|
|
|
|||
|
|
@ -199,3 +199,114 @@ export function exportOptions(i: ExportOptionsInput): Record<string, PlistValue>
|
|||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// --- capabilities: App ID vs config vs the iOS project's entitlements
|
||||
|
||||
/** Entitlement keys (*.entitlements) -> the App ID capability they need. In-App Purchase has no entitlement. */
|
||||
export const ENTITLEMENT_CAPABILITIES: Record<string, CapabilityType> = {
|
||||
'aps-environment': 'PUSH_NOTIFICATIONS',
|
||||
'com.apple.developer.aps-environment': 'PUSH_NOTIFICATIONS',
|
||||
'com.apple.developer.game-center': 'GAME_CENTER',
|
||||
'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS',
|
||||
'com.apple.developer.applesignin': 'APPLE_ID_AUTH',
|
||||
'com.apple.developer.icloud-container-identifiers': 'ICLOUD',
|
||||
'com.apple.developer.icloud-services': 'ICLOUD',
|
||||
'com.apple.developer.ubiquity-kvstore-identifier': 'ICLOUD',
|
||||
'com.apple.developer.ubiquity-container-identifiers': 'ICLOUD',
|
||||
'com.apple.security.application-groups': 'APP_GROUPS',
|
||||
'com.apple.developer.healthkit': 'HEALTHKIT',
|
||||
'com.apple.developer.homekit': 'HOMEKIT',
|
||||
'com.apple.developer.in-app-payments': 'APPLE_PAY',
|
||||
'com.apple.developer.pass-type-identifiers': 'WALLET',
|
||||
'com.apple.developer.siri': 'SIRIKIT',
|
||||
'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS',
|
||||
'com.apple.developer.networking.vpn.api': 'PERSONAL_VPN',
|
||||
'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING',
|
||||
'com.apple.developer.networking.wifi-info': 'ACCESS_WIFI_INFORMATION',
|
||||
'com.apple.developer.default-data-protection': 'DATA_PROTECTION',
|
||||
'com.apple.developer.ClassKit-environment': 'CLASSKIT',
|
||||
'com.apple.developer.authentication-services.autofill-credential-provider': 'AUTOFILL_CREDENTIAL_PROVIDER',
|
||||
'com.apple.developer.networking.multipath': 'MULTIPATH',
|
||||
'com.apple.developer.networking.HotspotConfiguration': 'HOT_SPOT',
|
||||
'inter-app-audio': 'INTER_APP_AUDIO',
|
||||
'com.apple.external-accessory.wireless-configuration': 'WIRELESS_ACCESSORY_CONFIGURATION',
|
||||
'com.apple.developer.coremedia.hls.low-latency': 'COREMEDIA_HLS_LOW_LATENCY',
|
||||
'com.apple.developer.user-management': 'USER_MANAGEMENT',
|
||||
};
|
||||
|
||||
/** Top-level entitlement keys switched on in an .entitlements plist (a key whose value is <false/> is off). */
|
||||
export function entitlementKeys(xml: string): string[] {
|
||||
const body = xml.replace(/<!--[\s\S]*?-->/g, '');
|
||||
return [...body.matchAll(/<key>\s*([^<]+?)\s*<\/key>\s*(<false\s*\/>)?/g)].filter(m => !m[2]).map(m => m[1]!);
|
||||
}
|
||||
|
||||
export interface ProjectEntitlements { file: string; caps: { cap: CapabilityType; key: string }[]; referenced: boolean | null }
|
||||
|
||||
export function projectEntitlements(file: string, xml: string, referenced: boolean | null): ProjectEntitlements {
|
||||
const caps: { cap: CapabilityType; key: string }[] = [];
|
||||
for (const key of entitlementKeys(xml)) {
|
||||
const cap = ENTITLEMENT_CAPABILITIES[key];
|
||||
if (cap && !caps.some(c => c.cap === cap)) caps.push({ cap, key });
|
||||
}
|
||||
return { file, caps, referenced };
|
||||
}
|
||||
|
||||
export interface CapMismatch { cap: string; missingIn: 'app-id' | 'config' | 'project'; 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[] {
|
||||
const out: CapMismatch[] = [];
|
||||
const has = new Set(i.appId ?? []);
|
||||
const projCaps = new Map<string, { file: string; key: string }>();
|
||||
for (const p of i.project ?? []) for (const c of p.caps) if (!projCaps.has(c.cap)) projCaps.set(c.cap, { file: p.file, key: c.key });
|
||||
const needsEntitlement = new Set<string>(Object.values(ENTITLEMENT_CAPABILITIES));
|
||||
if (i.appId) {
|
||||
for (const c of i.config) if (!has.has(c))
|
||||
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in apple.signing.capabilities but not on the App ID`, fix: 'storebound apple signing ensure --dry, then without --dry' });
|
||||
for (const [c, w] of projCaps) if (!has.has(c) && !i.config.includes(c))
|
||||
out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in ${w.file} (${w.key}) but not on the App ID: the archive's profile will not allow it`, fix: `add '${c}' to apple.signing.capabilities, then storebound apple signing ensure` });
|
||||
}
|
||||
for (const [c, w] of projCaps) if (!i.config.includes(c))
|
||||
out.push({ cap: c, missingIn: 'config', msg: `${c} is in ${w.file} (${w.key}) but not in apple.signing.capabilities`, fix: `add '${c}' to apple.signing.capabilities so signing ensure keeps the App ID in step` });
|
||||
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` });
|
||||
}
|
||||
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)` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reads the App ID's capabilities until every wanted one is there (App Store Connect lags right after a write) or the
|
||||
* time is up. `missing` empty = all there. */
|
||||
export async function verifyCapabilities(read: () => Promise<string[]>, want: string[], o: { tries?: number; waitMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise<void>; onWait?: (missing: string[], n: number) => void } = {}): Promise<{ have: string[]; missing: string[]; reads: number }> {
|
||||
const tries = o.tries ?? 5, waitMs = o.waitMs ?? 3000, deadline = Date.now() + (o.timeoutMs ?? 45_000);
|
||||
const sleep = o.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
|
||||
let have: string[] = [], missing = want, reads = 0;
|
||||
for (let n = 0; n < tries; n++) {
|
||||
if (n) { o.onWait?.(missing, n); if (Date.now() + waitMs * n > deadline) break; await sleep(waitMs * n); }
|
||||
have = await read(); reads++;
|
||||
missing = want.filter(w => !have.includes(w));
|
||||
if (!missing.length) break;
|
||||
}
|
||||
return { have, missing, reads };
|
||||
}
|
||||
|
||||
/** Provisioning profiles storebound made ("storebound <bundle id> ..."), not in `inUse`: unused leftovers whose
|
||||
* deletion is the owner's call. */
|
||||
export const unusedStoreboundProfiles = (list: ApiProfile[], inUse: Set<string>): ApiProfile[] =>
|
||||
list.filter(p => /^storebound /.test(p.attributes.name ?? '') && !inUse.has(p.id));
|
||||
|
||||
export const deleteProfileCmd = (p: ApiProfile): string => `storebound api apple profiles_deleteInstance --param id=${p.id} --yes # "${p.attributes.name}" (in a terminal; asks you to type the operation id)`;
|
||||
|
||||
/** What `signing ensure` writes: automatic signing only adds capabilities (Xcode owns certificates and profiles). */
|
||||
export function ensureScope(style: 'automatic' | 'manual' | undefined): { certificates: boolean; profiles: boolean; why: string } {
|
||||
return (style ?? 'automatic') === 'automatic'
|
||||
? { certificates: false, profiles: false, why: 'automatic signing: ensure only keeps the App ID and its capabilities in step; certificates and provisioning profiles are Xcode\'s (xcodebuild -allowProvisioningUpdates with the API key makes them at archive time)' }
|
||||
: { certificates: true, profiles: true, why: 'manual signing: ensure also keeps the distribution certificate and an App Store profile per bundle id' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export interface AppleSnapshot {
|
|||
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
|
||||
expectedProducts: string[];
|
||||
privacyUrl: UrlCheck;
|
||||
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
|
||||
capabilityMismatches?: { msg: string; fix: string }[];
|
||||
/** Local renders (Apple sizes only), checked before upload. Optional: validate fills it when a renders dir exists. */
|
||||
renders?: RendersCheck;
|
||||
/** Auto-renewable subscriptions on App Store Connect (validate reads them when the config has subscriptions). */
|
||||
|
|
@ -218,6 +220,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
|
||||
|
||||
f.push(...appleSubscriptionRules(s));
|
||||
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);
|
||||
if (s.capabilityMismatches && !s.capabilityMismatches.length) add('ok', 'capabilities', 'capabilities match (App ID, config, project entitlements)');
|
||||
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
|
||||
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
|
||||
f.push(...rendersFindings('apple', s.renders));
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
|
||||
import { templateBlockers } from './lib/template-answers';
|
||||
import { ascClient, type J } from './apple/asc';
|
||||
import { capabilityReport } from './apple/signing';
|
||||
import { requiredDisplayTypes } from './apple/screenshots';
|
||||
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
|
||||
import { reviewDemoAccount } from './lib/credentials';
|
||||
|
|
@ -112,6 +113,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
availability: !!avail?.data,
|
||||
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
|
||||
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
|
||||
capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,24 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
|
|||
capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this
|
||||
Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store
|
||||
profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed.
|
||||
It also compares three places a capability must be and names what is missing where: the App ID, the config's
|
||||
`apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS,
|
||||
`com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...;
|
||||
an entitlements file must also be set in CODE_SIGN_ENTITLEMENTS). `apple status` and `validate` warn on the same.
|
||||
2. **Pick the style.** `apple.signing.style`:
|
||||
- `automatic` (default): `xcodebuild -allowProvisioningUpdates` with the API key creates and fetches what it needs.
|
||||
Nothing to do in step 3 unless capabilities are missing. Easiest for one developer on one Mac.
|
||||
`ensure` only adds missing capabilities to the App ID: it never creates certificates or profiles (Xcode owns
|
||||
them; its managed profile is renewed with the new capabilities at the next archive). Easiest for one developer.
|
||||
- `manual`: certificate and profiles from step 3, named in ExportOptions.plist. Best for CI and teams; set the app
|
||||
target to manual signing with the installed profile in Xcode (Signing & Capabilities) once.
|
||||
3. **Ensure.** `storebound apple signing ensure --dry`, show it, then without `--dry` after a yes:
|
||||
- adds missing capabilities (never removes one; extra ones are only reported). Adding a capability makes existing
|
||||
profiles invalid: `ensure` creates new ones in the same run.
|
||||
- creates an Apple Distribution certificate only when no valid one has its key on this machine. The RSA key is made
|
||||
- adds missing capabilities (never removes one; extra ones are only reported), then reads the App ID back (a few
|
||||
tries, about 45 s at most) and fails with the API's answers when one did not stick. Never report a capability
|
||||
as added from the POST alone. With manual signing, adding one makes existing profiles stale: `ensure` creates
|
||||
new ones in the same run.
|
||||
- lists provisioning profiles storebound made that nothing uses ("storebound <bundle id> App Store <date>"), with
|
||||
the exact delete command; deleting them is the owner's call, storebound never runs it.
|
||||
- manual signing only from here on. Creates an Apple Distribution certificate only when no valid one has its key on this machine. The RSA key is made
|
||||
locally, kept 0600 in `.storebound/signing/<id>.key` next to `<id>.cer`; it is never printed and never leaves the
|
||||
machine. `--keychain` imports both into the login keychain (or `--keychain <path>`) so xcodebuild can sign.
|
||||
Back the key up (encrypted). Apple allows 3 distribution certificates per team: at the limit, revoking one is
|
||||
|
|
|
|||
|
|
@ -740,3 +740,30 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
"price changes on existing products (held)" section. `apple iap` never changes an existing schedule.
|
||||
- **Guard:** `allowPriceChanges` / `heldSection` (cli/lib/price-gate.ts), `tests/price-gate.test.ts` (TTY and non-TTY:
|
||||
a "y" creates the new product and holds the live price; the flag's question names each change).
|
||||
|
||||
## 64. `signing ensure` made a manual App Store profile for an automatic-signing app
|
||||
- **Do:** with `apple.signing.style: automatic` (the default), `apple signing ensure` only keeps the App ID and its
|
||||
capabilities in step. It never creates certificates or profiles: Xcode owns them (`xcodebuild
|
||||
-allowProvisioningUpdates` with the API key) and renews its managed profile when the capabilities change. The dry
|
||||
run says so. Profiles storebound made that nothing uses ("storebound <bundle id> App Store <date>") are listed by
|
||||
name with the exact `storebound api apple profiles_deleteInstance --param id=<id> --yes` command; deleting them is
|
||||
the owner's call, never run by storebound.
|
||||
- **Guard:** `ensureScope`, `unusedStoreboundProfiles` (cli/lib/signing.ts), `tests/signing-capabilities.test.ts`.
|
||||
|
||||
## 65. A capability add "succeeded" but the App ID kept only IN_APP_PURCHASE
|
||||
- **Do:** never report a capability as added from the POST answer. `signing ensure` reads the App ID back after any
|
||||
capability write (a few tries with growing waits, about 45 s at most) and fails with the API's answers when one is
|
||||
missing. The capability names were right (CapabilityType: PUSH_NOTIFICATIONS, GAME_CENTER); what an App ID shows
|
||||
and what a build may use are different things: a provisioning profile keeps the entitlements of the moment it was
|
||||
made, and an app whose project has no `.entitlements` (or one not set in CODE_SIGN_ENTITLEMENTS) ships without
|
||||
the capability whatever the App ID says. `apple status`, `apple signing status` and `validate` compare the App ID,
|
||||
`apple.signing.capabilities` and the project's entitlements and name what is missing where.
|
||||
- **Guard:** `verifyCapabilities`, `compareCapabilities`, `ENTITLEMENT_CAPABILITIES` (cli/lib/signing.ts),
|
||||
`checkCapabilitiesStuck` (cli/apple/signing.ts), `tests/signing-capabilities.test.ts` (live-shape
|
||||
bundleIdCapabilities answers, lag, a write that never sticks).
|
||||
|
||||
## 66. `storebound api apple ... --body f.json --yes` only dry-ran from an agent
|
||||
- **Do:** expected: writes through `storebound api` are sent only from an interactive terminal, by design. Use the
|
||||
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`.
|
||||
|
|
|
|||
143
tests/signing-capabilities.test.ts
Normal file
143
tests/signing-capabilities.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Bundle id capabilities from real use (STAR DASH 1.0.1, October 2026): automatic signing must not create profiles or
|
||||
// certificates, a capability write is read back (lag, never sticking), the App ID / config / project entitlements
|
||||
// comparison, unused storebound profiles, and the `api` write message naming the dedicated command. Answers follow
|
||||
// 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 { dedicatedCommand, decide } from '../cli/api/gate';
|
||||
import { upsertTiming } from '../cli/lib/upsert';
|
||||
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
|
||||
|
||||
const BID = 'BUNDLE0001';
|
||||
const cap = (t: string) => ({ type: 'bundleIdCapabilities', id: `${BID}_${t}`, attributes: { settings: null, capabilityType: t } });
|
||||
const bundles = (types: string[]) => ({
|
||||
data: [{ type: 'bundleIds', id: BID, attributes: { identifier: 'com.example.notes', platform: 'UNIVERSAL', seedId: 'ABCDE12345' },
|
||||
relationships: { bundleIdCapabilities: { data: types.map(t => ({ type: 'bundleIdCapabilities', id: `${BID}_${t}` })) } } }],
|
||||
included: types.map(cap), links: { self: '' }, meta: { paging: { total: 1, limit: 200 } },
|
||||
});
|
||||
const page = (data: unknown[]) => ({ data, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } });
|
||||
const sbProfile = { type: 'profiles', id: 'PROF0001', attributes: { name: 'storebound com.example.notes App Store 2026-10-14 10:45', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2027-09-15T01:29:54.000+00:00', uuid: 'profile-uuid-1' } };
|
||||
const ENT = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<!-- <key>com.apple.developer.healthkit</key> commented out -->
|
||||
<key>aps-environment</key><string>development</string>
|
||||
<key>com.apple.developer.game-center</key><true/>
|
||||
<key>com.apple.developer.applesignin</key><false/>
|
||||
</dict></plist>`;
|
||||
const PBX = (ref: boolean) => `// !$*UTF8*$!\n{ buildSettings = { CODE_SIGN_STYLE = Automatic; ${ref ? 'CODE_SIGN_ENTITLEMENTS = App/App.entitlements;' : ''} }; }\n`;
|
||||
|
||||
beforeEach(() => { upsertTiming.waitMs = 0; });
|
||||
afterEach(() => { restore(); upsertTiming.waitMs = 3000; });
|
||||
|
||||
describe('entitlements and the three-way comparison', () => {
|
||||
test('entitlementKeys skips comments and <false/> keys', () => {
|
||||
expect(entitlementKeys(ENT)).toEqual(['aps-environment', 'com.apple.developer.game-center']);
|
||||
expect(projectEntitlements('App/App.entitlements', ENT, true).caps.map(c => c.cap)).toEqual(['PUSH_NOTIFICATIONS', 'GAME_CENTER']);
|
||||
});
|
||||
|
||||
test('names what is missing where', () => {
|
||||
const proj = [projectEntitlements('ios/App/App/App.entitlements', ENT, true)];
|
||||
const m = compareCapabilities({ appId: ['IN_APP_PURCHASE'], config: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS'], project: proj });
|
||||
const s = m.map(x => `${x.cap}:${x.missingIn}`);
|
||||
expect(s).toContain('PUSH_NOTIFICATIONS:app-id'); // config wants it, App ID lacks it
|
||||
expect(s).toContain('GAME_CENTER:app-id'); // entitlement without App ID capability
|
||||
expect(s).toContain('GAME_CENTER:config'); // entitlement not in the config
|
||||
expect(s).not.toContain('IN_APP_PURCHASE:project'); // IAP needs no entitlement
|
||||
expect(m.find(x => x.cap === 'GAME_CENTER' && x.missingIn === 'app-id')!.msg).toContain('com.apple.developer.game-center');
|
||||
});
|
||||
|
||||
test('config capabilities without entitlements, and an entitlements file the project does not reference', () => {
|
||||
const none = compareCapabilities({ appId: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [] });
|
||||
expect(none.map(x => `${x.cap}:${x.missingIn}`)).toEqual(['PUSH_NOTIFICATIONS:project', 'GAME_CENTER:project']);
|
||||
expect(none[0]!.msg).toContain('aps-environment');
|
||||
const unref = compareCapabilities({ appId: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [projectEntitlements('App/App.entitlements', ENT, false)] });
|
||||
expect(unref.map(x => x.msg).join('\n')).toContain('not referenced by CODE_SIGN_ENTITLEMENTS');
|
||||
expect(compareCapabilities({ appId: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [projectEntitlements('App/App.entitlements', ENT, true)] })).toEqual([]);
|
||||
expect(compareCapabilities({ appId: ['IN_APP_PURCHASE'], config: ['GAME_CENTER'], project: null }).map(x => x.missingIn)).toEqual(['app-id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-back after a capability write', () => {
|
||||
test('lag: the third read has it', async () => {
|
||||
const reads = [['IN_APP_PURCHASE'], ['IN_APP_PURCHASE'], ['IN_APP_PURCHASE', 'GAME_CENTER']];
|
||||
const r = await verifyCapabilities(async () => reads.shift() ?? [], ['IN_APP_PURCHASE', 'GAME_CENTER'], { waitMs: 0, sleep: async () => {} });
|
||||
expect(r).toEqual({ have: ['IN_APP_PURCHASE', 'GAME_CENTER'], missing: [], reads: 3 });
|
||||
});
|
||||
|
||||
test('stops at the timeout', async () => {
|
||||
const r = await verifyCapabilities(async () => ['IN_APP_PURCHASE'], ['GAME_CENTER'], { tries: 50, waitMs: 1000, timeoutMs: 1500, sleep: async () => {} });
|
||||
expect(r.missing).toEqual(['GAME_CENTER']);
|
||||
expect(r.reads).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple signing ensure', () => {
|
||||
const cfg = (style?: 'automatic' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, signing: { ...(style ? { style } : {}), capabilities: ['IN_APP_PURCHASE' as const, 'PUSH_NOTIFICATIONS' as const, 'GAME_CENTER' as const] } } });
|
||||
const routes = (after: string[]) => [
|
||||
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(['IN_APP_PURCHASE']), times: 1 },
|
||||
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(after) },
|
||||
{ match: /^\/v1\/certificates\?/, answer: page([]) },
|
||||
{ match: /^\/v1\/bundleIds\/BUNDLE0001\/profiles/, answer: page([sbProfile]) },
|
||||
{ match: /^\/v1\/profiles\/PROF0001\/relationships\/certificates/, answer: page([{ type: 'certificates', id: 'CERT0001' }]) },
|
||||
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => ({ data: cap((c.body as { data: { attributes: { capabilityType: string } } }).data.attributes.capabilityType) }) },
|
||||
];
|
||||
|
||||
test('automatic: --dry plans only the capabilities and says why; lists the unused storebound profile with its delete command', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg(), { 'ios/App/App.xcodeproj/project.pbxproj': PBX(true), 'ios/App/App/App.entitlements': ENT });
|
||||
const calls = mockFetch(routes(['IN_APP_PURCHASE']));
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
|
||||
const all = r.out + r.err;
|
||||
expect(all).toContain('automatic signing: ensure only keeps the App ID');
|
||||
expect(all).toContain('would POST /v1/bundleIdCapabilities');
|
||||
expect(all).not.toMatch(/POST \/v1\/(profiles|certificates)/);
|
||||
expect(all).toContain('storebound api apple profiles_deleteInstance --param id=PROF0001');
|
||||
expect(all).toContain('PUSH_NOTIFICATIONS is in apple.signing.capabilities but not on the App ID');
|
||||
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
|
||||
});
|
||||
|
||||
test('automatic: a real run adds capabilities, reads them back and creates no certificate or profile', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('automatic'));
|
||||
const calls = mockFetch(routes(['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER']));
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--yes'])));
|
||||
expect(r.exit).toBeNull();
|
||||
const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`);
|
||||
expect(writes).toEqual(['POST /v1/bundleIdCapabilities', 'POST /v1/bundleIdCapabilities']);
|
||||
expect(r.out + r.err).toContain('read back: the App ID has IN_APP_PURCHASE, PUSH_NOTIFICATIONS, GAME_CENTER');
|
||||
});
|
||||
|
||||
test('a write that never sticks fails with the API answers', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg());
|
||||
mockFetch(routes(['IN_APP_PURCHASE']));
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--yes']))).catch(e => ({ out: '', err: String(e), exit: 1 }));
|
||||
expect(r.err).toContain('capability write did not stick');
|
||||
expect(r.err).toContain('PUSH_NOTIFICATIONS, GAME_CENTER');
|
||||
expect(r.err).toContain(`${BID}_GAME_CENTER`); // the POST answers are in the message
|
||||
});
|
||||
});
|
||||
|
||||
describe('scope and leftovers', () => {
|
||||
test('ensureScope', () => {
|
||||
expect(ensureScope(undefined)).toMatchObject({ certificates: false, profiles: false });
|
||||
expect(ensureScope('manual')).toMatchObject({ certificates: true, profiles: true });
|
||||
});
|
||||
test('unusedStoreboundProfiles keeps Xcode-managed and in-use ones', () => {
|
||||
const managed = { id: 'X', attributes: { name: 'iOS Team Store Provisioning Profile: com.example.notes' }, managed: true };
|
||||
expect(unusedStoreboundProfiles([sbProfile, managed], new Set()).map(p => p.id)).toEqual(['PROF0001']);
|
||||
expect(unusedStoreboundProfiles([sbProfile], new Set(['PROF0001']))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storebound api writes outside a terminal', () => {
|
||||
test('the dry reason says TTY by design and the dedicated command is named', () => {
|
||||
const d = decide({ kind: 'write', method: 'POST', dry: false, yes: true, getOnly: false, tty: false, agent: false });
|
||||
expect(d).toMatchObject({ action: 'dry' });
|
||||
expect((d as { why: string }).why).toContain('by design');
|
||||
expect(dedicatedCommand('apple', 'bundleIdCapabilities_createInstance')).toContain('apple signing ensure');
|
||||
expect(dedicatedCommand('apple', 'apps_getInstance')).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue