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:
CRTkafa 2026-10-25 22:34:14 +07:00
parent d978bb7a8b
commit 304407e523
20 changed files with 590 additions and 60 deletions

View file

@ -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)');
}