apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs
This commit is contained in:
parent
2e1c7cdc3c
commit
779cbdfcfd
17 changed files with 829 additions and 42 deletions
|
|
@ -2,7 +2,13 @@
|
|||
// provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings.
|
||||
// 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]
|
||||
// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]
|
||||
// every signable target of the Xcode project (app, app extensions such as a share extension or widget, watch app,
|
||||
// App Clip: PRODUCT_BUNDLE_IDENTIFIER + entitlements of project.pbxproj, or apple.signing.extensions) gets its
|
||||
// bundle id registered, the capabilities its entitlements need and (manual style) an App Store profile.
|
||||
// App Groups: the API cannot assign a group to a bundle id, so a profile lacks the group until it is ticked in the
|
||||
// developer portal; ensure compares the entitlements with the profile and names the portal link and the steps;
|
||||
// --refresh-profile makes the new profile after that.
|
||||
// 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
|
||||
|
|
@ -20,14 +26,15 @@ 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 BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo,
|
||||
type ApiCert, type ApiProfile, type AppleSigning, type CapabilitySpec, 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 { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode';
|
||||
import { parsePlist } from '../lib/plist';
|
||||
import { parseMobileprovision, parsePlist } from '../lib/plist';
|
||||
import { appGroupGapsFor, appGroupSteps, type AppGroupGap, pbxTargets, type TargetKind } from '../lib/targets';
|
||||
import { zipEntries, zipRead } from '../lib/zip';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import { textTable } from '../lib/table';
|
||||
|
|
@ -37,7 +44,7 @@ import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert';
|
|||
import * as rt from '../lib/rt';
|
||||
|
||||
const TAG = 'signing';
|
||||
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]';
|
||||
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install] [--refresh-profile]';
|
||||
|
||||
export const signingDir = (root: string): string => join(root, '.storebound', 'signing');
|
||||
|
||||
|
|
@ -98,6 +105,78 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n
|
|||
return out;
|
||||
}
|
||||
|
||||
export interface SigningTarget {
|
||||
bundleId: string; name: string; kind: TargetKind | 'configured'; source: 'xcode' | 'config';
|
||||
/** the .entitlements file read (relative to the repository), if any */
|
||||
entitlements: string | null;
|
||||
capabilities: CapabilitySpec[];
|
||||
/** com.apple.security.application-groups of the entitlements */
|
||||
appGroups: string[];
|
||||
}
|
||||
export interface TargetScan { main: SigningTarget | null; extensions: SigningTarget[]; notes: string[] }
|
||||
|
||||
/** Every signable target of the iOS project: the app and its extensions / widgets / watch app / App Clip, read from the
|
||||
* pbxproj (Release configuration) and their entitlements files. apple.signing.extensions replaces the detected
|
||||
* extensions; apple.signing.extraBundleIds are added (no capabilities). Capabilities come from the entitlements:
|
||||
* keychain-access-groups has no App ID capability (nothing to enable). Offline. */
|
||||
export function discoverTargets(root: string, config: ShipConfig): TargetScan {
|
||||
const main = config.apple?.bundleId ?? '';
|
||||
const s: AppleSigning = config.apple?.signing ?? {};
|
||||
const notes: string[] = [];
|
||||
const found: SigningTarget[] = [];
|
||||
const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p));
|
||||
for (const proj of projects) {
|
||||
let pbx = '';
|
||||
try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { continue; }
|
||||
for (const t of pbxTargets(pbx)) {
|
||||
if (!t.bundleId) { notes.push(`${proj}: target ${t.name} has bundle id "${t.unresolved ?? '?'}" which storebound cannot resolve (an xcconfig or a build variable): list it in apple.signing.extensions`); continue; }
|
||||
if (found.some(f => f.bundleId === t.bundleId)) continue;
|
||||
let caps: CapabilitySpec[] = [], groups: string[] = [], file: string | null = null;
|
||||
if (t.entitlements) {
|
||||
const f = join(dirname(join(root, proj)), t.entitlements);
|
||||
try {
|
||||
const xml = readFileSync(f, 'utf8');
|
||||
file = relative(root, f).split(sep).join('/');
|
||||
caps = projectEntitlements(file, xml, true).caps.map((c): CapabilitySpec => ({ type: c.cap }));
|
||||
const g = (parsePlist(xml) as Record<string, unknown>)['com.apple.security.application-groups'];
|
||||
groups = Array.isArray(g) ? g.map(String) : [];
|
||||
} catch { notes.push(`${proj}: target ${t.name}: entitlements file ${t.entitlements} not readable`); }
|
||||
}
|
||||
found.push({ bundleId: t.bundleId, name: t.name, kind: t.kind, source: 'xcode', entitlements: file, capabilities: caps, appGroups: groups });
|
||||
}
|
||||
}
|
||||
const mainTarget = found.find(t => t.bundleId === main) ?? null;
|
||||
let extensions: SigningTarget[];
|
||||
if (s.extensions?.length) {
|
||||
extensions = s.extensions.map((x): SigningTarget => {
|
||||
const det = found.find(t => t.bundleId === x.bundleId);
|
||||
return { bundleId: x.bundleId, name: x.name ?? det?.name ?? x.bundleId, kind: det?.kind ?? 'configured', source: 'config', entitlements: det?.entitlements ?? null,
|
||||
capabilities: (x.capabilities ?? det?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)), appGroups: x.appGroups ?? det?.appGroups ?? [] };
|
||||
});
|
||||
notes.push(`apple.signing.extensions replaces the ${found.filter(t => t.bundleId !== main).length} extension target(s) found in the Xcode project`);
|
||||
} else extensions = found.filter(t => t.bundleId !== main);
|
||||
for (const b of s.extraBundleIds ?? []) if (b !== main && !extensions.some(t => t.bundleId === b)) extensions.push({ bundleId: b, name: b, kind: 'configured', source: 'config', entitlements: null, capabilities: [], appGroups: [] });
|
||||
return { main: mainTarget, extensions, notes };
|
||||
}
|
||||
|
||||
/** Name of a bundle id on the developer portal (letters, digits, spaces). */
|
||||
export const bundleIdName = (appName: string, t: SigningTarget): string => `${appName} ${t.name}`.replace(/[^A-Za-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60);
|
||||
|
||||
const platformOf = (type: ProfileType): 'IOS' | 'MAC_OS' => (type.startsWith('MAC_') ? 'MAC_OS' : 'IOS');
|
||||
|
||||
/** The entitlements of the newest ACTIVE profile of a bundle id that the API serves with its content (a managed one
|
||||
* is not readable: null = unknown). */
|
||||
export function profileEntitlements(list: ApiProfile[]): Record<string, unknown> | null {
|
||||
const p = list.filter(x => !x.managed && x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent)
|
||||
.sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0];
|
||||
if (!p) return null;
|
||||
try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record<string, unknown>; } catch { return null; }
|
||||
}
|
||||
|
||||
export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void {
|
||||
for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l);
|
||||
}
|
||||
|
||||
const git = (root: string, args: string[]): string | null => {
|
||||
try {
|
||||
const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
|
|
@ -171,7 +250,9 @@ export async function capabilityReport(c: Asc, config: ShipConfig, root: string)
|
|||
const b = await findBundleId(c, bundleId);
|
||||
const appId = b ? (b.capabilities as string[]) : null;
|
||||
const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type);
|
||||
const project = readProjectEntitlements(root);
|
||||
// an extension's entitlements belong to its own bundle id (signing ensure handles it), not to the app's App ID
|
||||
const extFiles = new Set(discoverTargets(root, config).extensions.map(t => t.entitlements).filter((f): f is string => !!f));
|
||||
const project = readProjectEntitlements(root)?.filter(p => !extFiles.has(p.file)) ?? null;
|
||||
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;
|
||||
|
|
@ -219,6 +300,9 @@ export interface SigningState {
|
|||
capabilities: { present: string[]; missing: string[]; extra: string[] };
|
||||
certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null };
|
||||
profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[];
|
||||
/** every signable target: where it was found, its capabilities (present / missing on its App ID) and App Groups */
|
||||
targets: { bundleId: string; name: string; kind: string; source: string; registered: boolean; capabilities: string[]; missingCapabilities: string[]; appGroups: string[] }[];
|
||||
appGroupGaps: AppGroupGap[];
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -237,7 +321,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
|
||||
// --- read everything first (read-only)
|
||||
const ids = [bundleId, ...(s.extraBundleIds ?? [])];
|
||||
const scan = discoverTargets(root, config);
|
||||
const exts = scan.extensions;
|
||||
const ids = [bundleId, ...exts.map(t => t.bundleId)];
|
||||
const targetOf = (id: string): SigningTarget | undefined => (id === bundleId ? scan.main ?? undefined : exts.find(t => t.bundleId === id));
|
||||
const refresh = a.has('refresh-profile');
|
||||
const refreshIds = new Set(ids.filter(id => (targetOf(id)?.appGroups.length ?? 0) > 0));
|
||||
if (refresh && !refreshIds.size) ids.forEach(id => refreshIds.add(id));
|
||||
const bundles = new Map<string, J | null>();
|
||||
for (const id of ids) bundles.set(id, await findBundleId(c, id));
|
||||
const main = bundles.get(bundleId);
|
||||
|
|
@ -257,8 +347,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const b = bundles.get(id);
|
||||
if (!b) { profiles.set(id, { list: [], plan: null }); continue; }
|
||||
const list = await profilesOf(c, b.id, profileType);
|
||||
profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null });
|
||||
profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType, refresh: refresh && refreshIds.has(id) }) : null });
|
||||
}
|
||||
const wantCaps = (t: SigningTarget): CapabilitySpec[] => (t.bundleId === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true) : t.capabilities);
|
||||
const targetList: SigningTarget[] = [...(scan.main ? [scan.main] : []), ...exts];
|
||||
const gapsOf = (): AppGroupGap[] => appGroupGapsFor(targetList, id => { const b = bundles.get(id); return b ? profileEntitlements(profiles.get(id)?.list ?? []) : null; }, id => bundles.get(id)?.id ?? null);
|
||||
const state: SigningState = {
|
||||
bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main,
|
||||
capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra },
|
||||
|
|
@ -266,7 +359,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null },
|
||||
profiles: ids.map(id => {
|
||||
const p = profiles.get(id)!;
|
||||
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)' };
|
||||
if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: id === bundleId ? 'register it first (storebound apple bundle)' : 'storebound apple signing ensure registers it' };
|
||||
const r = p.plan?.reuse;
|
||||
const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE');
|
||||
if (style === 'automatic')
|
||||
|
|
@ -277,8 +370,17 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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' };
|
||||
}),
|
||||
targets: [], appGroupGaps: [],
|
||||
};
|
||||
|
||||
state.targets = ids.map(id => {
|
||||
const t = targetOf(id), b = bundles.get(id);
|
||||
const want = t ? wantCaps(t).map(w => w.type) : id === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type) : [];
|
||||
return { bundleId: id, name: t?.name ?? id, kind: t?.kind ?? 'app', source: t?.source ?? 'config', registered: !!b, capabilities: want,
|
||||
missingCapabilities: want.filter(w => !(b?.capabilities ?? []).includes(w)), appGroups: t?.appGroups ?? [] };
|
||||
});
|
||||
state.appGroupGaps = style === 'manual' ? gapsOf() : [];
|
||||
|
||||
// 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));
|
||||
|
|
@ -296,6 +398,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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)`);
|
||||
for (const n of scan.notes) log(TAG, n);
|
||||
console.log(textTable(['target', 'kind', 'bundle id', 'registered', 'capabilities', 'app groups'], state.targets.map(t => [t.name, t.kind, t.bundleId, t.registered ? 'yes' : 'no', t.capabilities.join(', ') || '-', t.appGroups.join(', ') || '-'])));
|
||||
warnAppGroupGaps(state.appGroupGaps, TAG);
|
||||
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();
|
||||
|
|
@ -305,11 +410,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
// --- ensure: plan, show, ask once, then write
|
||||
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)`);
|
||||
for (const n of scan.notes) log(TAG, n);
|
||||
// extensions: register the bundle id (not there yet), then the capabilities its entitlements need
|
||||
const toRegister = exts.filter(t => !bundles.get(t.bundleId));
|
||||
const registerReq = (t: SigningTarget): Req => ({ method: 'POST', path: '/v1/bundleIds', note: `register ${t.bundleId} (${t.kind} ${t.name})`,
|
||||
body: { data: { type: 'bundleIds', attributes: { identifier: t.bundleId, name: bundleIdName(config.app.name, t), platform: platformOf(profileType) } } } });
|
||||
const extCapPlans = exts.map(t => { const b = bundles.get(t.bundleId); return { t, b, plan: b ? planCapabilities(b.id, b.capabilities ?? [], t.capabilities) : null }; });
|
||||
const extCapLines = extCapPlans.flatMap(({ t, b, plan: cp }) => (b ? (cp?.create ?? []).map(r => `${describeReq(r)} # ${t.bundleId}`)
|
||||
: t.capabilities.map(w => `POST /v1/bundleIdCapabilities {capabilityType="${w.type}"} # ${t.bundleId}, after it is registered`)));
|
||||
const needCert = scope.certificates && !choice.cert;
|
||||
const plan: string[] = [
|
||||
...toRegister.map(t => `${describeReq(registerReq(t))}`),
|
||||
...capPlan.create.map(r => describeReq(r)),
|
||||
...extCapLines,
|
||||
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
|
||||
...(!scope.profiles ? [] : ids.flatMap(id => {
|
||||
const p = profiles.get(id)!.plan;
|
||||
|
|
@ -339,6 +452,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
|
||||
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses);
|
||||
if (gcPlan.length && gcSw) await ensureGcSwitch(c, gcSw, TAG);
|
||||
for (const t of toRegister) {
|
||||
const made = (await createOnce<J>({ what: `${t.bundleId} bundle id`, tag: TAG,
|
||||
find: async () => findBundleId(c, t.bundleId),
|
||||
create: async () => (await c.asc('POST', '/v1/bundleIds', registerReq(t).body)).data })).value;
|
||||
bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? { ...made, capabilities: [] });
|
||||
log(TAG, `${t.bundleId}: bundle id registered (${t.kind} ${t.name})`);
|
||||
}
|
||||
for (const { t } of extCapPlans) {
|
||||
const bid = bundles.get(t.bundleId)!;
|
||||
const cp = planCapabilities(bid.id, bid.capabilities ?? [], t.capabilities);
|
||||
if (!cp.create.length) continue;
|
||||
const rs = await execute(c.asc, cp.create, { dry: false, tag: TAG });
|
||||
await checkCapabilitiesStuck(c, t.bundleId, t.capabilities.map(w => w.type), rs);
|
||||
}
|
||||
if (!scope.profiles) {
|
||||
printCapabilityReport(await capabilityReport(c, config, root), TAG);
|
||||
cleanup();
|
||||
|
|
@ -385,10 +512,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)');
|
||||
cert = created;
|
||||
}
|
||||
const finalEnt = new Map<string, Record<string, unknown>>();
|
||||
for (const id of ids) {
|
||||
const b = bundles.get(id)!;
|
||||
const list = profiles.get(id)!.list;
|
||||
const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
|
||||
const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType, refresh: refresh && refreshIds.has(id) });
|
||||
let prof: ApiProfile;
|
||||
if (p.create) {
|
||||
// an unclear answer is checked by reading the bundle id's profiles again (one active profile with this
|
||||
|
|
@ -407,9 +535,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log(TAG, `${id}: ${p.why}`);
|
||||
}
|
||||
if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`);
|
||||
try { finalEnt.set(id, (parseMobileprovision(Buffer.from(prof.attributes.profileContent ?? '', 'base64')).Entitlements ?? {}) as Record<string, unknown>); } catch { /* unreadable: not compared */ }
|
||||
}
|
||||
cleanup();
|
||||
if (json) printJson({ ...state, plan, done: true });
|
||||
// App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal
|
||||
const gaps = appGroupGapsFor(targetList, id => finalEnt.get(id) ?? null, id => bundles.get(id)?.id ?? null);
|
||||
if (json) printJson({ ...state, plan, appGroupGaps: gaps, done: !gaps.length });
|
||||
if (gaps.length) {
|
||||
warnAppGroupGaps(gaps, TAG);
|
||||
throw new Error(`profile(s) installed, but ${gaps.map(g => `${g.bundleId} lacks ${g.groups.join(', ')}${g.url ? ` (${g.url})` : ''}`).join('; ')}: the build would fail at codesign. Tick the App Group(s) in the developer portal (steps above), then run storebound apple signing ensure --refresh-profile.`);
|
||||
}
|
||||
done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue