614 lines
44 KiB
TypeScript
614 lines
44 KiB
TypeScript
// Code signing through the App Store Connect API: bundle id capabilities, the distribution certificate and the
|
|
// 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] [--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
|
|
// holds no key for any valid one (RSA 2048 key generated here, kept 0600 in .storebound/signing/, never printed;
|
|
// --keychain also imports key + certificate into the login keychain or the given one), creates or reuses an
|
|
// App Store profile per bundle id (apple.bundleId + apple.signing.extraBundleIds) and installs it for Xcode.
|
|
// Every write is shown first (--dry shows only that) and asks once before anything is created. Developer ID
|
|
// certificates need the Account Holder: the API's 403 is explained. Pure planning: cli/lib/signing.ts.
|
|
import { ensureGcSwitch, gcSwitchPlan, readGcSwitch } from './game-center';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path';
|
|
import type { Ctx } from '../lib/ctx';
|
|
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 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 { parseMobileprovision, parsePlist } from '../lib/plist';
|
|
import { appGroupSteps, appGroupVerdicts, type AppGroupGap, type GroupEvidence, type GroupVerdicts, pbxTargets, type TargetKind } from '../lib/targets';
|
|
import { zipEntries, zipRead } from '../lib/zip';
|
|
import type { ShipConfig } from '../lib/config';
|
|
import { textTable } from '../lib/table';
|
|
import { type Asc, ascClient, type J } from './asc';
|
|
import { describeReq, execute, type Req } from './jsonapi';
|
|
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] [--refresh-profile]';
|
|
|
|
export const signingDir = (root: string): string => join(root, '.storebound', 'signing');
|
|
|
|
/** SPKIs of the private keys in .storebound/signing (file name = certificate id once created). */
|
|
function localKeys(root: string): Map<string, string> {
|
|
const dir = signingDir(root);
|
|
const out = new Map<string, string>();
|
|
if (!existsSync(dir)) return out;
|
|
for (const f of readdirSync(dir).filter(f => f.endsWith('.key'))) {
|
|
try { out.set(spkiOfKey(readFileSync(join(dir, f), 'utf8')), f); } catch { warn(TAG, `${f}: not a private key, ignored`); }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */
|
|
function keychainIdentities(): Set<string> {
|
|
if (process.platform !== 'darwin' || !rt.which('security')) return new Set();
|
|
const r = rt.spawnSync(['security', 'find-identity', '-v'], { stdout: 'pipe', stderr: 'pipe' });
|
|
return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1));
|
|
}
|
|
|
|
export async function findBundleId(c: Asc, identifier: string): Promise<J | null> {
|
|
const r = await c.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(identifier)}&include=bundleIdCapabilities&limit=200`);
|
|
const hit = (r.data as J[]).find(b => b.attributes.identifier === identifier) ?? null;
|
|
if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id))
|
|
.map((x: J) => x.attributes.capabilityType as string);
|
|
if (hit) hit.capabilityObjects = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)).map((x: J) => x.attributes);
|
|
return hit;
|
|
}
|
|
|
|
/** The iOS project's .entitlements files (next to each .xcodeproj, build copies skipped) with the capabilities they
|
|
* need and whether the project references them (CODE_SIGN_ENTITLEMENTS). null when there is no Xcode project. */
|
|
export function readProjectEntitlements(root: string): ProjectEntitlements[] | null {
|
|
const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p));
|
|
if (!projects.length) return null;
|
|
const out: ProjectEntitlements[] = [];
|
|
for (const proj of projects) {
|
|
let pbx = '';
|
|
try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { /* unreadable */ }
|
|
const refs = [...pbx.matchAll(/CODE_SIGN_ENTITLEMENTS\s*=\s*"?([^";]+)"?;/g)].map(m => m[1]!.trim().replace(/^\$\(SRCROOT\)\//, ''));
|
|
const walk = (dir: string, d: number): void => {
|
|
let names: string[] = [];
|
|
try { names = readdirSync(dir); } catch { return; }
|
|
for (const n of names) {
|
|
const f = join(dir, n);
|
|
if (n.endsWith('.entitlements')) {
|
|
// forward slashes on every OS: the pbxproj writes App/App.entitlements, Windows paths use backslashes
|
|
const rel = relative(root, f).split(sep).join('/');
|
|
const fwd = f.split(sep).join('/');
|
|
if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue;
|
|
out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => fwd.endsWith('/' + r.replace(/\\/g, '/'))) : null));
|
|
} else if (d > 0 && !n.startsWith('.') && !['node_modules', 'Pods', 'build', 'DerivedData'].includes(n) && !n.endsWith('.xcodeproj')) {
|
|
try { if (statSync(f).isDirectory()) walk(f, d - 1); } catch { /* unreadable */ }
|
|
}
|
|
}
|
|
};
|
|
walk(dirname(join(root, proj)), 3);
|
|
}
|
|
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; }
|
|
}
|
|
|
|
/** The entitlements of the newest ACTIVE profile of a bundle id with content, an Xcode-managed one too (automatic
|
|
* signing: the list serves the content even where /v1/profiles/{id} answers 404). null = none readable. */
|
|
export function newestProfileEntitlements(list: ApiProfile[]): Record<string, unknown> | null {
|
|
const p = list.filter(x => 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; }
|
|
}
|
|
|
|
/** What the API shows about the App Group association of one bundle id: the APP_GROUPS capability (and its settings)
|
|
* and the entitlements of its newest readable profile (manual signing: storebound's own; automatic: any, managed too). */
|
|
export function groupEvidence(b: J | null | undefined, list: ApiProfile[], style: 'automatic' | 'manual'): GroupEvidence | null {
|
|
if (!b) return null;
|
|
const caps = (b.capabilities as string[] | undefined) ?? [];
|
|
const obj = ((b.capabilityObjects as { capabilityType?: string; settings?: unknown }[] | undefined) ?? []).find(x => x.capabilityType === 'APP_GROUPS');
|
|
return { resourceId: b.id ?? null, capabilityOn: caps.includes('APP_GROUPS'), capabilitySettings: obj?.settings ?? null,
|
|
profileEnt: style === 'manual' ? profileEntitlements(list) : newestProfileEntitlements(list) };
|
|
}
|
|
|
|
/** Reads the evidence for every target with App Groups (API only, no writes): the bundle id with its capabilities and
|
|
* the profiles listed under it. */
|
|
export async function readGroupEvidence(c: Asc, targets: { bundleId: string; appGroups: string[] }[], style: 'automatic' | 'manual', type: ProfileType): Promise<(bundleId: string) => GroupEvidence | null> {
|
|
const out = new Map<string, GroupEvidence | null>();
|
|
for (const t of targets.filter(x => x.appGroups.length)) {
|
|
const b = await findBundleId(c, t.bundleId);
|
|
let list: ApiProfile[] = [];
|
|
if (b) {
|
|
try { list = (await c.all(`/v1/bundleIds/${b.id}/profiles?limit=200`) as J[]).filter(p => p.attributes?.profileType === type).map((p): ApiProfile => ({ id: p.id, attributes: p.attributes })); } catch { /* not listable: unproven */ }
|
|
}
|
|
out.set(t.bundleId, groupEvidence(b, list, style));
|
|
}
|
|
return id => out.get(id) ?? null;
|
|
}
|
|
|
|
export function warnAppGroupGaps(gaps: AppGroupGap[], tag: string): void {
|
|
for (const g of gaps) for (const l of appGroupSteps(g)) warn(tag, l);
|
|
}
|
|
|
|
/** Early exit text of a proven App Group gap (before the archive / at the end of ensure): the gap, the exact portal
|
|
* steps with the deep link, the plan command and the refresh. */
|
|
export function appGroupError(gaps: AppGroupGap[], what: string): string {
|
|
const nl = String.fromCharCode(10);
|
|
return `${gaps.map(g => `${g.bundleId}: ${g.why ?? `lacks ${g.groups.join(', ')}`}`).join('; ')}; ${what}.${nl}${gaps.map(g => appGroupSteps(g).slice(1).join(nl)).join(nl)}`;
|
|
}
|
|
|
|
const git = (root: string, args: string[]): string | null => {
|
|
try {
|
|
const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
return r.success ? r.stdout.toString() : null;
|
|
} catch { return null; }
|
|
};
|
|
|
|
/** The current git branch and the other local branches (and the worktrees that have them out) whose tree has an
|
|
* .entitlements file (build copies skipped). null outside a git repository. */
|
|
export function checkoutInfo(root: string): CheckoutInfo | null {
|
|
const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
if (head === null) return null;
|
|
const branch = head.trim() === 'HEAD' ? null : head.trim();
|
|
const worktrees = new Map<string, string>();
|
|
let wt = '';
|
|
for (const l of (git(root, ['worktree', 'list', '--porcelain']) ?? '').split('\n')) {
|
|
if (l.startsWith('worktree ')) wt = l.slice(9);
|
|
else if (l.startsWith('branch refs/heads/')) worktrees.set(l.slice(18), wt);
|
|
}
|
|
const elsewhere: string[] = [];
|
|
const branches = (git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']) ?? '').split('\n').filter(Boolean).filter(b => b !== branch).slice(0, 100);
|
|
for (const b of branches) {
|
|
const files = (git(root, ['ls-tree', '-r', '--name-only', b]) ?? '').split('\n').filter(f => f.endsWith('.entitlements') && !isBuildCopyPath(f));
|
|
if (!files.length) continue;
|
|
const w = worktrees.get(b);
|
|
elsewhere.push(`branch ${b} (${files.slice(0, 2).join(', ')}${files.length > 2 ? ', ...' : ''}${w ? `; worktree ${w}` : ''})`);
|
|
}
|
|
return { branch, elsewhere };
|
|
}
|
|
|
|
/** The real entitlements of the newest built IPA (highest build number, newest file per number): codesign on macOS,
|
|
* else the Mach-O signature's entitlements blob. null when there is no IPA or it cannot be read. */
|
|
export function builtIpaEntitlements(root: string, config: ShipConfig): BuildEntitlements | null {
|
|
const dir = join(resolvePath(root, config.apple?.build?.out ?? 'build/ios'), 'export');
|
|
const ipas: { file: string; build: string; exe: string }[] = [];
|
|
for (const f of exportedIpas(dir)) {
|
|
try {
|
|
const e = zipEntries(f).find(x => /^Payload\/[^/]+\.app\/Info\.plist$/.test(x.name))!;
|
|
const p = parsePlist(zipRead(f, e)) as Record<string, unknown>;
|
|
ipas.push({ file: f, build: ipaInfo(f).build, exe: `${e.name.slice(0, -'Info.plist'.length)}${String(p.CFBundleExecutable ?? '')}` });
|
|
} catch { /* not an app archive */ }
|
|
}
|
|
// exportedIpas is newest first; a stable sort by build number keeps the newest file of each number first
|
|
ipas.sort((x, y) => y.build.localeCompare(x.build, 'en', { numeric: true }));
|
|
const pick = ipas[0];
|
|
if (!pick) return null;
|
|
const entry = zipEntries(pick.file).find(x => x.name === pick.exe);
|
|
if (!entry) return null;
|
|
const bin = zipRead(pick.file, entry);
|
|
const rel = relative(root, pick.file);
|
|
if (process.platform === 'darwin' && rt.which('codesign')) {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'storebound-ent-'));
|
|
try {
|
|
const f = join(tmp, basename(pick.exe));
|
|
writeFileSync(f, bin);
|
|
const r = rt.spawnSync(['codesign', '-d', '--entitlements', ':-', f], { stdout: 'pipe', stderr: 'pipe' });
|
|
const xml = r.stdout.toString();
|
|
if (r.success && /<plist/.test(xml)) return buildEntitlements(pick.build, rel, xml, 'codesign');
|
|
} catch { /* fall back to the Mach-O blob */ } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
}
|
|
const xml = machoEntitlements(bin)[0];
|
|
return xml === undefined ? null : buildEntitlements(pick.build, rel, xml, 'mach-o');
|
|
}
|
|
|
|
export interface CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; checkout: CheckoutInfo | null; build: BuildEntitlements | null; mismatches: CapMismatch[] }
|
|
|
|
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements, and the
|
|
* entitlements of the newest local IPA when there is one. */
|
|
export async function capabilityReport(c: Asc, config: ShipConfig, root: string): Promise<CapabilityReport> {
|
|
const bundleId = config.apple!.bundleId;
|
|
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);
|
|
// 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;
|
|
return { bundleId, appId, config: cfg, project, checkout, build, mismatches: compareCapabilities({ appId, config: cfg, project, checkout, build }) };
|
|
}
|
|
|
|
export function printCapabilityReport(r: CapabilityReport, tag: string): void {
|
|
const proj = r.project === null ? 'no Xcode project found' : r.project.length ? r.project.map(p => `${p.file}: ${p.caps.map(c => c.cap).join(', ') || 'none'}${p.referenced === false ? ' (NOT referenced by CODE_SIGN_ENTITLEMENTS)' : ''}`).join('; ') : 'no .entitlements file';
|
|
const built = r.build ? `uploaded build ${r.build.number} (${r.build.file}, via ${r.build.via}): ${r.build.caps.map(c => c.cap).join(', ') || 'none'}` : 'no local IPA (the App Store Connect API does not expose a build\'s entitlements)';
|
|
log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj} | ${built}`);
|
|
for (const m of r.mismatches) warn(tag, `${m.msg}. Fix: ${m.fix}`);
|
|
if (!r.mismatches.length) log(tag, 'capabilities match (App ID, config, project)');
|
|
}
|
|
|
|
const profilesOf = (c: Asc, bundleIdId: string, type: ProfileType): Promise<ApiProfile[]> => readProfiles(p => c.all(p), bundleIdId, type);
|
|
|
|
const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []);
|
|
|
|
function install(p: ApiProfile): string[] {
|
|
const content = p.attributes.profileContent;
|
|
if (!content || !p.attributes.uuid) throw new Error(`profile ${p.attributes.name}: no content to install`);
|
|
const out: string[] = [];
|
|
for (const d of profileDirs(homedir())) {
|
|
mkdirSync(d, { recursive: true });
|
|
const f = join(d, `${p.attributes.uuid}.mobileprovision`);
|
|
writeFileSync(f, Buffer.from(content, 'base64'));
|
|
out.push(f);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function keychainImport(keyFile: string, cerFile: string, keychain: string | null): void {
|
|
if (process.platform !== 'darwin') throw new Error('--keychain: macOS only (on other systems keep the key in .storebound/signing)');
|
|
const kc = keychain ? ['-k', keychain] : [];
|
|
for (const cmd of [['security', 'import', keyFile, ...kc, '-t', 'priv', '-f', 'openssl', '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'], ['security', 'import', cerFile, ...kc, '-t', 'cert']]) {
|
|
const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' });
|
|
const msg = (r.stdout.toString() + r.stderr.toString()).trim();
|
|
if (r.exitCode !== 0 && !/already exists/i.test(msg)) throw new Error(`security import failed: ${msg.slice(0, 300)}`);
|
|
}
|
|
log(TAG, `key and certificate imported into ${keychain ?? 'the default keychain'}`);
|
|
}
|
|
|
|
export interface SigningState {
|
|
bundleId: string; teamId: string | null; registered: boolean;
|
|
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[];
|
|
/** targets whose App Group association the API cannot show (warned, not blocked) */
|
|
appGroupUnproven: AppGroupGap[];
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const sub = a._[0];
|
|
if (sub !== 'status' && sub !== 'ensure') throw new Error(USAGE);
|
|
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
|
const s: AppleSigning = config.apple?.signing ?? {};
|
|
const certType: CertType = s.certificateType ?? 'DISTRIBUTION';
|
|
const profileType: ProfileType = s.profileType ?? 'IOS_APP_STORE';
|
|
const dry = a.has('dry');
|
|
const json = a.has('json');
|
|
const style = s.style ?? 'automatic';
|
|
const scope = ensureScope(style);
|
|
const c = await ascClient(root, bundleId, config.apple?.appId);
|
|
|
|
// --- read everything first (read-only)
|
|
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);
|
|
const capPlan = main ? planCapabilities(main.id, main.capabilities ?? [], capabilitySpecs(s, config.games?.gameCenter === true)) : { create: [], present: [], extra: [] };
|
|
const certs: ApiCert[] = await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`);
|
|
const keys = localKeys(root);
|
|
const identities = keychainIdentities();
|
|
const hasKey = (x: ApiCert): boolean => {
|
|
const content = x.attributes.certificateContent;
|
|
if (!content) return false;
|
|
const spki = certSpki(content);
|
|
return (!!spki && keys.has(spki)) || identities.has(certSha1(content));
|
|
};
|
|
const choice = pickCertificate(certs, certType, hasKey);
|
|
const profiles = new Map<string, { list: ApiProfile[]; plan: ReturnType<typeof planProfile> | null }>();
|
|
for (const id of ids) {
|
|
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, 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 verdictsOf = (): GroupVerdicts => appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], style));
|
|
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 },
|
|
certificate: { type: certType, chosen: choice.cert?.attributes.name ?? choice.cert?.attributes.displayName ?? null, why: choice.why,
|
|
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: 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')
|
|
return managed
|
|
? { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0,
|
|
why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'}; renewed with the App ID's current capabilities when they change)` }
|
|
: { bundleId: id, name: null, uuid: null, state: 'Xcode-managed', installed: false, why: 'automatic signing: xcodebuild -allowProvisioningUpdates makes it at archive time' };
|
|
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: [], appGroupUnproven: [],
|
|
};
|
|
|
|
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 ?? [] };
|
|
});
|
|
{ const v = verdictsOf(); state.appGroupGaps = v.gaps; state.appGroupUnproven = v.unproven; }
|
|
|
|
// 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));
|
|
const cleanup = (): void => {
|
|
if (!unused.length) return;
|
|
log(TAG, `${unused.length} provisioning profile(s) storebound made that nothing uses${style === 'automatic' ? ' (automatic signing never needs them)' : ''}: ${unused.map(p => `"${p.attributes.name}"`).join(', ')}`);
|
|
log(TAG, 'deleting them is your call; not run by storebound:');
|
|
for (const p of unused) log(TAG, ` ${deleteProfileCmd(p)}`);
|
|
};
|
|
const report = await capabilityReport(c, config, root);
|
|
|
|
if (sub === 'status') {
|
|
if (json) { printJson({ ...state, style, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
|
|
log(TAG, `${bundleId}: ${main ? `registered, team ${state.teamId ?? '?'}` : 'NOT registered (storebound apple bundle)'}`);
|
|
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, ...state.appGroupUnproven], 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();
|
|
done(TAG, 'status read');
|
|
return;
|
|
}
|
|
|
|
// --- 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`);
|
|
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;
|
|
return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : [];
|
|
})),
|
|
];
|
|
// GAME_CENTER on the App ID: the app and its editable version need the Game Center switch too (apple game-center)
|
|
const gcSw = capabilitySpecs(s, config.games?.gameCenter === true).some(w => w.type === 'GAME_CENTER') ? await (async () => { try { return await readGcSwitch(c, await c.app(), await c.editableVersion()); } catch { return null; /* no app record yet: apple game-center later */ } })() : null;
|
|
const gcPlan = gcSw ? gcSwitchPlan(gcSw) : [];
|
|
plan.push(...gcPlan);
|
|
log(TAG, scope.why);
|
|
if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit)
|
|
warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`);
|
|
if (json && dry) { printJson({ ...state, style, scope, plan, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; }
|
|
for (const line of plan) log(TAG, `${dry ? 'would ' : ''}${line}`);
|
|
if (!plan.length) log(TAG, 'nothing to create on App Store Connect');
|
|
if (dry) {
|
|
if (scope.profiles) for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`);
|
|
if (capPlan.create.length) log(TAG, `would read the App ID's capabilities back after the write and fail if ${capPlan.create.map(r => (r.body as J).data.attributes.capabilityType).join(' / ')} is not there`);
|
|
printCapabilityReport(report, TAG);
|
|
warnAppGroupGaps([...state.appGroupGaps, ...state.appGroupUnproven], TAG);
|
|
cleanup();
|
|
done(TAG, `signing checked (dry run): ${plan.length} write(s)`);
|
|
return;
|
|
}
|
|
if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return;
|
|
|
|
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();
|
|
// automatic signing: Xcode makes the profile at archive time, so the App Group association is read from the App ID
|
|
// and the newest profile the API lists now (the writes above may have added capabilities: read the App IDs again)
|
|
for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null);
|
|
const v = appGroupVerdicts(targetList, id => groupEvidence(bundles.get(id), profiles.get(id)?.list ?? [], 'automatic'));
|
|
if (json) printJson({ ...state, style, scope, plan, appGroupGaps: v.gaps, appGroupUnproven: v.unproven, done: !v.gaps.length });
|
|
warnAppGroupGaps(v.unproven, TAG);
|
|
if (v.gaps.length) throw new Error(`capabilities in step, but ${appGroupError(v.gaps, 'the archive would fail at codesign (empty application-groups in the profile)')}`);
|
|
done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`);
|
|
return;
|
|
}
|
|
let cert = choice.cert;
|
|
if (needCert) {
|
|
const dir = signingDir(root);
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const { key } = newSigningKey();
|
|
const pending = join(dir, `pending-${Date.now()}.key`);
|
|
writeFileSync(pending, key.export({ type: 'pkcs1', format: 'pem' }).toString(), { mode: 0o600 });
|
|
chmodSync(pending, 0o600);
|
|
let created: J;
|
|
try {
|
|
const csr = buildCsr(key, { commonName: `storebound ${config.app.name}`.slice(0, 64) });
|
|
created = (await c.asc('POST', '/v1/certificates', certificateReq(certType, csrContent(csr)).body)).data;
|
|
} catch (e) {
|
|
if (isAmbiguous(e)) {
|
|
// the certificate may exist: it is the one whose public key is this pending key's. The key stays on disk either
|
|
// way (a later run finds the certificate through it), so no certificate is ever orphaned or made twice.
|
|
const mine = spkiOfKey(readFileSync(pending, 'utf8'));
|
|
let found: J | undefined;
|
|
for (let i = 0; i < 4 && !found; i++) {
|
|
if (i) await upsertTiming.sleep(upsertTiming.waitMs * i);
|
|
found = (await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`)).find((x: ApiCert) => !!x.attributes.certificateContent && certSpki(x.attributes.certificateContent) === mine);
|
|
}
|
|
if (!found) throw new Error(`${(e as Error).message}\n\nThe certificate request got no clear answer and App Store Connect does not list a certificate for the new key (yet). The key is kept in ${pending.replace(root + '/', '')}; run \`storebound apple signing ensure\` again in a minute: it finds the certificate through that key instead of making another one.`);
|
|
created = found;
|
|
log(TAG, 'the certificate request went through (found by its public key)');
|
|
} else {
|
|
rmSync(pending, { force: true });
|
|
const why = explainCertError(e, certType);
|
|
throw new Error(why ? `${(e as Error).message}\n\n${why}` : (e as Error).message);
|
|
}
|
|
}
|
|
const keyFile = join(dir, `${created.id}.key`), cerFile = join(dir, `${created.id}.cer`);
|
|
renameSync(pending, keyFile);
|
|
writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 });
|
|
log(TAG, `certificate ${created.attributes.name ?? created.id} created (expires ${String(created.attributes.expirationDate).slice(0, 10)}); key and certificate in .storebound/signing/ (gitignored, back them up)`);
|
|
if (a.has('keychain')) keychainImport(keyFile, cerFile, a.get('keychain') === 'true' ? null : a.get('keychain')!);
|
|
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, 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
|
|
// certificate is reused), never re-sent blindly
|
|
const r = (await createOnce<J>({ what: `${id} profile`, tag: TAG,
|
|
find: async () => {
|
|
const again = planProfile(await profilesOf(c, b.id, profileType), { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType });
|
|
return again.reuse ? { id: again.reuse.id, attributes: (await c.asc('GET', `/v1/profiles/${again.reuse.id}`)).data.attributes } : null;
|
|
},
|
|
create: async () => (await c.asc('POST', p.create!.path, p.create!.body)).data })).value;
|
|
prof = { id: r.id, attributes: r.attributes };
|
|
log(TAG, `${id}: profile "${prof.attributes.name}" created (${prof.attributes.uuid})`);
|
|
} else {
|
|
prof = p.reuse!;
|
|
if (!prof.attributes.profileContent) prof = { ...prof, attributes: (await c.asc('GET', `/v1/profiles/${prof.id}`)).data.attributes };
|
|
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();
|
|
// App Groups: the profile carries a group only when it is assigned to the bundle id in the developer portal
|
|
for (const t of targetList) if (t.appGroups.length) bundles.set(t.bundleId, (await findBundleId(c, t.bundleId)) ?? bundles.get(t.bundleId) ?? null);
|
|
const v = appGroupVerdicts(targetList, id => { const ent = finalEnt.get(id); const b = bundles.get(id); return ent ? { ...groupEvidence(b, [], 'manual')!, profileEnt: ent } : groupEvidence(b, [], 'manual'); });
|
|
const gaps = v.gaps;
|
|
if (json) printJson({ ...state, plan, appGroupGaps: gaps, appGroupUnproven: v.unproven, done: !gaps.length });
|
|
warnAppGroupGaps(v.unproven, TAG);
|
|
if (gaps.length) throw new Error(`profile(s) installed, but ${appGroupError(gaps, 'the build would fail at codesign')}`);
|
|
done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)');
|
|
}
|
|
|
|
|
|
/** After a capability write: read the App ID back (App Store Connect lags a little) and fail, with the API's answers,
|
|
* when a wanted capability is still not there. Seen live: an add that "succeeded" while the App ID kept only
|
|
* IN_APP_PURCHASE. */
|
|
export async function checkCapabilitiesStuck(c: Asc, bundleId: string, want: string[], responses: unknown[], o: { waitMs?: number; timeoutMs?: number } = {}): Promise<void> {
|
|
const r = await verifyCapabilities(async () => ((await findBundleId(c, bundleId))?.capabilities as string[] | undefined) ?? [], want,
|
|
{ waitMs: o.waitMs ?? upsertTiming.waitMs, timeoutMs: o.timeoutMs, sleep: upsertTiming.sleep, onWait: (m, n) => log(TAG, `read-back ${n}: ${m.join(', ')} not on the App ID yet, checking again`) });
|
|
if (!r.missing.length) { log(TAG, `read back: the App ID has ${want.join(', ')}`); return; }
|
|
throw new Error(`capability write did not stick: ${bundleId} still lacks ${r.missing.join(', ')} after ${r.reads} read(s) (it has: ${r.have.join(', ') || 'none'}).\n`
|
|
+ `API answers to the writes: ${JSON.stringify(responses).slice(0, 1500)}\n`
|
|
+ 'Check the bundle id in the config (apple.bundleId), that the API key has the Admin role, and add the capability in '
|
|
+ 'developer.apple.com > Certificates, Identifiers & Profiles > Identifiers if the API keeps refusing it; then run storebound apple signing status.');
|
|
}
|