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:
parent
357319a775
commit
daae3715bf
12 changed files with 463 additions and 23 deletions
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue