diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a36300..5a15c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`) 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 ` (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 `), 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 ""` (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 ` (`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 ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. diff --git a/cli/api.ts b/cli/api.ts index a629aed..953919f 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -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 (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 } diff --git a/cli/api/gate.ts b/cli/api/gate.ts index d3f6ef7..c7c8cef 100644 --- a/cli/api/gate.ts +++ b/cli/api/gate.ts @@ -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 (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 (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 (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 }; diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index d361d9a..4a8fca5 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -134,7 +134,7 @@ export const WRAPPED: { apple: Record; play: Record { } 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 \`), 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`); diff --git a/cli/apple/build.ts b/cli/apple/build.ts index 74f0056..97ac211 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -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 ] [--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 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/, 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 }[] { + const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record }[] = []; 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): Promise { log(TAG, `$ ${show}`); log(TAG, `full output: ${logFile}`); @@ -142,33 +171,45 @@ export async function run(ctx: Ctx): Promise { if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42'); const buildNo = build ?? ''; - const ids = [bundleId, ...(signing.extraBundleIds ?? [])]; + const scan = discoverTargets(root, config); + const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)]; const profiles: Record = {}; 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 ?? '', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '' }) : 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 ?? '', []).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 { // 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 \` (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 { 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 }); diff --git a/cli/apple/signing.ts b/cli/apple/signing.ts index cea09e0..09fb9b0 100644 --- a/cli/apple/signing.ts +++ b/cli/apple/signing.ts @@ -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)['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 | 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; } 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 { @@ -237,7 +321,13 @@ export async function run(ctx: Ctx): Promise { 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(); 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 { 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 { 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 { 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 { 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 { // --- 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, ''))] : []), ...(!scope.profiles ? [] : ids.flatMap(id => { const p = profiles.get(id)!.plan; @@ -339,6 +452,20 @@ export async function run(ctx: Ctx): Promise { 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({ 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 { 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>(); 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 { 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); } 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)'); } diff --git a/cli/data/flags.json b/cli/data/flags.json index 7473ef4..2e3f8ba 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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]", diff --git a/cli/lib/keychain.ts b/cli/lib/keychain.ts new file mode 100644 index 0000000..5106f43 --- /dev/null +++ b/cli/lib/keychain.ts @@ -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 , 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 \` (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}" \`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" \`).\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 --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain 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 { + 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}`]; diff --git a/cli/lib/signing.ts b/cli/lib/signing.ts index 70019c9..9b28e45 100644 --- a/cli/lib/signing.ts +++ b/cli/lib/signing.ts @@ -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]) } } }, diff --git a/cli/lib/targets.ts b/cli/lib/targets.ts new file mode 100644 index 0000000..fc8e8b9 --- /dev/null +++ b/cli/lib/targets.ts @@ -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 { + const out = new Map(); + 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 { + const at = body.search(/buildSettings\s*=\s*\{/); + if (at < 0) return {}; + const inner = body.slice(body.indexOf('{', at) + 1); + const out: Record = {}; + 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 { + 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 = { ...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 | 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 | 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 | 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; +} diff --git a/cli/lib/xcode.ts b/cli/lib/xcode.ts index fe0dd52..a961fd6 100644 --- a/cli/lib/xcode.ts +++ b/cli/lib/xcode.ts @@ -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 */ 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[] { diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 9388e08..5a00c04 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -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 | | diff --git a/skills/build-and-sign/SKILL.md b/skills/build-and-sign/SKILL.md index e54b8e9..9845d21 100644 --- a/skills/build-and-sign/SKILL.md +++ b/skills/build-and-sign/SKILL.md @@ -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 ` signs with a build keychain (`--keychain --keychain-unlock` + unlocks that one; `signing ensure --keychain ` 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 diff --git a/skills/store-api/SKILL.md b/skills/store-api/SKILL.md index dae8e94..74e61dc 100644 --- a/skills/store-api/SKILL.md +++ b/skills/store-api/SKILL.md @@ -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 `, 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 diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 15a874c..4d19934 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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 ` (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 --keychain-unlock` for a + build keychain that holds the Apple Distribution key + certificate (`apple signing ensure --keychain ` imports + them); `--keychain` also signs the archive with `OTHER_CODE_SIGN_FLAGS=--keychain ` 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/` 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. diff --git a/tests/signing-targets.test.ts b/tests/signing-targets.test.ts new file mode 100644 index 0000000..0104925 --- /dev/null +++ b/tests/signing-targets.test.ts @@ -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 = ` + + aps-environmentdevelopment + com.apple.security.application-groupsgroup.com.example.notes +`; +const SHARE_ENT = ` + + com.apple.security.application-groupsgroup.com.example.notes + keychain-access-groups$(AppIdentifierPrefix)com.example.notes + com.apple.developer.associated-domainsapplinks:example.com +`; + +/** 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 => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00trailer`, 'latin1').toString('base64'); + const profile = (ent: Record) => ({ 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 => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record })}\x00`, 'latin1').toString('base64'); + + function store(o: { groupInProfile: boolean }) { + const caps: Record = { [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 } }; + 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 '); + 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 ', () => { + 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'); + }); +});