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 5b6e0992ca
commit 445ab1adbc
17 changed files with 832 additions and 42 deletions

View file

@ -35,7 +35,9 @@ export const USAGE = `usage:
GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only
operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the
operation id. Agents without a terminal: --yes --confirm <operationId> (must equal the operation id; plain writes
only, never owner-only operations). Full table: docs/api-coverage.md`;
only, never owner-only operations), e.g.
storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance
Full table: docs/api-coverage.md`;
export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string }

View file

@ -94,7 +94,7 @@ export type Decision =
| { action: 'refuse'; why: string };
/** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm <operationId> (agents, plain writes only), or use the dedicated storebound command.';
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm <operationId> (agents, plain writes only: --yes --confirm bundleIds_createInstance for the operation bundleIds_createInstance), or use the dedicated storebound command.';
/** --confirm never stands in for the owner's typed id on an owner-only operation. */
export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.';
@ -107,7 +107,7 @@ export function decide(g: GateInput): Decision {
if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner'
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : g.kind === 'owner' ? 'writes are dry by default: add --yes (in a terminal; owner-only operations also ask for the typed operation id)' : `writes are dry by default: add --yes in a terminal, or from an agent's shell --yes --confirm <operationId> (e.g. \`storebound api apple ${g.opId ?? 'bundleIds_createInstance'} --body body.json --yes --confirm ${g.opId ?? 'bundleIds_createInstance'}\`)` };
if (g.confirm !== undefined) {
// an agent's typed confirmation: only for plain writes, only when it names this exact operation
if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM };

View file

@ -134,7 +134,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'builds_getCollection': 'apple/build apple/build-mas apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
'builds_updateInstance': 'apple/encryption lib/build-ops',
'bundleIdCapabilities_createInstance': 'lib/signing',
'bundleIds_createInstance': 'apple/bundle',
'bundleIds_createInstance': 'apple/bundle apple/signing',
'bundleIds_getCollection': 'apple/adhoc apple/bundle apple/signing',
'bundleIds_profiles_getToManyRelated': 'apple/adhoc lib/signing',
'certificates_createInstance': 'apple/signing lib/signing',

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

View file

