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

@ -6,6 +6,12 @@
- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas.
- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them).
- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size.
||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs)
- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table.
- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/<id>`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides.
- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain <path>` (sign with a build keychain).
- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm <operationId>`), and the store-api skill says both flags are needed.
- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch).
- `apple upload` / `play upload` `--pre-check "<cmd with {file}>"` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence <path.json>` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`.
- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate "<cmd>"`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language.

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[] {

View file

@ -672,7 +672,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `bundleIds_app_getToOneRelationship` | GET | `/v1/bundleIds/{id}/relationships/app` | read | |
| `bundleIds_bundleIdCapabilities_getToManyRelated` | GET | `/v1/bundleIds/{id}/bundleIdCapabilities` | read | |
| `bundleIds_bundleIdCapabilities_getToManyRelationship` | GET | `/v1/bundleIds/{id}/relationships/bundleIdCapabilities` | read | |
| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle |
| `bundleIds_createInstance` | POST | `/v1/bundleIds` | write (dry by default) | apple/bundle, apple/signing |
| `bundleIds_deleteInstance` | DELETE | `/v1/bundleIds/{id}` | owner only (typed confirm) | |
| `bundleIds_getCollection` | GET | `/v1/bundleIds` | read | apple/adhoc, apple/bundle, apple/signing |
| `bundleIds_getInstance` | GET | `/v1/bundleIds/{id}` | read | |

View file

@ -19,7 +19,7 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which
capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this
Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store
profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed.
profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group the profile lacks is reported with the developer portal link and steps (the API cannot assign a group; then `signing ensure --refresh-profile`, store-pitfalls 121).
It also compares three places a capability must be and names what is missing where: the App ID, the config's
`apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS,
`com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...;
@ -57,7 +57,12 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload`
stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool
folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor`
flags it).
flags it). Over SSH or in CI (no GUI session) codesign fails with errSecInternalComponent on a locked keychain:
`apple build` stops first and names the fixes. `STOREBOUND_KEYCHAIN_PASSWORD=... storebound apple build
--keychain-unlock` unlocks the login keychain and sets its key partition list (the password is read from the
environment and never printed); `--keychain <path>` signs with a build keychain (`--keychain <path> --keychain-unlock`
unlocks that one; `signing ensure --keychain <path>` imports the key + certificate into it). With manual signing it
also stops when a target's App Group is missing from its installed profile (store-pitfalls 121).
- Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g.
`"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build`
run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer

View file

@ -42,7 +42,9 @@ storebound api apple salesReports_getCollection --param ... --out report.gz #
on stderr (store-pitfalls 78). Never pipe or redirect a write; use the dedicated command the message names.
An agent without a terminal sends a plain (non-owner) write with `--yes --confirm <operationId>`, the id equal
to the one the call resolved to (store-pitfalls 92), after showing the dry run. `--confirm` never applies to
owner-only operations.
owner-only operations. Both flags are needed, and `--confirm` repeats the operation id:
`storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance`
(without `--yes` it stays a dry run; a `--confirm` that differs from the id is refused).
3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased
release changes, product page test start / stop, price schedules and price migrations, review replies, tester and
user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for

View file

