apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests

This commit is contained in:
CRTkafa 2026-10-14 19:10:01 +08:00
parent 357319a775
commit daae3715bf
12 changed files with 463 additions and 23 deletions

View file

@ -3,23 +3,29 @@
// 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]
// adds missing capabilities (apple.signing.capabilities), creates a distribution certificate when this machine
// 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 { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dirname, join, relative } 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, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, readProfiles,
type ApiCert, type ApiProfile, type AppleSigning, type CapMismatch, 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 { scanXcode } from '../lib/xcode';
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';
@ -57,6 +63,54 @@ export async function findBundleId(c: Asc, identifier: string): Promise<J | null
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')) {
const rel = relative(root, f);
if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue;
out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => f.endsWith('/' + r)) : 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 CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; mismatches: CapMismatch[] }
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements. */
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).map(x => x.type);
const project = readProjectEntitlements(root);
return { bundleId, appId, config: cfg, project, mismatches: compareCapabilities({ appId, config: cfg, project }) };
}
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';
log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj}`);
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) : []);
@ -103,6 +157,8 @@ export async function run(ctx: Ctx): Promise<void> {
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)
@ -138,21 +194,36 @@ export async function run(ctx: Ctx): Promise<void> {
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)' };
const r = p.plan?.reuse;
const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE');
if (!r && managed && (s.style ?? 'automatic') === 'automatic')
return { 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) ?? '?'})` };
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' };
}),
};
// 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); return; }
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)`);
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;
}
@ -161,28 +232,40 @@ export async function run(ctx: Ctx): Promise<void> {
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)`);
const needCert = !choice.cert;
const needCert = scope.certificates && !choice.cert;
const plan: string[] = [
...capPlan.create.map(r => describeReq(r)),
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
...ids.flatMap(id => {
...(!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'}`] : [];
}),
})),
];
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, plan }); return; }
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) {
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 (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);
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;
await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s).map(w => w.type), responses);
if (!scope.profiles) {
printCapabilityReport(await capabilityReport(c, config, root), TAG);
cleanup();
if (json) printJson({ ...state, style, scope, plan, done: true });
done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`);
return;
}
let cert = choice.cert;
if (needCert) {
const dir = signingDir(root);
@ -245,7 +328,21 @@ export async function run(ctx: Ctx): Promise<void> {
}
if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`);
}
cleanup();
if (json) printJson({ ...state, plan, done: true });
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.');
}

View file

@ -9,6 +9,8 @@
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
// storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
// Also compares the App ID's capabilities, apple.signing.capabilities and the iOS project's entitlements and warns,
// naming what is missing where.
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { log, printJson } from '../lib/log';
@ -16,6 +18,7 @@ import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '..
import { EMPTY_DRAFT } from '../lib/review-submit';
import { type Asc, ascClient, type J } from './asc';
import { notCancellable, readItems, type SubItem } from './submission';
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
import * as rt from '../lib/rt';
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
@ -68,6 +71,8 @@ export async function run(ctx: Ctx): Promise<void> {
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
let caps: CapabilityReport | { error: string };
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
// a submission back from App Review: its items and their states, and how to resubmit it
const unresolved: { id: string; items: SubItem[] }[] = [];
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
@ -97,7 +102,7 @@ export async function run(ctx: Ctx): Promise<void> {
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
if (v) buildVersions[b.id] = v;
}
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] });
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], capabilityCheck: caps });
return;
}
@ -113,4 +118,6 @@ export async function run(ctx: Ctx): Promise<void> {
const newest = builds.data[0]?.attributes?.version;
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
}
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
else printCapabilityReport(caps, 'apple');
}