@ -66,6 +66,7 @@
"allow-price-changes"
],
"apple build": [
"allow-app-group-gap",
"allow-ssh",
"allow-stale-web",
"allow-version-mismatch",
@ -73,6 +74,7 @@
"configuration",
"env-file",
"keychain",
"keychain-unlock",
"locale-map",
"no-install",
"no-prebuild",
@ -82,6 +84,7 @@
"profile",
"progress",
"project",
"refresh-profile",
"result-file",
"scan-paths",
"scheme",
@ -122,7 +125,8 @@
"apple game-center": [
"keychain",
"locale-map",
"no-install"
"no-install",
"refresh-profile"
],
"apple iap": [
"all",
@ -238,6 +242,7 @@
"no-submit",
"platform",
"progress",
"refresh-profile",
"resolve-iaps",
"result-file",
"version",
@ -261,7 +266,8 @@
"apple signing": [
"keychain",
"locale-map",
"no-install"
"no-install",
"refresh-profile"
],
"apple status": [
"cancel-submission",
@ -269,6 +275,7 @@
"locale-map",
"no-install",
"platform",
"refresh-profile",
"timeout",
"version",
"wait-review"
@ -283,6 +290,7 @@
"no-submit",
"platform",
"progress",
"refresh-profile",
"resolve-iaps",
"result-file",
"version",
@ -460,6 +468,7 @@
"name",
"no-install",
"react-native",
"refresh-profile",
"sku",
"storefront",
"unity"
@ -563,7 +572,8 @@
"games status": [
"keychain",
"locale-map",
"no-install"
"no-install",
"refresh-profile"
],
"health": [
"apple",
@ -1067,6 +1077,7 @@
"play",
"prune",
"react-native",
"refresh-profile",
"repair-stuck",
"signature",
"skip-out-of-range-regions",
@ -1113,6 +1124,7 @@
"agent",
"all",
"all-pages",
"allow-app-group-gap",
"allow-availability-changes",
"allow-existing",
"allow-price-changes",
@ -1212,6 +1224,7 @@
"keep",
"keep-booted",
"keychain",
"keychain-unlock",
"keyword",
"keywords",
"kind",
@ -1292,6 +1305,7 @@
"rating",
"react-native",
"reconvert",
"refresh-profile",
"region",
"release",
"release-date",
@ -1497,7 +1511,7 @@
],
"apple signing": [
"storebound apple signing status [--json] read-only: capabilities, certificates (with / without a",
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]"
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]"
],
"apple status": [
"storebound apple status [--platform ios|macos|tvos|visionos] [--json]",

45
cli/lib/keychain.ts Normal file
View file

@ -0,0 +1,45 @@
// Code signing keychain for `storebound apple build` outside the Mac's logged-in GUI session. codesign in an SSH
// session (or any session without the login keychain unlocked) fails late with errSecInternalComponent. The fixes:
// - run from the GUI session (a .command file opened with `open -a Terminal`)
// - a CI runner / Jenkins agent started in that session, or with an unlocked keychain
// - a build keychain: --keychain <path>, unlocked with --keychain-unlock (password from STOREBOUND_KEYCHAIN_PASSWORD,
// never printed) and its key partition list set so codesign may use the keys without a prompt.
// Pure planning here; cli/apple/build.ts runs it.
export const KEYCHAIN_PASSWORD_ENV = 'STOREBOUND_KEYCHAIN_PASSWORD';
/** The refusal for a build in an SSH / non-GUI session, naming every fix. */
export function sshBuildRefusal(reason: string, o: { unlockFlag?: boolean } = {}): string {
const unlock = o.unlockFlag !== false;
return `apple build: ${reason}; codesign there fails with errSecInternalComponent (locked keychain). Fixes, pick one:\n`
+ ` 1. Run it in the Mac's logged-in GUI session: write the command into a .command file and \`open -a Terminal <file>\` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside).\n`
+ ` 2. A CI runner / Jenkins agent started in the GUI session, or one whose keychain is unlocked first (\`security unlock-keychain -p "$${KEYCHAIN_PASSWORD_ENV}" <keychain>\`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" <keychain>\`).\n`
+ (unlock ? ` 3. Let storebound do step 2: export ${KEYCHAIN_PASSWORD_ENV}=... (the keychain password; never printed) and add --keychain-unlock (the login keychain), or --keychain <path> --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain <path> imports them).\n`
+ ` --allow-ssh skips this check when the keychain is already unlocked in this session.`
: ' --allow-ssh skips this check when the keychain is already unlocked in this session.');
}
export interface KeychainPlan { keychain: string; steps: string[][] }
/** The `security` calls --keychain-unlock runs, with PASSWORD as a placeholder (substituted by the caller, never logged). */
export function unlockSteps(keychain: string, searchList: string[]): string[][] {
const steps: string[][] = [['security', 'unlock-keychain', '-p', 'PASSWORD', keychain],
['security', 'set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', 'PASSWORD', keychain]];
if (!searchList.includes(keychain)) steps.push(['security', 'list-keychains', '-d', 'user', '-s', keychain, ...searchList]);
return steps;
}
/** `security list-keychains` output -> paths (quotes stripped). */
export const parseKeychainList = (out: string): string[] => out.split('\n').map(l => l.trim().replace(/^"|"$/g, '')).filter(Boolean);
/** The password from the environment, or an error that names the variable (the value is never in a message). */
export function keychainPassword(env: Record<string, string | undefined>): string {
const p = env[KEYCHAIN_PASSWORD_ENV];
if (!p) throw new Error(`--keychain-unlock needs the keychain password in the environment variable ${KEYCHAIN_PASSWORD_ENV} (not an argument: arguments end up in shell history)`);
return p;
}
/** A command line for logs with the password replaced. */
export const showSecurity = (argv: string[]): string => argv.map(a => (a === 'PASSWORD' ? '$' + KEYCHAIN_PASSWORD_ENV : a)).join(' ');
/** Settings that make xcodebuild sign with a given keychain. */
export const keychainSigningArgs = (keychain: string): string[] => [`OTHER_CODE_SIGN_FLAGS=--keychain ${keychain}`];

View file

@ -43,6 +43,20 @@ export interface AppleSigning {
capabilities?: (CapabilityType | CapabilitySpec)[];
/** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */
extraBundleIds?: string[];
/** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project
* (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is
* registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */
extensions?: ExtensionSpec[];
}
export interface ExtensionSpec {
bundleId: string;
/** A label for messages and the bundle id's name on the developer portal. */
name?: string;
/** Default: the capabilities of the target's entitlements file (none when there is none). */
capabilities?: (CapabilityType | CapabilitySpec)[];
/** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */
appGroups?: string[];
}
export function validateSigning(s: AppleSigning | undefined): string[] {
@ -56,6 +70,14 @@ export function validateSigning(s: AppleSigning | undefined): string[] {
const t = typeof c === 'string' ? c : c?.type;
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`);
}
for (const x of s.extensions ?? []) {
if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; }
for (const c of x.capabilities ?? []) {
const t = typeof c === 'string' ? c : c?.type;
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`);
}
if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`);
}
for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`);
return e;
}
@ -158,19 +180,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date
export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string }
/** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan {
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan {
const now = o.now ?? Date.now();
const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE'
&& Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId))
.sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!));
if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed);
const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed);
const why = [
stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '',
managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '',
].filter(Boolean).join('; ') || `no ${o.type} profile`;
return { reuse: null, why, create: {
const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why;
return { reuse: null, why: why2, create: {
method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`,
body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type },
relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } },

138
cli/lib/targets.ts Normal file
View file

@ -0,0 +1,138 @@
// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an
// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the
// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts.
// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a
// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in
// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline.
export type TargetKind = 'app' | 'extension' | 'watch' | 'clip';
export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null }
/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test
* bundles, tools and libraries are not. */
export function targetKind(productType: string): TargetKind | null {
const m = /^com\.apple\.product-type\.(.+)$/.exec(productType);
const t = m?.[1];
if (!t) return null;
if (t === 'application.on-demand-install-capable') return 'clip';
if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch';
if (t === 'application') return 'app';
if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension';
return null;
}
/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */
function pbxObjects(pbx: string): Map<string, string> {
const out = new Map<string, string>();
const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm;
for (let m = re.exec(pbx); m; m = re.exec(pbx)) {
const open = m.index + m[0].length - 1;
let depth = 0, q = false, i = open;
for (; i < pbx.length; i++) {
const ch = pbx[i];
if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; }
if (ch === '"') q = true;
else if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) break;
}
out.set(m[1]!, pbx.slice(open + 1, i));
re.lastIndex = i;
}
return out;
}
const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; };
const field = (body: string, name: string): string | null => {
const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body);
return m ? unquote(m[1]!) : null;
};
function buildSettings(body: string): Record<string, string> {
const at = body.search(/buildSettings\s*=\s*\{/);
if (at < 0) return {};
const inner = body.slice(body.indexOf('{', at) + 1);
const out: Record<string, string> = {};
for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!);
return out;
}
/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier)
* makes the value an identifier. Anything still containing $( afterwards is unresolved. */
export function resolveSetting(value: string, vars: Record<string, string>): string {
let v = value;
for (let n = 0; n < 5 && /\$[({]/.test(v); n++) {
v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => {
const r = vars[name];
if (r === undefined || r === `$(${name})`) return all;
return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r;
});
}
return v;
}
/** The signable targets of one project.pbxproj, in file order. */
export function pbxTargets(pbx: string): PbxTarget[] {
const objs = pbxObjects(pbx);
const out: PbxTarget[] = [];
for (const body of objs.values()) {
if (field(body, 'isa') !== 'PBXNativeTarget') continue;
const productType = field(body, 'productType') ?? '';
const kind = targetKind(productType);
if (!kind) continue;
const name = field(body, 'name') ?? field(body, 'productName') ?? '?';
const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!);
const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!);
const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b);
const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1];
const s = cfg ? buildSettings(cfg) : {};
const vars: Record<string, string> = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) };
const raw = s.PRODUCT_BUNDLE_IDENTIFIER;
const bundleId = raw ? resolveSetting(raw, vars) : null;
const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null;
out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent });
}
return out;
}
/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */
export const profileAppGroups = (ent: Record<string, unknown> | undefined): string[] => {
const g = ent?.['com.apple.security.application-groups'];
return Array.isArray(g) ? g.map(String) : [];
};
/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */
export function appGroupGaps(want: string[], profileEnt: Record<string, unknown> | undefined): string[] {
const have = profileAppGroups(profileEnt);
if (have.includes('*')) return [];
return want.filter(g => !have.includes(g));
}
/** 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 }
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */
export function appGroupSteps(g: AppGroupGap): string[] {
const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list';
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.`,
];
}
/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */
export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record<string, unknown> | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] {
const out: AppGroupGap[] = [];
for (const t of targets) {
if (!t.appGroups.length) continue;
const ent = profileEnt(t.bundleId);
if (ent === null) continue; // no readable profile: unknown, not a gap
const groups = appGroupGaps(t.appGroups, ent);
if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); }
}
return out;
}

View file

@ -125,14 +125,14 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string }
const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []);
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null }
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[] }
/** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */
export function archiveArgs(i: ArchiveInput): string[] {
return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]),
'-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath,
...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])];
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])];
}
export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] {