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

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

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