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