apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs

This commit is contained in:
CRTkafa 2026-10-24 13:02:35 +08:00
parent 2e1c7cdc3c
commit 779cbdfcfd
17 changed files with 829 additions and 42 deletions

View file

@ -21,6 +21,7 @@ import { done, log, printJson, warn } from '../lib/log';
import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist';
import { nextBuildNumber } from '../lib/release';
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
import { sshBuildRefusal } from '../lib/keychain';
import { remoteBuildSession, showCmd } from '../lib/xcode';
import {
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
@ -158,7 +159,8 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)');
const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim());
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal <file>\`), or add --unsigned for a pipeline check.`);
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })}
Or add --unsigned for a pipeline check.`);
mkdirSync(out, { recursive: true });
writeFileSync(entFile, entitlementsXml(ent));
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);

View file

@ -3,6 +3,11 @@
// (Mac App Store build of a Tauri app: cli/apple/build-mas.ts)
// storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]
// [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json]
// [--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).
// 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,
@ -24,20 +29,22 @@ import { pump } from '../lib/runner';
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 { 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 { findBundleId } from './signing';
import { discoverTargets, findBundleId } from './signing';
import * as rt from '../lib/rt';
import { shipPhase } from '../lib/ship-step';
const TAG = 'build';
/** Installed App Store profiles for a bundle id, newest expiry first: { uuid, name, team }. */
export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string }[] {
const out: { uuid: string; name: string; team: string; expires: string }[] = [];
export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string; entitlements: Record<string, unknown> }[] {
const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record<string, unknown> }[] = [];
for (const d of profileDirs(home)) {
let files: string[] = [];
try { files = readdirSync(d).filter(f => f.endsWith('.mobileprovision')); } catch { continue; }
@ -50,13 +57,35 @@ export function installedProfiles(bundleId: string, teamId: string | null, home
const store = !p.ProvisionedDevices && !p.ProvisionsAllDevices && ent?.['get-task-allow'] !== true;
if (!store || appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue;
if (Date.parse(String(p.ExpirationDate)) < Date.now()) continue;
if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) });
if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate), entitlements: ent ?? {} });
} catch { /* not a profile */ }
}
}
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
}
const security = (argv: string[]): { ok: boolean; out: string } => { const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { ok: r.success, out: (r.stdout.toString() + r.stderr.toString()).trim() }; };
/** Puts a build keychain on the user's keychain search list (xcodebuild -exportArchive finds identities there). */
function addToSearchList(keychain: string): void {
const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out);
const step = unlockSteps(keychain, list).find(x => x[1] === 'list-keychains');
if (step) { const r = security(step); if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.slice(0, 200)}`); log(TAG, `${keychain} added to the keychain search list`); }
}
/** --keychain-unlock: unlocks the keychain (the login keychain, or --keychain) with STOREBOUND_KEYCHAIN_PASSWORD and
* sets the key partition list so codesign uses the keys without a prompt. The password is never printed. */
function unlockKeychain(keychain: string | null): void {
const pw = keychainPassword(process.env);
const kc = keychain ?? parseKeychainList(security(['security', 'default-keychain', '-d', 'user']).out)[0] ?? join(homedir(), 'Library/Keychains/login.keychain-db');
const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out);
for (const step of unlockSteps(kc, list)) {
const r = security(step.map(x => (x === 'PASSWORD' ? pw : x)));
if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.split(pw).join('***').slice(0, 300)}`);
}
log(TAG, `keychain ${kc} unlocked, key partition list set (password from ${'STOREBOUND_KEYCHAIN_PASSWORD'})`);
}
export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
log(TAG, `$ ${show}`);
log(TAG, `full output: ${logFile}`);
@ -142,33 +171,45 @@ export async function run(ctx: Ctx): Promise<void> {
if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42');
const buildNo = build ?? '<next build number>';
const ids = [bundleId, ...(signing.extraBundleIds ?? [])];
const scan = discoverTargets(root, config);
const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)];
const profiles: Record<string, string> = {};
if (style === 'manual') for (const id of ids) {
const p = installedProfiles(id, teamId)[0];
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)`);
}
const opts = exportOptions({ teamId: teamId ?? '<TEAM_ID>', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '<profile uuid>' }) : undefined });
const plist = toXmlPlist(opts);
const keychainFlag = a.get('keychain');
const keychain = keychainFlag && keychainFlag !== 'true' ? resolve(keychainFlag) : null;
const unlock = a.has('keychain-unlock');
const archivePath = join(out, `${target.scheme}.xcarchive`);
const exportPath = exportDir(out, version, buildNo);
const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' };
const auth = style === 'automatic' ? authPreview : null;
const steps = [
...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []),
showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth }), auth),
showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth),
showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth),
];
const unlockPreview = unlock ? unlockSteps(keychain ?? '<login keychain>', []).map(showSecurity) : [];
const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration,
version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes };
version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
if (dry) {
const web = await webStep({ ...webOpts, dry: true, quiet: json });
if (json) { printJson({ ...plan, web }); return; }
log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`);
for (const n of target.notes) log(TAG, n);
for (const s of steps) log(TAG, `would run: ${s}`);
for (const s of plan.steps) log(TAG, `would run: ${s}`);
console.log(`\n${relative(root, join(out, 'ExportOptions.plist'))}:\n${plist}`);
done(TAG, 'build planned (dry run)');
return;
@ -177,7 +218,11 @@ export async function run(ctx: Ctx): Promise<void> {
// codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent
// (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101).
const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim());
if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal <file>\` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`);
if (remote && !a.has('allow-ssh') && !unlock && !keychain) throw new Error(sshBuildRefusal(remote));
if (unlock || keychain) {
if (unlock) unlockKeychain(keychain);
else addToSearchList(keychain!);
}
shipPhase('prebuild', 'web build / prebuild');
const web = await webStep({ ...webOpts, dry: false });
for (const n of target.notes) log(TAG, n);
@ -190,7 +235,7 @@ export async function run(ctx: Ctx): Promise<void> {
try {
if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null));
shipPhase('build', `xcodebuild archive, build ${build}`);
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth });
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: keychain ? keychainSigningArgs(keychain) : undefined });
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
shipPhase('sign', 'xcodebuild -exportArchive (signing)');
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });

View file

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