apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs
This commit is contained in:
parent
2e1c7cdc3c
commit
779cbdfcfd
17 changed files with 829 additions and 42 deletions
|
|
@ -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)');
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue