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

@ -25,11 +25,12 @@ import type { ProjectSignals } from './project-signals';
export const FORM_IDS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] as const;
export type FormId = typeof FORM_IDS[number];
/** Forms with a console plan only (the agent drives them; no scripted fill): Play Store settings (the App category the
/** Forms with a console plan only (the agent drives them; no scripted fill): apple-app-group (the developer portal App Group
* assignment the API cannot do, cli/lib/console-apple-group-plan.ts), Play Store settings (the App category the
* API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */
export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details',
'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release',
'play-games-services', 'play-publish-changes'] as const;
'play-games-services', 'play-publish-changes', 'apple-app-group'] as const;
export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number];
/** Every form `console plan` / `console verify` know. */
export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const;

View file

@ -0,0 +1,84 @@
// `storebound console plan apple-app-group`: the App Store Connect API can switch the APP_GROUPS capability on for a
// bundle id but cannot assign a group to it, so an app extension (a packet-tunnel extension, a share extension, a
// widget) whose entitlements list `com.apple.security.application-groups` gets a profile with an empty group list until
// the group is ticked in the developer portal. Agent-first like the other plans: the agent drives the signed-in
// developer.apple.com page with its own browser tool, presses Save itself (it is a capability edit on the owner's own
// App ID, not a store submission: finalByAgent), then `storebound apple signing ensure --refresh-profile` and
// `storebound console verify apple-app-group` (read-only, API). Pure.
// Modeled on Apple's help ("Configure App Groups", "Register an App Group"), not yet recorded on a real portal page: the
// labels are where to start, the agent adapts.
import { bundleIdPortalUrl, PORTAL_IDENTIFIERS_URL } from './targets';
import { NEVER_PRESS } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStep } from './console-plan';
export const PORTAL_REMOVE = ['Remove', 'Remove App ID', 'Delete', 'Delete identifier', 'Revoke', 'Revoke certificate', 'Download'];
const CHECK = 'document.querySelector("main")?.innerText ?? document.body.innerText';
export function appleAppGroupPlan(p: PlanIds): ConsolePlan {
const targets = p.appGroupTargets ?? [];
const one = targets.length === 1 ? targets[0]! : null;
const answers: PlanAnswer[] = [];
const steps: PlanStep[] = [];
const add = (s: Omit<PlanStep, 'n'>): void => { steps.push({ n: steps.length + 1, ...s }); };
const a = (x: PlanAnswer): void => { answers.push(x); };
if (!targets.length) {
a({ id: 'targets', question: 'Which bundle id needs which App Group(s)?', value: null, text: '(none found)', status: 'ask-owner', source: 'entitlements of the Xcode targets',
note: 'No target of the project lists com.apple.security.application-groups. Pass --bundle-id <id> --groups group.a,group.b, or list the extension in apple.signing.extensions with appGroups.' });
}
for (const t of targets) {
a({ id: `${t.bundleId}/capability`, question: `${t.bundleId}: App Groups capability`, value: true, text: 'on', status: 'certain', source: t.source ?? 'entitlements', control: 'checkbox', label: 'App Groups', action: 'tick', where: 'Capabilities list of the App ID page' });
for (const g of t.groups) a({ id: `${t.bundleId}/${g}`, question: `${t.bundleId}: App Group`, value: g, text: g, status: 'certain', source: t.source ?? 'entitlements', control: 'checkbox', label: g, action: 'tick', where: 'the App Group assignment dialog (App Groups > Configure)' });
}
const start = one?.resourceId
? { url: bundleIdPortalUrl(one.resourceId), deepLink: true, reach: [] as string[] }
: { url: PORTAL_IDENTIFIERS_URL, deepLink: false, reach: ['Certificates, Identifiers & Profiles > Identifiers', targets.length ? `search the bundle id (${targets.map(t => t.bundleId).join(', ')}) and open it` : 'search the bundle id and open it', 'with --lookup the plan gets the deep link (https://developer.apple.com/account/resources/identifiers/bundleId/edit/<resource id>)'] };
add({ text: `Open ${start.url} in the signed-in browser (never sign in, never type the account password: if the page asks to sign in, tell the owner).${start.deepLink ? '' : ' Identifiers > search the bundle id > open it.'}` });
for (const t of targets) {
const url = t.resourceId ? bundleIdPortalUrl(t.resourceId) : null;
const gs = t.groups.join(', ');
add({ text: `${t.bundleId}${url ? ` (${url})` : ''}: on the "Edit Your App ID Configuration" page, find App Groups in the Capabilities list and tick its checkbox if it is not ticked.`, answers: [`${t.bundleId}/capability`] });
add({ text: `${t.bundleId}: press "Configure" next to App Groups. In the dialog that lists the registered App Groups tick ${gs} (exact identifiers; tick nothing else), then press "Continue". A group that is not listed must be registered first: Identifiers > the blue + > App Groups > Continue > Description and Identifier (the group id of the sheet) > Continue > Register; then reopen this page.`, click: ['Configure', 'Continue'], answers: t.groups.map(g => `${t.bundleId}/${g}`) });
add({ text: `${t.bundleId}: press "Save" at the top right. If a dialog says the provisioning profiles of this App ID become invalid, press "Confirm" (a new profile is made by \`storebound apple signing ensure --refresh-profile\`). Read the page back: App Groups shows the configured count.`, click: ['Save', 'Confirm'] });
}
add({ text: `Then run \`storebound apple signing ensure --refresh-profile\` (a new profile that carries the group), and \`storebound console verify apple-app-group${one ? ` --bundle-id ${one.bundleId}` : ''}\` (read-only: reads the App ID and the newest profile through the API and checks every group).` });
return {
form: 'apple-app-group', store: 'apple', title: 'Apple Developer portal: assign App Groups to an App ID (the API cannot)',
ownerBatch: [], start, steps, answers,
askOwner: answers.filter(x => x.status !== 'certain').map(x => `${x.question}: ask the owner${x.note ? ` (${x.note})` : ''}`),
buttons: {
neverPress: [...new Set([...PORTAL_REMOVE, 'Revoke', 'Account', 'Certificates', 'Keys', ...NEVER_PRESS])],
final: 'Save', finalByAgent: true,
allowed: [
{ button: 'Configure', class: 'navigation', where: 'App Groups row: opens the group assignment dialog' },
{ button: 'Continue', class: 'navigation', where: 'the group assignment dialog and the App Group registration form' },
{ button: 'Register', class: 'portal-register', where: 'App Groups registration form only: registers the group id of the answer sheet (a new group identifier, nothing public)' },
{ button: 'Save', class: 'portal-save', where: 'top right of the App ID page: saves the capability edit (the agent presses it; not a store submission)' },
{ button: 'Confirm', class: 'portal-save', where: 'the dialog that warns the profiles become invalid after Save' },
],
rule: 'The agent presses everything it needs here, including Save (a capability edit on the owner\'s own App ID; no store submission, nothing public). Never press Remove / Delete / Revoke, never untick or edit another capability, never touch Certificates, Keys or Account pages; stop and tell the owner if the page asks to sign in.',
},
uiFacts: [
'Modeled on Apple\'s help pages (not recorded on a real portal page yet): Certificates, Identifiers & Profiles > Identifiers > the App ID > Capabilities list > App Groups (checkbox) > Configure > the group list > Continue > Save (top right) > Confirm.',
'The App Store Connect API can enable the APP_GROUPS capability (POST /v1/bundleIdCapabilities) but has no field that assigns a group: an extension\'s profile has an empty application-groups list until the group is ticked here, and the archive then fails at codesign.',
'Saving the App ID invalidates its provisioning profiles; manual signing needs `storebound apple signing ensure --refresh-profile`, automatic signing lets Xcode renew its managed profile at archive time.',
'The deep link uses the bundle id\'s App Store Connect API resource id: https://developer.apple.com/account/resources/identifiers/bundleId/edit/<resource id> (`console plan apple-app-group --bundle-id <id> --lookup` reads it).',
'Group identifiers start with group. and are registered once per team under Identifiers > App Groups.',
],
driving: [
'Use your own browser tool (Claude in Chrome with the owner\'s signed-in session). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps, but never press anything in buttons.neverPress.',
'Tick boxes by clicking the checkbox, then read its checked state back. After Save read the App ID page again.',
'Press Save yourself (buttons.finalByAgent): it is not a store submission. Never sign in, never type passwords or 2FA codes.',
],
inPageLoop: '',
verify: {
command: `storebound console verify apple-app-group${one ? ` --bundle-id ${one.bundleId}` : ''}`, source: 'api', capture: CHECK,
expect: ['Every App Group of the answer sheet is on the App ID\'s newest readable profile (re-download after `storebound apple signing ensure --refresh-profile`) or named by the App Groups capability settings.', 'Exit code 1 while a group is missing or cannot be proven.'],
},
fillable: true,
guidance: ['Open: developer.apple.com > Account > Certificates, Identifiers & Profiles > Identifiers > the App ID'],
};
}

