apple build: --derived-data (default <out>/DerivedData with --out), --path-free (Swift/clang prefix maps, no serialized debug options) and an archive path scan
This commit is contained in:
parent
cb20912f0e
commit
f109c519ae
10 changed files with 276 additions and 7 deletions
|
|
@ -3,6 +3,7 @@
|
|||
// (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]
|
||||
// [--derived-data <dir>] [--path-free] [--scan-paths]
|
||||
// [--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
|
||||
|
|
@ -16,15 +17,21 @@
|
|||
// profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key
|
||||
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<version>-<build>/<App>.ipa for `storebound apple upload`. Each build exports into its own
|
||||
// folder, cleaned first: xcodebuild refuses to overwrite an earlier App.ipa ("exportArchive Copy failed", pitfall 53).
|
||||
// DerivedData: --derived-data <dir> (apple.build.derivedData) is xcodebuild -derivedDataPath; with --out (or apple.build.out)
|
||||
// it defaults to <out>/DerivedData, so a build is hermetic per output folder (without --out Xcode keeps ~/Library/...).
|
||||
// --path-free (apple.build.pathFree) remaps the home folder, checkout, out and DerivedData out of the binary and the dSYM /
|
||||
// Symbols (Swift -debug-prefix-map / -file-prefix-map, clang -ffile-prefix-map), then scans the archive and stops before the
|
||||
// export when a home path is left; --scan-paths only scans and warns. A --out outside the home folder prints a hint.
|
||||
// Full xcodebuild output goes to <out>/xcodebuild-*.log; the terminal gets the result lines.
|
||||
// Local only: nothing is sent to App Store Connect except reads (build numbers, team id, profiles).
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { ascKeyFile } from '../lib/credentials';
|
||||
import type { Ctx } from '../lib/ctx';
|
||||
import { need } from '../lib/ctx';
|
||||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { derivedDataFor, archiveScanDirs, leakNeedles, leakProblem, outsideHome, pathFreeSettings, scanForPaths } from '../lib/path-free';
|
||||
import { pump } from '../lib/runner';
|
||||
import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
||||
import { nextBuildNumber } from '../lib/release';
|
||||
|
|
@ -148,6 +155,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (sub) target.notes.unshift(`Capacitor project: ${sub} (${sub}/${cap!.config}; the only one found; set apple.build.workspace to use another)`);
|
||||
const configuration = a.str('configuration', cfg.configuration ?? 'Release');
|
||||
const out = resolve(a.get('out') ?? cfg.out ?? 'build/ios');
|
||||
const outChosen = !!(a.get('out') ?? cfg.out);
|
||||
const derivedData = derivedDataFor({ flag: a.get('derived-data'), config: cfg.derivedData, out, outChosen, resolve });
|
||||
const pathFree = a.has('path-free') || cfg.pathFree === true;
|
||||
const scanPaths = pathFree || a.has('scan-paths');
|
||||
const home = homedir();
|
||||
const pfSettings = pathFree ? pathFreeSettings({ home, root, out, derivedData }) : [];
|
||||
if (!pathFree && outsideHome(out, home)) warn(TAG, `--out is outside the home folder, but the build embeds the home folder and checkout paths (binary, dSYM, Symbols): add --path-free (or apple.build.pathFree) to remap them and scan the archive`);
|
||||
if (pathFree && (!derivedData || !outsideHome(derivedData, home))) warn(TAG, `--path-free: DerivedData ${derivedData ? 'is' : 'defaults to'} inside the home folder, and dsymutil / Xcode record that path in the dSYM, so the scan below will find it. Build with --out (or --derived-data) outside the home folder, e.g. a shared folder.`);
|
||||
const version = a.get('version');
|
||||
if (version && !/^\d+(\.\d+){0,2}$/.test(version)) throw new Error('--version: x.y.z');
|
||||
|
||||
|
|
@ -212,12 +227,12 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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, settings: keychain ? keychainSigningArgs(keychain) : undefined }), auth),
|
||||
showCmd(archiveArgs({ target, root, configuration, archivePath, version, build: buildNo, teamId: teamId ?? undefined, auth, settings: [...(keychain ? keychainSigningArgs(keychain) : []), ...pfSettings], derivedData }), 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: [...unlockPreview, ...steps], notes: target.notes };
|
||||
version: version ?? '(project value)', build: buildNo, derivedData: derivedData ?? '(Xcode default)', pathFree, 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 });
|
||||
|
|
@ -245,13 +260,21 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (rsync) log(TAG, `note: ${rsync} is first on your PATH; xcodebuild runs with /usr/bin first (a non-Apple rsync breaks Xcode's IPA export)`);
|
||||
mkdirSync(out, { recursive: true });
|
||||
writeFileSync(join(out, 'ExportOptions.plist'), plist);
|
||||
let leaks = 0;
|
||||
const key = style === 'automatic' ? ascKeyFile(root) : null;
|
||||
const realAuth: AscAuth | null = key ? { keyPath: key.keyPath, keyId: key.keyId, issuerId: key.issuerId } : null;
|
||||
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, settings: keychain ? keychainSigningArgs(keychain) : undefined });
|
||||
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth, settings: [...(keychain ? keychainSigningArgs(keychain) : []), ...pfSettings], derivedData });
|
||||
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
|
||||
if (scanPaths) {
|
||||
shipPhase('verify', 'scanning the archive for local paths');
|
||||
const found = scanForPaths(archiveScanDirs(archivePath), leakNeedles({ home, user: userInfo().username, root }));
|
||||
leaks = found.total;
|
||||
const why = leakProblem(found, relative(root, archivePath));
|
||||
if (why) { if (pathFree) throw new Error(why); warn(TAG, why.split(String.fromCharCode(10))[0]!); } else log(TAG, 'no local path in the archive (app, dSYMs, Symbols)');
|
||||
}
|
||||
shipPhase('sign', 'xcodebuild -exportArchive (signing)');
|
||||
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });
|
||||
} finally { key?.cleanup(); }
|
||||
|
|
@ -265,7 +288,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// ITMS-91064 (Invalid Binary after the upload): NSPrivacyTracking / NSPrivacyTrackingDomains of every manifest in the IPA
|
||||
const privacy = bundleTrackingIssues(ipaManifests(join(exportPath, ipa)));
|
||||
if (privacy.length) throw new Error(`${relative(root, join(exportPath, ipa))} was built, but Apple would refuse it at upload:\n${privacy.map(x => ` ${x}`).join('\n')}\nFix the manifest and build again.`);
|
||||
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle };
|
||||
const result = { ipa: join(exportPath, ipa), bundleId: info.bundleId, version: info.version, build: info.build, web: web.bundle, derivedData: derivedData ?? null, pathFree, ...(scanPaths ? { leaks } : {}) };
|
||||
writeFileSync(join(out, 'latest.json'), `${JSON.stringify(result, null, 2)}
|
||||
`);
|
||||
if (json) printJson(result);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
"allow-version-mismatch",
|
||||
"build-number",
|
||||
"configuration",
|
||||
"derived-data",
|
||||
"env-file",
|
||||
"keychain",
|
||||
"keychain-unlock",
|
||||
|
|
@ -1201,6 +1202,7 @@
|
|||
"debug-dump",
|
||||
"declarations",
|
||||
"delay",
|
||||
"derived-data",
|
||||
"detail",
|
||||
"device",
|
||||
"dir",
|
||||
|
|
|
|||
99
cli/lib/path-free.ts
Normal file
99
cli/lib/path-free.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Path-free iOS builds: no home folder, user name or checkout path inside the app binary or the archive's debug
|
||||
// symbols (a privacy gate over a shared release folder found hundreds of hits). Used by `apple build --path-free`.
|
||||
// - derivedDataFor: DerivedData per output folder (hermetic), not ~/Library/Developer/Xcode/DerivedData
|
||||
// - pathFreeSettings: xcodebuild build settings with Swift / clang prefix maps (dSYM and Symbols stay, paths are remapped)
|
||||
// - scanArchiveLeaks / leakProblem: scan the archive's .app, dSYMs and Symbols for the home folder and the user name
|
||||
// Pure except scanArchiveLeaks (bounded file reads).
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import { posix } from 'node:path';
|
||||
|
||||
const trim = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, '') : p);
|
||||
|
||||
/** DerivedData folder of a build: --derived-data, else apple.build.derivedData, else <out>/DerivedData when the output
|
||||
* folder was chosen (--out / apple.build.out), else null (Xcode's default under ~/Library). */
|
||||
export function derivedDataFor(o: { flag?: string; config?: string; out: string; outChosen: boolean; resolve: (p: string) => string }): string | null {
|
||||
const given = o.flag ?? o.config;
|
||||
if (given) return o.resolve(given);
|
||||
return o.outChosen ? posix.join(o.out, 'DerivedData') : null;
|
||||
}
|
||||
|
||||
/** True when `dir` is not inside `home`. */
|
||||
export const outsideHome = (dir: string, home: string): boolean => { const h = trim(home); return !!h && dir !== h && !dir.startsWith(`${h}/`); };
|
||||
|
||||
export interface PathFreeInput { home: string; root: string; out: string; derivedData?: string | null }
|
||||
|
||||
/** [from, to] prefix maps: the home folder, the checkout, the output folder and DerivedData, longest (most specific) first.
|
||||
* swiftc applies the FIRST matching map, clang the LAST: pathFreeSettings reverses the list for clang. */
|
||||
export function prefixMaps(i: PathFreeInput): [string, string][] {
|
||||
const all: [string, string][] = [[trim(i.home), '/home'], [trim(i.root), '.'], [trim(i.out), '/out']];
|
||||
if (i.derivedData) all.push([trim(i.derivedData), '/derived']);
|
||||
const seen = new Set<string>();
|
||||
return all.filter(([from]) => from.length > 1 && !seen.has(from) && seen.add(from)).sort((a, b) => b[0].length - a[0].length);
|
||||
}
|
||||
|
||||
const q = (s: string): string => (/[\s"'$]/.test(s) ? `"${s.replace(/(["\$])/g, '\$1')}"` : s);
|
||||
|
||||
/** xcodebuild build settings that remap every local path in Swift, C, C++ and Objective-C output and in DWARF. The
|
||||
* debug format is left alone (dwarf-with-dsym), so symbol upload keeps working. */
|
||||
export function pathFreeSettings(i: PathFreeInput): string[] {
|
||||
const maps = prefixMaps(i);
|
||||
const swift = maps.flatMap(([f, t]) => ['-debug-prefix-map', q(`${f}=${t}`), '-file-prefix-map', q(`${f}=${t}`)]);
|
||||
const clang = [...maps].reverse().flatMap(([f, t]) => [q(`-ffile-prefix-map=${f}=${t}`)]);
|
||||
// SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO: Xcode embeds the compiler's include paths, module cache and -ffile-compilation-dir
|
||||
// (the checkout) in the binary's __swift_ast section; no prefix map reaches it. Only lldb's Swift expression evaluation
|
||||
// uses it; crash symbolication (the dSYM's functions and lines) does not.
|
||||
return ['SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO', `OTHER_SWIFT_FLAGS=$(inherited) ${swift.join(' ')}`, `OTHER_CFLAGS=$(inherited) ${clang.join(' ')}`, `OTHER_CPLUSPLUSFLAGS=$(inherited) ${clang.join(' ')}`];
|
||||
}
|
||||
|
||||
export interface PathHit { file: string; kind: string; offset: number }
|
||||
|
||||
/** The needles of a leak scan, named by kind so a report never prints the value: the home folder, /Users/<user>/ and the
|
||||
* checkout. DerivedData is no needle: outside the home folder it is not the user's (dsymutil and Xcode record it in the
|
||||
* dSYM and the __swift_ast section and no prefix map reaches it); inside the home folder the home needle finds it. */
|
||||
export function leakNeedles(i: { home: string; user: string; root: string }): { kind: string; value: string }[] {
|
||||
const list = [{ kind: 'home folder', value: trim(i.home) + '/' }, { kind: 'user folder', value: `/Users/${i.user}/` }, { kind: 'checkout', value: trim(i.root) + '/' }];
|
||||
const seen = new Set<string>();
|
||||
return list.filter(n => n.value.length >= 6 && !seen.has(n.value) && seen.add(n.value));
|
||||
}
|
||||
|
||||
const MAX_FILE = 600 * 1048576;
|
||||
|
||||
/** Every needle occurrence in the files under `dirs` (all of them are counted; the first `perFile` offsets per file kept). */
|
||||
export function scanForPaths(dirs: string[], needles: { kind: string; value: string }[], perFile = 3): { hits: PathHit[]; total: number; files: number } {
|
||||
const ns = needles.map(n => ({ kind: n.kind, b: Buffer.from(n.value) }));
|
||||
const hits: PathHit[] = [];
|
||||
const bad = new Set<string>();
|
||||
let total = 0;
|
||||
const walk = (base: string, d: string): void => {
|
||||
let names: string[]; try { names = readdirSync(d); } catch { return; }
|
||||
for (const name of names) {
|
||||
const p = join(d, name);
|
||||
let st; try { st = statSync(p); } catch { continue; }
|
||||
if (st.isDirectory()) { walk(base, p); continue; }
|
||||
if (!st.isFile() || st.size > MAX_FILE) continue;
|
||||
const buf = readFileSync(p);
|
||||
let kept = 0;
|
||||
for (const { kind, b } of ns) {
|
||||
for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) {
|
||||
total++; bad.add(p);
|
||||
if (kept++ < perFile) hits.push({ file: relative(base, p).split(sep).join('/'), kind, offset: at });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const d of dirs) if (existsSync(d)) walk(d, d);
|
||||
return { hits, total, files: bad.size };
|
||||
}
|
||||
|
||||
/** What the archive holds that ships or symbolicates: Products (the .app, extensions, frameworks), dSYMs, Symbols. */
|
||||
export const archiveScanDirs = (archive: string): string[] => ['Products', 'dSYMs', 'Symbols'].map(x => join(archive, x));
|
||||
|
||||
/** The refusal text of a path-free build that still has local paths, else null. Counts, kinds, file and offset only. */
|
||||
export function leakProblem(r: { hits: PathHit[]; total: number; files: number }, archive: string): string | null {
|
||||
if (!r.total) return null;
|
||||
const lines = r.hits.slice(0, 8).map(h => ` ${h.file} @${h.offset}: ${h.kind}`);
|
||||
return `${r.total} local path${r.total === 1 ? '' : 's'} left in ${r.files} file${r.files === 1 ? '' : 's'} of ${archive} (home folder, user name or checkout):\n${lines.join('\n')}${r.hits.length > 8 ? '\n ...' : ''}\n`
|
||||
+ 'Stopped before the export. Causes: #file / #filePath in Swift sources (no prefix map reaches those literals: use #fileID), a prebuilt framework or Pod that carries its own paths '
|
||||
+ '(rebuild it path-free), or DerivedData / --out inside the home folder (dsymutil and Xcode record that path: use --out or --derived-data outside it, e.g. a shared folder).';
|
||||
}
|
||||
|
|
@ -18,6 +18,10 @@ export interface AppleBuildConfig {
|
|||
configuration?: string;
|
||||
/** Output folder for the archive, ExportOptions.plist, the IPA and logs. Default build/ios. */
|
||||
out?: string;
|
||||
/** xcodebuild -derivedDataPath. Default <out>/DerivedData when the output folder is chosen (--out / out), else Xcode's own folder under ~/Library. */
|
||||
derivedData?: string;
|
||||
/** Remap the home folder, checkout, output folder and DerivedData out of the binary and the debug symbols, then scan the archive (same as --path-free). */
|
||||
pathFree?: boolean;
|
||||
}
|
||||
|
||||
export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] {
|
||||
|
|
@ -26,6 +30,8 @@ export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] {
|
|||
if (b.workspace && !b.workspace.endsWith('.xcworkspace')) e.push('apple.build.workspace: a path ending in .xcworkspace');
|
||||
if (b.project && !b.project.endsWith('.xcodeproj')) e.push('apple.build.project: a path ending in .xcodeproj');
|
||||
if (b.scheme !== undefined && !b.scheme.trim()) e.push('apple.build.scheme: empty');
|
||||
if (b.derivedData !== undefined && (typeof b.derivedData !== 'string' || !b.derivedData.trim())) e.push('apple.build.derivedData: a folder path');
|
||||
if (b.pathFree !== undefined && typeof b.pathFree !== 'boolean') e.push('apple.build.pathFree: true or false');
|
||||
return e;
|
||||
}
|
||||
|
||||
|
|
@ -125,13 +131,13 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string }
|
|||
|
||||
const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []);
|
||||
|
||||
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[] }
|
||||
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[]; /** xcodebuild -derivedDataPath */ derivedData?: string | null }
|
||||
|
||||
/** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */
|
||||
export function archiveArgs(i: ArchiveInput): string[] {
|
||||
return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]),
|
||||
'-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath,
|
||||
...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
|
||||
...(i.derivedData ? ['-derivedDataPath', i.derivedData] : []), ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
|
||||
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue