mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a09a59630 | ||
|
|
a7ab98c51d | ||
|
|
d1fb077744 | ||
|
|
2bf50ef910 | ||
|
|
c5ed3ccf37 | ||
|
|
adcd3a121d |
No files matched your search
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [worker] Prewarm Xcode interface and asset build tools in the background to reduce iOS build times. ([#4211](https://github.com/expo/eas-cli/pull/4211) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disallow 0-length uploads. ([#4192](https://github.com/expo/eas-cli/pull/4192) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
const spawnResult: SpawnResult = {
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: { kill: jest.fn() },
|
||||
}) as unknown as SpawnPromise<SpawnResult>;
|
||||
}
|
||||
|
||||
function setProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
}
|
||||
|
||||
async function loadModule(): Promise<{
|
||||
module: typeof import('../ios/prewarmXcodeBuildTools');
|
||||
spawnMock: jest.MockedFunction<typeof import('@expo/turtle-spawn').default>;
|
||||
fsMock: any;
|
||||
}> {
|
||||
jest.resetModules();
|
||||
const spawnMock = jest.mocked((await import('@expo/turtle-spawn')).default);
|
||||
const fsMock = jest.mocked((await import('fs-extra')).default);
|
||||
const module = await import('../ios/prewarmXcodeBuildTools');
|
||||
return { module, spawnMock, fsMock };
|
||||
}
|
||||
|
||||
function prepareFsMock(fsMock: any): void {
|
||||
fsMock.mkdtemp.mockResolvedValue('/tmp/eas-xcode-build-tool-prewarm-test');
|
||||
fsMock.outputFile.mockResolvedValue(undefined);
|
||||
fsMock.ensureDir.mockResolvedValue(undefined);
|
||||
fsMock.remove.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
describe('Xcode build tool prewarming', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setProcessPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
it('does nothing outside macOS', async () => {
|
||||
setProcessPlatform('linux');
|
||||
const { module, spawnMock } = await loadModule();
|
||||
|
||||
await module.startXcodeBuildToolsPrewarming({ env: process.env, logger: logger as any });
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prewarms ibtool before both actool targets, once, and removes temporary files', async () => {
|
||||
setProcessPlatform('darwin');
|
||||
const { module, spawnMock, fsMock } = await loadModule();
|
||||
prepareFsMock(fsMock);
|
||||
spawnMock.mockImplementation(() => createSpawnPromise(Promise.resolve(spawnResult)));
|
||||
|
||||
await module.startXcodeBuildToolsPrewarming({ env: process.env, logger: logger as any });
|
||||
await module.startXcodeBuildToolsPrewarming({ env: process.env, logger: logger as any });
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining(['ibtool']),
|
||||
expect.objectContaining({ env: process.env, stdio: 'pipe' })
|
||||
);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining(['actool', 'iphonesimulator']),
|
||||
expect.objectContaining({ env: process.env, stdio: 'pipe' })
|
||||
);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining(['actool', 'iphoneos']),
|
||||
expect.objectContaining({ env: process.env, stdio: 'pipe' })
|
||||
);
|
||||
expect(spawnMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
spawnMock.mock.invocationCallOrder[1]
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Xcode interface and asset build tools were prewarmed')
|
||||
);
|
||||
|
||||
expect(fsMock.remove).toHaveBeenCalledWith('/tmp/eas-xcode-build-tool-prewarm-test');
|
||||
});
|
||||
|
||||
it('continues when a prewarm command fails', async () => {
|
||||
setProcessPlatform('darwin');
|
||||
const { module, spawnMock, fsMock } = await loadModule();
|
||||
prepareFsMock(fsMock);
|
||||
spawnMock
|
||||
.mockImplementationOnce(() => createSpawnPromise(Promise.reject(new Error('failed'))))
|
||||
.mockImplementation(() => createSpawnPromise(Promise.resolve(spawnResult)));
|
||||
|
||||
await expect(
|
||||
module.startXcodeBuildToolsPrewarming({ env: process.env, logger: logger as any })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ errors: [expect.any(Error)] }),
|
||||
expect.stringContaining('continuing the build')
|
||||
);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import omit from 'lodash/omit';
|
||||
import config from './config';
|
||||
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
|
||||
import { Analytics, Event, logProjectDependenciesAsync } from './external/analytics';
|
||||
import { startXcodeBuildToolsPrewarming } from './ios/prewarmXcodeBuildTools';
|
||||
import { prepareRuntimeEnvironment } from './runtimeEnvironment';
|
||||
import { cleanUpWorkingdir } from './workingdir';
|
||||
|
||||
@@ -36,6 +37,13 @@ export async function build({
|
||||
BuildPhase.SPIN_UP_BUILDER,
|
||||
async () => {
|
||||
displayWorkerRuntimeInfo(ctx);
|
||||
if (
|
||||
job.platform === Platform.IOS &&
|
||||
job.mode === BuildMode.BUILD &&
|
||||
ctx.env.EAS_BUILD_DISABLE_XCODE_BUILD_TOOL_PREWARMING !== '1'
|
||||
) {
|
||||
void startXcodeBuildToolsPrewarming({ env: ctx.env, logger: ctx.logger });
|
||||
}
|
||||
ctx.logger.info(
|
||||
{ job: omit(ctx.job, 'secrets', 'projectArchive') },
|
||||
'Builder is ready, starting build'
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const PREWARM_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// Keep this fixture compatible with the oldest Xcode version supported by EAS Build.
|
||||
const STORYBOARD = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="view-controller">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<scene sceneID="scene">
|
||||
<objects>
|
||||
<viewController id="view-controller" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="view">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="first-responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>`;
|
||||
|
||||
const ASSET_CATALOG_CONTENTS = JSON.stringify({
|
||||
info: { author: 'expo', version: 1 },
|
||||
});
|
||||
|
||||
const IMAGE_SET_CONTENTS = JSON.stringify({
|
||||
images: [{ filename: 'Warmup.png', idiom: 'universal', scale: '1x' }],
|
||||
info: { author: 'expo', version: 1 },
|
||||
});
|
||||
|
||||
const WARMUP_IMAGE = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLkWQAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
const COLOR_SET_CONTENTS = JSON.stringify({
|
||||
colors: [
|
||||
{
|
||||
color: {
|
||||
'color-space': 'srgb',
|
||||
components: { alpha: '1.000', blue: '1.000', green: '1.000', red: '1.000' },
|
||||
},
|
||||
idiom: 'universal',
|
||||
},
|
||||
],
|
||||
info: { author: 'expo', version: 1 },
|
||||
});
|
||||
|
||||
let prewarmPromise: Promise<void> | undefined;
|
||||
|
||||
/** Starts prewarming at most once without making a prewarm failure fail the build. */
|
||||
export function startXcodeBuildToolsPrewarming({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
prewarmPromise ??= prewarmXcodeBuildToolsAsync({ env, logger }).catch((error: any) => {
|
||||
logger.warn({ err: error }, 'Xcode build tool prewarming failed; continuing the build.');
|
||||
});
|
||||
return prewarmPromise;
|
||||
}
|
||||
|
||||
async function prewarmXcodeBuildToolsAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const temporaryDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-xcode-build-tool-prewarm-')
|
||||
);
|
||||
|
||||
try {
|
||||
const storyboardPath = path.join(temporaryDirectory, 'Warmup.storyboard');
|
||||
const storyboardOutputPath = path.join(temporaryDirectory, 'storyboard-output');
|
||||
const assetCatalogPath = path.join(temporaryDirectory, 'Warmup.xcassets');
|
||||
const colorSetPath = path.join(assetCatalogPath, 'Warmup.colorset');
|
||||
const imageSetPath = path.join(assetCatalogPath, 'Warmup.imageset');
|
||||
const simulatorOutputPath = path.join(temporaryDirectory, 'asset-catalog-simulator-output');
|
||||
const deviceOutputPath = path.join(temporaryDirectory, 'asset-catalog-device-output');
|
||||
|
||||
await Promise.all([
|
||||
fs.outputFile(storyboardPath, STORYBOARD),
|
||||
fs.outputFile(path.join(assetCatalogPath, 'Contents.json'), ASSET_CATALOG_CONTENTS),
|
||||
fs.outputFile(path.join(colorSetPath, 'Contents.json'), COLOR_SET_CONTENTS),
|
||||
fs.outputFile(path.join(imageSetPath, 'Contents.json'), IMAGE_SET_CONTENTS),
|
||||
fs.outputFile(path.join(imageSetPath, 'Warmup.png'), WARMUP_IMAGE),
|
||||
fs.ensureDir(storyboardOutputPath),
|
||||
fs.ensureDir(simulatorOutputPath),
|
||||
fs.ensureDir(deviceOutputPath),
|
||||
]);
|
||||
|
||||
logger.info('Prewarming Xcode interface and asset build tools in the background.');
|
||||
|
||||
// ibtool performs the expensive shared Interface Builder initialization. Start actool only
|
||||
// after it finishes to avoid running two instances of the same initialization concurrently.
|
||||
const results: PromiseSettledResult<SpawnResult>[] = await Promise.allSettled([
|
||||
spawnWithTimeout(
|
||||
'xcrun',
|
||||
[
|
||||
'ibtool',
|
||||
'--errors',
|
||||
'--warnings',
|
||||
'--notices',
|
||||
'--target-device',
|
||||
'iphone',
|
||||
'--target-device',
|
||||
'ipad',
|
||||
'--minimum-deployment-target',
|
||||
'12.0',
|
||||
'--output-format',
|
||||
'human-readable-text',
|
||||
storyboardPath,
|
||||
'--compilation-directory',
|
||||
storyboardOutputPath,
|
||||
],
|
||||
{ env }
|
||||
),
|
||||
]);
|
||||
|
||||
results.push(
|
||||
...(await Promise.allSettled([
|
||||
runActoolAsync({
|
||||
assetCatalogPath,
|
||||
env,
|
||||
outputPath: simulatorOutputPath,
|
||||
platform: 'iphonesimulator',
|
||||
}),
|
||||
runActoolAsync({
|
||||
assetCatalogPath,
|
||||
env,
|
||||
outputPath: deviceOutputPath,
|
||||
platform: 'iphoneos',
|
||||
}),
|
||||
]))
|
||||
);
|
||||
|
||||
const failedResults = results.filter(result => result.status === 'rejected');
|
||||
if (failedResults.length > 0) {
|
||||
logger.warn(
|
||||
{ errors: failedResults.map(result => result.reason) },
|
||||
'Some Xcode build tools could not be prewarmed; continuing the build.'
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Xcode interface and asset build tools were prewarmed in ${(
|
||||
(Date.now() - startedAt) /
|
||||
1000
|
||||
).toFixed(1)} seconds.`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await fs.remove(temporaryDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async function runActoolAsync({
|
||||
assetCatalogPath,
|
||||
env,
|
||||
outputPath,
|
||||
platform,
|
||||
}: {
|
||||
assetCatalogPath: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
outputPath: string;
|
||||
platform: 'iphoneos' | 'iphonesimulator';
|
||||
}): Promise<SpawnResult> {
|
||||
return await spawnWithTimeout(
|
||||
'xcrun',
|
||||
[
|
||||
'actool',
|
||||
assetCatalogPath,
|
||||
'--compile',
|
||||
outputPath,
|
||||
'--output-partial-info-plist',
|
||||
`${outputPath}.plist`,
|
||||
'--output-format',
|
||||
'human-readable-text',
|
||||
'--notices',
|
||||
'--warnings',
|
||||
'--platform',
|
||||
platform,
|
||||
'--minimum-deployment-target',
|
||||
'12.0',
|
||||
'--target-device',
|
||||
'iphone',
|
||||
'--target-device',
|
||||
'ipad',
|
||||
],
|
||||
{ env }
|
||||
);
|
||||
}
|
||||
|
||||
async function spawnWithTimeout(
|
||||
command: string,
|
||||
args: string[],
|
||||
{ env }: { env: NodeJS.ProcessEnv }
|
||||
): Promise<SpawnResult> {
|
||||
const spawnPromise: SpawnPromise<SpawnResult> = spawn(command, args, {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
spawnPromise.child.kill('SIGKILL');
|
||||
}, PREWARM_TIMEOUT_MS);
|
||||
timeout.unref();
|
||||
|
||||
try {
|
||||
return await spawnPromise;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user