View file

@ -12,6 +12,7 @@ import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, i
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SAVE_EACH_PAGE, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { PLAY_CREATE_CAPTURE } from './console-verify';
import { appleAppGroupPlan } from './console-apple-group-plan';
import { APPLE_PURPOSES, IARC_DIGITAL_GOODS, IARC_DIGITAL_PURCHASES } from './console-answers';
export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement';
@ -87,6 +88,8 @@ export interface ConsolePlan {
allowed: { button: string; class: string; where: string }[];
/** The rule in one line. */
rule: string;
/** true: the final button is the agent's own (a portal capability edit such as Save on an App ID, not a store submission or a public change). */
finalByAgent?: boolean;
};
/** Known UI facts of this form (verified ones say so). */
uiFacts: string[];
@ -110,6 +113,8 @@ export interface PlanIds {
signals?: ProjectSignals;
/** play-publish-changes: the production releases ready to publish per the API (null/undefined: not read). */
readyChanges?: { track: string; version: string | null; build: string | null; rollout: string }[] | null;
/** apple-app-group: the targets whose entitlements list App Groups (their bundle id, groups and, with --lookup, the App Store Connect resource id for the portal deep link). */
appGroupTargets?: { bundleId: string; name?: string; groups: string[]; resourceId?: string | null; source?: string }[];
}
const ASC = 'https://appstoreconnect.apple.com';
@ -538,6 +543,7 @@ export interface PlanOpts { all?: boolean }
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}, o: PlanOpts = {}): ConsolePlan {
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
if (form === 'play-app-content') return appContentPlan(cfg, p, o.all);
if (form === 'apple-app-group') return appleAppGroupPlan(p);
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
@ -583,7 +589,7 @@ export function planText(pl: ConsolePlan): string {
L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`);
if (a.status !== 'certain' && a.note) L.push(` ${a.note}`);
}
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`),
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (${pl.buttons.finalByAgent ? "the agent's own, not a store submission" : "the owner's"}): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`),
'', 'In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):', ...pl.inPageLoop.split('\n').map(l => ` ${l}`),
'', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`));

