apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs
This commit is contained in:
parent
5b6e0992ca
commit
445ab1adbc
17 changed files with 832 additions and 42 deletions
|
|
@ -6,6 +6,12 @@
|
|||
- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas.
|
||||
- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them).
|
||||
- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size.
|
||||
||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs)
|
||||
|
||||
- `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table.
|
||||
- App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/<id>`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides.
|
||||
- `apple build` over SSH / in CI: the refusal now names the fixes (GUI session `.command` file, a CI runner with an unlocked keychain, a build keychain). New `--keychain-unlock` (keychain password from `STOREBOUND_KEYCHAIN_PASSWORD`, never printed; runs `security unlock-keychain` and `set-key-partition-list`) and `--keychain <path>` (sign with a build keychain).
|
||||
- `storebound api` writes: the usage, the dry-run message and the refusals show the agent form with an example (`--yes --confirm <operationId>`), and the store-api skill says both flags are needed.
|
||||
- Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch).
|
||||
- `apple upload` / `play upload` `--pre-check "<cmd with {file}>"` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence <path.json>` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`.
|
||||
- New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate "<cmd>"`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export const USAGE = `usage:
|
|||
GETs run right away. Writes print the exact request and are sent only with --yes in a terminal; owner-only
|
||||
operations (submit, release, delete, cancel, prices, testers, review replies, experiments) also ask you to type the
|
||||
operation id. Agents without a terminal: --yes --confirm <operationId> (must equal the operation id; plain writes
|
||||
only, never owner-only operations). Full table: docs/api-coverage.md`;
|
||||
only, never owner-only operations), e.g.
|
||||
storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance
|
||||
Full table: docs/api-coverage.md`;
|
||||
|
||||
export interface Row { store: Store; id: string; method: string; path: string; gate: GateKind; wrapped: string }
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export type Decision =
|
|||
| { action: 'refuse'; why: string };
|
||||
|
||||
/** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */
|
||||
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm <operationId> (agents, plain writes only), or use the dedicated storebound command.';
|
||||
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm <operationId> (agents, plain writes only: --yes --confirm bundleIds_createInstance for the operation bundleIds_createInstance), or use the dedicated storebound command.';
|
||||
|
||||
/** --confirm never stands in for the owner's typed id on an owner-only operation. */
|
||||
export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.';
|
||||
|
|
@ -107,7 +107,7 @@ export function decide(g: GateInput): Decision {
|
|||
if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner'
|
||||
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
|
||||
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
|
||||
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
|
||||
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : g.kind === 'owner' ? 'writes are dry by default: add --yes (in a terminal; owner-only operations also ask for the typed operation id)' : `writes are dry by default: add --yes in a terminal, or from an agent's shell --yes --confirm <operationId> (e.g. \`storebound api apple ${g.opId ?? 'bundleIds_createInstance'} --body body.json --yes --confirm ${g.opId ?? 'bundleIds_createInstance'}\`)` };
|
||||
if (g.confirm !== undefined) {
|
||||
// an agent's typed confirmation: only for plain writes, only when it names this exact operation
|
||||
if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM };
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
|||
'builds_getCollection': 'apple/build apple/build-mas apple/encryption apple/listing apple/next-build apple/resubmit apple/status apple/testflight apple/upload art health lib/store-apps triage',
|
||||
'builds_updateInstance': 'apple/encryption lib/build-ops',
|
||||
'bundleIdCapabilities_createInstance': 'lib/signing',
|
||||
'bundleIds_createInstance': 'apple/bundle',
|
||||
'bundleIds_createInstance': 'apple/bundle apple/signing',
|
||||
'bundleIds_getCollection': 'apple/adhoc apple/bundle apple/signing',
|
||||
'bundleIds_profiles_getToManyRelated': 'apple/adhoc lib/signing',
|
||||
'certificates_createInstance': 'apple/signing lib/signing',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { done, log, printJson, warn } from '../lib/log';
|
|||
import { parseMobileprovision, parsePlist, type PlistValue } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { parseIdentities, profileDirs, readProfiles, type ApiProfile } from '../lib/signing';
|
||||
import { sshBuildRefusal } from '../lib/keychain';
|
||||
import { remoteBuildSession, showCmd } from '../lib/xcode';
|
||||
import {
|
||||
appProblems, entitlementKeysOf, entitlementsXml, findBuiltApp, findTauri, leakNeedles, masEntitlements, type MasProfile, masSteps, pathFreeEnv, pickMasIdentities,
|
||||
|
|
@ -158,7 +159,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (!mac) throw new Error('apple build --platform macos needs macOS (tauri build, codesign, productbuild)');
|
||||
const remote = remoteBuildSession(process.env, run1(['launchctl', 'managername'], 5000).out.trim());
|
||||
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session (write the command into a .command file and \`open -a Terminal <file>\`), or add --unsigned for a pipeline check.`);
|
||||
if (remote && !unsigned && !a.has('allow-ssh')) throw new Error(`${sshBuildRefusal(remote, { unlockFlag: false })}
|
||||
Or add --unsigned for a pipeline check.`);
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(entFile, entitlementsXml(ent));
|
||||
writeFileSync(configFile, `${JSON.stringify(overlay, null, 2)}\n`);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
// (Mac App Store build of a Tauri app: cli/apple/build-mas.ts)
|
||||
// storebound apple build [--version 1.2.0] [--build-number N] [--scheme S] [--workspace W | --project P]
|
||||
// [--configuration Release] [--out build/ios] [--no-prebuild] [--allow-stale-web] [--dry] [--json]
|
||||
// [--keychain <path>] [--keychain-unlock] [--allow-ssh] [--allow-app-group-gap]
|
||||
// Over SSH (no GUI session) codesign needs an unlocked keychain: --keychain-unlock unlocks it with the password in
|
||||
// STOREBOUND_KEYCHAIN_PASSWORD (never printed) and sets its key partition list; --keychain <path> signs with a build
|
||||
// keychain instead of the login keychain. Manual signing: stops before the build when a target's App Group is not in its
|
||||
// installed profile (the developer portal assigns groups; storebound apple signing status/ensure names the steps).
|
||||
// First the prebuild (build.prebuild / apple.prebuild: e.g. the web build + npx cap sync; once per flow run), then a
|
||||
// Capacitor project's copied web assets (ios/App/App/public) must be newer than the source, else it stops.
|
||||
// Finds the workspace / project and scheme (native, Capacitor ios/App, React Native ios/<Name>, Flutter ios/Runner,
|
||||
|
|
@ -24,20 +29,22 @@ import { pump } from '../lib/runner';
|
|||
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { keychainPassword, keychainSigningArgs, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../lib/keychain';
|
||||
import { appGroupGapsFor, appGroupSteps } from '../lib/targets';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, remoteBuildSession, xcodePath, flutterConfigArgs, ipaInfo, ipaVersionProblem, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { parsePlatform } from './platform';
|
||||
import { findBundleId } from './signing';
|
||||
import { discoverTargets, findBundleId } from './signing';
|
||||
import * as rt from '../lib/rt';
|
||||
import { shipPhase } from '../lib/ship-step';
|
||||
|
||||
const TAG = 'build';
|
||||
|
||||
/** Installed App Store profiles for a bundle id, newest expiry first: { uuid, name, team }. */
|
||||
export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string }[] {
|
||||
const out: { uuid: string; name: string; team: string; expires: string }[] = [];
|
||||
export function installedProfiles(bundleId: string, teamId: string | null, home = homedir()): { uuid: string; name: string; team: string; expires: string; entitlements: Record<string, unknown> }[] {
|
||||
const out: { uuid: string; name: string; team: string; expires: string; entitlements: Record<string, unknown> }[] = [];
|
||||
for (const d of profileDirs(home)) {
|
||||
let files: string[] = [];
|
||||
try { files = readdirSync(d).filter(f => f.endsWith('.mobileprovision')); } catch { continue; }
|
||||
|
|
@ -50,13 +57,35 @@ export function installedProfiles(bundleId: string, teamId: string | null, home
|
|||
const store = !p.ProvisionedDevices && !p.ProvisionsAllDevices && ent?.['get-task-allow'] !== true;
|
||||
if (!store || appId !== `${team}.${bundleId}` || (teamId && team !== teamId)) continue;
|
||||
if (Date.parse(String(p.ExpirationDate)) < Date.now()) continue;
|
||||
if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate) });
|
||||
if (!out.some(x => x.uuid === p.UUID)) out.push({ uuid: String(p.UUID), name: String(p.Name), team, expires: String(p.ExpirationDate), entitlements: ent ?? {} });
|
||||
} catch { /* not a profile */ }
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
|
||||
}
|
||||
|
||||
const security = (argv: string[]): { ok: boolean; out: string } => { const r = rt.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 60_000 }); return { ok: r.success, out: (r.stdout.toString() + r.stderr.toString()).trim() }; };
|
||||
|
||||
/** Puts a build keychain on the user's keychain search list (xcodebuild -exportArchive finds identities there). */
|
||||
function addToSearchList(keychain: string): void {
|
||||
const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out);
|
||||
const step = unlockSteps(keychain, list).find(x => x[1] === 'list-keychains');
|
||||
if (step) { const r = security(step); if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.slice(0, 200)}`); log(TAG, `${keychain} added to the keychain search list`); }
|
||||
}
|
||||
|
||||
/** --keychain-unlock: unlocks the keychain (the login keychain, or --keychain) with STOREBOUND_KEYCHAIN_PASSWORD and
|
||||
* sets the key partition list so codesign uses the keys without a prompt. The password is never printed. */
|
||||
function unlockKeychain(keychain: string | null): void {
|
||||
const pw = keychainPassword(process.env);
|
||||
const kc = keychain ?? parseKeychainList(security(['security', 'default-keychain', '-d', 'user']).out)[0] ?? join(homedir(), 'Library/Keychains/login.keychain-db');
|
||||
const list = parseKeychainList(security(['security', 'list-keychains', '-d', 'user']).out);
|
||||
for (const step of unlockSteps(kc, list)) {
|
||||
const r = security(step.map(x => (x === 'PASSWORD' ? pw : x)));
|
||||
if (!r.ok) throw new Error(`${showSecurity(step)} failed: ${r.out.split(pw).join('***').slice(0, 300)}`);
|
||||
}
|
||||
log(TAG, `keychain ${kc} unlocked, key partition list set (password from ${'STOREBOUND_KEYCHAIN_PASSWORD'})`);
|
||||
}
|
||||
|
||||
export async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
|
||||
log(TAG, `$ ${show}`);
|
||||
log(TAG, `full output: ${logFile}`);
|
||||
|
|
@ -142,33 +171,45 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (build && !/^\d+(\.\d+){0,2}$/.test(build)) throw new Error('--build-number: digits, e.g. 42 or 1.0.42');
|
||||
const buildNo = build ?? '<next build number>';
|
||||
|
||||
const ids = [bundleId, ...(signing.extraBundleIds ?? [])];
|
||||
const scan = discoverTargets(root, config);
|
||||
const ids = [bundleId, ...scan.extensions.map(t => t.bundleId)];
|
||||
const profiles: Record<string, string> = {};
|
||||
if (style === 'manual') for (const id of ids) {
|
||||
const p = installedProfiles(id, teamId)[0];
|
||||
if (p) profiles[id] = p.uuid; else if (!dry) throw new Error(`no installed App Store profile for ${id}: storebound apple signing ensure`);
|
||||
else warn(TAG, `no installed App Store profile for ${id} (storebound apple signing ensure)`);
|
||||
}
|
||||
// App Groups: a manual-signing profile carries a group only when the portal assigned it to the bundle id
|
||||
const groupGaps = style === 'manual' ? appGroupGapsFor([...(scan.main ? [scan.main] : []), ...scan.extensions], id => { const p = installedProfiles(id, teamId)[0]; return p ? p.entitlements : null; }, () => null) : [];
|
||||
for (const g of groupGaps) {
|
||||
const msg = `${g.bundleId}: the installed profile lacks App Group(s) ${g.groups.join(', ')}`;
|
||||
if (a.has('allow-app-group-gap') || dry) warn(TAG, msg);
|
||||
else throw new Error(`${msg}; codesign would refuse the build.\n${appGroupSteps(g).slice(1).join('\n')}\n(--allow-app-group-gap builds anyway)`);
|
||||
}
|
||||
const opts = exportOptions({ teamId: teamId ?? '<TEAM_ID>', style, profiles: style === 'manual' ? (Object.keys(profiles).length ? profiles : { [bundleId]: '<profile uuid>' }) : undefined });
|
||||
const plist = toXmlPlist(opts);
|
||||
const keychainFlag = a.get('keychain');
|
||||
const keychain = keychainFlag && keychainFlag !== 'true' ? resolve(keychainFlag) : null;
|
||||
const unlock = a.has('keychain-unlock');
|
||||
const archivePath = join(out, `${target.scheme}.xcarchive`);
|
||||
const exportPath = exportDir(out, version, buildNo);
|
||||
const authPreview: AscAuth = { keyPath: '$ASC_P8', keyId: '$ASC_KEY_ID', issuerId: '$ASC_ISSUER_ID' };
|
||||
const auth = style === 'automatic' ? authPreview : null;
|
||||
const steps = [
|
||||
...(target.framework === 'flutter' ? [showCmd(flutterConfigArgs(buildNo, version), null)] : []),
|
||||
showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth }), auth),
|
||||
showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth),
|
||||
showCmd(exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, auth), auth),
|
||||
];
|
||||
const unlockPreview = unlock ? unlockSteps(keychain ?? '<login keychain>', []).map(showSecurity) : [];
|
||||
const plan = { framework: target.framework, workspace: target.workspace ?? null, project: target.project ?? null, scheme: target.scheme, configuration,
|
||||
version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps, notes: target.notes };
|
||||
version: version ?? '(project value)', build: buildNo, teamId, signingStyle: style, exportOptions: opts, out: relative(root, out) || '.', steps: [...unlockPreview, ...steps], notes: target.notes };
|
||||
const webOpts = { config, root, cap, store: 'apple' as const, tag: TAG, noPrebuild: a.has('no-prebuild'), allowStale: a.has('allow-stale-web') };
|
||||
if (dry) {
|
||||
const web = await webStep({ ...webOpts, dry: true, quiet: json });
|
||||
if (json) { printJson({ ...plan, web }); return; }
|
||||
log(TAG, `${target.framework}: ${target.workspace ?? target.project} scheme ${target.scheme} (${configuration}), build ${buildNo}${version ? `, version ${version}` : ''}, signing ${style}`);
|
||||
for (const n of target.notes) log(TAG, n);
|
||||
for (const s of steps) log(TAG, `would run: ${s}`);
|
||||
for (const s of plan.steps) log(TAG, `would run: ${s}`);
|
||||
console.log(`\n${relative(root, join(out, 'ExportOptions.plist'))}:\n${plist}`);
|
||||
done(TAG, 'build planned (dry run)');
|
||||
return;
|
||||
|
|
@ -177,7 +218,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// codesign in an SSH session cannot use the login keychain: the archive fails late with errSecInternalComponent
|
||||
// (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101).
|
||||
const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim());
|
||||
if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal <file>\` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`);
|
||||
if (remote && !a.has('allow-ssh') && !unlock && !keychain) throw new Error(sshBuildRefusal(remote));
|
||||
if (unlock || keychain) {
|
||||
if (unlock) unlockKeychain(keychain);
|
||||
else addToSearchList(keychain!);
|
||||
}
|
||||
shipPhase('prebuild', 'web build / prebuild');
|
||||
const web = await webStep({ ...webOpts, dry: false });
|
||||
for (const n of target.notes) log(TAG, n);
|
||||
|
|
@ -190,7 +235,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try {
|
||||
if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null));
|
||||
shipPhase('build', `xcodebuild archive, build ${build}`);
|
||||
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth });
|
||||
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: keychain ? keychainSigningArgs(keychain) : undefined });
|
||||
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
|
||||
shipPhase('sign', 'xcodebuild -exportArchive (signing)');
|
||||
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
// provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings.
|
||||
// storebound apple signing status [--json] read-only: capabilities, certificates (with / without a
|
||||
// private key on this machine), profiles, what is installed
|
||||
// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]
|
||||
// storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]
|
||||
// every signable target of the Xcode project (app, app extensions such as a share extension or widget, watch app,
|
||||
// App Clip: PRODUCT_BUNDLE_IDENTIFIER + entitlements of project.pbxproj, or apple.signing.extensions) gets its
|
||||
// bundle id registered, the capabilities its entitlements need and (manual style) an App Store profile.
|
||||
// App Groups: the API cannot assign a group to a bundle id, so a profile lacks the group until it is ticked in the
|
||||
// developer portal; ensure compares the entitlements with the profile and names the portal link and the steps;
|
||||
// --refresh-profile makes the new profile after that.
|
||||
// adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick).
|
||||
// style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also
|
||||
// creates a distribution certificate when this machine
|
||||
|
|
@ -20,14 +26,15 @@ import { confirm, need } from '../lib/ctx';
|
|||
import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import {
|
||||
type ApiCert, type ApiProfile, type AppleSigning, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo,
|
||||
type ApiCert, type ApiProfile, type AppleSigning, type CapabilitySpec, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo,
|
||||
ENTITLEMENT_CAPABILITIES, machoEntitlements, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
|
||||
compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs,
|
||||
type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities,
|
||||
} from '../lib/signing';
|
||||
import { isBuildCopyPath } from '../lib/privacy-manifest';
|
||||
import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode';
|
||||
import { parsePlist } from '../lib/plist';
|
||||
import { parseMobileprovision, parsePlist } from '../lib/plist';
|
||||
import { appGroupGapsFor, appGroupSteps, type AppGroupGap, pbxTargets, type TargetKind } from '../lib/targets';
|
||||
import { zipEntries, zipRead } from '../lib/zip';
|
||||
import type { ShipConfig } from '../lib/config';
|
||||
import { textTable } from '../lib/table';
|
||||
|
|
@ -37,7 +44,7 @@ import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert';
|
|||
import * as rt from '../lib/rt';
|
||||
|
||||
const TAG = 'signing';
|
||||
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]';
|
||||
const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install] [--refresh-profile]';
|
||||
|
||||
export const signingDir = (root: string): string => join(root, '.storebound', 'signing');
|
||||
|
||||
|
|
@ -98,6 +105,78 @@ export function readProjectEntitlements(root: string): ProjectEntitlements[] | n
|
|||
return out;
|
||||
}
|
||||
|
||||
export interface SigningTarget {
|
||||
bundleId: string; name: string; kind: TargetKind | 'configured'; source: 'xcode' | 'config';
|
||||
/** the .entitlements file read (relative to the repository), if any */
|
||||
entitlements: string | null;
|
||||
capabilities: CapabilitySpec[];
|
||||
/** com.apple.security.application-groups of the entitlements */
|
||||
appGroups: string[];
|
||||
}
|
||||
export interface TargetScan { main: SigningTarget | null; extensions: SigningTarget[]; notes: string[] }
|
||||
|
||||
/** Every signable target of the iOS project: the app and its extensions / widgets / watch app / App Clip, read from the
|
||||
* pbxproj (Release configuration) and their entitlements files. apple.signing.extensions replaces the detected
|
||||
* extensions; apple.signing.extraBundleIds are added (no capabilities). Capabilities come from the entitlements:
|
||||
* keychain-access-groups has no App ID capability (nothing to enable). Offline. */
|
||||
export function discoverTargets(root: string, config: ShipConfig): TargetScan {
|
||||
const main = config.apple?.bundleId ?? '';
|
||||
const s: AppleSigning = config.apple?.signing ?? {};
|
||||
const notes: string[] = [];
|
||||
const found: SigningTarget[] = [];
|
||||
const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p));
|
||||
for (const proj of projects) {
|
||||
let pbx = '';
|
||||
try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { continue; }
|
||||
for (const t of pbxTargets(pbx)) {
|
||||
if (!t.bundleId) { notes.push(`${proj}: target ${t.name} has bundle id "${t.unresolved ?? '?'}" which storebound cannot resolve (an xcconfig or a build variable): list it in apple.signing.extensions`); continue; }
|
||||
if (found.some(f => f.bundleId === t.bundleId)) continue;
|
||||
let caps: CapabilitySpec[] = [], groups: string[] = [], file: string | null = null;
|
||||
if (t.entitlements) {
|
||||
const f = join(dirname(join(root, proj)), t.entitlements);
|
||||
try {
|
||||
const xml = readFileSync(f, 'utf8');
|
||||
file = relative(root, f).split(sep).join('/');
|
||||
caps = projectEntitlements(file, xml, true).caps.map((c): CapabilitySpec => ({ type: c.cap }));
|
||||
const g = (parsePlist(xml) as Record<string, unknown>)['com.apple.security.application-groups'];
|
||||
groups = Array.isArray(g) ? g.map(String) : [];
|
||||
} catch { notes.push(`${proj}: target ${t.name}: entitlements file ${t.entitlements} not readable`); }
|
||||
}
|
||||
found.push({ bundleId: t.bundleId, name: t.name, kind: t.kind, source: 'xcode', entitlements: file, capabilities: caps, appGroups: groups });
|
||||
}
|
||||
}
|
||||
const mainTarget = found.find(t => t.bundleId === main) ?? null;
|
||||
let extensions: SigningTarget[];
|
||||
if (s.extensions?.length) {
|
||||
extensions = s.extensions.map((x): SigningTarget => {
|
||||
const det = found.find(t => t.bundleId === x.bundleId);
|
||||
return { bundleId: x.bundleId, name: x.name ?? det?.name ?? x.bundleId, kind: det?.kind ?? 'configured', source: 'config', entitlements: det?.entitlements ?? null,
|
||||
capabilities: (x.capabilities ?? det?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)), appGroups: x.appGroups ?? det?.appGroups ?? [] };
|
||||
});
|
||||
notes.push(`apple.signing.extensions replaces the ${found.filter(t => t.bundleId !== main).length} extension target(s) found in the Xcode project`);
|
||||
} else extensions = found.filter(t => t.bundleId !== main);
|
||||
for (const b of s.extraBundleIds ?? []) if (b !== main && !extensions.some(t => t.bundleId === b)) extensions.push({ bundleId: b, name: b, kind: 'configured', source: 'config', entitlements: null, capabilities: [], appGroups: [] });
|
||||
return { main: mainTarget, extensions, notes };
|
||||
}
|
||||
|
||||
/** Name of a bundle id on the developer portal (letters, digits, spaces). */
|
||||
export const bundleIdName = (appName: string, t: SigningTarget): string => `${appName} ${t.name}`.replace(/[^A-Za-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60);
|
||||
|
||||
const platformOf = (type: ProfileType): 'IOS' | 'MAC_OS' => (type.startsWith('MAC_') ? 'MAC_OS' : 'IOS');
|
||||
|
||||
/** The entitlements of the newest ACTIVE profile of a bundle id that the API serves with its content (a managed one
|
||||
* is not readable: null = unknown). */
|
||||
export function profileEntitlements(list: ApiProfile[]): Record<string, unknown> | null {
|
||||
const p = list.filter(x => !x.managed && x.attributes.profileState === 'ACTIVE' && x.attributes.profileContent)
|
||||
.sort((a, b) => Date.parse(b.attributes.expirationDate ?? '') - Date.parse(a.attributes.expirationDate ?? ''))[0];
|
||||
if (!p) return null;
|
||||
try { return (parseMobileprovision(Buffer.from(p.attributes.profileContent!, 'base64')).Entitlements ?? {}) as Record<string, unknown>; } catch { return null; }
|
||||
}
|
||||
|
||||
export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void {
|
||||
for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l);
|
||||
}
|
||||
|
||||
const git = (root: string, args: string[]): string | null => {
|
||||
try {
|
||||
const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
|
|
@ -171,7 +250,9 @@ export async function capabilityReport(c: Asc, config: ShipConfig, root: string)
|
|||
const b = await findBundleId(c, bundleId);
|
||||
const appId = b ? (b.capabilities as string[]) : null;
|
||||
const cfg = capabilitySpecs(config.apple?.signing, config.games?.gameCenter === true).map(x => x.type);
|
||||
const project = readProjectEntitlements(root);
|
||||
// an extension's entitlements belong to its own bundle id (signing ensure handles it), not to the app's App ID
|
||||
const extFiles = new Set(discoverTargets(root, config).extensions.map(t => t.entitlements).filter((f): f is string => !!f));
|
||||
const project = readProjectEntitlements(root)?.filter(p => !extFiles.has(p.file)) ?? null;
|
||||
let build: BuildEntitlements | null = null;
|
||||
try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ }
|
||||
const checkout = project && cfg.some(x => Object.values(ENTITLEMENT_CAPABILITIES).includes(x as CapabilityType)) && !project.some(p => p.caps.length) ? checkoutInfo(root) : null;
|
||||
|
|
@ -219,6 +300,9 @@ export interface SigningState {
|
|||
capabilities: { present: string[]; missing: string[]; extra: string[] };
|
||||
certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null };
|
||||
profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[];
|
||||
/** every signable target: where it was found, its capabilities (present / missing on its App ID) and App Groups */
|
||||
targets: { bundleId: string; name: string; kind: string; source: string; registered: boolean; capabilities: string[]; missingCapabilities: string[]; appGroups: string[] }[];
|
||||
appGroupGaps: AppGroupGap[];
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -237,7 +321,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
|
||||
// --- read everything first (read-only)
|
||||
const ids = [bundleId, ...(s.extraBundleIds ?? [])];
|
||||
const scan = discoverTargets(root, config);
|
||||
const exts = scan.extensions;
|
||||
const ids = [bundleId, ...exts.map(t => t.bundleId)];
|
||||
const targetOf = (id: string): SigningTarget | undefined => (id === bundleId ? scan.main ?? undefined : exts.find(t => t.bundleId === id));
|
||||
const refresh = a.has('refresh-profile');
|
||||
const refreshIds = new Set(ids.filter(id => (targetOf(id)?.appGroups.length ?? 0) > 0));
|
||||
if (refresh && !refreshIds.size) ids.forEach(id => refreshIds.add(id));
|
||||
const bundles = new Map<string, J | null>();
|
||||
for (const id of ids) bundles.set(id, await findBundleId(c, id));
|
||||
const main = bundles.get(bundleId);
|
||||
|
|
@ -257,8 +347,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const b = bundles.get(id);
|
||||
if (!b) { profiles.set(id, { list: [], plan: null }); continue; }
|
||||
const list = await profilesOf(c, b.id, profileType);
|
||||
profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null });
|
||||
profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType, refresh: refresh && refreshIds.has(id) }) : null });
|
||||
}
|
||||
const wantCaps = (t: SigningTarget): CapabilitySpec[] => (t.bundleId === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true) : t.capabilities);
|
||||
const targetList: SigningTarget[] = [...(scan.main ? [scan.main] : []), ...exts];
|
||||
const gapsOf = (): AppGroupGap[] => appGroupGapsFor(targetList, id => { const b = bundles.get(id); return b ? profileEntitlements(profiles.get(id)?.list ?? []) : null; }, id => bundles.get(id)?.id ?? null);
|
||||
const state: SigningState = {
|
||||
bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main,
|
||||
capabilities: { present: capPlan.present, missing: capabilitySpecs(s, config.games?.gameCenter === true).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra },
|
||||
|
|
@ -266,7 +359,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null },
|
||||
profiles: ids.map(id => {
|
||||
const p = profiles.get(id)!;
|
||||
if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' };
|
||||
if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: id === bundleId ? 'register it first (storebound apple bundle)' : 'storebound apple signing ensure registers it' };
|
||||
const r = p.plan?.reuse;
|
||||
const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE');
|
||||
if (style === 'automatic')
|
||||
|
|
@ -277,8 +370,17 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile',
|
||||
installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' };
|
||||
}),
|
||||
targets: [], appGroupGaps: [],
|
||||
};
|
||||
|
||||
state.targets = ids.map(id => {
|
||||
const t = targetOf(id), b = bundles.get(id);
|
||||
const want = t ? wantCaps(t).map(w => w.type) : id === bundleId ? capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type) : [];
|
||||
return { bundleId: id, name: t?.name ?? id, kind: t?.kind ?? 'app', source: t?.source ?? 'config', registered: !!b, capabilities: want,
|
||||
missingCapabilities: want.filter(w => !(b?.capabilities ?? []).includes(w)), appGroups: t?.appGroups ?? [] };
|
||||
});
|
||||
state.appGroupGaps = style === 'manual' ? gapsOf() : [];
|
||||
|
||||
// storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one)
|
||||
const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []);
|
||||
const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse));
|
||||
|
|
@ -296,6 +398,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`);
|
||||
log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`);
|
||||
for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`);
|
||||
for (const n of scan.notes) log(TAG, n);
|
||||
console.log(textTable(['target', 'kind', 'bundle id', 'registered', 'capabilities', 'app groups'], state.targets.map(t => [t.name, t.kind, t.bundleId, t.registered ? 'yes' : 'no', t.capabilities.join(', ') || '-', t.appGroups.join(', ') || '-'])));
|
||||
warnAppGroupGaps(state.appGroupGaps, TAG);
|
||||
console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why])));
|
||||
printCapabilityReport(report, TAG);
|
||||
cleanup();
|
||||
|
|
@ -305,11 +410,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
// --- ensure: plan, show, ask once, then write
|
||||
if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`);
|
||||
const missingExtra = ids.filter(id => !bundles.get(id));
|
||||
if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`);
|
||||
for (const n of scan.notes) log(TAG, n);
|
||||
// extensions: register the bundle id (not there yet), then the capabilities its entitlements need
|
||||
const toRegister = exts.filter(t => !bundles.get(t.bundleId));
|
||||
const registerReq = (t: SigningTarget): Req => ({ method: 'POST', path: '/v1/bundleIds', note: `register ${t.bundleId} (${t.kind} ${t.name})`,
|
||||
body: { data: { type: 'bundleIds', attributes: { identifier: t.bundleId, name: bundleIdName(config.app.name, t), platform: platformOf(profileType) } } } });
|
||||
const extCapPlans = exts.map(t => { const b = bundles.get(t.bundleId); return { t, b, plan: b ? planCapabilities(b.id, b.capabilities ?? [], t.capabilities) : null }; });
|
||||
const extCapLines = extCapPlans.flatMap(({ t, b, plan: cp }) => (b ? (cp?.create ?? []).map(r => `${describeReq(r)} # ${t.bundleId}`)
|
||||
: t.capabilities.map(w => `POST /v1/bundleIdCapabilities {capabilityType="${w.type}"} # ${t.bundleId}, after it is registered`)));
|
||||
const needCert = scope.certificates && !choice.cert;
|
||||
const plan: string[] = [
|
||||
...toRegister.map(t => `${describeReq(registerReq(t))}`),
|
||||
...capPlan.create.map(r => describeReq(r)),
|
||||
...extCapLines,
|
||||
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
|
||||
...(!scope.profiles ? [] : ids.flatMap(id => {
|
||||
const p = profiles.get(id)!.plan;
|
||||
|
|
@ -339,6 +452,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
|
||||
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s, config.games?.gameCenter === true).map(w => w.type), responses);
|
||||
if (gcPlan.length && gcSw) await ensureGcSwitch(c, gcSw, TAG);
|
||||
for (const t of toRegister) {
|
||||
const made = (await createOnce<J>({ what: `${t.bundleId} bundle id`, tag: TAG,
|
||||
find: async () => findBundleId(c, t.bundleId),
|
||||
create: async () => (await c.asc('POST', '/v1/bundleIds', registerReq(t).body)).data })).value;
|
||||
bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? { ...made, capabilities: [] });
|
||||
log(TAG, `${t.bundleId}: bundle id registered (${t.kind} ${t.name})`);
|
||||
}
|
||||
for (const { t } of extCapPlans) {
|
||||
const bid = bundles.get(t.bundleId)!;
|
||||
const cp = planCapabilities(bid.id, bid.capabilities ?? [], t.capabilities);
|
||||
if (!cp.create.length) continue;
|
||||
const rs = await execute(c.asc, cp.create, { dry: false, tag: TAG });
|
||||
await checkCapabilitiesStuck(c, t.bundleId, t.capabilities.map(w => w.type), rs);
|
||||
}
|
||||
if (!scope.profiles) {
|
||||
printCapabilityReport(await capabilityReport(c, config, root), TAG);
|
||||
cleanup();
|
||||
|
|
@ -385,10 +512,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)');
|
||||
cert = created;
|
||||
}
|
||||
const finalEnt = new Map<string, Record<string, unknown>>();
|
||||
for (const id of ids) {
|
||||
const b = bundles.get(id)!;
|
||||
const list = profiles.get(id)!.list;
|
||||
const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
|
||||
const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType, refresh: refresh && refreshIds.has(id) });
|
||||
let prof: ApiProfile;
|
||||
if (p.create) {
|
||||
// an unclear answer is checked by reading the bundle id's profiles again (one active profile with this
|
||||
|
|
@ -407,9 +535,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log(TAG, `${id}: ${p.why}`);
|
||||
}
|
||||
if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`);
|
||||
try { finalEnt.set(id, (parseMobileprovision(Buffer.from(prof.attributes.profileContent ?? '', 'base64')).Entitlements ?? {}) as Record<string, unknown>); } catch { /* unreadable: not compared */ }
|
||||
}
|
||||
cleanup();
|
||||
if (json) printJson({ ...state, plan, done: true });
|
||||
// App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal
|
||||
const gaps = appGroupGapsFor(targetList, id => finalEnt.get(id) ?? null, id => bundles.get(id)?.id ?? null);
|
||||
if (json) printJson({ ...state, plan, appGroupGaps: gaps, done: !gaps.length });
|
||||
if (gaps.length) {
|
||||
warnAppGroupGaps(gaps, TAG);
|
||||
throw new Error(`profile(s) installed, but ${gaps.map(g => `${g.bundleId} lacks ${g.groups.join(', ')}${g.url ? ` (${g.url})` : ''}`).join('; ')}: the build would fail at codesign. Tick the App Group(s) in the developer portal (steps above), then run storebound apple signing ensure --refresh-profile.`);
|
||||
}
|
||||
done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
"allow-price-changes"
|
||||
],
|
||||
"apple build": [
|
||||
"allow-app-group-gap",
|
||||
"allow-ssh",
|
||||
"allow-stale-web",
|
||||
"allow-version-mismatch",
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"configuration",
|
||||
"env-file",
|
||||
"keychain",
|
||||
"keychain-unlock",
|
||||
"locale-map",
|
||||
"no-install",
|
||||
"no-prebuild",
|
||||
|
|
@ -82,6 +84,7 @@
|
|||
"profile",
|
||||
"progress",
|
||||
"project",
|
||||
"refresh-profile",
|
||||
"result-file",
|
||||
"scan-paths",
|
||||
"scheme",
|
||||
|
|
@ -122,7 +125,8 @@
|
|||
"apple game-center": [
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"no-install"
|
||||
"no-install",
|
||||
"refresh-profile"
|
||||
],
|
||||
"apple iap": [
|
||||
"all",
|
||||
|
|
@ -238,6 +242,7 @@
|
|||
"no-submit",
|
||||
"platform",
|
||||
"progress",
|
||||
"refresh-profile",
|
||||
"resolve-iaps",
|
||||
"result-file",
|
||||
"version",
|
||||
|
|
@ -261,7 +266,8 @@
|
|||
"apple signing": [
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"no-install"
|
||||
"no-install",
|
||||
"refresh-profile"
|
||||
],
|
||||
"apple status": [
|
||||
"cancel-submission",
|
||||
|
|
@ -269,6 +275,7 @@
|
|||
"locale-map",
|
||||
"no-install",
|
||||
"platform",
|
||||
"refresh-profile",
|
||||
"timeout",
|
||||
"version",
|
||||
"wait-review"
|
||||
|
|
@ -283,6 +290,7 @@
|
|||
"no-submit",
|
||||
"platform",
|
||||
"progress",
|
||||
"refresh-profile",
|
||||
"resolve-iaps",
|
||||
"result-file",
|
||||
"version",
|
||||
|
|
@ -460,6 +468,7 @@
|
|||
"name",
|
||||
"no-install",
|
||||
"react-native",
|
||||
"refresh-profile",
|
||||
"sku",
|
||||
"storefront",
|
||||
"unity"
|
||||
|
|
@ -563,7 +572,8 @@
|
|||
"games status": [
|
||||
"keychain",
|
||||
"locale-map",
|
||||
"no-install"
|
||||
"no-install",
|
||||
"refresh-profile"
|
||||
],
|
||||
"health": [
|
||||
"apple",
|
||||
|
|
@ -1067,6 +1077,7 @@
|
|||
"play",
|
||||
"prune",
|
||||
"react-native",
|
||||
"refresh-profile",
|
||||
"repair-stuck",
|
||||
"signature",
|
||||
"skip-out-of-range-regions",
|
||||
|
|
@ -1113,6 +1124,7 @@
|
|||
"agent",
|
||||
"all",
|
||||
"all-pages",
|
||||
"allow-app-group-gap",
|
||||
"allow-availability-changes",
|
||||
"allow-existing",
|
||||
"allow-price-changes",
|
||||
|
|
@ -1212,6 +1224,7 @@
|
|||
"keep",
|
||||
"keep-booted",
|
||||
"keychain",
|
||||
"keychain-unlock",
|
||||
"keyword",
|
||||
"keywords",
|
||||
"kind",
|
||||
|
|
@ -1292,6 +1305,7 @@
|
|||
"rating",
|
||||
"react-native",
|
||||
"reconvert",
|
||||
"refresh-profile",
|
||||
"region",
|
||||
"release",
|
||||
"release-date",
|
||||
|
|
@ -1497,7 +1511,7 @@
|
|||
],
|
||||
"apple signing": [
|
||||
"storebound apple signing status [--json] read-only: capabilities, certificates (with / without a",
|
||||
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json]"
|
||||
"storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--refresh-profile] [--json]"
|
||||
],
|
||||
"apple status": [
|
||||
"storebound apple status [--platform ios|macos|tvos|visionos] [--json]",
|
||||
|
|
|
|||
45
cli/lib/keychain.ts
Normal file
45
cli/lib/keychain.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Code signing keychain for `storebound apple build` outside the Mac's logged-in GUI session. codesign in an SSH
|
||||
// session (or any session without the login keychain unlocked) fails late with errSecInternalComponent. The fixes:
|
||||
// - run from the GUI session (a .command file opened with `open -a Terminal`)
|
||||
// - a CI runner / Jenkins agent started in that session, or with an unlocked keychain
|
||||
// - a build keychain: --keychain <path>, unlocked with --keychain-unlock (password from STOREBOUND_KEYCHAIN_PASSWORD,
|
||||
// never printed) and its key partition list set so codesign may use the keys without a prompt.
|
||||
// Pure planning here; cli/apple/build.ts runs it.
|
||||
export const KEYCHAIN_PASSWORD_ENV = 'STOREBOUND_KEYCHAIN_PASSWORD';
|
||||
|
||||
/** The refusal for a build in an SSH / non-GUI session, naming every fix. */
|
||||
export function sshBuildRefusal(reason: string, o: { unlockFlag?: boolean } = {}): string {
|
||||
const unlock = o.unlockFlag !== false;
|
||||
return `apple build: ${reason}; codesign there fails with errSecInternalComponent (locked keychain). Fixes, pick one:\n`
|
||||
+ ` 1. Run it in the Mac's logged-in GUI session: write the command into a .command file and \`open -a Terminal <file>\` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside).\n`
|
||||
+ ` 2. A CI runner / Jenkins agent started in the GUI session, or one whose keychain is unlocked first (\`security unlock-keychain -p "$${KEYCHAIN_PASSWORD_ENV}" <keychain>\`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" <keychain>\`).\n`
|
||||
+ (unlock ? ` 3. Let storebound do step 2: export ${KEYCHAIN_PASSWORD_ENV}=... (the keychain password; never printed) and add --keychain-unlock (the login keychain), or --keychain <path> --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain <path> imports them).\n`
|
||||
+ ` --allow-ssh skips this check when the keychain is already unlocked in this session.`
|
||||
: ' --allow-ssh skips this check when the keychain is already unlocked in this session.');
|
||||
}
|
||||
|
||||
export interface KeychainPlan { keychain: string; steps: string[][] }
|
||||
|
||||
/** The `security` calls --keychain-unlock runs, with PASSWORD as a placeholder (substituted by the caller, never logged). */
|
||||
export function unlockSteps(keychain: string, searchList: string[]): string[][] {
|
||||
const steps: string[][] = [['security', 'unlock-keychain', '-p', 'PASSWORD', keychain],
|
||||
['security', 'set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', 'PASSWORD', keychain]];
|
||||
if (!searchList.includes(keychain)) steps.push(['security', 'list-keychains', '-d', 'user', '-s', keychain, ...searchList]);
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** `security list-keychains` output -> paths (quotes stripped). */
|
||||
export const parseKeychainList = (out: string): string[] => out.split('\n').map(l => l.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
||||
|
||||
/** The password from the environment, or an error that names the variable (the value is never in a message). */
|
||||
export function keychainPassword(env: Record<string, string | undefined>): string {
|
||||
const p = env[KEYCHAIN_PASSWORD_ENV];
|
||||
if (!p) throw new Error(`--keychain-unlock needs the keychain password in the environment variable ${KEYCHAIN_PASSWORD_ENV} (not an argument: arguments end up in shell history)`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A command line for logs with the password replaced. */
|
||||
export const showSecurity = (argv: string[]): string => argv.map(a => (a === 'PASSWORD' ? '$' + KEYCHAIN_PASSWORD_ENV : a)).join(' ');
|
||||
|
||||
/** Settings that make xcodebuild sign with a given keychain. */
|
||||
export const keychainSigningArgs = (keychain: string): string[] => [`OTHER_CODE_SIGN_FLAGS=--keychain ${keychain}`];
|
||||
|
|
@ -43,6 +43,20 @@ export interface AppleSigning {
|
|||
capabilities?: (CapabilityType | CapabilitySpec)[];
|
||||
/** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */
|
||||
extraBundleIds?: string[];
|
||||
/** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project
|
||||
* (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is
|
||||
* registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */
|
||||
extensions?: ExtensionSpec[];
|
||||
}
|
||||
|
||||
export interface ExtensionSpec {
|
||||
bundleId: string;
|
||||
/** A label for messages and the bundle id's name on the developer portal. */
|
||||
name?: string;
|
||||
/** Default: the capabilities of the target's entitlements file (none when there is none). */
|
||||
capabilities?: (CapabilityType | CapabilitySpec)[];
|
||||
/** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */
|
||||
appGroups?: string[];
|
||||
}
|
||||
|
||||
export function validateSigning(s: AppleSigning | undefined): string[] {
|
||||
|
|
@ -56,6 +70,14 @@ export function validateSigning(s: AppleSigning | undefined): string[] {
|
|||
const t = typeof c === 'string' ? c : c?.type;
|
||||
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`);
|
||||
}
|
||||
for (const x of s.extensions ?? []) {
|
||||
if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; }
|
||||
for (const c of x.capabilities ?? []) {
|
||||
const t = typeof c === 'string' ? c : c?.type;
|
||||
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`);
|
||||
}
|
||||
if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`);
|
||||
}
|
||||
for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`);
|
||||
return e;
|
||||
}
|
||||
|
|
@ -158,19 +180,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date
|
|||
export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string }
|
||||
|
||||
/** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */
|
||||
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan {
|
||||
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan {
|
||||
const now = o.now ?? Date.now();
|
||||
const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE'
|
||||
&& Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId))
|
||||
.sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!));
|
||||
if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
|
||||
if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
|
||||
const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed);
|
||||
const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed);
|
||||
const why = [
|
||||
stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '',
|
||||
managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '',
|
||||
].filter(Boolean).join('; ') || `no ${o.type} profile`;
|
||||
return { reuse: null, why, create: {
|
||||
const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why;
|
||||
return { reuse: null, why: why2, create: {
|
||||
method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`,
|
||||
body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type },
|
||||
relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } },
|
||||
|
|
|
|||
138
cli/lib/targets.ts
Normal file
138
cli/lib/targets.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an
|
||||
// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the
|
||||
// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts.
|
||||
// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a
|
||||
// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in
|
||||
// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline.
|
||||
export type TargetKind = 'app' | 'extension' | 'watch' | 'clip';
|
||||
|
||||
export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null }
|
||||
|
||||
/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test
|
||||
* bundles, tools and libraries are not. */
|
||||
export function targetKind(productType: string): TargetKind | null {
|
||||
const m = /^com\.apple\.product-type\.(.+)$/.exec(productType);
|
||||
const t = m?.[1];
|
||||
if (!t) return null;
|
||||
if (t === 'application.on-demand-install-capable') return 'clip';
|
||||
if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch';
|
||||
if (t === 'application') return 'app';
|
||||
if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */
|
||||
function pbxObjects(pbx: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm;
|
||||
for (let m = re.exec(pbx); m; m = re.exec(pbx)) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
let depth = 0, q = false, i = open;
|
||||
for (; i < pbx.length; i++) {
|
||||
const ch = pbx[i];
|
||||
if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; }
|
||||
if (ch === '"') q = true;
|
||||
else if (ch === '{') depth++;
|
||||
else if (ch === '}' && --depth === 0) break;
|
||||
}
|
||||
out.set(m[1]!, pbx.slice(open + 1, i));
|
||||
re.lastIndex = i;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; };
|
||||
const field = (body: string, name: string): string | null => {
|
||||
const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body);
|
||||
return m ? unquote(m[1]!) : null;
|
||||
};
|
||||
|
||||
function buildSettings(body: string): Record<string, string> {
|
||||
const at = body.search(/buildSettings\s*=\s*\{/);
|
||||
if (at < 0) return {};
|
||||
const inner = body.slice(body.indexOf('{', at) + 1);
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier)
|
||||
* makes the value an identifier. Anything still containing $( afterwards is unresolved. */
|
||||
export function resolveSetting(value: string, vars: Record<string, string>): string {
|
||||
let v = value;
|
||||
for (let n = 0; n < 5 && /\$[({]/.test(v); n++) {
|
||||
v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => {
|
||||
const r = vars[name];
|
||||
if (r === undefined || r === `$(${name})`) return all;
|
||||
return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r;
|
||||
});
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** The signable targets of one project.pbxproj, in file order. */
|
||||
export function pbxTargets(pbx: string): PbxTarget[] {
|
||||
const objs = pbxObjects(pbx);
|
||||
const out: PbxTarget[] = [];
|
||||
for (const body of objs.values()) {
|
||||
if (field(body, 'isa') !== 'PBXNativeTarget') continue;
|
||||
const productType = field(body, 'productType') ?? '';
|
||||
const kind = targetKind(productType);
|
||||
if (!kind) continue;
|
||||
const name = field(body, 'name') ?? field(body, 'productName') ?? '?';
|
||||
const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!);
|
||||
const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!);
|
||||
const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b);
|
||||
const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1];
|
||||
const s = cfg ? buildSettings(cfg) : {};
|
||||
const vars: Record<string, string> = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) };
|
||||
const raw = s.PRODUCT_BUNDLE_IDENTIFIER;
|
||||
const bundleId = raw ? resolveSetting(raw, vars) : null;
|
||||
const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null;
|
||||
out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */
|
||||
export const profileAppGroups = (ent: Record<string, unknown> | undefined): string[] => {
|
||||
const g = ent?.['com.apple.security.application-groups'];
|
||||
return Array.isArray(g) ? g.map(String) : [];
|
||||
};
|
||||
|
||||
/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */
|
||||
export function appGroupGaps(want: string[], profileEnt: Record<string, unknown> | undefined): string[] {
|
||||
const have = profileAppGroups(profileEnt);
|
||||
if (have.includes('*')) return [];
|
||||
return want.filter(g => !have.includes(g));
|
||||
}
|
||||
|
||||
/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */
|
||||
export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`;
|
||||
|
||||
export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null }
|
||||
|
||||
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */
|
||||
export function appGroupSteps(g: AppGroupGap): string[] {
|
||||
const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list';
|
||||
return [
|
||||
`${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the API can enable the App Groups capability but cannot assign a group to a bundle id).`,
|
||||
`Open ${url} in the signed-in browser (agent-first: you drive it).`,
|
||||
'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (and Confirm in the dialog that says the profiles get invalidated).',
|
||||
'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button.',
|
||||
`Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */
|
||||
export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record<string, unknown> | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] {
|
||||
const out: AppGroupGap[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.appGroups.length) continue;
|
||||
const ent = profileEnt(t.bundleId);
|
||||
if (ent === null) continue; // no readable profile: unknown, not a gap
|
||||
const groups = appGroupGaps(t.appGroups, ent);
|
||||
if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -125,14 +125,14 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string }
|
|||
|
||||
const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []);
|
||||
|
||||
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null }
|
||||
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[] }
|
||||
|
||||
/** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */
|
||||
export function archiveArgs(i: ArchiveInput): string[] {
|
||||
return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]),
|
||||
'-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath,
|
||||
...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
|
||||
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])];
|
||||
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])];
|
||||
}
|
||||
|
||||
export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] {
|
||||
|
|
|
|||
|
|
@ -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 | |
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
|
|||
1. **Read.** `storebound apple signing status`: is the bundle id registered (else `storebound apple bundle`), which
|
||||
capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this
|
||||
Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store
|
||||
profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed.
|
||||
profiles per bundle id (the app and every extension / widget / watch app / App Clip found in the Xcode project, or `apple.signing.extensions`; `extraBundleIds` adds more) and whether Xcode has them installed. `signing ensure` registers the extensions' bundle ids, enables the capabilities their entitlements need and (manual style) makes their profiles; an App Group the profile lacks is reported with the developer portal link and steps (the API cannot assign a group; then `signing ensure --refresh-profile`, store-pitfalls 121).
|
||||
It also compares three places a capability must be and names what is missing where: the App ID, the config's
|
||||
`apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS,
|
||||
`com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...;
|
||||
|
|
@ -57,7 +57,12 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
|
|||
number**: later steps use exactly it. Any archive or export failure exits non-zero, so `apple build && apple upload`
|
||||
stops there; an export failure shows the IDEDistribution log's real error lines. xcodebuild runs with Apple's tool
|
||||
folders first on PATH (a Homebrew rsync breaks Xcode's IPA step: "exportArchive Copy failed"; `storebound doctor`
|
||||
flags it).
|
||||
flags it). Over SSH or in CI (no GUI session) codesign fails with errSecInternalComponent on a locked keychain:
|
||||
`apple build` stops first and names the fixes. `STOREBOUND_KEYCHAIN_PASSWORD=... storebound apple build
|
||||
--keychain-unlock` unlocks the login keychain and sets its key partition list (the password is read from the
|
||||
environment and never printed); `--keychain <path>` signs with a build keychain (`--keychain <path> --keychain-unlock`
|
||||
unlocks that one; `signing ensure --keychain <path>` imports the key + certificate into it). With manual signing it
|
||||
also stops when a target's App Group is missing from its installed profile (store-pitfalls 121).
|
||||
- Capacitor: the archive ships the web assets `npx cap sync` copied last. Set `build.prebuild` (e.g.
|
||||
`"npm run build && npx cap sync"`; per store `apple.prebuild` / `play.prebuild`) and `apple build` / `play build`
|
||||
run it first (once per `flow run`; `--no-prebuild` skips it). Then the copied `public/index.html` must be newer
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ storebound api apple salesReports_getCollection --param ... --out report.gz #
|
|||
on stderr (store-pitfalls 78). Never pipe or redirect a write; use the dedicated command the message names.
|
||||
An agent without a terminal sends a plain (non-owner) write with `--yes --confirm <operationId>`, the id equal
|
||||
to the one the call resolved to (store-pitfalls 92), after showing the dry run. `--confirm` never applies to
|
||||
owner-only operations.
|
||||
owner-only operations. Both flags are needed, and `--confirm` repeats the operation id:
|
||||
`storebound api apple bundleIds_createInstance --body bundle.json --yes --confirm bundleIds_createInstance`
|
||||
(without `--yes` it stays a dry run; a `--confirm` that differs from the id is refused).
|
||||
3. Owner-only operations (cli/api/gate.ts: every DELETE, submissions and their cancel, release requests, phased
|
||||
release changes, product page test start / stop, price schedules and price migrations, review replies, tester and
|
||||
user invitations, Play `edits.commit`, product activation, refunds, revokes and cancels, app signing) also ask for
|
||||
|
|
|
|||
|
|
@ -1229,8 +1229,13 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
(and read relative to the working directory, not the project).
|
||||
- **Do:** run the build in the Mac's logged-in session: a CI agent started there, or from SSH a `.command` file opened
|
||||
with `open -a Terminal <file>` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). `--allow-ssh` after
|
||||
`security unlock-keychain` skips the check.
|
||||
- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive; `credPath` in
|
||||
`security unlock-keychain` skips the check. To build over SSH or in CI anyway: export `STOREBOUND_KEYCHAIN_PASSWORD`
|
||||
(never printed, never an argument of storebound) and add `--keychain-unlock` (unlocks the login keychain and runs
|
||||
`security set-key-partition-list -S apple-tool:,apple:,codesign:`), or `--keychain <path> --keychain-unlock` for a
|
||||
build keychain that holds the Apple Distribution key + certificate (`apple signing ensure --keychain <path>` imports
|
||||
them); `--keychain` also signs the archive with `OTHER_CODE_SIGN_FLAGS=--keychain <path>` and puts the keychain on the search list.
|
||||
- **Guard:** `remoteBuildSession` in `cli/lib/xcode.ts` stops `apple build` before the archive, and its message names
|
||||
these fixes (`sshBuildRefusal`, `cli/lib/keychain.ts`; `tests/signing-targets.test.ts`); `credPath` in
|
||||
`cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an
|
||||
absolute path (`tests/build-sign.test.ts`).
|
||||
|
||||
|
|
@ -1454,3 +1459,30 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and
|
||||
names the MISS with the size and every candidate's match count.
|
||||
- **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`.
|
||||
||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs)
|
||||
|
||||
## 121. An app extension (share extension, widget, watch app) could not be signed: bundle id not registered, capabilities missing, App Group not in the profile
|
||||
|
||||
- **Symptom:** an iOS app with a share extension archived, then failed at export / codesign: "No profile for team 'X'
|
||||
matching 'com.example.app.share' found", "Provisioning profile ... doesn't include the App Groups capability" or
|
||||
"doesn't match the entitlements file's value for the com.apple.security.application-groups entitlement". `apple
|
||||
signing ensure` knew only the app's bundle id and refused (`not registered`) for `extraBundleIds`.
|
||||
- **Cause:** every signable target of the archive (app, app extension, widget, watch app, App Clip) is its own bundle id
|
||||
with its own capabilities and profile. And Apple's API can switch the APP_GROUPS capability on for a bundle id but
|
||||
cannot assign a group container to it: the profile carries `com.apple.security.application-groups` only after the
|
||||
group was ticked in the developer portal (Identifiers > the bundle id > App Groups > Configure).
|
||||
- **Do:** `storebound apple signing ensure --dry` lists every target found in the Xcode project (PRODUCT_BUNDLE_IDENTIFIER
|
||||
and CODE_SIGN_ENTITLEMENTS of the Release configuration) and what it would register, enable and create; without
|
||||
`--dry` it registers the extension bundle ids, enables the capabilities its entitlements need (the entitlement-to-
|
||||
capability table; `keychain-access-groups` needs none) and, for manual signing, creates an App Store profile per
|
||||
target. It then decodes each profile and compares its App Groups with the target's entitlements: a gap fails with
|
||||
the developer portal deep link `https://developer.apple.com/account/resources/identifiers/bundleId/edit/<id>` and the
|
||||
steps to tick the group (drive it in the signed-in browser; never press Remove / Delete / Revoke). After that
|
||||
`storebound apple signing ensure --refresh-profile` makes the profile again (a normal run reuses the active one, so it
|
||||
does not pile up profiles while the group is unassigned). `apple build` (manual signing) stops before the archive on the
|
||||
same gap (`--allow-app-group-gap` overrides). A variable bundle id (set in an xcconfig) is not resolvable: list the
|
||||
extension in `apple.signing.extensions` (`bundleId`, `capabilities`, `appGroups`), which replaces the detected ones.
|
||||
- **Guard:** `cli/lib/targets.ts` (pbxproj targets, App Group comparison, portal link and steps), `discoverTargets` in
|
||||
`cli/apple/signing.ts`, `tests/signing-targets.test.ts` (fixture pbxproj with an app, a share extension and app group
|
||||
entitlements, a decoded profile plist, a fake App Store Connect). There is no `console plan` form for the portal page:
|
||||
the steps are in the error and the warning.
|
||||
|
|
|
|||
341
tests/signing-targets.test.ts
Normal file
341
tests/signing-targets.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
// Signable targets of an Xcode project (app + share extension + App Group entitlements): `apple signing ensure`
|
||||
// registers the extension's bundle id, enables the capabilities its entitlements need, creates its profile and checks
|
||||
// the App Group against the profile (the API cannot assign a group to a bundle id); `apple build` over SSH names the
|
||||
// keychain fixes; `storebound api` writes say how an agent confirms. Fixtures are placeholders (com.example.*).
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { toXmlPlist } from '../cli/lib/plist';
|
||||
import { run as signing, discoverTargets, profileEntitlements } from '../cli/apple/signing';
|
||||
import { decide, NOT_A_TTY } from '../cli/api/gate';
|
||||
import { USAGE } from '../cli/api';
|
||||
import { keychainPassword, KEYCHAIN_PASSWORD_ENV, parseKeychainList, showSecurity, sshBuildRefusal, unlockSteps } from '../cli/lib/keychain';
|
||||
import { planProfile, validateSigning } from '../cli/lib/signing';
|
||||
import { appGroupGaps, appGroupGapsFor, appGroupSteps, bundleIdPortalUrl, pbxTargets, targetKind } from '../cli/lib/targets';
|
||||
import { upsertTiming } from '../cli/lib/upsert';
|
||||
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
|
||||
|
||||
const APP_ENT = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>aps-environment</key><string>development</string>
|
||||
<key>com.apple.security.application-groups</key><array><string>group.com.example.notes</string></array>
|
||||
</dict></plist>`;
|
||||
const SHARE_ENT = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.security.application-groups</key><array><string>group.com.example.notes</string></array>
|
||||
<key>keychain-access-groups</key><array><string>$(AppIdentifierPrefix)com.example.notes</string></array>
|
||||
<key>com.apple.developer.associated-domains</key><array><string>applinks:example.com</string></array>
|
||||
</dict></plist>`;
|
||||
|
||||
/** A pbxproj the way Xcode 15 writes it: app, share extension, a unit test bundle, a framework, one variable bundle id. */
|
||||
const PBX = `// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
objects = {
|
||||
/* Begin PBXNativeTarget section */
|
||||
AAAAAAAAAAAAAAAAAAAAAA01 /* App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC01 /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildPhases = ( );
|
||||
name = App;
|
||||
productName = App;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
AAAAAAAAAAAAAAAAAAAAAA02 /* ShareExt */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC02 /* Build configuration list for PBXNativeTarget "ShareExt" */;
|
||||
name = ShareExt;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AAAAAAAAAAAAAAAAAAAAAA03 /* AppTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */;
|
||||
name = AppTests;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
AAAAAAAAAAAAAAAAAAAAAA04 /* Kit */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC03 /* x */;
|
||||
name = Kit;
|
||||
productType = "com.apple.product-type.framework";
|
||||
};
|
||||
AAAAAAAAAAAAAAAAAAAAAA05 /* Widget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CCCCCCCCCCCCCCCCCCCCCC05 /* x */;
|
||||
name = Widget;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
/* Begin XCBuildConfiguration section */
|
||||
BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = com.example.notes.debug; };
|
||||
name = Debug;
|
||||
};
|
||||
BBBBBBBBBBBBBBBBBBBBBB02 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"{braces}",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.notes;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BBBBBBBBBBBBBBBBBBBBBB03 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Share/Share.entitlements";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.example.notes.$(PRODUCT_NAME:rfc1034identifier)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BBBBBBBBBBBBBBBBBBBBBB04 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = "$(MY_BASE_ID).widget"; };
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
/* Begin XCConfigurationList section */
|
||||
CCCCCCCCCCCCCCCCCCCCCC01 /* x */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BBBBBBBBBBBBBBBBBBBBBB01 /* Debug */,
|
||||
BBBBBBBBBBBBBBBBBBBBBB02 /* Release */,
|
||||
);
|
||||
};
|
||||
CCCCCCCCCCCCCCCCCCCCCC02 /* x */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB03 /* Release */, );
|
||||
};
|
||||
CCCCCCCCCCCCCCCCCCCCCC03 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( ); };
|
||||
CCCCCCCCCCCCCCCCCCCCCC05 /* x */ = { isa = XCConfigurationList; buildConfigurations = ( BBBBBBBBBBBBBBBBBBBBBB04 ); };
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
const files = { 'ios/App/App.xcodeproj/project.pbxproj': PBX, 'ios/App/App/App.entitlements': APP_ENT, 'ios/App/Share/Share.entitlements': SHARE_ENT };
|
||||
|
||||
describe('pbxproj targets', () => {
|
||||
test('targetKind: only signable product types', () => {
|
||||
expect(targetKind('com.apple.product-type.application')).toBe('app');
|
||||
expect(targetKind('com.apple.product-type.app-extension')).toBe('extension');
|
||||
expect(targetKind('com.apple.product-type.app-extension.messages-sticker-pack')).toBe('extension');
|
||||
expect(targetKind('com.apple.product-type.application.watchapp2')).toBe('watch');
|
||||
expect(targetKind('com.apple.product-type.application.on-demand-install-capable')).toBe('clip');
|
||||
expect(targetKind('com.apple.product-type.framework')).toBeNull();
|
||||
expect(targetKind('com.apple.product-type.bundle.unit-test')).toBeNull();
|
||||
});
|
||||
|
||||
test('app + share extension + widget: bundle ids resolved from Release, entitlements paths, tests and frameworks skipped', () => {
|
||||
const t = pbxTargets(PBX);
|
||||
expect(t.map(x => x.name)).toEqual(['App', 'ShareExt', 'Widget']);
|
||||
expect(t[0]).toMatchObject({ kind: 'app', bundleId: 'com.example.notes', entitlements: 'App/App.entitlements' });
|
||||
expect(t[1]).toMatchObject({ kind: 'extension', bundleId: 'com.example.notes.ShareExt', entitlements: 'Share/Share.entitlements' });
|
||||
expect(t[2]).toMatchObject({ bundleId: null, unresolved: '$(MY_BASE_ID).widget' });
|
||||
});
|
||||
|
||||
test('discoverTargets reads the entitlements: capabilities and app groups per target; unresolved ids are noted', () => {
|
||||
const p = project({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101' } }, files);
|
||||
const s = discoverTargets(p.root, p.loaded.config);
|
||||
expect(s.main).toMatchObject({ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] });
|
||||
expect(s.main!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'PUSH_NOTIFICATIONS']);
|
||||
expect(s.extensions).toHaveLength(1);
|
||||
expect(s.extensions[0]).toMatchObject({ bundleId: 'com.example.notes.ShareExt', kind: 'extension', source: 'xcode', appGroups: ['group.com.example.notes'] });
|
||||
expect(s.extensions[0]!.capabilities.map(c => c.type).sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']); // keychain-access-groups has no capability
|
||||
expect(s.notes.join('\n')).toContain('Widget has bundle id "$(MY_BASE_ID).widget"');
|
||||
});
|
||||
|
||||
test('apple.signing.extensions replaces the detected extensions; extraBundleIds are added', () => {
|
||||
const cfg = { ...BASE, apple: { bundleId: 'com.example.notes', signing: { extensions: [{ bundleId: 'com.example.notes.widget', capabilities: ['PUSH_NOTIFICATIONS' as const], appGroups: ['group.com.example.notes'] }], extraBundleIds: ['com.example.notes.watch'] } } };
|
||||
const p = project(cfg, files);
|
||||
const s = discoverTargets(p.root, p.loaded.config);
|
||||
expect(s.extensions.map(t => t.bundleId)).toEqual(['com.example.notes.widget', 'com.example.notes.watch']);
|
||||
expect(s.extensions[0]).toMatchObject({ source: 'config', appGroups: ['group.com.example.notes'], capabilities: [{ type: 'PUSH_NOTIFICATIONS' }] });
|
||||
});
|
||||
|
||||
test('validateSigning checks the extensions override', () => {
|
||||
expect(validateSigning({ extensions: [{ bundleId: 'com.example.app.share', capabilities: ['APP_GROUPS'], appGroups: ['group.com.example.app'] }] })).toEqual([]);
|
||||
const e = validateSigning({ extensions: [{ bundleId: 'bad id', capabilities: ['NOPE' as never], appGroups: ['x'] }, { bundleId: 'com.example.a', capabilities: ['NOPE' as never], appGroups: ['x'] }] });
|
||||
expect(e.join('\n')).toContain('"bad id" is not a bundle id');
|
||||
expect(e.join('\n')).toContain('unknown capability "NOPE"');
|
||||
expect(e.join('\n')).toContain('appGroups are ids like group.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Group check', () => {
|
||||
const prov = (ent: Record<string, unknown>): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record<string, string | string[]> })}\x00trailer`, 'latin1').toString('base64');
|
||||
const profile = (ent: Record<string, unknown>) => ({ id: 'P1', attributes: { name: 'p', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z', profileContent: prov(ent) } });
|
||||
|
||||
test('the profile plist is decoded and compared with the target\'s groups', () => {
|
||||
const ent = profileEntitlements([profile({ 'application-identifier': 'ABCDE12345.com.example.notes.share', 'com.apple.security.application-groups': ['group.com.example.other'] })])!;
|
||||
expect(appGroupGaps(['group.com.example.notes', 'group.com.example.other'], ent)).toEqual(['group.com.example.notes']);
|
||||
expect(appGroupGaps(['group.com.example.notes'], { 'com.apple.security.application-groups': ['*'] })).toEqual([]);
|
||||
expect(appGroupGaps(['group.com.example.notes'], {})).toEqual(['group.com.example.notes']);
|
||||
});
|
||||
|
||||
test('a managed or contentless profile is unknown, not a gap', () => {
|
||||
expect(profileEntitlements([{ id: 'M', attributes: { profileType: 'IOS_APP_STORE', profileState: 'ACTIVE' }, managed: true }])).toBeNull();
|
||||
expect(appGroupGapsFor([{ bundleId: 'com.example.notes', appGroups: ['group.com.example.notes'] }], () => null, () => 'X')).toEqual([]);
|
||||
});
|
||||
|
||||
test('the gap carries the portal deep link and the agent-first steps with a never-press list', () => {
|
||||
const g = appGroupGapsFor([{ bundleId: 'com.example.notes.share', appGroups: ['group.com.example.notes'] }], () => ({}), () => 'EXT0001');
|
||||
expect(g).toEqual([{ bundleId: 'com.example.notes.share', resourceId: 'EXT0001', groups: ['group.com.example.notes'], url: 'https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001' }]);
|
||||
expect(bundleIdPortalUrl('EXT0001')).toBe(g[0]!.url!);
|
||||
const steps = appGroupSteps(g[0]!).join('\n');
|
||||
expect(steps).toContain(g[0]!.url!);
|
||||
expect(steps).toContain('Never press');
|
||||
expect(steps).toContain('signing ensure --refresh-profile');
|
||||
});
|
||||
|
||||
test('planProfile: --refresh-profile makes a new profile although an active one is reusable', () => {
|
||||
const existing = [{ id: 'P1', certIds: ['C1'], attributes: { name: 'old', profileType: 'IOS_APP_STORE' as const, profileState: 'ACTIVE', expirationDate: '2099-01-01T00:00:00Z' } }];
|
||||
const o = { bundleId: 'com.example.notes', bundleIdId: 'B1', certId: 'C1', type: 'IOS_APP_STORE' as const };
|
||||
expect(planProfile(existing, o).reuse?.id).toBe('P1');
|
||||
const r = planProfile(existing, { ...o, refresh: true });
|
||||
expect(r.reuse).toBeNull();
|
||||
expect(r.create?.path).toBe('/v1/profiles');
|
||||
expect(r.why).toContain('--refresh-profile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signing ensure with an extension (fake App Store Connect)', () => {
|
||||
beforeEach(() => { upsertTiming.waitMs = 0; });
|
||||
afterEach(() => { restore(); upsertTiming.waitMs = 3000; });
|
||||
|
||||
const MAIN = 'BUNDLE0001', EXT = 'EXT0001';
|
||||
const cfg = (style: 'automatic' | 'manual') => ({ ...BASE, apple: { bundleId: 'com.example.notes', appId: '6400000101', signing: { style, capabilities: ['IN_APP_PURCHASE' as const] } } });
|
||||
const prov = (ent: Record<string, unknown>): string => Buffer.from(`\x30\x82\x01junk${toXmlPlist({ Name: 'x', Entitlements: ent as Record<string, string | string[]> })}\x00`, 'latin1').toString('base64');
|
||||
|
||||
function store(o: { groupInProfile: boolean }) {
|
||||
const caps: Record<string, string[]> = { [MAIN]: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'APP_GROUPS'], [EXT]: [] };
|
||||
let registered = false;
|
||||
const bundle = (id: string, ident: string) => ({ type: 'bundleIds', id, attributes: { identifier: ident, platform: 'IOS', seedId: 'ABCDE12345' },
|
||||
relationships: { bundleIdCapabilities: { data: caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}` })) } } });
|
||||
const incl = (id: string) => caps[id]!.map(t => ({ type: 'bundleIdCapabilities', id: `${id}_${t}`, attributes: { capabilityType: t, settings: null } }));
|
||||
const page = (data: unknown[], included: unknown[] = []) => ({ data, included, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } });
|
||||
const routes = [
|
||||
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes\.ShareExt/, answer: () => (registered ? page([bundle(EXT, 'com.example.notes.ShareExt')], incl(EXT)) : page([])) },
|
||||
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes&/, answer: () => page([bundle(MAIN, 'com.example.notes')], incl(MAIN)) },
|
||||
{ method: 'POST', match: /^\/v1\/bundleIds$/, answer: () => { registered = true; return { data: bundle(EXT, 'com.example.notes.ShareExt') }; } },
|
||||
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => {
|
||||
const a = (c.body as { data: { attributes: { capabilityType: string }; relationships: { bundleId: { data: { id: string } } } } }).data;
|
||||
caps[a.relationships.bundleId.data.id]!.push(a.attributes.capabilityType);
|
||||
return { data: { type: 'bundleIdCapabilities', id: 'x', attributes: { capabilityType: a.attributes.capabilityType } } };
|
||||
} },
|
||||
{ match: /^\/v1\/certificates\?/, answer: page([]) },
|
||||
{ method: 'POST', match: /^\/v1\/certificates$/, answer: { data: { type: 'certificates', id: 'CERT0001', attributes: { certificateType: 'DISTRIBUTION', name: 'Apple Distribution: Example', expirationDate: '2099-01-01T00:00:00.000+00:00', certificateContent: Buffer.from('cert').toString('base64') } } } },
|
||||
{ match: /^\/v1\/bundleIds\/[A-Z0-9]+\/profiles/, answer: page([]) },
|
||||
{ method: 'POST', match: /^\/v1\/profiles$/, answer: (c: { body: unknown }) => {
|
||||
const id = (c.body as { data: { relationships: { bundleId: { data: { id: string } } } } }).data.relationships.bundleId.data.id;
|
||||
const groups = o.groupInProfile ? { 'com.apple.security.application-groups': ['group.com.example.notes'] } : {};
|
||||
return { data: { type: 'profiles', id: `PROF_${id}`, attributes: { name: `storebound ${id}`, profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', uuid: `uuid-${id}`, expirationDate: '2099-01-01T00:00:00.000+00:00', profileContent: prov({ 'application-identifier': `ABCDE12345.${id}`, ...groups }) } } };
|
||||
} },
|
||||
{ match: /^\/v1\/apps\/6400000101\/gameCenterDetail/, answer: { errors: [{ status: '404' }] }, status: 404 },
|
||||
];
|
||||
return { routes: routes as never, caps, isRegistered: () => registered };
|
||||
}
|
||||
|
||||
test('--dry lists every target: the extension bundle id, its capabilities and (manual) its profile', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('manual'), files);
|
||||
const s = store({ groupInProfile: true });
|
||||
const calls = mockFetch(s.routes);
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
|
||||
const all = r.out + r.err;
|
||||
expect(all).toContain('POST /v1/bundleIds');
|
||||
expect(all).toContain('register com.example.notes.ShareExt');
|
||||
expect(all).toMatch(/POST \/v1\/bundleIdCapabilities \{capabilityType="APP_GROUPS"\} # com\.example\.notes\.ShareExt, after it is registered/);
|
||||
expect(all).toContain('ASSOCIATED_DOMAINS');
|
||||
expect(all).toContain('# com.example.notes.ShareExt: with the new certificate');
|
||||
expect(all).not.toContain('ASSOCIATED_DOMAINS is in ios/App/Share/Share.entitlements'); // the extension's entitlements are not compared with the app's App ID
|
||||
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
|
||||
});
|
||||
|
||||
test('automatic: registers the extension bundle id and its capabilities, reads them back, no profile', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('automatic'), files);
|
||||
const s = store({ groupInProfile: true });
|
||||
const calls = mockFetch(s.routes);
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--yes'])));
|
||||
expect(r.exit).toBeNull();
|
||||
const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`);
|
||||
expect(writes[0]).toBe('POST /v1/bundleIds');
|
||||
expect(writes.filter(w => w === 'POST /v1/bundleIdCapabilities')).toHaveLength(2);
|
||||
expect(writes.some(w => /profiles|certificates/.test(w))).toBe(false);
|
||||
expect(s.caps.EXT0001!.sort()).toEqual(['APP_GROUPS', 'ASSOCIATED_DOMAINS']);
|
||||
const reg = calls.find(c => c.method === 'POST' && c.path === '/v1/bundleIds')!.body as { data: { attributes: Record<string, string> } };
|
||||
expect(reg.data.attributes).toMatchObject({ identifier: 'com.example.notes.ShareExt', platform: 'IOS', name: 'Example Notes ShareExt' });
|
||||
});
|
||||
|
||||
test('manual: a profile for the app and the extension; the group missing from the profile fails with the portal link and steps', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('manual'), files);
|
||||
const s = store({ groupInProfile: false });
|
||||
const calls = mockFetch(s.routes);
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install']))).catch(e => ({ out: '', err: String(e.message ?? e), exit: 1 }));
|
||||
const all = r.out + r.err;
|
||||
expect(calls.filter(c => c.method === 'POST' && c.path === '/v1/profiles')).toHaveLength(2);
|
||||
expect(all).toContain('com.example.notes.ShareExt lacks group.com.example.notes');
|
||||
expect(all).toContain('https://developer.apple.com/account/resources/identifiers/bundleId/edit/EXT0001');
|
||||
expect(all).toContain('signing ensure --refresh-profile');
|
||||
});
|
||||
|
||||
test('manual: the profile carries the group: ensure finishes', async () => {
|
||||
ascEnv();
|
||||
const p = project(cfg('manual'), files);
|
||||
const s = store({ groupInProfile: true });
|
||||
mockFetch(s.routes);
|
||||
const r = await capture(() => signing(p.ctx(['ensure', '--yes', '--no-install'])));
|
||||
expect(r.exit).toBeNull();
|
||||
expect(r.out + r.err).toContain('signing ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('apple build over SSH: the keychain fixes', () => {
|
||||
test('the refusal names the GUI session, the CI runner and the keychain flags', () => {
|
||||
const m = sshBuildRefusal('this is an SSH session');
|
||||
expect(m).toContain('errSecInternalComponent');
|
||||
expect(m).toContain('open -a Terminal');
|
||||
expect(m).toContain('Jenkins');
|
||||
expect(m).toContain('security unlock-keychain');
|
||||
expect(m).toContain('security set-key-partition-list');
|
||||
expect(m).toContain('--keychain-unlock');
|
||||
expect(m).toContain('--keychain <path>');
|
||||
expect(m).toContain(KEYCHAIN_PASSWORD_ENV);
|
||||
expect(sshBuildRefusal('x', { unlockFlag: false })).not.toContain('--keychain-unlock');
|
||||
});
|
||||
|
||||
test('unlock steps carry a PASSWORD placeholder that is never shown; the build keychain joins the search list', () => {
|
||||
const steps = unlockSteps('/tmp/build.keychain-db', ['/Users/example/Library/Keychains/login.keychain-db']);
|
||||
expect(steps.map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list', 'list-keychains']);
|
||||
expect(steps[1]).toContain('apple-tool:,apple:,codesign:');
|
||||
expect(steps[2]).toEqual(['security', 'list-keychains', '-d', 'user', '-s', '/tmp/build.keychain-db', '/Users/example/Library/Keychains/login.keychain-db']);
|
||||
expect(showSecurity(steps[0]!)).toBe(`security unlock-keychain -p $${KEYCHAIN_PASSWORD_ENV} /tmp/build.keychain-db`);
|
||||
expect(unlockSteps('/a', ['/a']).map(s => s[1])).toEqual(['unlock-keychain', 'set-key-partition-list']);
|
||||
expect(parseKeychainList(' "/Users/example/Library/Keychains/login.keychain-db"\n "/Library/Keychains/System.keychain"\n')).toEqual(['/Users/example/Library/Keychains/login.keychain-db', '/Library/Keychains/System.keychain']);
|
||||
});
|
||||
|
||||
test('the password comes from the environment; its absence is an error that names the variable only', () => {
|
||||
expect(() => keychainPassword({})).toThrow(KEYCHAIN_PASSWORD_ENV);
|
||||
expect(keychainPassword({ [KEYCHAIN_PASSWORD_ENV]: 'pw-for-test' })).toBe('pw-for-test');
|
||||
try { keychainPassword({}); } catch (e) { expect((e as Error).message).not.toContain('pw-for-test'); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('storebound api writes: --yes --confirm <operationId>', () => {
|
||||
const w = { store: 'apple' as const, method: 'POST', kind: 'write' as const, opId: 'bundleIds_createInstance', yes: false, tty: false, dry: false, agent: false };
|
||||
test('the usage and the refusals show the example', () => {
|
||||
expect(USAGE).toContain('--yes --confirm bundleIds_createInstance');
|
||||
expect(NOT_A_TTY).toContain('--yes --confirm bundleIds_createInstance');
|
||||
const d = decide(w as never);
|
||||
expect(d.action).toBe('dry');
|
||||
const why = (d as { why: string }).why;
|
||||
expect(why).toContain('--yes --confirm bundleIds_createInstance');
|
||||
expect(why).toContain('storebound api apple bundleIds_createInstance --body body.json');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue