Compare commits

..
Author SHA1 Message Date
Mustafa Abban bad188babe Merge branch 'main' into eas-build-valid-lock 2026-04-30 10:46:50 -05:00
Ash Wu 91c9331e43 [eas-cli] remove image override in eas build:resign (#3661)
* [eas-cli] remove image override in eas build:resign

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] add changelog

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-30 22:57:39 +08:00
Stanisław Chmiela 02ba5880c4 [build-tools] Add expo package version to Datadog error logs (#3658)
## Summary
- Resolve the installed Expo package version in the worker when sending Turtle error logs.
- Tag Datadog error logs with `expo_package_version` while leaving `sdk_version` unchanged.
- Add unit coverage for exact-version resolution, missing package resolution, invalid semver, and the error-log payload.

## Testing
- `yarn workspace @expo/worker test:unit -- service.test.ts`
- `yarn workspace @expo/worker typecheck`
- `yarn exec oxfmt .`
2026-04-30 16:09:03 +02:00
Ash Wu 8229dc8265 [build-tools] Port Maestro Tests bash step to TypeScript (#3641)
* [build-tools] Port Maestro Tests bash step to TypeScript

Replace the bash shellscript step (defined in www/WorkflowMaestroTestJob)
with a typed BuildFunction. Behaviorally identical to the bash version:
runs maestro with the full flow_paths set, retries the whole set on
failure, copies the latest attempt's JUnit XML to final_report_path.

Smart retry (only re-running failed flows) is added in a follow-up PR.

Signed-off-by: Ash Wu <[email protected]>

* address comments

Signed-off-by: Ash Wu <[email protected]>

* address comments 2

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-30 17:47:35 +08:00
Stanisław Chmiela 15a456970b [build-tools] Scope precompiled modules env to pod install (#3646)
## Summary

- Stops adding precompiled modules environment variables to the whole worker build environment.
- Adds `EXPO_USE_PRECOMPILED_MODULES=1` only to the iOS `pod install` environment when requested and when the installed Expo package is at least `55.0.18`.
- Logs the detected Expo package version, the reason precompiled modules are not enabled, or the exact pod-install environment values that are added.
- Leaves the precompiled modules base URL helper commented out for the later third-party module rollout.

## Validation

- `yarn oxfmt packages/build-tools/src/ios/pod.ts packages/build-tools/src/ios/__tests__/pod.test.ts packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/build-tools jest-unit pod.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/worker typecheck`
2026-04-30 11:31:52 +02:00
Expo CI e74751c26c update CHANGELOG.md 2026-04-30 00:35:41 +00:00
Expo CI 0e2a1756ce v18.9.1 2026-04-30 00:33:23 +00:00
Brent Vatne 766e278aec [eas-cli] Alias --build-id to --id in build:download (#3656) 2026-04-29 17:29:41 -07:00
Expo CI 77e22c740a update CHANGELOG.md 2026-04-30 00:26:16 +00:00
Mustafa Abban f6ba878b29 Merge branch 'main' into eas-build-valid-lock 2026-03-13 12:59:07 -05:00
AbbanMustafa 07fbe5c777 fmt 2026-03-11 14:32:41 -05:00
AbbanMustafa c8739b66af changelog 2026-03-11 14:16:43 -05:00
AbbanMustafa 777a3c947f package lockfile-utils 2026-03-11 14:14:17 -05:00
AbbanMustafa ce3d17ae4c casing 2026-03-11 13:47:16 -05:00
AbbanMustafa 2f9dfb6610 env var to skip check 2026-03-11 13:45:53 -05:00
AbbanMustafa b862a0575c turtlebot 2026-03-02 16:02:30 -06:00
AbbanMustafa c1fe243a70 peer dependency conflicts 2026-03-02 15:33:39 -06:00
AbbanMustafa 8065715d4c changelog 2026-03-02 16:14:29 -05:00
AbbanMustafa 402cc40bb9 clean log 2026-03-02 15:57:11 -05:00
AbbanMustafa 61eeabb404 verify package versions 2026-03-02 15:48:01 -05:00
AbbanMustafa edbaaf8000 warn on staleness 2026-03-01 16:34:18 -05:00
AbbanMustafa 68e561e879 [eas-build] - lockfile preflight checks to eas build command 2026-02-26 12:51:07 -05:00
33 changed files with 2710 additions and 155 deletions

No files matched your search

+13 -2
View File
@@ -8,15 +8,26 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
### 🧹 Chores
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 New features
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.9.0",
"version": "18.9.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
@@ -0,0 +1,149 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const WORKING_DIR = '/workingdir';
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
usePrecompiledModules = true,
}: {
expoVersion?: string;
usePrecompiledModules?: boolean;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
...(expoVersion
? {
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
version: expoVersion,
}),
}
: {}),
});
const job: Ios.Job = {
...createTestIosJob(),
builderEnvironment: {
env: {
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
},
},
};
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
});
});
it('adds precompiled modules env var for Expo versions that support them', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
}),
})
);
expect(
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
);
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
);
});
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
});
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
const ctx = makeIosBuildContext({});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
});
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '55.0.18',
usePrecompiledModules: false,
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
});
+82 -1
View File
@@ -1,9 +1,14 @@
import { Ios } from '@expo/eas-build-job';
import { Env, Ios } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
@@ -12,6 +17,7 @@ export async function installPods<TJob extends Ios.Job>(
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
return {
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
@@ -20,6 +26,7 @@ export async function installPods<TJob extends Ios.Job>(
env: {
...ctx.env,
LANG: 'en_US.UTF-8',
...precompiledModulesEnv,
},
lineTransformer: (line?: string) => {
if (
@@ -35,3 +42,77 @@ export async function installPods<TJob extends Ios.Job>(
}),
};
}
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
} catch (err) {
ctx.logger.info(
{ err },
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
return {};
}
const validExpoPackageVersion = semver.valid(expoPackageVersion);
if (!validExpoPackageVersion) {
ctx.logger.info(
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
);
return {};
}
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
);
return {};
}
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
};
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
env
)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\nPrecompiled modules pod install environment is configured.`
);
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
// return PRECOMPILED_MODULES_BASE_URL;
// }
//
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
// return PRECOMPILED_MODULES_BASE_URL.replace(
// `${parsedUrl.protocol}//${parsedUrl.host}`,
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
// );
// }
@@ -35,6 +35,7 @@ import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
@@ -93,6 +94,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
];
if (ctx.hasBuildJob()) {
@@ -1,6 +1,8 @@
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
copyLatestAttemptXml,
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
@@ -803,3 +805,22 @@ describe(parseJUnitTestCases, () => {
expect(results[0].properties).toEqual({});
});
});
describe('copyLatestAttemptXml', () => {
it('picks the highest attempt number', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
});
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
});
it('throws when source has no xml files', async () => {
vol.fromJSON({ '/tmp/r/.keep': '' });
await expect(
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
).rejects.toThrow();
});
});
@@ -0,0 +1,473 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as parser from '../maestroResultParser';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockedSpawn = jest.mocked(spawn);
const SPAWN_SUCCESS = {
status: 0,
pid: 1,
output: [''],
stdout: '',
stderr: '',
signal: null,
} as any;
const rejectExit1 = (): Error => {
const e: any = new Error('exit 1');
e.status = 1;
return e;
};
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
}
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub copyLatestAttemptXml so tests that don't care about it
// don't hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
// Make spawn reject so the step still throws; we're locking in that outputs
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
// `final_report_path` is empty).
mockedSpawn.mockRejectedValue(new Error('stop'));
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow();
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
/\.maestro\/tests\/junit-reports$/
);
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
});
it('succeeds when maestro exits 0 on first attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawn.mock.calls[0];
expect(cmd).toBe('maestro');
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError for unknown-shape spawn rejections', async () => {
mockedSpawn.mockRejectedValue(new Error('mystery'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('killed'), {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('treats reject-with-status as a retryable test failure', async () => {
const rejectWithExit = (status: number): Error =>
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
status,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
});
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
// Default `retries` is 0, so even though the first attempt rejects with
// `status: 1`, no retry occurs and the post-loop check throws UserError.
// Spawn ran once.
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries all flows on failure', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
});
it('writes final report via copyLatestAttemptXml on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('swallows copyLatestAttemptXml failure when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError early on empty flow_path', async () => {
const step = createStep({ flow_path: [], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'HTML',
platform: 'ios',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
});
it('does not include --output when output_format is undefined', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: '',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args.some(a => a.startsWith('--output'))).toBe(false);
expect(args).not.toContain('--output');
expect(args).not.toContain('--output=undefined');
expect(args).not.toContain('--output=null');
expect(args).not.toContain('--output=');
});
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'JUNIT',
platform: 'android',
});
await step.executeAsync();
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
shards: 3,
include_tags: 'smoke,critical',
exclude_tags: 'slow',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args).toContain('--shard-split=3');
expect(args).toContain('--include-tags=smoke,critical');
expect(args).toContain('--exclude-tags=slow');
});
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
cwd?: string;
env?: Record<string, string | undefined>;
};
expect(spawnOptions).toBeDefined();
expect(typeof spawnOptions.cwd).toBe('string');
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!.HOME).toBe('/home/expo');
});
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
// it in the spawn env.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
});
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
// Even when os.homedir() differs, the step should derive paths from env.HOME
// so maestro (which receives env) and the step (which inspects those paths)
// agree on where reports land.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: { HOME: '/custom/home' } }
);
await step.executeAsync();
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/custom/home/.maestro/tests/junit-reports'
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/custom/home/.maestro/tests/android-maestro-junit.xml'
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
);
});
it('throws SystemError when env.HOME is unset', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: {} }
);
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws UserError on non-string entries in flow_path', async () => {
const step = createStep({
flow_path: ['flows/a.yaml', 2 as unknown as string],
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is negative', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: -1,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is a non-integer number', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 1.5,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is zero', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is negative', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is a non-integer', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('accepts shards: undefined (optional input)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
});
@@ -17,6 +17,9 @@ export interface MaestroFlowResult {
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
@@ -239,9 +242,6 @@ export async function parseMaestroResults(
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Parse attempt index from filename pattern: *-attempt-N.*
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
@@ -322,3 +322,41 @@ async function relativizePathAsync(flowFilePath: string, projectRoot: string): P
}
return relative;
}
/**
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
* After the maestro retry loop completes, this produces a single canonical
* JUnit report at final_report_path matching the bash step's "cp latest
* attempt" semantics.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
export async function copyLatestAttemptXml(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`No *.xml files found in ${args.sourceDir}`);
}
// Pick the file with the highest attempt index. Files without the marker are
// treated as attempt 0. Ties are broken by sorted filename — later wins.
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
return m ? parseInt(m[1], 10) : 0;
})();
for (let i = 1; i < xmlFiles.length; i++) {
const candidate = xmlFiles[i];
const match = candidate.match(ATTEMPT_PATTERN);
const attempt = match ? parseInt(match[1], 10) : 0;
if (attempt >= winnerAttempt) {
winner = candidate;
winnerAttempt = attempt;
}
}
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
}
@@ -0,0 +1,257 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { copyLatestAttemptXml } from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
function parseInput<S extends z.ZodTypeAny>(
schema: S,
value: unknown,
message: string
): z.output<S> {
const result = schema.safeParse(value);
if (!result.success) {
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
}
return result.data;
}
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
function buildMaestroArgs(args: {
flow_path: string[];
outputPath: string | null;
output_format: string | undefined;
shards: number | undefined;
include_tags: string | undefined;
exclude_tags: string | undefined;
}): string[] {
const out: string[] = ['test'];
if (args.output_format) {
out.push(`--format=${args.output_format.toUpperCase()}`);
}
if (args.outputPath) {
out.push(`--output=${args.outputPath}`);
}
if (args.shards !== undefined) {
out.push(`--shard-split=${args.shards}`);
}
if (args.include_tags) {
out.push(`--include-tags=${args.include_tags}`);
}
if (args.exclude_tags) {
out.push(`--exclude-tags=${args.exclude_tags}`);
}
out.push(...args.flow_path);
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'retries',
required: false,
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'include_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'exclude_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_format',
required: false,
defaultValue: 'junit',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'platform',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger, global } = stepCtx;
const platformInput = inputs.platform.value as string | undefined;
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
const includeTags = inputs.include_tags.value as string | undefined;
const excludeTags = inputs.exclude_tags.value as string | undefined;
const platform: 'ios' | 'android' =
platformInput === 'ios' || platformInput === 'android'
? platformInput
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
? 'ios'
: 'android';
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
// this env and writes debug output under $HOME/.maestro/tests; the step
// must read from the same place or stale files leak across runs.
const home = env.HOME;
if (!home) {
throw new SystemError('HOME env var is not set');
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
}
const flowPaths = parseInput(
FlowPathSchema,
inputs.flow_path.value,
'flow_path must be a non-empty array of non-empty strings.'
);
const retries = parseInput(
RetriesSchema,
inputs.retries.value,
'retries must be a non-negative integer.'
);
const shards = parseInput(
ShardsSchema,
inputs.shards.value,
'shards must be a positive integer.'
);
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
} catch (err) {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
let lastAttemptExitCode: number | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
try {
await spawn(
'maestro',
buildMaestroArgs({
flow_path: flowPaths,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
}),
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
);
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
throw new SystemError('Failed to invoke maestro', { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
}
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
logger.info('Test failed, retrying...');
await sleepAsync(2000);
}
// Copy the latest attempt's JUnit to the final report path so downstream
// upload/report steps have a single canonical file.
if (finalReportPath !== undefined) {
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure usually means maestro itself failed early
// (bad YAML wrote no *.xml). Throwing SystemError here would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
const totalAttempts = retries + 1;
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
);
}
},
});
}
+93 -93
View File
@@ -183,7 +183,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -200,7 +200,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -222,7 +222,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -239,7 +239,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -253,7 +253,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -305,7 +305,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -326,7 +326,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -346,7 +346,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -366,7 +366,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -389,7 +389,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -427,7 +427,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -447,7 +447,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -465,7 +465,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -485,7 +485,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -507,7 +507,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -534,7 +534,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -572,7 +572,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -612,7 +612,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -642,7 +642,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -668,7 +668,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -720,7 +720,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -738,7 +738,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -756,7 +756,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -773,7 +773,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -794,7 +794,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -815,7 +815,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -837,7 +837,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -861,7 +861,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -881,7 +881,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -903,7 +903,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -925,7 +925,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -960,7 +960,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -983,7 +983,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1004,7 +1004,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1021,7 +1021,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1039,7 +1039,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1068,7 +1068,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1093,7 +1093,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1114,7 +1114,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1135,7 +1135,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1172,7 +1172,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1192,7 +1192,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1213,7 +1213,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1234,7 +1234,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1248,7 +1248,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1262,7 +1262,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1294,7 +1294,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1320,7 +1320,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1342,7 +1342,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1370,7 +1370,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1398,7 +1398,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1421,7 +1421,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1443,7 +1443,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1477,7 +1477,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1521,7 +1521,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1552,7 +1552,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1631,7 +1631,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1650,7 +1650,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1668,7 +1668,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas login`
@@ -1720,7 +1720,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1738,7 +1738,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1756,7 +1756,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1808,7 +1808,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1822,7 +1822,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1845,7 +1845,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1869,7 +1869,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1887,7 +1887,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1920,7 +1920,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1963,7 +1963,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1984,7 +1984,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2005,7 +2005,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2030,7 +2030,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -2057,7 +2057,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2084,7 +2084,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2122,7 +2122,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2150,7 +2150,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2180,7 +2180,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2201,7 +2201,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2225,7 +2225,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2246,7 +2246,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2268,7 +2268,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2288,7 +2288,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2307,7 +2307,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2330,7 +2330,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2347,7 +2347,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2468,7 +2468,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2488,7 +2488,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2512,7 +2512,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2552,7 +2552,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2574,7 +2574,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2602,7 +2602,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2622,7 +2622,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2644,5 +2644,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.9.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.9.1/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.9.0",
"version": "18.9.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -0,0 +1,377 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { CommonContext } from '../context';
import { checkLockfileAsync } from '../validateLockfile';
import { Client } from '../../vcs/vcs';
jest.mock('fs');
jest.mock('@expo/package-manager', () => ({
NPM_LOCK_FILE: 'package-lock.json',
YARN_LOCK_FILE: 'yarn.lock',
PNPM_LOCK_FILE: 'pnpm-lock.yaml',
BUN_LOCK_FILE: 'bun.lockb',
BUN_TEXT_LOCK_FILE: 'bun.lock',
resolveWorkspaceRoot: jest.fn(() => null),
}));
const { resolveWorkspaceRoot } = jest.requireMock<{
resolveWorkspaceRoot: jest.Mock;
}>('@expo/package-manager');
const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
function createMockContext(
overrides: Partial<{
projectDir: string;
requiredPackageManager: string | null;
isFileIgnoredAsync: (filePath: string) => Promise<boolean>;
getRootPathAsync: () => Promise<string>;
}> = {}
): CommonContext<Platform> {
const vcsClient = {
isFileIgnoredAsync: overrides.isFileIgnoredAsync ?? (async () => false),
getRootPathAsync: overrides.getRootPathAsync ?? (async () => '/project'),
} as unknown as Client;
return {
projectDir: overrides.projectDir ?? '/project',
requiredPackageManager: overrides.requiredPackageManager ?? null,
vcsClient,
} as unknown as CommonContext<Platform>;
}
const PACKAGE_JSON_WITH_REACT = JSON.stringify({
dependencies: { react: '19.0.4', 'react-native': '~0.79.0' },
});
const NPM_LOCKFILE_SYNCED = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
'node_modules/react-native': { version: '0.79.4' },
},
});
const NPM_LOCKFILE_STALE = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-native': { version: '0.79.4' },
},
});
// bun.lock uses JSONC with trailing commas
const BUN_LOCKFILE_STALE = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const BUN_LOCKFILE_SYNCED = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
},
}`;
const YARN_LOCKFILE_STALE = `# yarn lockfile v1
[email protected]:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz"
`;
const YARN_LOCKFILE_SYNCED = `# yarn lockfile v1
[email protected]:
version "19.0.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
react-native@~0.79.0:
version "0.79.4"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
`;
beforeEach(() => {
vol.reset();
resolveWorkspaceRoot.mockReturnValue(null);
mockProcessExit.mockClear();
delete process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK;
});
describe(checkLockfileAsync, () => {
it('skips all checks when EAS_BUILD_SKIP_LOCKFILE_CHECK is set', async () => {
process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK = '1';
// This would normally fail — no lockfile, no package manager
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when no package manager is detected', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: null });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when npm is detected but package-lock.json is missing', async () => {
vol.fromJSON({ '/project/package.json': '{}' });
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when lockfile exists and is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when lockfile exists but is gitignored', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({
requiredPackageManager: 'yarn',
isFileIgnoredAsync: async () => true,
});
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when conflicting lockfiles from multiple managers exist', async () => {
vol.fromJSON({
'/project/package.json': '{}',
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
'/project/yarn.lock': '# yarn lockfile',
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when npm lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/package-lock.json': NPM_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when bun.lock version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when bun.lock is in sync', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('exits when yarn lockfile version does not satisfy package.json specifier', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/yarn.lock': YARN_LOCKFILE_STALE,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when yarn lockfile is in sync', async () => {
vol.fromJSON({
'/project/package.json': PACKAGE_JSON_WITH_REACT,
'/project/yarn.lock': YARN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('finds lockfile at workspace root for monorepos', async () => {
vol.fromJSON({
'/monorepo/yarn.lock': YARN_LOCKFILE_SYNCED,
'/monorepo/packages/app/package.json': PACKAGE_JSON_WITH_REACT,
});
resolveWorkspaceRoot.mockReturnValue('/monorepo');
const ctx = createMockContext({
projectDir: '/monorepo/packages/app',
requiredPackageManager: 'yarn',
getRootPathAsync: async () => '/monorepo',
});
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('does not report a conflict when both bun.lock and bun.lockb are present', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
'/project/bun.lockb': 'binary',
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips sync check for non-registry specifiers', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: {
'my-local-pkg': 'file:../local',
'my-workspace-pkg': 'workspace:*',
react: '19.0.4',
},
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
describe('peer dependency conflicts', () => {
it('exits when a direct dependency has an unsatisfied peer (npm)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('exits when a direct dependency has an unsatisfied peer (bun)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "react": "19.0.0", "react-dom": "19.2.4" } } },
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('does not error when peer dependencies are satisfied', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { react: '19.2.4', 'react-dom': '19.2.4' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.2.4' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (npm peerDependenciesMeta)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/package-lock.json': JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
peerDependenciesMeta: { graphql: { optional: true } },
},
},
}),
});
const ctx = createMockContext({ requiredPackageManager: 'npm' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
it('skips optional peer dependencies (bun optionalPeers)', async () => {
vol.fromJSON({
'/project/package.json': JSON.stringify({
dependencies: { '@0no-co/graphql.web': '1.2.0' },
}),
'/project/bun.lock': `{
"lockfileVersion": 1,
"workspaces": { "": { "dependencies": { "@0no-co/graphql.web": "1.2.0" } } },
"packages": {
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-abc"],
},
}`,
});
const ctx = createMockContext({ requiredPackageManager: 'bun' });
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
expect(mockProcessExit).not.toHaveBeenCalled();
});
});
});
@@ -23,6 +23,7 @@ import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync
import { prepareIosBuildAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
import { checkLockfileAsync } from './validateLockfile';
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from './utils/repository';
import { Analytics } from '../analytics/AnalyticsManager';
@@ -84,6 +85,7 @@ import { Client } from '../vcs/vcs';
let metroConfigValidated = false;
let sdkVersionChecked = false;
let lockfileChecked = false;
let hasWarnedAboutUsageOverages = false;
export interface BuildFlags {
@@ -414,6 +416,11 @@ async function prepareAndStartBuildAsync({
env,
});
if (!lockfileChecked) {
await checkLockfileAsync(buildCtx);
lockfileChecked = true;
}
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
hasWarnedAboutUsageOverages = true;
Log.newLine();
@@ -0,0 +1,231 @@
import {
BUN_LOCK_FILE,
BUN_TEXT_LOCK_FILE,
NPM_LOCK_FILE,
PNPM_LOCK_FILE,
YARN_LOCK_FILE,
resolveWorkspaceRoot,
} from '@expo/package-manager';
import { Platform } from '@expo/eas-build-job';
import {
LockfileData,
findLockfileMismatches,
findPeerDependencyConflicts,
parseLockfile,
} from '@expo/lockfile-utils';
import fs from 'fs-extra';
import path from 'path';
import { CommonContext } from './context';
import Log from '../log';
const SKIP_HINT =
'To skip this check, set the EAS_BUILD_SKIP_LOCKFILE_CHECK=1 environment variable.';
interface LockfileInfo {
/** Filename of the lockfile (e.g. 'yarn.lock') */
filename: string;
/** Package manager name for display (e.g. 'yarn') */
managerName: string;
}
const LOCKFILE_BY_MANAGER: Record<string, LockfileInfo> = {
npm: { filename: NPM_LOCK_FILE, managerName: 'npm' },
yarn: { filename: YARN_LOCK_FILE, managerName: 'yarn' },
pnpm: { filename: PNPM_LOCK_FILE, managerName: 'pnpm' },
bun: { filename: BUN_LOCK_FILE, managerName: 'bun' },
};
const ALL_LOCKFILES: { filename: string; manager: string }[] = [
{ filename: NPM_LOCK_FILE, manager: 'npm' },
{ filename: YARN_LOCK_FILE, manager: 'yarn' },
{ filename: PNPM_LOCK_FILE, manager: 'pnpm' },
{ filename: BUN_LOCK_FILE, manager: 'bun' },
{ filename: BUN_TEXT_LOCK_FILE, manager: 'bun' },
];
/**
* Find the lockfile for the given filename by checking the workspace root first
* (for monorepos), then the project directory.
*/
async function findLockfileAsync(
projectDir: string,
filename: string
): Promise<string | null> {
const workspaceRoot = resolveWorkspaceRoot(projectDir);
if (workspaceRoot) {
const workspaceLockfile = path.join(workspaceRoot, filename);
if (await fs.pathExists(workspaceLockfile)) {
return workspaceLockfile;
}
}
const projectLockfile = path.join(projectDir, filename);
if (await fs.pathExists(projectLockfile)) {
return projectLockfile;
}
return null;
}
async function readLockfileDataAsync(
projectDir: string,
lockfilePath: string,
manager: string
): Promise<{ lockfileData: LockfileData; packageJson: Record<string, unknown> } | null> {
const packageJsonPath = path.join(projectDir, 'package.json');
if (!(await fs.pathExists(packageJsonPath))) {
return null;
}
const packageJson = await fs.readJson(packageJsonPath);
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
const lockfileData = parseLockfile(lockfileContent, manager, lockfilePath);
if (!lockfileData) {
return null;
}
return { lockfileData, packageJson };
}
/**
* Pre-flight lockfile validation for `eas build`. Catches common lockfile
* problems before uploading to the builder.
*/
export async function checkLockfileAsync<T extends Platform>(
ctx: CommonContext<T>
): Promise<void> {
if (process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
Log.warn('Skipping lockfile validation (EAS_BUILD_SKIP_LOCKFILE_CHECK is set).');
return;
}
const { projectDir, requiredPackageManager, vcsClient } = ctx;
// --- 1. Lockfile existence ---
if (!requiredPackageManager) {
Log.error('No lockfile found in the project directory.');
Log.error(
'A lockfile is required to ensure deterministic dependency installation on the EAS builder.'
);
Log.error(
'Run your package manager\'s install command (e.g. "npm install") to generate one, then commit it to version control.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
const lockInfo = LOCKFILE_BY_MANAGER[requiredPackageManager];
if (!lockInfo) {
// Unknown package manager — nothing we can validate
return;
}
// For bun, also check the text lockfile variant
const lockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_LOCK_FILE)) ??
(await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE))
: await findLockfileAsync(projectDir, lockInfo.filename);
if (!lockfilePath) {
Log.error(
`${lockInfo.managerName} lockfile (${lockInfo.filename}) not found.`
);
Log.error(
`Run "${requiredPackageManager} install" to generate a lockfile, then commit it to version control.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 2. Lockfile tracked in VCS ---
const rootDir = path.normalize(await vcsClient.getRootPathAsync());
const relativeLockfilePath = path.relative(rootDir, lockfilePath);
if (await vcsClient.isFileIgnoredAsync(relativeLockfilePath)) {
Log.error(
`${lockInfo.managerName} lockfile (${relativeLockfilePath}) is ignored by your version control system and won't be uploaded to the EAS builder.`
);
Log.error(
'Remove the lockfile from .gitignore (and .easignore, if applicable), then commit it.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 3. Conflicting lockfiles ---
const foundManagers = new Set<string>();
for (const { filename, manager } of ALL_LOCKFILES) {
if (await findLockfileAsync(projectDir, filename)) {
foundManagers.add(manager);
}
}
if (foundManagers.size > 1) {
const managerList = [...foundManagers].sort().join(', ');
Log.error(`Conflicting lockfiles detected from multiple package managers: ${managerList}.`);
Log.error(
'Having lockfiles from multiple package managers can cause install failures on the EAS builder. Remove the lockfiles you do not need and commit the change.'
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 4 & 5. Parse lockfile for dependency sync and peer dep checks ---
// For bun, prefer the text lockfile for parsing (bun.lockb is binary)
const parsableLockfilePath =
requiredPackageManager === 'bun'
? (await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE)) ?? lockfilePath
: lockfilePath;
const parsed = await readLockfileDataAsync(
projectDir,
parsableLockfilePath,
requiredPackageManager
);
if (!parsed) {
return; // Can't parse — skip remaining checks
}
// --- 4. Lockfile out of sync ---
const mismatches = findLockfileMismatches(parsed.packageJson, parsed.lockfileData);
if (mismatches.length > 0) {
Log.error('Lockfile is out of sync with package.json:');
for (const m of mismatches) {
Log.error(
` ${m.name}: package.json requires ${m.specifier}, but lockfile has ${m.lockedVersion}`
);
}
Log.error(
`Run "${requiredPackageManager} install" to update your lockfile, then commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
// --- 5. Peer dependency conflicts ---
const peerConflicts = findPeerDependencyConflicts(parsed.packageJson, parsed.lockfileData);
if (peerConflicts.length > 0) {
Log.error('Peer dependency conflicts detected:');
for (const c of peerConflicts) {
Log.error(
` ${c.source}@${c.sourceVersion} requires peer ${c.peer}@"${c.peerRange}", but ${c.peer}@${c.installedVersion} is installed`
);
}
Log.error(
'Update the conflicting packages to compatible versions, then run ' +
`"${requiredPackageManager} install" and commit the changes.`
);
Log.error(SKIP_HINT);
process.exit(1);
return;
}
}
@@ -70,6 +70,16 @@ describe(Download, () => {
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
});
it('fetches build by ID when --id alias is provided', async () => {
mockByIdAsync.mockResolvedValue(makeBuild());
const command = createCommand(['--id', 'build-123']);
await command.runAsync();
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'build-123');
expect(mockViewBuildsOnAppAsync).not.toHaveBeenCalled();
});
it('fetches builds by fingerprint when --fingerprint is provided', async () => {
mockViewBuildsOnAppAsync.mockResolvedValue([makeBuild()]);
@@ -31,6 +31,7 @@ export default class Download extends EasCommand {
static override flags = {
'build-id': Flags.string({
aliases: ['id'],
description:
'ID of the build to download. Mutually exclusive with --fingerprint, --platform, and --dev-client; the platform is derived from the build itself.',
exclusive: ['fingerprint', 'platform', 'dev-client'],
@@ -215,7 +215,6 @@ export default class BuildResign extends EasCommand {
},
},
secrets: prepareCredentialsToResign(credentialsResult.credentials),
builderEnvironment: { image: 'default' },
};
const newBuild = await BuildMutation.retryIosBuildAsync(graphqlClient, {
buildId: build.id,
+13
View File
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: 'src',
testMatch: ['**/__tests__/*.test.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
@@ -0,0 +1,5 @@
if (process.env.NODE_ENV !== 'test') {
throw new Error("NODE_ENV environment variable must be set to 'test'.");
}
// Always mock:
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@expo/lockfile-utils",
"version": "18.0.1",
"description": "Parse npm, yarn, pnpm, and bun lockfiles and validate them against package.json for version mismatches and peer dependency conflicts",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/lockfile-utils"
},
"files": [
"dist"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
},
"dependencies": {
"semver": "^7.6.0"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"@types/semver": "^7.5.8",
"jest": "^29.7.0",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
}
@@ -0,0 +1,152 @@
import {
parseLockfile,
parseNpmLock,
parseYarnLock,
parsePnpmLock,
parseBunLock,
} from '../parsers';
const NPM_LOCKFILE = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.4' },
'node_modules/react-native': { version: '0.79.4' },
'node_modules/react/node_modules/loose-envify': { version: '1.4.0' },
},
});
const NPM_LOCKFILE_WITH_PEERS = JSON.stringify({
lockfileVersion: 3,
packages: {
'node_modules/react': { version: '19.0.0' },
'node_modules/react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
'node_modules/@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
peerDependenciesMeta: { graphql: { optional: true } },
},
},
});
const YARN_LOCKFILE = `# yarn lockfile v1
[email protected]:
version "19.0.4"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
react-native@~0.79.0:
version "0.79.4"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
`;
const BUN_LOCKFILE = `{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"react": "19.0.4",
},
},
},
"packages": {
"react": ["[email protected]", "", {}, "sha512-abc"],
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-ghi"],
},
}`;
const PNPM_LOCKFILE = `lockfileVersion: '9.0'
packages:
[email protected]:
resolution: {integrity: sha512-abc}
@types/[email protected]:
resolution: {integrity: sha512-def}
`;
describe('parseNpmLock', () => {
it('parses top-level packages', () => {
const result = parseNpmLock(NPM_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
});
it('skips nested node_modules (transitive deps)', () => {
const result = parseNpmLock(NPM_LOCKFILE);
expect(result!.packages.has('loose-envify')).toBe(false);
});
it('parses peer dependencies and optional peers', () => {
const result = parseNpmLock(NPM_LOCKFILE_WITH_PEERS);
const reactDom = result!.packages.get('react-dom');
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
expect(reactDom?.optionalPeers).toBeUndefined();
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
});
it('returns null for invalid JSON', () => {
expect(parseNpmLock('not json')).toBeNull();
});
});
describe('parseYarnLock', () => {
it('parses package versions', () => {
const result = parseYarnLock(YARN_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
});
});
describe('parsePnpmLock', () => {
it('parses package versions including scoped packages', () => {
const result = parsePnpmLock(PNPM_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
expect(result!.packages.get('@types/react')?.version).toBe('19.0.4');
});
});
describe('parseBunLock', () => {
it('parses package versions from JSONC format', () => {
const result = parseBunLock(BUN_LOCKFILE);
expect(result).not.toBeNull();
expect(result!.packages.get('react')?.version).toBe('19.0.4');
});
it('parses peer dependencies and optional peers', () => {
const result = parseBunLock(BUN_LOCKFILE);
const reactDom = result!.packages.get('react-dom');
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
});
it('returns null for invalid content', () => {
expect(parseBunLock('not json at all {')).toBeNull();
});
});
describe('parseLockfile', () => {
it('dispatches to the correct parser by manager', () => {
expect(parseLockfile(NPM_LOCKFILE, 'npm', 'package-lock.json')).not.toBeNull();
expect(parseLockfile(YARN_LOCKFILE, 'yarn', 'yarn.lock')).not.toBeNull();
expect(parseLockfile(PNPM_LOCKFILE, 'pnpm', 'pnpm-lock.yaml')).not.toBeNull();
expect(parseLockfile(BUN_LOCKFILE, 'bun', 'bun.lock')).not.toBeNull();
});
it('returns null for binary bun.lockb', () => {
expect(parseLockfile('binary', 'bun', 'bun.lockb')).toBeNull();
});
it('returns null for unknown manager', () => {
expect(parseLockfile('{}', 'deno', 'deno.lock')).toBeNull();
});
});
@@ -0,0 +1,118 @@
import { findLockfileMismatches, findPeerDependencyConflicts } from '../validators';
import { LockfileData, PackageInfo } from '../types';
function makeLockfileData(packages: Record<string, PackageInfo>): LockfileData {
return { packages: new Map(Object.entries(packages)) };
}
describe('findLockfileMismatches', () => {
it('returns empty when lockfile is in sync', () => {
const packageJson = { dependencies: { react: '19.0.4', 'react-native': '~0.79.0' } };
const lockfile = makeLockfileData({
react: { version: '19.0.4' },
'react-native': { version: '0.79.4' },
});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
it('detects version mismatch', () => {
const packageJson = { dependencies: { react: '19.0.4' } };
const lockfile = makeLockfileData({ react: { version: '19.0.0' } });
const mismatches = findLockfileMismatches(packageJson, lockfile);
expect(mismatches).toHaveLength(1);
expect(mismatches[0]).toEqual({
name: 'react',
specifier: '19.0.4',
lockedVersion: '19.0.0',
});
});
it('checks devDependencies and optionalDependencies', () => {
const packageJson = {
devDependencies: { typescript: '^5.5.0' },
optionalDependencies: { fsevents: '^2.3.0' },
};
const lockfile = makeLockfileData({
typescript: { version: '5.5.4' },
fsevents: { version: '2.3.3' },
});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
it('skips non-registry specifiers', () => {
const packageJson = {
dependencies: {
'my-local': 'file:../local',
'my-workspace': 'workspace:*',
'my-git': 'git+https://github.com/foo/bar.git',
'my-github': 'github:foo/bar',
'my-latest': 'latest',
},
};
const lockfile = makeLockfileData({});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
it('skips packages not found in lockfile', () => {
const packageJson = { dependencies: { 'new-dep': '^1.0.0' } };
const lockfile = makeLockfileData({});
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
});
});
describe('findPeerDependencyConflicts', () => {
it('returns empty when peers are satisfied', () => {
const packageJson = { dependencies: { react: '19.2.4', 'react-dom': '19.2.4' } };
const lockfile = makeLockfileData({
react: { version: '19.2.4' },
'react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
});
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
});
it('detects unsatisfied peer dependency', () => {
const packageJson = { dependencies: { react: '19.0.0', 'react-dom': '19.2.4' } };
const lockfile = makeLockfileData({
react: { version: '19.0.0' },
'react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
});
const conflicts = findPeerDependencyConflicts(packageJson, lockfile);
expect(conflicts).toHaveLength(1);
expect(conflicts[0]).toEqual({
source: 'react-dom',
sourceVersion: '19.2.4',
peer: 'react',
peerRange: '^19.2.4',
installedVersion: '19.0.0',
});
});
it('skips optional peers', () => {
const packageJson = { dependencies: { '@0no-co/graphql.web': '1.2.0' } };
const lockfile = makeLockfileData({
'@0no-co/graphql.web': {
version: '1.2.0',
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
optionalPeers: new Set(['graphql']),
},
});
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
});
it('skips peers not installed in lockfile', () => {
const packageJson = { dependencies: { 'react-dom': '19.2.4' } };
const lockfile = makeLockfileData({
'react-dom': {
version: '19.2.4',
peerDependencies: { react: '^19.2.4' },
},
});
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
});
});
+8
View File
@@ -0,0 +1,8 @@
export { parseLockfile, parseNpmLock, parseYarnLock, parsePnpmLock, parseBunLock } from './parsers';
export { findLockfileMismatches, findPeerDependencyConflicts } from './validators';
export type {
PackageInfo,
LockfileData,
DependencyMismatch,
PeerDependencyConflict,
} from './types';
+154
View File
@@ -0,0 +1,154 @@
import { LockfileData, PackageInfo } from './types';
export function parseNpmLock(content: string): LockfileData | null {
try {
const lockfile = JSON.parse(content);
const rawPackages: Record<
string,
{
version?: string;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
}
> = lockfile.packages ?? {};
const packages = new Map<string, PackageInfo>();
for (const [key, value] of Object.entries(rawPackages)) {
if (!key.startsWith('node_modules/')) {
continue;
}
const rest = key.slice('node_modules/'.length);
// Skip nested node_modules (transitive deps)
if (rest.includes('node_modules/')) {
continue;
}
if (value.version) {
const optionalPeers = new Set<string>();
if (value.peerDependenciesMeta) {
for (const [peer, meta] of Object.entries(value.peerDependenciesMeta)) {
if (meta.optional) {
optionalPeers.add(peer);
}
}
}
packages.set(rest, {
version: value.version,
peerDependencies: value.peerDependencies,
optionalPeers: optionalPeers.size > 0 ? optionalPeers : undefined,
});
}
}
return { packages };
} catch {
return null;
}
}
export function parseYarnLock(content: string): LockfileData | null {
try {
const packages = new Map<string, PackageInfo>();
const lines = content.split('\n');
let currentPackage: string | null = null;
for (const line of lines) {
// Entry header: not indented, not a comment, ends with ':'
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':')) {
// Examples:
// yarn v1: react@^19.0.0: or "react@^19.0.0":
// yarn v2+: "react@npm:^19.0.0":
const entry = line.replace(/^"/, '').replace(/:$/, '').replace(/"$/, '');
const atIndex = entry.indexOf('@', entry.startsWith('@') ? 1 : 0);
if (atIndex > 0) {
currentPackage = entry.slice(0, atIndex);
}
}
// Version line (indented): ` version "19.0.0"` or ` version: 19.0.0`
if (currentPackage) {
const versionMatch = line.match(/^\s+version:?\s+"?([^"\s]+)"?/);
if (versionMatch) {
if (!packages.has(currentPackage)) {
packages.set(currentPackage, { version: versionMatch[1] });
}
currentPackage = null;
}
}
}
// yarn.lock doesn't include peer dependency info
return { packages };
} catch {
return null;
}
}
export function parsePnpmLock(content: string): LockfileData | null {
try {
const packages = new Map<string, PackageInfo>();
// Match entries in the packages section like:
// [email protected]: or @types/[email protected]:
const packageRegex = /^ {2}((?:@[\w.-]+\/)?[\w.-]+)@(\d+[^:\s]*?):/gm;
let match;
while ((match = packageRegex.exec(content)) !== null) {
if (!packages.has(match[1])) {
packages.set(match[1], { version: match[2] });
}
}
// pnpm-lock.yaml doesn't readily expose peer deps in a parseable way without YAML
return { packages };
} catch {
return null;
}
}
export function parseBunLock(content: string): LockfileData | null {
try {
// bun.lock is JSONC (has trailing commas) — strip them for JSON.parse
const jsonContent = content.replace(/,(\s*[}\]])/g, '$1');
const lockfile = JSON.parse(jsonContent);
const rawPackages: Record<string, unknown[]> = lockfile.packages ?? {};
const packages = new Map<string, PackageInfo>();
for (const [name, value] of Object.entries(rawPackages)) {
// Format: "react": ["[email protected]", "", { peerDependencies: {...}, optionalPeers: [...] }, "sha512-..."]
if (Array.isArray(value) && typeof value[0] === 'string') {
const entry: string = value[0];
const atIndex = entry.lastIndexOf('@');
if (atIndex > 0) {
const meta = (typeof value[2] === 'object' && value[2] !== null ? value[2] : {}) as {
peerDependencies?: Record<string, string>;
optionalPeers?: string[];
};
packages.set(name, {
version: entry.slice(atIndex + 1),
peerDependencies: meta.peerDependencies,
optionalPeers: meta.optionalPeers ? new Set(meta.optionalPeers) : undefined,
});
}
}
}
return { packages };
} catch {
return null;
}
}
export function parseLockfile(
content: string,
manager: string,
lockfilePath: string
): LockfileData | null {
switch (manager) {
case 'npm':
return parseNpmLock(content);
case 'yarn':
return parseYarnLock(content);
case 'pnpm':
return parsePnpmLock(content);
case 'bun':
// bun.lockb is binary — can only parse the text variant
if (lockfilePath.endsWith('.lockb')) {
return null;
}
return parseBunLock(content);
default:
return null;
}
}
+26
View File
@@ -0,0 +1,26 @@
export interface PackageInfo {
version: string;
peerDependencies?: Record<string, string>;
optionalPeers?: Set<string>;
}
export interface LockfileData {
packages: Map<string, PackageInfo>;
}
export interface DependencyMismatch {
name: string;
specifier: string;
lockedVersion: string;
}
export interface PeerDependencyConflict {
/** Package that declares the peer dependency */
source: string;
sourceVersion: string;
/** The peer dependency that is not satisfied */
peer: string;
peerRange: string;
/** The actual version of the peer in the lockfile */
installedVersion: string;
}
+92
View File
@@ -0,0 +1,92 @@
import semver from 'semver';
import { DependencyMismatch, LockfileData, PeerDependencyConflict } from './types';
function isNonRegistrySpecifier(specifier: string): boolean {
return (
specifier.startsWith('file:') ||
specifier.startsWith('link:') ||
specifier.startsWith('workspace:') ||
specifier.startsWith('git:') ||
specifier.startsWith('git+') ||
specifier.startsWith('github:') ||
specifier.startsWith('http:') ||
specifier.startsWith('https:') ||
specifier.startsWith('portal:') ||
specifier === '*' ||
specifier === 'latest' ||
specifier === 'next'
);
}
export function findLockfileMismatches(
packageJson: Record<string, unknown>,
lockfileData: LockfileData
): DependencyMismatch[] {
const allDeps: Record<string, string> = {
...(packageJson.dependencies as Record<string, string> | undefined),
...(packageJson.devDependencies as Record<string, string> | undefined),
...(packageJson.optionalDependencies as Record<string, string> | undefined),
};
const mismatches: DependencyMismatch[] = [];
for (const [name, specifier] of Object.entries(allDeps)) {
if (isNonRegistrySpecifier(specifier)) {
continue;
}
const pkg = lockfileData.packages.get(name);
if (!pkg) {
continue; // Package not found in lockfile — may be a new dep or hoisted differently
}
if (!semver.satisfies(pkg.version, specifier)) {
mismatches.push({ name, specifier, lockedVersion: pkg.version });
}
}
return mismatches;
}
export function findPeerDependencyConflicts(
packageJson: Record<string, unknown>,
lockfileData: LockfileData
): PeerDependencyConflict[] {
// Check peer deps of direct dependencies only (most common source of build failures)
const directDeps: Record<string, string> = {
...(packageJson.dependencies as Record<string, string> | undefined),
...(packageJson.devDependencies as Record<string, string> | undefined),
};
const conflicts: PeerDependencyConflict[] = [];
for (const depName of Object.keys(directDeps)) {
const pkg = lockfileData.packages.get(depName);
if (!pkg?.peerDependencies) {
continue;
}
for (const [peer, peerRange] of Object.entries(pkg.peerDependencies)) {
// Skip optional peers
if (pkg.optionalPeers?.has(peer)) {
continue;
}
const peerPkg = lockfileData.packages.get(peer);
if (!peerPkg) {
continue; // Peer not installed — might be optional or provided by a parent
}
if (!semver.satisfies(peerPkg.version, peerRange)) {
conflicts.push({
source: depName,
sourceVersion: pkg.version,
peer,
peerRange,
installedVersion: peerPkg.version,
});
}
}
}
return conflicts;
}
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"moduleResolution": "node",
"target": "es2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"composite": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"]
}
+4 -34
View File
@@ -5,11 +5,9 @@ import { getBuildEnv } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalCocoapodsCacheUrl = config.cocoapodsCacheUrl;
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
config.cocoapodsCacheUrl = originalCocoapodsCacheUrl;
});
it('passes the CLI sentry DSN to worker child processes', () => {
@@ -36,7 +34,7 @@ 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('does not add precompiled modules env vars to flagged iOS jobs', () => {
const env = getBuildEnv({
job: {
platform: Platform.IOS,
@@ -56,37 +54,9 @@ describe(getBuildEnv.name, () => {
buildId: 'build-id',
});
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://storage.googleapis.com/eas-build-precompiled-modules/'
);
expect(env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBeUndefined();
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('passes precompiled modules through the CocoaPods cache URL when available', () => {
config.cocoapodsCacheUrl = 'https://cache.example.com';
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {
EAS_USE_PRECOMPILED_MODULES: '1',
},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://cache.example.com/storage.googleapis.com/eas-build-precompiled-modules/'
);
});
});
@@ -0,0 +1,257 @@
import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
Platform,
Workflow,
errors,
} from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { build } from '../build';
import { createBuildContext } from '../context';
import BuildService, { getExpoPackageVersionAsync } from '../service';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../build', () => ({
build: jest.fn(),
}));
jest.mock('../context', () => ({
createBuildContext: jest.fn(),
}));
jest.mock('../external/analytics', () => ({
Analytics: jest.fn().mockImplementation(() => ({
flushEventsAsync: jest.fn(),
logEvent: jest.fn(),
})),
}));
jest.mock('../logger', () => {
const logger = {
child: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
level: jest.fn(),
warn: jest.fn(),
};
logger.child.mockReturnValue(logger);
return {
__esModule: true,
default: logger,
createBuildLoggerWithSecretsFilter: jest.fn(async () => ({
cleanUp: jest.fn(),
logBuffer: { getLogs: jest.fn(() => []), getPhaseLogs: jest.fn(() => []) },
logger,
})),
};
});
jest.mock('../sentry', () => ({
__esModule: true,
default: {
handleError: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
jest.mock('../config', () => {
const config = jest.requireActual('../config').default;
return {
__esModule: true,
default: {
...config,
buildId: 'build-id',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
},
};
});
describe(getExpoPackageVersionAsync, () => {
const projectRoot = '/test-project';
const expoPackageJsonPath = path.join(projectRoot, 'node_modules/expo/package.json');
const buildContext = {
env: { PATH: '/usr/bin' },
getReactNativeProjectDirectory: () => projectRoot,
} as any;
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
});
it('returns the exact installed expo package version', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: Buffer.from(expoPackageJsonPath) } as any);
vol.fromJSON({
[expoPackageJsonPath]: JSON.stringify({ version: '55.0.17' }),
});
await expect(getExpoPackageVersionAsync(buildContext)).resolves.toBe('55.0.17');
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: projectRoot,
env: buildContext.env,
})
);
});
it('throws a user error when expo package version resolution fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
});
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
});
it('throws a user error when the installed expo package version is not valid semver', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: Buffer.from(expoPackageJsonPath) } as any);
vol.fromJSON({
[expoPackageJsonPath]: JSON.stringify({ version: 'invalid-version' }),
});
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
});
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
});
});
describe(BuildService, () => {
const projectRoot = '/test-project';
const job = {
appId: 'app-id',
cache: {
clear: false,
disabled: false,
paths: [],
},
mode: BuildMode.BUILD,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://example.com/project.tar.gz',
},
projectRootDirectory: '.',
secrets: {
robotAccessToken: 'robot-token',
},
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.GENERIC,
};
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(path.join(projectRoot, 'node_modules/expo/package.json')),
} as any);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as any);
jest.mocked(build).mockRejectedValue(new Error('raw build failure'));
jest.mocked(createBuildContext).mockReturnValue({
env: { PATH: '/usr/bin' },
getReactNativeProjectDirectory: () => projectRoot,
job,
logger: {},
} as any);
});
it('sends expo_package_version to Datadog error logs without changing sdk_version', async () => {
vol.fromJSON({
[path.join(projectRoot, 'node_modules/expo/package.json')]: JSON.stringify({
version: '55.0.17',
}),
});
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
buildProfile: 'production',
reactNativeVersion: '0.83.0',
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: '55.0.17',
sdk_version: '55.0.0',
}),
}),
})
);
});
it('sends null expo_package_version to Datadog error logs when expo package version resolution fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: null,
sdk_version: '55.0.0',
}),
}),
})
);
});
it('does not try to resolve expo_package_version when build context setup fails', async () => {
jest.mocked(createBuildContext).mockImplementation(() => {
throw new Error('context setup failure');
});
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(spawn).not.toHaveBeenCalled();
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: null,
sdk_version: '55.0.0',
}),
}),
})
);
});
});
-19
View File
@@ -12,9 +12,6 @@ import {
} from './external/turtle';
import { getAccessedEnvs } from './utils/env';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
// keep in sync with local-build-plugin env vars
// see packages/local-build-plugin/src/build.ts
export function getBuildEnv({
@@ -56,10 +53,6 @@ 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') {
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
setEnv(env, 'EXPO_PRECOMPILED_MODULES_BASE_URL', getPrecompiledModulesBaseUrl());
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
setEnv(env, 'USE_CCACHE', '1');
@@ -170,18 +163,6 @@ function setEnv(env: Env, key: string, value: string | null | undefined): void {
}
}
function getPrecompiledModulesBaseUrl(): string {
if (!config.cocoapodsCacheUrl) {
return PRECOMPILED_MODULES_BASE_URL;
}
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
return PRECOMPILED_MODULES_BASE_URL.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${config.cocoapodsCacheUrl.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
const ResourceClassToMaxHeapSize: Record<ResourceClass, string | null> = {
[ResourceClass.LINUX_C3D_STANDARD_4]: '4g', // 4 out of 16 GB, 12 GB left
[ResourceClass.LINUX_C3D_STANDARD_8]: '8g', // 8 out of 32 GB, 24 GB left
+47
View File
@@ -17,9 +17,12 @@ import {
errors,
} from '@expo/eas-build-job';
import { LoggerLevel } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import assert from 'assert';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import { build } from './build';
@@ -333,6 +336,11 @@ export default class BuildService {
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken) {
const expoPackageVersionResult = this.buildContext
? await asyncResult(getExpoPackageVersionAsync(this.buildContext))
: null;
const expoPackageVersion =
expoPackageVersionResult?.ok === true ? expoPackageVersionResult.value : null;
let rawErrorMessage: string = '';
if (maybeRawError?.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
@@ -358,6 +366,7 @@ export default class BuildService {
platform: job.platform,
workflow: job.type,
sdk_version: metadata?.sdkVersion ?? null,
expo_package_version: expoPackageVersion,
react_native_version: metadata?.reactNativeVersion ?? null,
app_id: job.appId ?? null,
build_profile: metadata?.buildProfile ?? null,
@@ -401,3 +410,41 @@ function getLastNLines(numberOfLines: number, stream: string): string {
return lines.slice(lines.length - numberOfLines, lines.length).join('\n');
}
}
export async function getExpoPackageVersionAsync(ctx: BuildContext<Job>): Promise<string> {
const reactNativeProjectDirectory = ctx.getReactNativeProjectDirectory();
const expoPackageJsonPathResult = await asyncResult(
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: reactNativeProjectDirectory,
env: ctx.env,
stdio: 'pipe',
})
);
if (!expoPackageJsonPathResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
{ cause: expoPackageJsonPathResult.reason }
);
}
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
const expoPackageJsonResult = await asyncResult(fs.readJson(expoPackageJsonPath));
if (!expoPackageJsonResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
{ cause: expoPackageJsonResult.reason }
);
}
const expoPackageVersion = expoPackageJsonResult.value.version;
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
);
}
return expoPackageVersion;
}
Generated
+16
View File
@@ -3270,6 +3270,21 @@ __metadata:
languageName: node
linkType: hard
"@expo/lockfile-utils@npm:18.0.1, @expo/lockfile-utils@workspace:packages/lockfile-utils":
version: 0.0.0-use.local
resolution: "@expo/lockfile-utils@workspace:packages/lockfile-utils"
dependencies:
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
"@types/semver": "npm:^7.5.8"
jest: "npm:^29.7.0"
semver: "npm:^7.6.0"
ts-jest: "npm:^29.1.4"
typescript: "npm:^5.5.4"
languageName: unknown
linkType: soft
"@expo/logger@npm:18.0.1, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:18.5.0, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
@@ -11006,6 +11021,7 @@ __metadata:
"@expo/eas-json": "npm:18.8.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/lockfile-utils": "npm:18.0.1"
"@expo/logger": "npm:18.5.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"