View file

@ -534,7 +534,7 @@ function publishChanges(config: ShipConfig, p: PlanIds): Part {
};
}
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, p: PlanIds): Part {
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content' | 'apple-app-group'>, config: ShipConfig, p: PlanIds): Part {
switch (form) {
case 'play-production-countries': return productionCountries(config, p);
case 'play-production-release': return productionRelease(config, p);

View file

@ -19,6 +19,7 @@ import { APPLE_PRIVACY_TYPES } from './app-privacy';
import { typeLabels } from './console-forms';
import { playAppIdInText } from './play-app-id';
import { APPLE_PURPOSES } from './console-answers';
import type { AppGroupGap } from './targets';
export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner';
export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string }
@ -360,7 +361,7 @@ export function parseStates(raw: string): { states: InputState[]; text: string }
* the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No
* radios cannot be told apart in page text: reported, never counted as a match.
*/
export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, text: string): VerifyResult {
export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-content' | 'apple-app-group'>, config: ShipConfig, text: string): VerifyResult {
const part = playOnlyPart(form, config, {});
const st = parseStates(text);
const blob = fold(st ? st.text : text);
@ -444,6 +445,27 @@ export function verifyPublishChangesApi(ready: { track: string; version: string
...(ready.undetermined ? ['A release counts as ready only when the public store page shows an OLDER version; a 404, failed read or same version is never ready. Check Publishing overview ("Last published on ...") or pass the page text with --from-text -.'] : [])]);
}
/**
* apple-app-group (read-only, API): one row per (bundle id, App Group). match: the group is on the newest readable
* profile or named by the capability settings; mismatch: the evidence shows it missing; not-found: nothing readable
* proves it (after the portal Save the old profiles are invalid: run `storebound apple signing ensure --refresh-profile`,
* then verify again). ok only when every row matches.
*/
export function verifyAppGroupsApi(targets: { bundleId: string; appGroups: string[] }[], verdicts: { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] }): VerifyResult {
const rows: VerifyRow[] = [];
for (const t of targets.filter(x => x.appGroups.length)) {
const gap = verdicts.gaps.find(g => g.bundleId === t.bundleId), un = verdicts.unproven.find(g => g.bundleId === t.bundleId);
for (const g of t.appGroups) {
const item = `${t.bundleId}: ${g}`;
if (gap?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'missing', status: 'mismatch', ...(gap.why ? { note: gap.why } : {}) });
else if (un?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'cannot be proven', status: 'not-found', note: 'no ACTIVE profile shows it: run storebound apple signing ensure --refresh-profile, then verify again' });
else rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'present', status: 'match' });
}
}
const r = result('apple-app-group', 'api', rows, ['Reads the App Store Connect API: the App Groups capability and the newest readable provisioning profile of each bundle id.']);
return rows.some(x => x.status === 'not-found') ? { ...r, ok: false } : r;
}
/** Page-text verification of any form that has one. */
export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult {
if (form === 'play-app-content') return verifyAppContent(text);
@ -460,6 +482,7 @@ export function verifyText(form: PlanFormId, config: ShipConfig, text: string):
case 'play-target-audience': return verifyTargetAudience(config, text);
case 'apple-app-record': return verifyAppRecordText(config, text);
case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)');
case 'apple-app-group': throw new Error('console verify apple-app-group reads the App Store Connect API (no --from-text)');
}
}