@ -1229,8 +1229,13 @@ that fails without the fix (see CONTRIBUTING.md).
(and read relative to the working directory, not the project).
- **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened
with `open -a Terminal <file>` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after
`security unlock-keychain` skips the check.
- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in
`security unlock-keychain` skips the check. To build over SSH or in CI anyway: export `STOREBOUND_KEYCHAIN_PASSWORD`
(never printed, never an argument of storebound) and add `--keychain-unlock` (unlocks the login keychain and runs
`security set-key-partition-list -S apple-tool:,apple:,codesign:`), or `--keychain <path> --keychain-unlock` for a
build keychain that holds the Apple Distribution key + certificate (`apple signing ensure --keychain <path>` imports
them); `--keychain` also signs the archive with `OTHER_CODE_SIGN_FLAGS=--keychain <path>` and puts the keychain on the search list.
- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive, and its message names
these fixes (`sshBuildRefusal`, `cli/lib/keychain.ts`; `tests/signing-targets.test.ts`); `credPath` in
`cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an
absolute path (`tests/build-sign.test.ts`).
@ -1454,3 +1459,30 @@ that fails without the fix (see CONTRIBUTING.md).
attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and
names the MISS with the size and every candidate's match count.
- **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`.
||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs)
## 121. An app extension (share extension, widget, watch app) could not be signed: bundle id not registered, capabilities missing, App Group not in the profile
- **Symptom:** an iOS app with a share extension archived, then failed at export / codesign: "No profile for team 'X'
matching 'com.example.app.share' found", "Provisioning profile ... doesn't include the App Groups capability" or
"doesn't match the entitlements file's value for the com.apple.security.application-groups entitlement". `apple
signing ensure` knew only the app's bundle id and refused (`not registered`) for `extraBundleIds`.
- **Cause:** every signable target of the archive (app, app extension, widget, watch app, App Clip) is its own bundle id
with its own capabilities and profile. And Apple's API can switch the APP_GROUPS capability on for a bundle id but
cannot assign a group container to it: the profile carries `com.apple.security.application-groups` only after the
group was ticked in the developer portal (Identifiers > the bundle id > App Groups > Configure).
- **Do:** `storebound apple signing ensure --dry` lists every target found in the Xcode project (PRODUCT_BUNDLE_IDENTIFIER
and CODE_SIGN_ENTITLEMENTS of the Release configuration) and what it would register, enable and create; without
`--dry` it registers the extension bundle ids, enables the capabilities its entitlements need (the entitlement-to-
capability table; `keychain-access-groups` needs none) and, for manual signing, creates an App Store profile per
target. It then decodes each profile and compares its App Groups with the target's entitlements: a gap fails with
the developer portal deep link `https://developer.apple.com/account/resources/identifiers/bundleId/edit/<id>` and the
steps to tick the group (drive it in the signed-in browser; never press Remove / Delete / Revoke). After that
`storebound apple signing ensure --refresh-profile` makes the profile again (a normal run reuses the active one, so it
does not pile up profiles while the group is unassigned). `apple build` (manual signing) stops before the archive on the
same gap (`--allow-app-group-gap` overrides). A variable bundle id (set in an xcconfig) is not resolvable: list the
extension in `apple.signing.extensions` (`bundleId`, `capabilities`, `appGroups`), which replaces the detected ones.
- **Guard:** `cli/lib/targets.ts` (pbxproj targets, App Group comparison, portal link and steps), `discoverTargets` in
`cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group
entitlements, a decoded profile plist, a fake App Store Connect). There is no `console plan` form for the portal page:
the steps are in the error and the warning.

View file

@ -0,0 +1,341 @@
// Signable targets of an Xcode project (app + share extension + App Group entitlements): `apple signing ensure`
// registers the extension's bundle id, enables the capabilities its entitlements need, creates its profile and checks
// the App Group against the profile (the API cannot assign a group to a bundle id); `apple build` over SSH names the
// keychain fixes; `storebound api` writes say how an agent confirms. Fixtures are placeholders (com.example.*).
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { toXmlPlist } from '../cli/lib/plist';
import { run as signing, discoverTargets, profileEntitlements } from '../cli/apple/signing';
import { decide, NOT_A_TTY } from '../cli/api/gate';
import { USAGE } from '../cli/api';
import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain';
import { planProfile, validateSigning } from '../cli/lib/signing';
import { appGroupGaps, appGroupGapsFor, appGroupSteps, bundleIdPortalUrl, pbxTargets, targetKind } from '../cli/lib/targets';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
const APP_ENT = `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>aps-environment</key><string>development</string>
<key>com.apple.security.application-groups</key><array><string>group.com.example.notes</string></array>
</dict></plist>`;
const SHARE_ENT = `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>com.apple.security.application-groups</key><array><string>group.com.example.notes</string></array>
<key>keychain-access-groups</key><array><string>$(AppIdentifierPrefix)com.example.notes</string></array>
<key>com.apple.developer.associated-domains</key><array><string>applinks:example.com</string></array>
</dict></plist>`;
/** A pbxproj the way Xcode 15 writes it: app, share extension, a unit test bundle, a framework, one variable bundle id. */
const PBX = `// !$*UTF8*$!
{
archiveVersion = 1;
objects = {
/* Begin PBXNativeTarget section */
AAAAAAAAAAAAAAAAAAAAAA01 /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC01 /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = ( );
name = App;
productName = App;
productType = "com.apple.product-type.application";
};
AAAAAAAAAAAAAAAAAAAAAA02 /* ShareExt */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC02 /* Build configuration list for PBXNativeTarget "ShareExt" */;
name = ShareExt;
productType = "com.apple.product-type.app-extension";
};
AAAAAAAAAAAAAAAAAAAAAA03 /* AppTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */;
name = AppTests;
productType = "com.apple.product-type.bundle.unit-test";
};
AAAAAAAAAAAAAAAAAAAAAA04 /* Kit */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */;
name = Kit;
productType = "com.apple.product-type.framework";
};
AAAAAAAAAAAAAAAAAAAAAA05 /* Widget */ = {
isa = PBXNativeTarget;
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC05 /* x */;
name = Widget;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin XCBuildConfiguration section */
BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.notes.debug; };
name = Debug;
};
BBBBBBBBBBBBBBBBBBBBBB02 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"{braces}",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.notes;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
BBBBBBBBBBBBBBBBBBBBBB03 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Share/Share.entitlements";
PRODUCT_BUNDLE_IDENTIFIER = "com.example.notes.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
BBBBBBBBBBBBBBBBBBBBBB04 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(MY_BASE_ID).widget"; };
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
CCCCCCCCCCCCCCCCCCCCCC01 /* x */ = {
isa = XCConfigurationList;
buildConfigurations = (
BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */,
BBBBBBBBBBBBBBBBBBBBBB02 /* Release */,
);
};
CCCCCCCCCCCCCCCCCCCCCC02 /* x */ = {
isa = XCConfigurationList;
buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB03 /* Release */, );
};
CCCCCCCCCCCCCCCCCCCCCC03 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( ); };
CCCCCCCCCCCCCCCCCCCCCC05 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB04 ); };
/* End XCConfigurationList section */
};
}
`;
const files = { 'ios/App/App.xcodeproj/project.pbxproj': PBX, 'ios/App/App/App.entitlements': APP_ENT, 'ios/App/Share/Share.entitlements': SHARE_ENT };
describe('pbxproj targets', () => {
test('targetKind: only signable product types', () => {
expect(targetKind('com.apple.product-type.application')).toBe('app');
expect(targetKind('com.apple.product-type.app-extension')).toBe('extension');
expect(targetKind('com.apple.product-type.app-extension.messages-sticker-pack')).toBe('extension');
expect(targetKind('com.apple.product-type.application.watchapp2')).toBe('watch');
expect(targetKind('com.apple.product-type.application.on-demand-install-capable')).toBe('clip');
expect(targetKind('com.apple.product-type.framework')).toBeNull();
expect(targetKind('com.apple.product-type.bundle.unit-test')).toBeNull();
});
test('app + share extension + widget: bundle ids resolved from Release, entitlements paths, tests and frameworks skipped', () => {
const t = pbxTargets(PBX);
expect(t.map(x => x.name)).toEqual(['App', 'ShareExt', 'Widget']);
expect(t[0]).toMatchObject({ kind: 'app', bundleId: 'com.example.notes', entitlements: 'App/App.entitlements' });
expect(t[1]).toMatchObject({ kind: 'extension', bundleId: 'com.example.notes.ShareExt', entitlements: 'Share/Share.entitlements' });
expect(t[2]).toMatchObject({ bundleId: null, unresolved: '$(MY_BASE_ID).widget' });
});
test('discoverTargets reads the entitlements: capabilities and app groups per target; unresolved ids are noted', () => {
const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files);
const s = discoverTargets(p.root, p.loaded.config);
expect(s.main).toMatchObject({ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] });
expect(s.main!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'PUSH_NOTIFICATIONS']);
expect(s.extensions).toHaveLength(1);
expect(s.extensions[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', kind: 'extension', source: 'xcode', appGroups: ['group.com.example.notes'] });
expect(s.extensions[0]!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); // keychain-access-groups has no capability
expect(s.notes.join('\n')).toContain('Widget has bundle id "$(MY_BASE_ID).widget"');
});
test('apple.signing.extensions replaces the detected extensions; extraBundleIds are added', () => {
const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', signing: { extensions: [{ bundleId: 'com.example.notes.widget', capabilities: ['PUSH_NOTIFICATIONS' as const], appGroups: ['group.com.example.notes'] }], extraBundleIds: ['com.example.notes.watch'] } } };
const p = project(cfg, files);
const s = discoverTargets(p.root, p.loaded.config);
expect(s.extensions.map(t => t.bundleId)).toEqual(['com.example.notes.widget', 'com.example.notes.watch']);
expect(s.extensions[0]).toMatchObject({ source: 'config', appGroups: ['group.com.example.notes'], capabilities: [{ type: 'PUSH_NOTIFICATIONS' }] });
});
test('validateSigning checks the extensions override', () => {
expect(validateSigning({ extensions: [{ bundleId: 'com.example.app.share', capabilities: ['APP_GROUPS'], appGroups: ['group.com.example.app'] }] })).toEqual([]);
const e = validateSigning({ extensions: [{ bundleId: 'bad id', capabilities: ['NOPE' as never], appGroups: ['x'] }, { bundleId: 'com.example.a', capabilities: ['NOPE' as never], appGroups: ['x'] }] });
expect(e.join('\n')).toContain('"bad id" is not a bundle id');
expect(e.join('\n')).toContain('unknown capability "NOPE"');
expect(e.join('\n')).toContain('appGroups are ids like group.');
});
});
describe('App Group check', () => {
const prov = (ent: Record<string, unknown>): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record<string, string | string[]> })}\x00trailer`, 'latin1').toString('base64');
const profile = (ent: Record<string, unknown>) => ({ id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z', profileContent: prov(ent) } });
test('the profile plist is decoded and compared with the target\'s groups', () => {
const ent = profileEntitlements([profile({ 'application-identifier': 'ABCDE12345.com.example.notes.share', 'com.apple.security.application-groups': ['group.com.example.other'] })])!;
expect(appGroupGaps(['group.com.example.notes', 'group.com.example.other'], ent)).toEqual(['group.com.example.notes']);
expect(appGroupGaps(['group.com.example.notes'], { 'com.apple.security.application-groups': ['*'] })).toEqual([]);
expect(appGroupGaps(['group.com.example.notes'], {})).toEqual(['group.com.example.notes']);
});
test('a managed or contentless profile is unknown, not a gap', () => {
expect(profileEntitlements([{ id: 'M', attributes: { profileType: 'IOS_APP_STORE', profileState: 'ACTIVE' }, managed: true }])).toBeNull();
expect(appGroupGapsFor([{ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }], () => null, () => 'X')).toEqual([]);
});
test('the gap carries the portal deep link and the agent-first steps with a never-press list', () => {
const g = appGroupGapsFor([{ bundleId: 'com.example.notes.share', appGroups: ['group.com.example.notes'] }], () => ({}), () => 'EXT0001');
expect(g).toEqual([{ bundleId: 'com.example.notes.share', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001' }]);
expect(bundleIdPortalUrl('EXT0001')).toBe(g[0]!.url!);
const steps = appGroupSteps(g[0]!).join('\n');
expect(steps).toContain(g[0]!.url!);
expect(steps).toContain('Never press');
expect(steps).toContain('signing ensure --refresh-profile');
});
test('planProfile: --refresh-profile makes a new profile although an active one is reusable', () => {
const existing = [{ id: 'P1', certIds: ['C1'], attributes: { name: 'old', profileType: 'IOS_APP_STORE' as const, profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z' } }];
const o = { bundleId: 'com.example.notes', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const };
expect(planProfile(existing, o).reuse?.id).toBe('P1');
const r = planProfile(existing, { ...o, refresh: true });
expect(r.reuse).toBeNull();
expect(r.create?.path).toBe('/v1/profiles');
expect(r.why).toContain('--refresh-profile');
});
});
describe('signing ensure with an extension (fake App Store Connect)', () => {
beforeEach(() => { upsertTiming.waitMs = 0; });
afterEach(() => { restore(); upsertTiming.waitMs = 3000; });
const MAIN = 'BUNDLE0001', EXT = 'EXT0001';
const cfg = (style: 'automatic' | 'manual') => ({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style, capabilities: ['IN_APP_PURCHASE' as const] } } });
const prov = (ent: Record<string, unknown>): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record<string, string | string[]> })}\x00`, 'latin1').toString('base64');
function store(o: { groupInProfile: boolean }) {
const caps: Record<string, string[]> = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: [] };
let registered = false;
const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' },
relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } });
const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: null } }));
const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } });
const routes = [
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => (registered ? page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) : page([])) },
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) },
{ method: 'POST', match: /^\/v1\/bundleIds$/, answer: () => { registered = true; return { data: bundle(EXT, 'com.example.notes.ShareExt') }; } },
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => {
const a = (c.body as { data: { attributes: { capabilityType: string }; relationships: { bundleId: { data: { id: string } } } } }).data;
caps[a.relationships.bundleId.data.id]!.push(a.attributes.capabilityType);
return { data: { type: 'bundleIdCapabilities', id: 'x', attributes: { capabilityType: a.attributes.capabilityType } } };
} },
{ match: /^\/v1\/certificates\?/, answer: page([]) },
{ method: 'POST', match: /^\/v1\/certificates$/, answer: { data: { type: 'certificates', id: 'CERT0001', attributes: { certificateType: 'DISTRIBUTION', name: 'Apple Distribution: Example', expirationDate: '2099-01-01T00:00:00.000+00:00', certificateContent: Buffer.from('cert').toString('base64') } } } },
{ match: /^\/v1\/bundleIds\/[A-Z0-9]+\/profiles/, answer: page([]) },
{ method: 'POST', match: /^\/v1\/profiles$/, answer: (c: { body: unknown }) => {
const id = (c.body as { data: { relationships: { bundleId: { data: { id: string } } } } }).data.relationships.bundleId.data.id;
const groups = o.groupInProfile ? { 'com.apple.security.application-groups': ['group.com.example.notes'] } : {};
return { data: { type: 'profiles', id: `PROF_${id}`, attributes: { name: `storebound ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov({ 'application-identifier': `ABCDE12345.${id}`, ...groups }) } } };
} },
{ match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 },
];
return { routes: routes as never, caps, isRegistered: () => registered };
}
test('--dry lists every target: the extension bundle id, its capabilities and (manual) its profile', async () => {
ascEnv();
const p = project(cfg('manual'), files);
const s = store({ groupInProfile: true });
const calls = mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
const all = r.out + r.err;
expect(all).toContain('POST /v1/bundleIds');
expect(all).toContain('register com.example.notes.ShareExt');
expect(all).toMatch(/POST \/v1\/bundleIdCapabilities \{capabilityType="APP_GROUPS"\} # com\.example\.notes\.ShareExt, after it is registered/);
expect(all).toContain('ASSOCIATED_DOMAINS');
expect(all).toContain('# com.example.notes.ShareExt: with the new certificate');
expect(all).not.toContain('ASSOCIATED_DOMAINS is in ios/App/Share/Share.entitlements'); // the extension's entitlements are not compared with the app's App ID
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
});
test('automatic: registers the extension bundle id and its capabilities, reads them back, no profile', async () => {
ascEnv();
const p = project(cfg('automatic'), files);
const s = store({ groupInProfile: true });
const calls = mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--yes'])));
expect(r.exit).toBeNull();
const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`);
expect(writes[0]).toBe('POST /v1/bundleIds');
expect(writes.filter(w => w === 'POST /v1/bundleIdCapabilities')).toHaveLength(2);
expect(writes.some(w => /profiles|certificates/.test(w))).toBe(false);
expect(s.caps.EXT0001!.sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']);
const reg = calls.find(c => c.method === 'POST' && c.path === '/v1/bundleIds')!.body as { data: { attributes: Record<string, string> } };
expect(reg.data.attributes).toMatchObject({ identifier: 'com.example.notes.ShareExt', platform: 'IOS', name: 'Example Notes ShareExt' });
});
test('manual: a profile for the app and the extension; the group missing from the profile fails with the portal link and steps', async () => {
ascEnv();
const p = project(cfg('manual'), files);
const s = store({ groupInProfile: false });
const calls = mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 }));
const all = r.out + r.err;
expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2);
expect(all).toContain('com.example.notes.ShareExt lacks group.com.example.notes');
expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001');
expect(all).toContain('signing ensure --refresh-profile');
});
test('manual: the profile carries the group: ensure finishes', async () => {
ascEnv();
const p = project(cfg('manual'), files);
const s = store({ groupInProfile: true });
mockFetch(s.routes);
const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install'])));
expect(r.exit).toBeNull();
expect(r.out + r.err).toContain('signing ready');
});
});
describe('apple build over SSH: the keychain fixes', () => {
test('the refusal names the GUI session, the CI runner and the keychain flags', () => {
const m = sshBuildRefusal('this is an SSH session');
expect(m).toContain('errSecInternalComponent');
expect(m).toContain('open -a Terminal');
expect(m).toContain('Jenkins');
expect(m).toContain('security unlock-keychain');
expect(m).toContain('security set-key-partition-list');
expect(m).toContain('--keychain-unlock');
expect(m).toContain('--keychain <path>');
expect(m).toContain(KEYCHAIN_PASSWORD_ENV);
expect(sshBuildRefusal('x', { unlockFlag: false })).not.toContain('--keychain-unlock');
});
test('unlock steps carry a PASSWORD placeholder that is never shown; the build keychain joins the search list', () => {
const steps = unlockSteps('/tmp/build.keychain-db', ['/Users/example/Library/Keychains/login.keychain-db']);
expect(steps.map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list', 'list-keychains']);
expect(steps[1]).toContain('apple-tool:,apple:,codesign:');
expect(steps[2]).toEqual(['security', 'list-keychains', '-d', 'user', '-s', '/tmp/build.keychain-db', '/Users/example/Library/Keychains/login.keychain-db']);
expect(showSecurity(steps[0]!)).toBe(`security unlock-keychain -p $${KEYCHAIN_PASSWORD_ENV} /tmp/build.keychain-db`);
expect(unlockSteps('/a', ['/a']).map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list']);
expect(parseKeychainList(' "/Users/example/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n')).toEqual(['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']);
});
test('the password comes from the environment; its absence is an error that names the variable only', () => {
expect(() => keychainPassword({})).toThrow(KEYCHAIN_PASSWORD_ENV);
expect(keychainPassword({ [KEYCHAIN_PASSWORD_ENV]: 'pw-for-test' })).toBe('pw-for-test');
try { keychainPassword({}); } catch (e) { expect((e as Error).message).not.toContain('pw-for-test'); }
});
});
describe('storebound api writes: --yes --confirm <operationId>', () => {
const w = { store: 'apple' as const, method: 'POST', kind: 'write' as const, opId: 'bundleIds_createInstance', yes: false, tty: false, dry: false, agent: false };
test('the usage and the refusals show the example', () => {
expect(USAGE).toContain('--yes --confirm bundleIds_createInstance');
expect(NOT_A_TTY).toContain('--yes --confirm bundleIds_createInstance');
const d = decide(w as never);
expect(d.action).toBe('dry');
const why = (d as { why: string }).why;
expect(why).toContain('--yes --confirm bundleIds_createInstance');
expect(why).toContain('storebound api apple bundleIds_createInstance --body body.json');
});
});