Compare commits

...
14 changed files with 846 additions and 4 deletions

No files matched your search

+8
View File
@@ -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',
})
);
});
});
+10
View File
@@ -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 =
@@ -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');
}
}
+28 -2
View File
@@ -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);
});
});
+8 -1
View File
@@ -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 }
);
+4
View File
@@ -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,
+9 -1
View File
@@ -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') {