View file

@ -110,17 +110,20 @@ export function appGroupGaps(want: string[], profileEnt: Record<string, unknown>
/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */
export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`;
export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null }
export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null; /** why it is a gap / not proven (one line) */ why?: string }
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */
export const PORTAL_IDENTIFIERS_URL = 'https://developer.apple.com/account/resources/identifiers/list';
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text.
* The first line is the headline, the rest are the steps. */
export function appGroupSteps(g: AppGroupGap): string[] {
const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list';
const url = g.url ?? PORTAL_IDENTIFIERS_URL;
return [
`${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the API can enable the App Groups capability but cannot assign a group to a bundle id).`,
`Open ${url} in the signed-in browser (agent-first: you drive it).`,
'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (and Confirm in the dialog that says the profiles get invalidated).',
'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button.',
`Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.`,
`${g.bundleId}: ${g.why ?? `the profile lacks App Group(s) ${g.groups.join(', ')}`} (the App Store Connect API can enable the App Groups capability but cannot assign a group to a bundle id).`,
`Open ${url} in the signed-in browser (agent-first: you drive it; plan: storebound console plan apple-app-group --bundle-id ${g.bundleId}${g.url ? '' : ' --lookup'}).`,
`Certificates, Identifiers & Profiles > Identifiers > ${g.bundleId} > App Groups (tick it if it is not ticked) > Configure > tick ${g.groups.join(', ')} (register a new group first under Identifiers > App Groups) > Continue > Save (and Confirm in the dialog that says the profiles get invalidated).`,
'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button; change no other capability.',
'Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.',
];
}
@ -136,3 +139,46 @@ export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[]
}
return out;
}
/** What the API (or an installed profile) shows about one App ID's App Group association. */
export interface GroupEvidence {
/** App Store Connect API resource id of the bundle id (the portal deep link); null: not registered / unknown */
resourceId: string | null;
/** the APP_GROUPS capability is on the App ID (null: unknown) */
capabilityOn: boolean | null;
/** the raw settings of the APP_GROUPS capability (any field that names a group proves it) */
capabilitySettings?: unknown;
/** Entitlements of the newest readable profile of the bundle id (a managed one too); null: none readable */
profileEnt: Record<string, unknown> | null;
}
/** Groups of `want` that the capability's settings name (the API has no documented App Group field; if a field ever
* lists them, this proves the association without a profile). */
export const capabilityGroups = (settings: unknown, want: string[]): string[] => {
if (settings === null || settings === undefined) return [];
const text = JSON.stringify(settings);
return want.filter(g => text.includes(g));
};
export interface GroupVerdicts { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] }
/**
* Every target whose entitlements list App Groups: proven (a readable profile carries each group, or the capability
* settings name it), a gap (the evidence shows the group missing: a readable profile without it, or the capability
* off) or unproven (nothing readable: the API cannot show the association). Pure.
*/
export function appGroupVerdicts(targets: { bundleId: string; appGroups: string[] }[], evidence: (bundleId: string) => GroupEvidence | null): GroupVerdicts {
const out: GroupVerdicts = { gaps: [], unproven: [], ok: [] };
for (const t of targets) {
if (!t.appGroups.length) continue;
const ev = evidence(t.bundleId);
const rid = ev?.resourceId ?? null, url = rid ? bundleIdPortalUrl(rid) : null;
const viaCap = new Set(capabilityGroups(ev?.capabilitySettings, t.appGroups));
const missing = (ev?.profileEnt ? appGroupGaps(t.appGroups, ev.profileEnt) : t.appGroups).filter(g => !viaCap.has(g));
if (!missing.length) { out.ok.push(t.bundleId); continue; }
if (ev?.capabilityOn === false) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the App Groups capability is not enabled on the App ID, so ${missing.join(', ')} is not associated` });
else if (ev?.profileEnt) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the newest profile lacks App Group(s) ${missing.join(', ')} (an empty application-groups list means the group is not ticked on the App ID)` });
else out.unproven.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: ev ? `App Group(s) ${missing.join(', ')} cannot be proven: the API shows no group on the App ID's capability and no readable profile` : `App Group(s) ${missing.join(', ')} cannot be proven: the App ID is not registered or could not be read` });
}
return out;
}