Compare commits

...
4 changed files with 365 additions and 0 deletions

No files matched your search

+2
View File
@@ -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);
});
});
+8
View File
@@ -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);
}
}