apple signing: App Group association fails early under automatic signing too (API capability + newest profile, steps and deep link, exit before the archive); console plan/verify apple-app-group
This commit is contained in:
parent
d978bb7a8b
commit
304407e523
20 changed files with 590 additions and 60 deletions
|
|
@ -6,8 +6,8 @@
|
|||
// [--keychain <path>] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap]
|
||||
// Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in
|
||||
// STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain <path> signs with a build
|
||||
// keychain instead of the login keychain. Manual signing: stops before the build when a target's App Group is not in its
|
||||
// installed profile (the developer portal assigns groups; storebound apple signing status/ensure names the steps).
|
||||
// keychain instead of the login keychain. App Groups: stops before the archive when a target's App Group is not proven (manual: its
|
||||
// installed profile; automatic: the App ID's capability and the newest profile the API lists; the portal assigns groups: console plan apple-app-group).
|
||||
// First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a
|
||||
// Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops.
|
||||
// Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/<Name>, Flutter ios/Runner,
|
||||
|
|
@ -30,13 +30,13 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
|||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain';
|
||||
import { appGroupGapsFor, appGroupSteps } from '../lib/targets';
|
||||
import { appGroupSteps, appGroupVerdicts, type GroupEvidence } from '../lib/targets';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { parsePlatform } from './platform';
|
||||
import { discoverTargets, findBundleId } from './signing';
|
||||
import { discoverTargets, findBundleId, readGroupEvidence } from './signing';
|
||||
import * as rt from '../lib/rt';
|
||||
import { shipPhase } from '../lib/ship-step';
|
||||
|
||||
|
|
@ -179,12 +179,27 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`);
|
||||
else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`);
|
||||
}
|
||||
// App Groups: a manual-signing profile carries a group only when the portal assigned it to the bundle id
|
||||
const groupGaps = style === 'manual' ? appGroupGapsFor([...(scan.main ? [scan.main] : []), ...scan.extensions], id => { const p = installedProfiles(id, teamId)[0]; return p ? p.entitlements : null; }, () => null) : [];
|
||||
for (const g of groupGaps) {
|
||||
const msg = `${g.bundleId}: the installed profile lacks App Group(s) ${g.groups.join(', ')}`;
|
||||
if (a.has('allow-app-group-gap') || dry) warn(TAG, msg);
|
||||
else throw new Error(`${msg}; codesign would refuse the build.\n${appGroupSteps(g).slice(1).join('\n')}\n(--allow-app-group-gap builds anyway)`);
|
||||
// App Groups: a profile carries a group only when the portal assigned it to the bundle id (the API cannot). Manual
|
||||
// signing: the installed profile; automatic: the App ID's capability and the newest profile the API lists. A gap stops
|
||||
// the build before the archive; an association nothing can prove is warned with the same steps.
|
||||
const groupTargets = [...(scan.main ? [scan.main] : []), ...scan.extensions];
|
||||
let groupEvidence: ((id: string) => GroupEvidence | null) | null = null;
|
||||
if (groupTargets.some(t => t.appGroups.length)) {
|
||||
if (style === 'manual') groupEvidence = id => { const p = installedProfiles(id, teamId)[0]; return p ? { resourceId: null, capabilityOn: null, profileEnt: p.entitlements } : null; };
|
||||
else {
|
||||
try { groupEvidence = await readGroupEvidence(await ascClient(root, bundleId, config.apple?.appId), groupTargets, 'automatic', signing.profileType ?? 'IOS_APP_STORE'); }
|
||||
catch (e) { warn(TAG, `App Group association not read from App Store Connect (${(e as Error).message.split('\n')[0]?.slice(0, 120)})`); }
|
||||
}
|
||||
}
|
||||
const groupVerdicts = appGroupVerdicts(groupTargets, groupEvidence ?? (() => null));
|
||||
for (const g of groupVerdicts.unproven) for (const l of appGroupSteps(g)) warn(TAG, l);
|
||||
for (const g of groupVerdicts.gaps) {
|
||||
const msg = `${g.bundleId}: ${g.why}`;
|
||||
if (a.has('allow-app-group-gap') || dry) { warn(TAG, msg); for (const l of appGroupSteps(g).slice(1)) warn(TAG, l); }
|
||||
else {
|
||||
const auto = style === 'automatic' ? 'Automatic signing: Xcode renews its managed profile at archive time, so if you just ticked the group and the API still lists the old profile, build with --allow-app-group-gap.' : '';
|
||||
throw new Error([`${msg}; codesign would refuse the build (stopped before the archive).`, ...appGroupSteps(g).slice(1), ...(auto ? [auto] : []), '(--allow-app-group-gap builds anyway)'].join(String.fromCharCode(10)));
|
||||
}
|
||||
}
|
||||
const opts = exportOptions({ teamId: teamId ?? '<TEAM_ID>', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '<profile uuid>' }) : undefined });
|
||||
const plist = toXmlPlist(opts);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
import { isBuildCopyPath } from '../lib/privacy-manifest';
|
||||
import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode';
|
||||
import { parseMobileprovision, parsePlist } from '../lib/plist';
|
||||
import { appGroupGapsFor, appGroupSteps, type AppGroupGap, pbxTargets, type TargetKind } from '../lib/targets';
|
||||
import { appGroupSteps, appGroupVerdicts, type AppGroupGap, type GroupEvidence, type GroupVerdicts, pbxTargets, type TargetKind } from '../lib/targets';
|
||||
import { zipEntries, zipRead } from '../lib/zip';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import { textTable } from '../lib/table';
|
||||
|
|
@ -71,6 +71,7 @@ export async function findBundleId(c: Asc, identifier: string): Promise<J | null
|
|||
const hit = (r.data as J[]).find(b => b.attributes.identifier === identifier) ?? null;
|
||||
if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id))
|
||||
.map((x: J) => x.attributes.capabilityType as string);
|
||||
if (hit) hit.capabilityObjects = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)).map((x: J) => x.attributes);
|
||||
return hit;
|
||||
}
|
||||
|
||||
|
|
@ -173,10 +174,51 @@ export function profileEntitlements(list: ApiProfile[]): Record<string, unknown>
|
|||
try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record<string, unknown>; } catch { return null; }
|
||||
}
|
||||
|
||||
/** The entitlements of the newest ACTIVE profile of a bundle id with content, an Xcode-managed one too (automatic
|
||||
* signing: the list serves the content even where /v1/profiles/{id} answers 404). null = none readable. */
|
||||
export function newestProfileEntitlements(list: ApiProfile[]): Record<string, unknown> | null {
|
||||
const p = list.filter(x => 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; }
|
||||
}
|
||||
|
||||
/** What the API shows about the App Group association of one bundle id: the APP_GROUPS capability (and its settings)
|
||||
* and the entitlements of its newest readable profile (manual signing: storebound's own; automatic: any, managed too). */
|
||||
export function groupEvidence(b: J | null | undefined, list: ApiProfile[], style: 'automatic' | 'manual'): GroupEvidence | null {
|
||||
if (!b) return null;
|
||||
const caps = (b.capabilities as string[] | undefined) ?? [];
|
||||
const obj = ((b.capabilityObjects as { capabilityType?: string; settings?: unknown }[] | undefined) ?? []).find(x => x.capabilityType === 'APP_GROUPS');
|
||||
return { resourceId: b.id ?? null, capabilityOn: caps.includes('APP_GROUPS'), capabilitySettings: obj?.settings ?? null,
|
||||
profileEnt: style === 'manual' ? profileEntitlements(list) : newestProfileEntitlements(list) };
|
||||
}
|
||||
|
||||
/** Reads the evidence for every target with App Groups (API only, no writes): the bundle id with its capabilities and
|
||||
* the profiles listed under it. */
|
||||
export async function readGroupEvidence(c: Asc, targets: { bundleId: string; appGroups: string[] }[], style: 'automatic' | 'manual', type: ProfileType): Promise<(bundleId: string) => GroupEvidence | null> {
|
||||
const out = new Map<string, GroupEvidence | null>();
|
||||
for (const t of targets.filter(x => x.appGroups.length)) {
|
||||
const b = await findBundleId(c, t.bundleId);
|
||||
let list: ApiProfile[] = [];
|
||||
if (b) {
|
||||
try { list = (await c.all(`/v1/bundleIds/${b.id}/profiles?limit=200`) as J[]).filter(p => p.attributes?.profileType === type).map((p): ApiProfile => ({ id: p.id, attributes: p.attributes })); } catch { /* not listable: unproven */ }
|
||||
}
|
||||
out.set(t.bundleId, groupEvidence(b, list, style));
|
||||
}
|
||||
return id => out.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void {
|
||||
for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l);
|
||||
}
|
||||
|
||||
/** Early exit text of a proven App Group gap (before the archive / at the end of ensure): the gap, the exact portal
|
||||
* steps with the deep link, the plan command and the refresh. */
|
||||
export function appGroupError(gaps: AppGroupGap[], what: string): string {
|
||||
const nl = String.fromCharCode(10);
|
||||
return `${gaps.map(g => `${g.bundleId}: ${g.why ?? `lacks ${g.groups.join(', ')}`}`).join('; ')}; ${what}.${nl}${gaps.map(g => appGroupSteps(g).slice(1).join(nl)).join(nl)}`;
|
||||
}
|
||||
|
||||
const git = (root: string, args: string[]): string | null => {
|
||||
try {
|
||||
const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
|
|
@ -303,6 +345,8 @@ export interface SigningState {
|
|||
/** 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[];
|
||||
/** targets whose App Group association the API cannot show (warned, not blocked) */
|
||||
appGroupUnproven: AppGroupGap[];
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -351,7 +395,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
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 verdictsOf = (): GroupVerdicts => appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], style));
|
||||
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 },
|
||||
|
|
@ -370,7 +414,7 @@ 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: [],
|
||||
targets: [], appGroupGaps: [], appGroupUnproven: [],
|
||||
};
|
||||
|
||||
state.targets = ids.map(id => {
|
||||
|
|
@ -379,7 +423,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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() : [];
|
||||
{ const v = verdictsOf(); state.appGroupGaps = v.gaps; state.appGroupUnproven = v.unproven; }
|
||||
|
||||
// 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) : []);
|
||||
|
|
@ -400,7 +444,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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);
|
||||
warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], 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();
|
||||
|
|
@ -443,6 +487,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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);
|
||||
warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], TAG);
|
||||
cleanup();
|
||||
done(TAG, `signing checked (dry run): ${plan.length} write(s)`);
|
||||
return;
|
||||
|
|
@ -469,7 +514,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!scope.profiles) {
|
||||
printCapabilityReport(await capabilityReport(c, config, root), TAG);
|
||||
cleanup();
|
||||
if (json) printJson({ ...state, style, scope, plan, done: true });
|
||||
// automatic signing: Xcode makes the profile at archive time, so the App Group association is read from the App ID
|
||||
// and the newest profile the API lists now (the writes above may have added capabilities: read the App IDs again)
|
||||
for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null);
|
||||
const v = appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], 'automatic'));
|
||||
if (json) printJson({ ...state, style, scope, plan, appGroupGaps: v.gaps, appGroupUnproven: v.unproven, done: !v.gaps.length });
|
||||
warnAppGroupGaps(v.unproven, TAG);
|
||||
if (v.gaps.length) throw new Error(`capabilities in step, but ${appGroupError(v.gaps, 'the archive would fail at codesign (empty application-groups in the profile)')}`);
|
||||
done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -539,12 +590,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
cleanup();
|
||||
// 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.`);
|
||||
}
|
||||
for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null);
|
||||
const v = appGroupVerdicts(targetList, id => { const ent = finalEnt.get(id); const b = bundles.get(id); return ent ? { ...groupEvidence(b, [], 'manual')!, profileEnt: ent } : groupEvidence(b, [], 'manual'); });
|
||||
const gaps = v.gaps;
|
||||
if (json) printJson({ ...state, plan, appGroupGaps: gaps, appGroupUnproven: v.unproven, done: !gaps.length });
|
||||
warnAppGroupGaps(v.unproven, TAG);
|
||||
if (gaps.length) throw new Error(`profile(s) installed, but ${appGroupError(gaps, 'the build would fail at codesign')}`);
|
||||
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