apple build exports each build into build/ios/export/<version>-<build>/ (cleaned first), runs xcodebuild with Apple's tool folders first on PATH (Homebrew rsync 3.x broke Xcode's IPA step: exportArchive Copy failed), shows the IDEDistribution log's real error lines and exits non-zero on any export failure; apple upload fails with exit 1 when the IPA's build number is already on App Store Connect (stale IPA after a failed build), --allow-existing re-checks, --build N picks that build's IPA, release and beta flows use both; doctor flags a non-Apple rsync first on PATH; desktop publish calls rsync by absolute path without --chmod (publishSteps); site download notice per OS (macOS signed and notarized, Windows unsigned for now, Linux unsigned); game-report credential tests hermetic against a machine default profile (pitfalls 53, 54)
This commit is contained in:
parent
8fd4ac741e
commit
0e4f579899
20 changed files with 338 additions and 48 deletions
|
|
@ -7,10 +7,11 @@
|
|||
// Unity Unity-iPhone), takes the next safe build number from App Store Connect (apple next-build) unless
|
||||
// --build-number is given, writes <out>/ExportOptions.plist (method app-store-connect, team, signing style and the
|
||||
// profiles from apple.signing), runs xcodebuild archive and -exportArchive with the API key
|
||||
// (-allowProvisioningUpdates -authenticationKey*), and leaves <out>/export/<App>.ipa for `storebound apple upload`.
|
||||
// (-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).
|
||||
// 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, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { ascKeyFile } from '../lib/credentials';
|
||||
|
|
@ -22,7 +23,7 @@ import { parseMobileprovision, toXmlPlist } from '../lib/plist';
|
|||
import { nextBuildNumber } from '../lib/release';
|
||||
import { exportOptions, profileDirs } from '../lib/signing';
|
||||
import { capacitorProject, webStep } from '../lib/web-assets';
|
||||
import { archiveArgs, type AscAuth, exportArgs, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { archiveArgs, type AscAuth, distributionErrors, exportArgs, foreignRsync, xcodePath, flutterConfigArgs, ipaInfo, pickXcode, scanXcode, showCmd } from '../lib/xcode';
|
||||
import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files';
|
||||
import { ascClient } from './asc';
|
||||
import { findBundleId } from './signing';
|
||||
|
|
@ -52,17 +53,45 @@ export function installedProfiles(bundleId: string, teamId: string | null, home
|
|||
return out.sort((a, b) => Date.parse(b.expires) - Date.parse(a.expires));
|
||||
}
|
||||
|
||||
async function runLogged(argv: string[], cwd: string, logFile: string, show: string, env?: Record<string, string>): Promise<void> {
|
||||
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}`);
|
||||
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
|
||||
// Apple's tool folders first: Xcode's IPA step runs `rsync` from PATH, and Homebrew's rsync 3.x breaks it (pitfall 54)
|
||||
const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PATH: xcodePath(process.env.PATH, process.env.DEVELOPER_DIR), LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', ...env } });
|
||||
const chunks: string[] = [];
|
||||
const keep = /(\berror:|\*\* (ARCHIVE|EXPORT|BUILD) (SUCCEEDED|FAILED) \*\*|requires a provisioning profile|No signing certificate|Signing for .* requires|Exported .* to)/;
|
||||
const each = (line: string): void => { chunks.push(line); if (keep.test(line)) log(TAG, line.trim().slice(0, 300)); };
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
writeFileSync(logFile, chunks.join('\n'));
|
||||
if (code !== 0) throw new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`);
|
||||
if (code !== 0) throw Object.assign(new Error(`${argv[0]} ${argv[1] ?? ''} failed (exit ${code}); last lines:\n${chunks.slice(-15).join('\n')}\nfull log: ${logFile}`), { output: chunks.join('\n') });
|
||||
}
|
||||
|
||||
/** The export folder of one build: <out>/export/<version>-<build> (build-<build> when the project sets the version). */
|
||||
export function exportDir(out: string, version: string | undefined, build: string): string {
|
||||
return join(out, 'export', version ? `${version}-${build}` : `build-${build}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* xcodebuild -exportArchive into a fresh folder (removed first, so an IPA left by an earlier export never makes it
|
||||
* fail with "Copy failed" or pass as this build). Throws on a non-zero exit or when no .ipa came out, so
|
||||
* `apple build && apple upload` and `flow run` stop there.
|
||||
*/
|
||||
export async function exportIpa(o: { root: string; out: string; archivePath: string; exportPath: string; auth: AscAuth | null; argv?: string[]; tmp?: string }): Promise<string> {
|
||||
rmSync(o.exportPath, { recursive: true, force: true });
|
||||
const exp = o.argv ?? exportArgs(o.archivePath, join(o.out, 'ExportOptions.plist'), o.exportPath, o.auth);
|
||||
try {
|
||||
await runLogged(exp, o.root, join(o.out, 'xcodebuild-export.log'), showCmd(exp, o.auth));
|
||||
} catch (e) {
|
||||
// "Copy failed" says nothing: the IDEDistribution log has the real line (e.g. "rsync error: syntax or usage error")
|
||||
const d = distributionErrors((e as { output?: string }).output ?? '', o.tmp);
|
||||
if (d?.lines.length) (e as Error).message += `\nwhy (${d.log}):\n${d.lines.map(l => ` ${l}`).join('\n')}`
|
||||
+ (d.lines.some(l => /rsync/i.test(l)) ? '\nA non-Apple rsync (Homebrew) breaks Xcode\'s IPA step: storebound doctor shows which one is first on PATH.' : '');
|
||||
throw e;
|
||||
}
|
||||
const ipa = existsSync(o.exportPath) ? readdirSync(o.exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
||||
if (!ipa) throw new Error(`xcodebuild -exportArchive exited 0 but left no .ipa in ${o.exportPath}: see ${join(o.out, 'xcodebuild-export.log')}`);
|
||||
return join(o.exportPath, ipa);
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
|
|
@ -117,7 +146,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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 archivePath = join(out, `${target.scheme}.xcarchive`);
|
||||
const exportPath = join(out, 'export');
|
||||
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 = [
|
||||
|
|
@ -141,6 +170,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (process.platform !== 'darwin' || !rt.which('xcodebuild')) throw new Error('apple build needs macOS with Xcode (xcode-select --install is not enough: the full Xcode app)');
|
||||
const web = await webStep({ ...webOpts, dry: false });
|
||||
for (const n of target.notes) log(TAG, n);
|
||||
const rsync = foreignRsync(process.env.PATH);
|
||||
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);
|
||||
const key = style === 'automatic' ? ascKeyFile(root) : null;
|
||||
|
|
@ -149,11 +180,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null));
|
||||
const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth });
|
||||
await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth));
|
||||
const exp = exportArgs(archivePath, join(out, 'ExportOptions.plist'), exportPath, realAuth);
|
||||
await runLogged(exp, root, join(out, 'xcodebuild-export.log'), showCmd(exp, realAuth));
|
||||
await exportIpa({ root, out, archivePath, exportPath, auth: realAuth });
|
||||
} finally { key?.cleanup(); }
|
||||
const ipa = existsSync(exportPath) ? readdirSync(exportPath).find(f => f.endsWith('.ipa')) : undefined;
|
||||
if (!ipa) throw new Error(`export finished but no .ipa in ${exportPath}`);
|
||||
const ipa = readdirSync(exportPath).find(f => f.endsWith('.ipa'))!;
|
||||
const info = ipaInfo(join(exportPath, ipa));
|
||||
if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`);
|
||||
if (info.build !== build) warn(TAG, `the IPA has build ${info.build}, not ${build}: the project sets CFBundleVersion itself (use $(CURRENT_PROJECT_VERSION) in Info.plist)`);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Upload an IPA to App Store Connect and wait until Apple has processed the build.
|
||||
// storebound apple upload [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
|
||||
// Default IPA: the newest in <apple.build.out or build/ios>/export. The bundle id, version and build number come from
|
||||
// the IPA's Info.plist; the upload is refused when that build number is already on App Store Connect for the app
|
||||
// (`apple next-build` / `apple build` pick a free one). Before any network call every PrivacyInfo.xcprivacy in the IPA
|
||||
// storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]
|
||||
// Default IPA: the newest in <apple.build.out or build/ios>/export (and its <version>-<build>/ folders), or with
|
||||
// --build N the one with that build number. The bundle id, version and build number come from the IPA's Info.plist.
|
||||
// An IPA whose build number is already on App Store Connect fails with exit 1 (a stale IPA left by a failed
|
||||
// `apple build`, pitfall 53); only --allow-existing (flows that resume after a finished upload) re-checks it instead. Before any network call every PrivacyInfo.xcprivacy in the IPA
|
||||
// (app root, Frameworks/*.framework, bundles; XML or binary) is checked: NSPrivacyTracking and NSPrivacyTrackingDomains
|
||||
// must agree (ITMS-91064, Invalid Binary a minute after the upload), else nothing is uploaded.
|
||||
// --via api (default): the App Store Connect API build upload (Build Uploads, API 4.1+): POST /v1/buildUploads,
|
||||
|
|
@ -196,20 +197,48 @@ async function altoolUpload(root: string, ipa: string, deadline: number, o: Hear
|
|||
} finally { k.cleanup(); }
|
||||
}
|
||||
|
||||
/** Every .ipa in the export folder and its per-build subfolders (<version>-<build>/), newest first. */
|
||||
export function exportedIpas(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const out: string[] = [];
|
||||
for (const f of readdirSync(dir)) {
|
||||
const p = join(dir, f);
|
||||
if (f.endsWith('.ipa')) out.push(p);
|
||||
else if (statSync(p).isDirectory()) for (const g of readdirSync(p)) if (g.endsWith('.ipa')) out.push(join(p, g));
|
||||
}
|
||||
return out.sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* An IPA whose build number App Store Connect already has (a build, or a finished build upload): an Error (exit 1)
|
||||
* unless --allow-existing, then 'recheck' (wait for it, send nothing). A stale IPA from a failed `apple build` must
|
||||
* never pass as "uploaded" (pitfall 53).
|
||||
*/
|
||||
export function existingBuild(o: { ipa: string; build: string; onAsc: boolean; state?: string; uploadComplete: boolean; allowExisting: boolean; next: string }): 'upload' | 'recheck' | Error {
|
||||
if (!o.onAsc && !o.uploadComplete) return 'upload';
|
||||
if (o.allowExisting) return 'recheck';
|
||||
return new Error(`${o.ipa} is build ${o.build}, which is already on App Store Connect${o.state ? ` (${o.state})` : ''}: nothing uploaded. `
|
||||
+ `Did the last build fail? Run \`storebound apple build\` again (next free build number: ${o.next}) or pass the new ipa. `
|
||||
+ 'To only re-check a build this command uploaded before, add --allow-existing.');
|
||||
}
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const a = ctx.args;
|
||||
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
||||
const via = a.str('via', 'api');
|
||||
if (via !== 'api' && via !== 'altool') throw new Error('--via api | altool');
|
||||
const want = a.get('build');
|
||||
let ipa = a._.find(x => x.endsWith('.ipa'));
|
||||
if (!ipa) {
|
||||
const dir = join(resolve(config.apple?.build?.out ?? 'build/ios'), 'export');
|
||||
const found = existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs) : [];
|
||||
if (!found[0]) throw new Error(`usage: storebound apple upload <app.ipa> (nothing in ${relative(root, dir)}; storebound apple build makes one)`);
|
||||
let found = exportedIpas(dir);
|
||||
if (want) found = found.filter(f => { try { return ipaInfo(f).build === want; } catch { return false; } });
|
||||
if (!found[0]) throw new Error(`usage: storebound apple upload <app.ipa> (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`);
|
||||
ipa = found[0];
|
||||
} else ipa = resolve(ipa);
|
||||
const info = ipaInfo(ipa);
|
||||
if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`);
|
||||
if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`);
|
||||
// every PrivacyInfo.xcprivacy in the app (root, Frameworks, bundles): Apple refuses the build minutes after the
|
||||
// upload when NSPrivacyTracking and NSPrivacyTrackingDomains disagree (ITMS-91064), so stop before any network call
|
||||
|
|
@ -223,20 +252,22 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
|
||||
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version`);
|
||||
const builds = await c.all(`/v1/builds?filter[app]=${appId}&limit=200&fields[builds]=version,processingState`);
|
||||
const numbers = builds.map(b => String(b.attributes.version));
|
||||
const next = nextBuildNumber(numbers);
|
||||
// the same IPA uploaded by an earlier run: nothing to send again, only the wait
|
||||
const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined;
|
||||
if (already) {
|
||||
log(TAG, `build ${info.version} (${info.build}) was uploaded already (build upload ${already.id}); not sending it again`);
|
||||
const onAsc = builds.find(b => String(b.attributes.version) === info.build);
|
||||
const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next });
|
||||
if (decision instanceof Error) throw decision;
|
||||
if (decision === 'recheck') {
|
||||
log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`);
|
||||
if (!wait) { if (json) printJson({ ...{ ipa: relative(root, ipa), version: info.version, build: info.build }, uploaded: true, alreadyUploaded: true }); done(TAG, `build ${info.build} already uploaded`); return; }
|
||||
const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG });
|
||||
if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null });
|
||||
if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; }
|
||||
process.exit(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout);
|
||||
}
|
||||
if (numbers.includes(info.build)) throw new Error(`build ${info.build} is already on App Store Connect: rebuild with --build-number ${next} (storebound apple build)`);
|
||||
if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`);
|
||||
const reqs = buildUploadReqs(appId, info, basename(ipa), size);
|
||||
const plan = via === 'api'
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@
|
|||
"version"
|
||||
],
|
||||
"apple upload": [
|
||||
"allow-existing",
|
||||
"build",
|
||||
"csv",
|
||||
"group",
|
||||
|
|
@ -784,6 +785,7 @@
|
|||
"agent",
|
||||
"all",
|
||||
"all-pages",
|
||||
"allow-existing",
|
||||
"allow-price-changes",
|
||||
"allow-stale-web",
|
||||
"alpha",
|
||||
|
|
@ -1132,7 +1134,7 @@
|
|||
"storebound apple testflight expire (--keep N | --older-than D) [--version x.y.z] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple upload": [
|
||||
"storebound apple upload [<app.ipa>] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]"
|
||||
"storebound apple upload [<app.ipa>] [--build N] [--allow-existing] [--via api|altool] [--no-wait] [--timeout 60m] [--dry] [--yes] [--json]"
|
||||
],
|
||||
"apple win-back": [
|
||||
"storebound apple win-back [--only sub1,sub2] [--dry] [--yes] [--json]"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { log } from './lib/log';
|
|||
import { textTable } from './lib/table';
|
||||
import { androidSdk, type AndroidSdkLookup, detectTools, findPlaywright, installCommands, type ToolStatus } from './lib/tools';
|
||||
import * as rt from './lib/rt';
|
||||
import { foreignRsync } from './lib/xcode';
|
||||
|
||||
export interface DoctorRow {
|
||||
id: ToolStatus['id'] | 'playwright';
|
||||
|
|
@ -32,6 +33,8 @@ export interface DoctorResult {
|
|||
/** Store credentials of the project folder (env, .storebound/credentials.json, profile): set, missing or
|
||||
* placeholder (a template value, treated as missing). Never the values. */
|
||||
credentials: { key: string; status: 'set' | 'missing' | 'placeholder' }[];
|
||||
/** macOS: a non-Apple rsync first on PATH (Homebrew 3.x breaks Xcode's exportArchive, pitfall 54), else null. */
|
||||
foreignRsync: string | null;
|
||||
}
|
||||
|
||||
export function doctor(cwd: string, env: Record<string, string | undefined> = process.env): DoctorResult {
|
||||
|
|
@ -42,7 +45,8 @@ export function doctor(cwd: string, env: Record<string, string | undefined> = pr
|
|||
usedFor: 'screenshot renders and web captures from a terminal (the desktop app brings its own)', install: pw ? [] : installCommands('playwright'), url: 'https://playwright.dev/docs/intro' });
|
||||
const project = CONFIG_NAMES.some(n => existsSync(join(cwd, n))) ? cwd : null;
|
||||
const sdk = androidSdk([join(cwd, 'android'), cwd], env);
|
||||
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd) };
|
||||
return { storebound: pkg.version, bun: rt.bunVersion, runtime: `${rt.runtime.name} ${rt.runtime.version}`, platform: `${process.platform}-${process.arch}`, project, tools, missing: tools.filter(t => !t.found).length, androidSdk: sdk, credentials: credentialStatus(cwd),
|
||||
foreignRsync: process.platform === 'darwin' ? foreignRsync(env.PATH) : null };
|
||||
}
|
||||
|
||||
export async function run(args: Args): Promise<void> {
|
||||
|
|
@ -54,6 +58,7 @@ export async function run(args: Args): Promise<void> {
|
|||
: `Android SDK: not found (play build needs it). Checked: ${r.androidSdk.checked.join('; ')}`);
|
||||
const set = r.credentials.filter(c => c.status === 'set').map(c => c.key), ph = r.credentials.filter(c => c.status === 'placeholder').map(c => c.key);
|
||||
log('doctor', `credentials: ${set.length ? set.join(', ') : 'none set'}${ph.length ? ` · placeholders (treated as missing): ${ph.join(', ')}` : ''}`);
|
||||
if (r.foreignRsync) log('doctor', `rsync: ${r.foreignRsync} is first on PATH and breaks Xcode exportArchive ("Copy failed") outside storebound; storebound apple build puts /usr/bin first. Fix for everything else: brew unlink rsync, or put /usr/bin before it`);
|
||||
const missing = r.tools.filter(t => !t.found);
|
||||
if (!missing.length) { log('doctor', 'everything storebound can use is here'); return; }
|
||||
console.log('\nTo install what is missing (pick one per tool; storebound never runs these for you):');
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -186,3 +186,46 @@ export function uploadProblems(state: { errors?: { code?: string; message?: stri
|
|||
const f = (x: { code?: string; message?: string; description?: string }) => [x.code, x.message ?? x.description].filter(Boolean).join(': ');
|
||||
return { errors: (state?.errors ?? []).map(f), warnings: (state?.warnings ?? []).map(f) };
|
||||
}
|
||||
|
||||
/** Apple's own tool folders, first on PATH for xcodebuild. */
|
||||
export const APPLE_PATH = ['/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
||||
|
||||
/**
|
||||
* PATH for xcodebuild: Xcode's developer usr/bin (when known) and Apple's folders first. Xcode's IPA packaging
|
||||
* (IDEDistributionCreateIPAStep) runs `rsync` from PATH and fails with "rsync error: syntax or usage error (code 1)",
|
||||
* shown as "exportArchive Copy failed", when Homebrew's rsync 3.x comes first (pitfall 54).
|
||||
*/
|
||||
export function xcodePath(path: string | undefined, developerDir?: string): string {
|
||||
const first = [...(developerDir ? [join(developerDir, 'usr', 'bin')] : []), ...APPLE_PATH];
|
||||
const rest = (path ?? '').split(':').filter(p => p && !first.includes(p));
|
||||
return [...first, ...rest].join(':');
|
||||
}
|
||||
|
||||
/** The first rsync on PATH when it is not Apple's (/usr/bin, /bin): Homebrew, MacPorts, ...; else null. */
|
||||
export function foreignRsync(path: string | undefined, exists: (p: string) => boolean = existsSync): string | null {
|
||||
for (const d of (path ?? '').split(':').filter(Boolean)) {
|
||||
const p = join(d, 'rsync');
|
||||
if (exists(p)) return d === '/usr/bin' || d === '/bin' ? null : p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real reason behind "exportArchive Copy failed": the error lines of the newest IDEDistribution*.log in the
|
||||
* .xcdistributionlogs folder xcodebuild names in its output, else the newest such folder in the temp folder.
|
||||
*/
|
||||
export function distributionErrors(output: string, tmp: string = process.env.TMPDIR ?? '/tmp'): { log: string; lines: string[] } | null {
|
||||
let dir = (output.match(/\/[^\s"']+\.xcdistributionlogs/g) ?? []).filter(p => existsSync(p)).at(-1);
|
||||
if (!dir && existsSync(tmp)) {
|
||||
dir = readdirSync(tmp).filter(f => f.endsWith('.xcdistributionlogs')).map(f => join(tmp, f))
|
||||
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
||||
}
|
||||
if (!dir) return null;
|
||||
const d = dir;
|
||||
const logs = readdirSync(d).filter(f => /^IDEDistribution.*\.log$/.test(f)).map(f => join(d, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
||||
for (const l of logs) {
|
||||
const lines = readFileSync(l, 'utf8').split('\n').filter(x => /rsync error|\berror:/i.test(x)).map(x => x.trim().slice(0, 300));
|
||||
if (lines.length) return { log: l, lines: [...new Set(lines)].slice(-10) };
|
||||
}
|
||||
return logs[0] ? { log: logs[0], lines: [] } : null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue