mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 13:08:11 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902b9d1690 | ||
|
|
023159014a | ||
|
|
190a9daa2e | ||
|
|
7a283d03df | ||
|
|
a78b38b0c2 | ||
|
|
bdc6c62186 |
No files matched your search
@@ -19,5 +19,13 @@ export { PackageManager } from './utils/packageManager';
|
||||
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
export {
|
||||
maybeStartPreparingThirdPartyPrecompiledModules,
|
||||
shouldPrepareThirdPartyPrecompiledModules,
|
||||
shouldUsePrecompiledDependencies,
|
||||
startPreparingThirdPartyPrecompiledModules,
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
waitForThirdPartyPrecompiledModulesPreparationAsync,
|
||||
} from './utils/precompiledModules';
|
||||
|
||||
export * from './generic';
|
||||
@@ -0,0 +1,129 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import {
|
||||
startPreparingThirdPartyPrecompiledModules,
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
} from '../../utils/precompiledModules';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installPods.name, () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(move).mockImplementation(async () => undefined as any);
|
||||
let mkdtempCallCount = 0;
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => {
|
||||
mkdtempCallCount += 1;
|
||||
return mkdtempCallCount === 1
|
||||
? ('/tmp/precompiled-modules-archive' as any)
|
||||
: ('/tmp/precompiled-modules-staging' as any);
|
||||
});
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for third-party precompiled dependencies before running pod install', async () => {
|
||||
let resolvePreparation: (() => void) | undefined;
|
||||
const preparationPromise = new Promise<void>(resolve => {
|
||||
resolvePreparation = resolve;
|
||||
});
|
||||
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, [
|
||||
'https://example.com/precompiled-modules-0.zip',
|
||||
'https://example.com/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const installPodsPromise = installPods(ctx, {});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
|
||||
resolvePreparation?.();
|
||||
await installPodsPromise;
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
expect(mkdirp).toHaveBeenCalledWith(THIRD_PARTY_PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('continues with pod install when third-party precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
|
||||
const ctx = new BuildContext(
|
||||
createTestIosJob({
|
||||
buildCredentials: undefined,
|
||||
}),
|
||||
{
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, [
|
||||
'https://example.com/precompiled-modules-0.zip',
|
||||
]);
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
await expect(installPods(ctx, {})).resolves.toEqual({
|
||||
spawnPromise: expect.any(Promise),
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: '/workingdir/build/ios',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,21 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { waitForThirdPartyPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
try {
|
||||
await waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{ err },
|
||||
'Third-party precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
|
||||
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { waitForThirdPartyPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
|
||||
import { createInstallPodsBuildFunction } from '../installPods';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/precompiledModules', () => ({
|
||||
...jest.requireActual('../../../utils/precompiledModules'),
|
||||
waitForThirdPartyPrecompiledModulesPreparationAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe(createInstallPodsBuildFunction, () => {
|
||||
it('waits for third-party precompiled dependencies before running pod install', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest.mocked(waitForThirdPartyPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(waitForThirdPartyPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('continues with pod install when third-party precompiled dependencies preparation fails', async () => {
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
jest
|
||||
.mocked(waitForThirdPartyPrecompiledModulesPreparationAsync)
|
||||
.mockRejectedValue(new Error('third-party precompiled dependencies failed'));
|
||||
|
||||
const installPods = createInstallPodsBuildFunction();
|
||||
const globalContext = createGlobalContextMock({});
|
||||
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(waitForThirdPartyPrecompiledModulesPreparationAsync).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: globalContext.defaultWorkingDirectory,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BuildFunction } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { waitForThirdPartyPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
|
||||
|
||||
export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -8,6 +10,14 @@ export function createInstallPodsBuildFunction(): BuildFunction {
|
||||
name: 'Install Pods',
|
||||
__metricsId: 'eas/install_pods',
|
||||
fn: async (stepsCtx, { env }) => {
|
||||
try {
|
||||
await waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
} catch (err) {
|
||||
stepsCtx.logger.warn(
|
||||
{ err },
|
||||
'Third-party precompiled dependencies were not prepared successfully, continuing with pod install'
|
||||
);
|
||||
}
|
||||
stepsCtx.logger.info('Installing pods');
|
||||
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag =
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
import fs from 'fs-extra';
|
||||
import nock from 'nock';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
describe('thirdPartyPrecompiledModules integration', () => {
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('merges the extracted debug and release xcframework trees', async () => {
|
||||
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = homeDirectory;
|
||||
|
||||
try {
|
||||
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
|
||||
const debugArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
|
||||
);
|
||||
const releaseArchive = await fs.readFile(
|
||||
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
|
||||
);
|
||||
|
||||
nock('https://fixtures.expo.test')
|
||||
.get('/xcframeworks-Debug.zip')
|
||||
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
|
||||
.get('/xcframeworks-Release.zip')
|
||||
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('os', () => ({
|
||||
...jest.requireActual('os'),
|
||||
homedir: () => homeDirectory,
|
||||
}));
|
||||
const {
|
||||
startPreparingThirdPartyPrecompiledModules,
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
waitForThirdPartyPrecompiledModulesPreparationAsync,
|
||||
} = await import('../precompiledModules');
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(
|
||||
{
|
||||
logger: createMockLogger(),
|
||||
env: {},
|
||||
} as any,
|
||||
[
|
||||
'https://fixtures.expo.test/xcframeworks-Debug.zip',
|
||||
'https://fixtures.expo.test/xcframeworks-Release.zip',
|
||||
]
|
||||
);
|
||||
await waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(THIRD_PARTY_PRECOMPILED_MODULES_PATH).toBe(
|
||||
path.join(homeDirectory, '.expo', 'precompiled-modules')
|
||||
);
|
||||
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-modules-core fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('debug expo-camera fixture\n');
|
||||
await expect(
|
||||
fs.readFile(
|
||||
path.join(
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toBe('release expo-application fixture\n');
|
||||
} finally {
|
||||
jest.dontMock('os');
|
||||
jest.resetModules();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.remove(homeDirectory);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { mkdirp, mkdtemp, moveSync, remove, removeSync } from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import {
|
||||
shouldPrepareThirdPartyPrecompiledModules,
|
||||
startPreparingThirdPartyPrecompiledModules,
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
waitForThirdPartyPrecompiledModulesPreparationAsync,
|
||||
} from '../precompiledModules';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('thirdPartyPrecompiledModules', () => {
|
||||
const extract = jest.fn();
|
||||
const close = jest.fn();
|
||||
const getMkdtempPath = jest.fn();
|
||||
|
||||
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
|
||||
({
|
||||
logger: createMockLogger(),
|
||||
env,
|
||||
}) as unknown as BuildContext;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(remove).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
|
||||
jest.mocked(removeSync).mockImplementation(() => undefined as any);
|
||||
jest.mocked(moveSync).mockImplementation(() => undefined as any);
|
||||
getMkdtempPath.mockReset();
|
||||
getMkdtempPath
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
|
||||
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
|
||||
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
extract.mockReset().mockResolvedValue(undefined);
|
||||
close.mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(StreamZip.async).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
extract,
|
||||
close,
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('downloads through CocoaPods proxy when enabled', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prepare third-party precompiled modules when explicitly disabled', () => {
|
||||
expect(
|
||||
shouldPrepareThirdPartyPrecompiledModules({
|
||||
EAS_USE_PRECOMPILED_MODULES: '1',
|
||||
EAS_DISABLE_THIRD_PARTY_PRECOMPILED_MODULES: '1',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
|
||||
jest
|
||||
.mocked(downloadFile)
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(
|
||||
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
|
||||
[
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]
|
||||
);
|
||||
|
||||
await waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts all configured archives into the well-known destination path', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(createCtx(), [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
]);
|
||||
|
||||
await waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
|
||||
expect(mkdirp).toHaveBeenCalledWith(THIRD_PARTY_PRECOMPILED_MODULES_PATH);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(downloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
|
||||
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
|
||||
{ retry: 3 }
|
||||
);
|
||||
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
|
||||
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
|
||||
expect(moveSync).toHaveBeenCalledWith(
|
||||
'/tmp/precompiled-modules-staging',
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the destination empty when third-party preparation fails', async () => {
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
extract.mockRejectedValueOnce(new Error('extract failed'));
|
||||
|
||||
const ctx = createCtx();
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, [
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
|
||||
]);
|
||||
|
||||
await expect(waitForThirdPartyPrecompiledModulesPreparationAsync()).rejects.toThrow(
|
||||
'extract failed'
|
||||
);
|
||||
|
||||
expect(ctx.logger.error).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
'Failed to prepare third-party precompiled dependencies'
|
||||
);
|
||||
expect(moveSync).not.toHaveBeenCalled();
|
||||
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
|
||||
expect(remove).toHaveBeenCalledWith(THIRD_PARTY_PRECOMPILED_MODULES_PATH);
|
||||
expect(mkdirp).toHaveBeenCalledWith(THIRD_PARTY_PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
|
||||
it('logs background failures even if nobody waits for third-party preparation', async () => {
|
||||
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
|
||||
const ctx = createCtx();
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, ['https://example.com/xcframeworks-Debug.zip']);
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
expect(ctx.logger.error).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
'Failed to prepare third-party precompiled dependencies'
|
||||
);
|
||||
await expect(waitForThirdPartyPrecompiledModulesPreparationAsync()).rejects.toThrow(
|
||||
'download failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when third-party precompiled dependencies are still not ready after 15 seconds', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => undefined) as any);
|
||||
const ctx = createCtx();
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, ['https://example.com/xcframeworks-Debug.zip']);
|
||||
|
||||
const waitPromise = waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
const waitExpectation = expect(waitPromise).rejects.toThrow(
|
||||
'Timed out waiting for third-party precompiled dependencies after 15 seconds'
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await waitExpectation;
|
||||
expect(moveSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not publish staged third-party modules after timing out', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.mocked(downloadFile).mockResolvedValue(undefined);
|
||||
let finishExtraction!: () => void;
|
||||
extract.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finishExtraction = resolve;
|
||||
})
|
||||
);
|
||||
const ctx = createCtx();
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, ['https://example.com/xcframeworks-Debug.zip']);
|
||||
|
||||
const waitPromise = waitForThirdPartyPrecompiledModulesPreparationAsync();
|
||||
const waitExpectation = expect(waitPromise).rejects.toThrow(
|
||||
'Timed out waiting for third-party precompiled dependencies after 15 seconds'
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await waitExpectation;
|
||||
|
||||
finishExtraction();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(moveSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
let thirdPartyPrecompiledModulesPreparationPromise: Promise<void> | null = null;
|
||||
let thirdPartyPrecompiledModulesPreparationAbortController: AbortController | null = null;
|
||||
const THIRD_PARTY_PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 15_000;
|
||||
export const THIRD_PARTY_PRECOMPILED_MODULES_PATH = path.join(
|
||||
os.homedir(),
|
||||
'.expo',
|
||||
'precompiled-modules'
|
||||
);
|
||||
|
||||
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
|
||||
return env.EAS_USE_PRECOMPILED_MODULES === '1';
|
||||
}
|
||||
|
||||
export function shouldPrepareThirdPartyPrecompiledModules(
|
||||
env: Record<string, string | undefined>
|
||||
): boolean {
|
||||
return (
|
||||
shouldUsePrecompiledDependencies(env) && env.EAS_DISABLE_THIRD_PARTY_PRECOMPILED_MODULES !== '1'
|
||||
);
|
||||
}
|
||||
|
||||
export function maybeStartPreparingThirdPartyPrecompiledModules(
|
||||
ctx: BuildContext,
|
||||
config: { thirdPartyPrecompiledModulesUrls: string[] }
|
||||
): void {
|
||||
if (!shouldPrepareThirdPartyPrecompiledModules(ctx.env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
startPreparingThirdPartyPrecompiledModules(ctx, config.thirdPartyPrecompiledModulesUrls);
|
||||
}
|
||||
|
||||
export function startPreparingThirdPartyPrecompiledModules(
|
||||
ctx: BuildContext,
|
||||
urls: string[]
|
||||
): void {
|
||||
const abortController = new AbortController();
|
||||
thirdPartyPrecompiledModulesPreparationAbortController = abortController;
|
||||
|
||||
thirdPartyPrecompiledModulesPreparationPromise = prepareThirdPartyPrecompiledModulesAsync({
|
||||
logger: ctx.logger,
|
||||
urls,
|
||||
destinationDirectory: THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
void thirdPartyPrecompiledModulesPreparationPromise.catch(error => {
|
||||
ctx.logger.error({ error }, 'Failed to prepare third-party precompiled dependencies');
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForThirdPartyPrecompiledModulesPreparationAsync(): Promise<void> {
|
||||
if (!thirdPartyPrecompiledModulesPreparationPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
thirdPartyPrecompiledModulesPreparationPromise,
|
||||
new Promise<void>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(
|
||||
new SystemError(
|
||||
`Timed out waiting for third-party precompiled dependencies after ${THIRD_PARTY_PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`,
|
||||
{
|
||||
metadata: {
|
||||
timeoutMs: THIRD_PARTY_PRECOMPILED_MODULES_WAIT_TIMEOUT_MS,
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
thirdPartyPrecompiledModulesPreparationAbortController?.abort();
|
||||
}, THIRD_PARTY_PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
|
||||
timeoutHandle.unref?.();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareThirdPartyPrecompiledModulesAsync({
|
||||
logger,
|
||||
urls,
|
||||
destinationDirectory,
|
||||
cocoapodsProxyUrl,
|
||||
signal,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
urls: string[];
|
||||
destinationDirectory: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
|
||||
const destinationParentDirectory = path.dirname(destinationDirectory);
|
||||
await fs.mkdirp(destinationParentDirectory);
|
||||
const stagingDirectory = await fs.mkdtemp(
|
||||
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
|
||||
);
|
||||
|
||||
logger.info('');
|
||||
logger.info(
|
||||
{
|
||||
destinationDirectory,
|
||||
urls,
|
||||
},
|
||||
'Starting third-party precompiled dependencies download'
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
throwIfPreparationAborted(signal);
|
||||
const archives = urls.map((url, index) => ({
|
||||
archiveName: path.basename(new URL(url).pathname),
|
||||
url,
|
||||
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
archives.map(async ({ url, archivePath }) => {
|
||||
await downloadThirdPartyPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
for (const { archiveName, url, archivePath } of archives) {
|
||||
throwIfPreparationAborted(signal);
|
||||
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
|
||||
await extractZipAsync(archivePath, stagingDirectory);
|
||||
logger.info(
|
||||
{ destinationDirectory: stagingDirectory },
|
||||
`Extracted ${archiveName} into staging third-party precompiled dependencies directory`
|
||||
);
|
||||
}
|
||||
|
||||
throwIfPreparationAborted(signal);
|
||||
// Keep the final publish step synchronous so the timeout callback cannot interleave
|
||||
// between removing the old directory and moving the staged one into place.
|
||||
fs.removeSync(destinationDirectory);
|
||||
fs.moveSync(stagingDirectory, destinationDirectory);
|
||||
logger.info(
|
||||
{ destinationDirectory },
|
||||
`Third-party precompiled modules ready in ${destinationDirectory}`
|
||||
);
|
||||
} catch (error) {
|
||||
await fs.remove(stagingDirectory);
|
||||
await fs.remove(destinationDirectory);
|
||||
await fs.mkdirp(destinationDirectory);
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.remove(archiveDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadThirdPartyPrecompiledModulesAsync({
|
||||
url,
|
||||
archivePath,
|
||||
cocoapodsProxyUrl,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
url: string;
|
||||
archivePath: string;
|
||||
cocoapodsProxyUrl?: string;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const proxiedUrl = cocoapodsProxyUrl
|
||||
? (() => {
|
||||
const parsedUrl = new URL(url);
|
||||
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
})()
|
||||
: null;
|
||||
if (proxiedUrl) {
|
||||
try {
|
||||
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
|
||||
return;
|
||||
} catch (err) {
|
||||
throwIfPreparationAborted(signal);
|
||||
logger.warn(
|
||||
{ err, proxiedUrl },
|
||||
'Failed to download through CocoaPods proxy, retrying directly'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await downloadFile(url, archivePath, { retry: 3 });
|
||||
}
|
||||
|
||||
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
|
||||
const zip = new StreamZip.async({ file: archivePath });
|
||||
try {
|
||||
await zip.extract(null, destinationDirectory);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfPreparationAborted(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Third-party precompiled dependencies preparation aborted');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { THIRD_PARTY_PRECOMPILED_MODULES_PATH } from '@expo/build-tools';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import config from '../config';
|
||||
@@ -34,7 +35,32 @@ describe(getBuildEnv.name, () => {
|
||||
expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn);
|
||||
});
|
||||
|
||||
it('passes through EXPO_USE_PRECOMPILED_MODULES for flagged iOS jobs', () => {
|
||||
it('enables precompiled modules env vars for flagged iOS jobs even when third-party downloads are disabled', () => {
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
EAS_USE_PRECOMPILED_MODULES: '1',
|
||||
EAS_DISABLE_THIRD_PARTY_PRECOMPILED_MODULES: '1',
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets the precompiled modules path when third-party downloads are enabled', () => {
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
@@ -55,6 +81,6 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBe(THIRD_PARTY_PRECOMPILED_MODULES_PATH);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
|
||||
import {
|
||||
Artifacts,
|
||||
BuildContext,
|
||||
Builders,
|
||||
maybeStartPreparingThirdPartyPrecompiledModules,
|
||||
runGenericJobAsync,
|
||||
} from '@expo/build-tools';
|
||||
import {
|
||||
Android,
|
||||
BuildJob,
|
||||
@@ -41,6 +47,7 @@ export async function build({
|
||||
{ job: omit(ctx.job, 'secrets', 'projectArchive') },
|
||||
'Builder is ready, starting build'
|
||||
);
|
||||
maybeStartPreparingThirdPartyPrecompiledModules(ctx, config);
|
||||
},
|
||||
{ doNotMarkStart: true }
|
||||
);
|
||||
|
||||
@@ -98,6 +98,10 @@ export default {
|
||||
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
thirdPartyPrecompiledModulesUrls: [
|
||||
'https://storage.googleapis.com/turtle-v2/precompiled-modules/b3e34ec259271164e537f6d33b446130ebc666aa/xcframeworks-Debug.zip',
|
||||
'https://storage.googleapis.com/turtle-v2/precompiled-modules/b3e34ec259271164e537f6d33b446130ebc666aa/xcframeworks-Release.zip',
|
||||
],
|
||||
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('runMetricsServer'),
|
||||
defaultValue: null,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
THIRD_PARTY_PRECOMPILED_MODULES_PATH,
|
||||
shouldPrepareThirdPartyPrecompiledModules,
|
||||
shouldUsePrecompiledDependencies,
|
||||
} from '@expo/build-tools';
|
||||
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { spawnSync } from 'child_process';
|
||||
import micromatch from 'micromatch';
|
||||
@@ -53,8 +58,11 @@ export function getBuildEnv({
|
||||
if (runnerPlatform === Platform.IOS) {
|
||||
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
|
||||
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
|
||||
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
|
||||
if (shouldUsePrecompiledDependencies(job.builderEnvironment?.env ?? {})) {
|
||||
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
|
||||
if (shouldPrepareThirdPartyPrecompiledModules(job.builderEnvironment?.env ?? {})) {
|
||||
setEnv(env, 'EXPO_PRECOMPILED_MODULES_PATH', THIRD_PARTY_PRECOMPILED_MODULES_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
|
||||
|
||||
Reference in new issue
Block a user