Compare commits

..
Author SHA1 Message Date
Expo CI 2e2dddb59a v18.5.0 2026-04-02 10:41:22 +00:00
Szymon ŚwierkandStanisław Chmiela b7df3ff4f8 [eas-build-job] Add app_store_connect.build_upload workflow interpolation context (#3566)
* Add app_store_connect.build_upload workflow interpolation context

* limit the possible states

* make the id type uuid

* Update packages/eas-build-job/src/common.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Update packages/eas-build-job/src/common.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* remove no longer valid test

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-04-02 12:06:44 +02:00
Stanisław Chmiela 8a74758d56 [build-tools] Remove reuse_devices: false dead code (#3560)
## Summary
- remove the internal `eas/__maestro_test` function registration and implementation
- delete the Maestro flow/config discovery helper used by that internal path
- remove the related tests and fixture files for Maestro config parsing and flow resolution

## Testing
- `yarn --cwd packages/build-tools typecheck`
- `yarn --cwd packages/build-tools jest-unit --runInBand maestroResultParser reportMaestroTestResults easFunctions`
2026-04-02 10:38:59 +02:00
Szymon Świerk 26fdf8fb75 [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext (#3530)
* [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext

* app_store_connect.app, not app_store_connect.event.app
2026-04-01 11:10:48 +02:00
Stanisław Chmiela 9328063547 [build-tools] Remove precompiled modules preparation flow (#3565)
## Summary
- remove the worker and build-tools plumbing for precompiled dependency downloads and extraction
- stop waiting on precompiled dependency preparation before `pod install`
- delete the dedicated precompiled dependency helpers, fixtures, and tests
- keep the `EAS_USE_PRECOMPILED_MODULES=1` passthrough so iOS jobs still set `EXPO_USE_PRECOMPILED_MODULES=1`

## Testing

CI should pass.
2026-04-01 09:44:39 +02:00
Stanisław Chmiela fc66b8251e [eas-cli] Fix hasIgnoredIosProjectAsync (#3562)
## Why

`hasIgnoredIosProjectAsync()` currently always returns `false` because its `finally` block unconditionally returns `false`, overriding the value from the `try` block.

That means callers never see a positive ignored result even when the Xcode project is actually ignored.

## How

- replace the unconditional `finally` return with a `catch` fallback
- add a unit test that covers the ignored iOS project case

## Test Plan

CI should pass. Verified the test would fail if not for this change.
2026-04-01 09:25:07 +02:00
Stanisław Chmiela 394576368a [worker] Lower project dependencies telemetry errors to warning (#3535)
## Summary
- mark `FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT` as a Sentry warning instead of an error
- reduce noise from non-critical analytics failures in worker project dependency reporting
2026-03-31 10:30:17 +02:00
Evan BaconandClaude Opus 4.6 f80e34cffe feat(metadata): add screenshots and preview support (#3301)
* Add support for Apple screenshots and video previews

Introduces new tasks for managing App Store screenshots and video previews, including downloading and uploading assets per locale and display type. Updates AppleData and PartialAppleData types to include screenshots and previews, and extends config reader/writer to handle these assets. Also bumps @expo/apple-utils to 2.1.13 and updates related tests and types.

* Update apple-utils and improve asset file handling

Bump @expo/apple-utils to 2.1.14 and add a version check for preview support. Normalize preview and screenshot asset directory structure and filenames for consistency and future compatibility.

* Use original type names for preview and screenshot dirs

Updated directory structure for storing Apple previews and screenshots to use the original previewType and displayType values instead of lowercased, hyphenated versions. This change ensures consistency with the original type naming.

* Improve Apple metadata tasks error handling and schema

Replaces assert-based error handling with conditional checks and log messages in Apple metadata tasks (age-rating, app-review-detail, previews, screenshots) to prevent crashes when context is missing. Updates the metadata schema to add support for 'screenshots' and 'previews' fields, including detailed structure for video previews.

* Use original filenames for Apple previews and screenshots

Updated the download logic to use the original filenames from Apple metadata for previews and screenshots instead of normalized index-based names. This change improves file matching during sync operations.

* Update tests to skip on missing context instead of abort

Changed age rating and app review detail task tests to expect skipping (not throwing) when required context is missing, reflecting updated behavior. Also updated CHANGELOG to note new screenshots and previews support for metadata commands.

* wip -- unit tests for metadata

* Include .query(true) in apple metadata tests

Allow nock mocks to match requests with query parameters in two tests. Added `.query(true)` to the GET interceptors in packages/eas-cli/src/metadata/apple/tasks/__tests__/previews.test.ts and packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts so the mocks won't fail when requests include query strings.

* [eas-cli] Fix age rating API and update @expo/apple-utils to 2.1.16

Move age rating from appStoreVersions to appInfos — Apple removed the
ageRatingDeclaration relationship from appStoreVersions. Also apply
formatting fixes from oxfmt.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Update CHANGELOG.md

* [eas-cli] Improve screenshots/previews tasks: native fs, fileSize matching, parallel fetching

- Replace fs-extra with native fs/fs.promises
- Add fileSize check alongside filename matching to detect changed files
- Parallelize locale fetching in prepareAsync with Promise.all
- Skip reorder API call when screenshot order is unchanged
- Remove unnecessary runtime method check for getAppPreviewSetsAsync
- Use consistent index-based fallback filenames for previews

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 17:46:55 -07:00
Quinlan Jung 🦥 59ad2c2ca0 Reapply "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534) (#3543)
This reverts commit 92ad6b5ba3.
2026-03-30 14:04:44 -07:00
84 changed files with 2188 additions and 2888 deletions

No files matched your search

+3 -2
View File
@@ -8,18 +8,19 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Auto-generate `ios.bundleIdentifier` and `android.package` in non-interactive mode when not set in app config. ([#3399](https://github.com/expo/eas-cli/pull/3399) by [@evanbacon](https://github.com/evanbacon))
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.4.0",
"version": "18.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.4.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -35,19 +35,19 @@
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.4.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
-7
View File
@@ -19,12 +19,5 @@ export { PackageManager } from './utils/packageManager';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export {
maybeStartPreparingPrecompiledModules,
PRECOMPILED_MODULES_PATH,
shouldUsePrecompiledDependencies,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from './utils/precompiledModules';
export * from './generic';
@@ -1,127 +0,0 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import {
startPreparingPrecompiledDependencies,
PRECOMPILED_MODULES_PATH,
} from '../../utils/precompiledModules';
import { installPods } from '../pod';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('@expo/turtle-spawn');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe(installPods.name, () => {
const extract = jest.fn();
const close = jest.fn();
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
let mkdtempCallCount = 0;
jest.mocked(mkdtemp).mockImplementation(async () => {
mkdtempCallCount += 1;
return mkdtempCallCount === 1
? ('/tmp/precompiled-modules-archive' as any)
: ('/tmp/precompiled-modules-staging' as any);
});
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
it('waits for precompiled dependencies before running pod install', async () => {
let resolvePreparation: (() => void) | undefined;
const preparationPromise = new Promise<void>(resolve => {
resolvePreparation = resolve;
});
jest.mocked(downloadFile).mockReturnValue(preparationPromise);
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, [
'https://example.com/precompiled-modules-0.zip',
'https://example.com/precompiled-modules-1.zip',
]);
jest.mocked(spawn).mockResolvedValue({} as any);
const installPodsPromise = installPods(ctx, {});
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
resolvePreparation?.();
await installPodsPromise;
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(downloadFile).mockRejectedValue(new Error('download failed'));
const ctx = new BuildContext(
createTestIosJob({
buildCredentials: undefined,
}),
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
startPreparingPrecompiledDependencies(ctx, ['https://example.com/precompiled-modules-0.zip']);
jest.mocked(spawn).mockResolvedValue({} as any);
await expect(installPods(ctx, {})).resolves.toEqual({
spawnPromise: expect.any(Promise),
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: '/workingdir/build/ios',
})
);
});
});
-10
View File
@@ -3,21 +3,11 @@ import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spa
import path from 'path';
import { BuildContext } from '../context';
import { waitForPrecompiledModulesPreparationAsync } from '../utils/precompiledModules';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
ctx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
const iosDir = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
@@ -17,7 +17,6 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
@@ -81,8 +80,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
@@ -1,59 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { waitForPrecompiledModulesPreparationAsync } from '../../../utils/precompiledModules';
import { createInstallPodsBuildFunction } from '../installPods';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/precompiledModules', () => ({
...jest.requireActual('../../../utils/precompiledModules'),
waitForPrecompiledModulesPreparationAsync: jest.fn(),
}));
describe(createInstallPodsBuildFunction, () => {
it('waits for precompiled dependencies before running pod install', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest.mocked(waitForPrecompiledModulesPreparationAsync).mockResolvedValue(undefined);
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await buildStep.executeAsync();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
it('continues with pod install when precompiled dependencies preparation fails', async () => {
jest.mocked(spawn).mockResolvedValue({} as any);
jest
.mocked(waitForPrecompiledModulesPreparationAsync)
.mockRejectedValue(new Error('precompiled dependencies failed'));
const installPods = createInstallPodsBuildFunction();
const globalContext = createGlobalContextMock({});
const buildStep = installPods.createBuildStepFromFunctionCall(globalContext, {
callInputs: {},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(waitForPrecompiledModulesPreparationAsync).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: globalContext.defaultWorkingDirectory,
})
);
});
});
@@ -1,238 +0,0 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('generates per-attempt report filenames when retrying', async () => {
// Mock sequence:
// 1. xcrun simctl shutdown → succeeds (beforeEach default is fine)
// 2. maestro test attempt 0 → fails (triggers retry)
// 3. maestro test attempt 1 → succeeds (beforeEach default)
mockedSpawnAsync
.mockResolvedValueOnce(undefined as any) // xcrun simctl shutdown
.mockRejectedValueOnce(new Error('Maestro test failed')); // maestro attempt 0
// Attempt 1 falls through to beforeEach's mockResolvedValue default
const step = createStep({
callInputs: { output_format: 'junit', retries: 2 },
});
await step.executeAsync();
// Filter to just the maestro test command calls (skip xcrun simctl shutdown)
const maestroCalls = mockedSpawnAsync.mock.calls.filter(([cmd]) => cmd === 'maestro');
expect(maestroCalls).toHaveLength(2);
// Extract --output paths from maestro calls
const outputArgs = maestroCalls.map(([, args]) => args[args.indexOf('--output') + 1]);
expect(outputArgs[0]).toContain('attempt-0');
expect(outputArgs[1]).toContain('attempt-1');
// Filenames must be unique (not overwriting)
expect(outputArgs[0]).not.toBe(outputArgs[1]);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -1,8 +1,6 @@
import { BuildFunction } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { waitForPrecompiledModulesPreparationAsync } from '../../utils/precompiledModules';
export function createInstallPodsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -10,14 +8,6 @@ export function createInstallPodsBuildFunction(): BuildFunction {
name: 'Install Pods',
__metricsId: 'eas/install_pods',
fn: async (stepsCtx, { env }) => {
try {
await waitForPrecompiledModulesPreparationAsync();
} catch (err) {
stepsCtx.logger.warn(
{ err },
'Precompiled dependencies were not prepared successfully, continuing with pod install'
);
}
stepsCtx.logger.info('Installing pods');
const verboseFlag = stepsCtx.global.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag =
@@ -1,684 +0,0 @@
import { GenericArtifactType } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { Result, asyncResult, result } from '@expo/results';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
spawnAsync,
} from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: '__maestro_test',
__metricsId: 'eas/__maestro_test',
inputProviders: [
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'platform',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
id: 'flow_paths',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'retries',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'include_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'exclude_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'shards',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'output_format',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
id: 'record_screen',
defaultValue: false,
required: false,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
const {
platform,
flow_paths,
retries,
include_tags,
exclude_tags,
shards,
output_format,
record_screen,
} = z
.object({
platform: z.enum(['ios', 'android']),
flow_paths: z.array(z.string()),
retries: z.number().default(1),
include_tags: z.string().optional(),
exclude_tags: z.string().optional(),
shards: z.number().default(1),
output_format: z.string().optional(),
record_screen: z.boolean().default(false),
})
.parse(
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
);
const flowPathsToExecute: string[] = [];
for (const flowPath of flow_paths) {
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: stepCtx.workingDirectory,
flowPath,
logger: stepCtx.logger,
includeTags: include_tags ? include_tags.split(',') : undefined,
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
});
if (flowPaths.length === 0) {
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
continue;
}
stepCtx.logger.info(
`Marking for execution:\n- ${flowPaths
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
stepCtx.logger.info('');
flowPathsToExecute.push(...flowPaths);
}
// TODO: Add support for shards. (Shouldn't be too difficult.)
if (shards > 1) {
stepCtx.logger.warn(
'Sharding support has been temporarily disabled. Running tests on a single shard.'
);
}
// eas/__maestro_test does not start devices, it expects a single device to be already running
// and configured with the app. Here we find the booted device and stop it.
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
switch (platform) {
case 'ios': {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
});
if (bootedDevices.length === 0) {
throw new Error('No booted iOS Simulator found.');
} else if (bootedDevices.length > 1) {
throw new Error('Multiple booted iOS Simulators found.');
}
const device = bootedDevices[0];
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
stepCtx.logger.info(`Preparing Simulator for tests...`);
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
logger: stepCtx.logger,
stdio: 'pipe',
});
sourceDeviceIdentifier = device.name;
break;
}
case 'android': {
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
if (connectedDevices.length === 0) {
throw new Error('No booted Android Emulator found.');
} else if (connectedDevices.length > 1) {
throw new Error('Multiple booted Android Emulators found.');
}
const { serialId } = connectedDevices[0];
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
mode: PipeMode.COMBINED,
env,
});
const avdName = adbEmuAvdNameResult.stdout
.replace(/\r\n/g, '\n')
.split('\n')[0] as AndroidVirtualDeviceName;
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
stepCtx.logger.info(`Preparing Emulator for tests...`);
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
stdio: 'pipe',
});
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
await setTimeout(1000);
sourceDeviceIdentifier = avdName;
break;
}
}
// During tests we generate reports and device logs. We store them in temporary directories
// and upload them once all tests are done. When a test is retried, new reports overwrite
// the old ones. The files are named "flow-${index}" for easier identification.
const maestroReportsDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'maestro-reports-')
);
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
const failedFlows: string[] = [];
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
stepCtx.logger.info('');
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
// Generate unique report path per attempt (not overwritten on retry)
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}-attempt-${attemptCount}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
| IosSimulatorName
| AndroidVirtualDeviceName;
// If the test passes, but the recording fails, we don't want to make the test fail,
// so we return two separate results.
const {
fnResult: { fnResult, recordingResult },
logsResult,
} = await withCleanDeviceAsync({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger: stepCtx.logger,
fn: async ({ deviceIdentifier }) => {
return await maybeWithScreenRecordingAsync({
shouldRecord: record_screen,
platform,
deviceIdentifier,
env,
logger: stepCtx.logger,
fn: async () => {
stepCtx.logger.info('');
const [command, ...args] = getMaestroTestCommand({
flow_path: flowPath,
output_format,
output_path: outputPath,
});
try {
await spawnAsync(command, args, {
logger: stepCtx.logger,
cwd: stepCtx.workingDirectory,
env,
stdio: 'pipe',
});
} finally {
stepCtx.logger.info('');
}
},
});
},
});
// Move device logs to the device logs directory.
if (logsResult?.ok) {
try {
const extension = path.extname(logsResult.value.outputPath);
const destinationPath = path.join(
deviceLogsDir,
`flow-${flowIndex}-attempt-${attemptCount}${extension}`
);
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
}
} else if (logsResult?.reason) {
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
}
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
if (isLastAttempt && recordingResult.value) {
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
name: `Screen Recording (${flowIndex}-${path.basename(
flowPath,
path.extname(flowPath)
)})`,
paths: [recordingResult.value],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
}
}
if (fnResult.ok) {
stepCtx.logger.info(`Test passed.`);
// Break out of the retry loop.
break;
}
if (attemptCount < retries - 1) {
stepCtx.logger.info(`Retrying test...`);
stepCtx.logger.info('');
continue;
}
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
failedFlows.push(flowPath);
}
}
stepCtx.logger.info('');
// When all tests are done, we upload the reports and device logs.
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
if (generatedMaestroReports.length === 0) {
stepCtx.logger.warn('No reports were generated.');
} else {
stepCtx.logger.info(`Uploading reports...`);
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
paths: [maestroReportsDir],
type: GenericArtifactType.OTHER,
},
});
if (artifactId) {
outputs.test_reports_artifact_id.set(artifactId);
}
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload reports.');
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
} else {
stepCtx.logger.info(`Uploading device logs...`);
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `Maestro Test Device Logs`,
paths: [deviceLogsDir],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
}
}
stepCtx.logger.info('');
// If any tests failed, we throw an error to mark the step as failed.
if (failedFlows.length > 0) {
throw new Error(
`Some Maestro tests failed:\n- ${failedFlows
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
} else {
stepCtx.logger.info('All Maestro tests passed.');
}
},
});
}
export function getMaestroTestCommand(params: {
flow_path: string;
output_format: string | undefined;
/** Unused if `output_format` is undefined */
output_path: string;
}): [command: string, ...args: string[]] {
let outputFormatFlags: string[] = [];
if (params.output_format) {
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
}
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
command: string,
...args: string[],
];
}
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
junit: 'xml',
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger,
fn,
}: {
env: BuildStepEnv;
logger: bunyan;
platform: 'ios' | 'android';
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
fn: ({
deviceIdentifier,
}: {
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
}) => Promise<TResult>;
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await IosSimulatorUtils.cloneAsync({
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
destinationDeviceName: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
await IosSimulatorUtils.waitForReadyAsync({
udid,
env,
});
localDeviceIdentifier = localDeviceName as IosSimulatorName;
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
// Stop the device
let logsResult: Result<{ outputPath: string }> | null = null;
try {
switch (platform) {
case 'ios': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
IosSimulatorUtils.collectLogsAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await IosSimulatorUtils.deleteAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
});
break;
}
case 'android': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
}
}
} catch (err) {
logger.error(`Error cleaning up device: ${err}`);
}
return { fnResult: fnResult.enforceValue(), logsResult };
}
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
async function maybeWithScreenRecordingAsync<TResult>({
shouldRecord,
platform,
deviceIdentifier,
env,
logger,
fn,
}: {
// As weird as it is, it's more convenient to have this function like `maybeWith...`
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
shouldRecord: boolean;
platform: 'ios' | 'android';
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
env: BuildStepEnv;
logger: bunyan;
fn: () => Promise<TResult>;
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
if (!shouldRecord) {
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
}
let recordingResult: Result<{
recordingSpawn: SpawnPromise<SpawnResult>;
outputPath?: string;
}>;
// Start screen recording
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
recordingResult = await asyncResult(
IosSimulatorUtils.startScreenRecordingAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorName,
env,
})
);
break;
}
case 'android': {
recordingResult = await asyncResult(
AndroidEmulatorUtils.startScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
env,
})
);
break;
}
}
if (!recordingResult.ok) {
logger.warn('Failed to start screen recording.', recordingResult.reason);
}
// Run the function
const fnResult = await asyncResult(fn());
// If recording failed there's nothing to stop, so we return the results
if (!recordingResult.ok) {
return { fnResult, recordingResult: result(recordingResult.reason) };
}
// If recording started, finish it
try {
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
await IosSimulatorUtils.stopScreenRecordingAsync({
recordingSpawn: recordingResult.value.recordingSpawn,
});
return {
fnResult,
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
recordingResult: result(recordingResult.value.outputPath!),
};
}
case 'android': {
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
recordingSpawn: recordingResult.value.recordingSpawn,
env,
});
return { fnResult, recordingResult: result(outputPath) };
}
}
} catch (err) {
logger.warn('Failed to stop screen recording.', err);
return { fnResult, recordingResult: result(err as Error) };
}
}
@@ -1,400 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import bunyan from 'bunyan';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
// We use node:fs
jest.unmock('node:fs');
// and fg uses fs.
jest.unmock('fs');
jest.setTimeout(15000);
/**
*
* You need to have a Simulator running to run these tests.
*
*/
describe('findMaestroPathsFlowsToExecuteAsync', () => {
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
let logger: bunyan;
beforeAll(() => {
logger = createMockLogger();
});
// Helper function to run maestro test and determine which flows it executed
// by parsing the output
async function getMaestroFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const args = ['test', flowPath, '--no-ansi'];
if (includeTags.length > 0) {
args.push('--include-tags', includeTags.join(','));
}
if (excludeTags.length > 0) {
args.push('--exclude-tags', excludeTags.join(','));
}
try {
const result = await spawnAsync('maestro', args, {
stdio: 'pipe',
});
return parseMaestroExecutedFlows(result.stdout + result.stderr);
} catch (error: any) {
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
return parseMaestroExecutedFlows(output);
}
}
function parseMaestroExecutedFlows(output: string): string[] {
return output
.split('\n')
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
.map(line => {
if (line.includes('[Passed]')) {
return line.split('[Passed]')[1].trim().split(' ')[0];
}
return line.split('> Flow')[1].trim();
})
.sort();
}
// Helper function to run our implementation
async function getOurFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const result = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: process.cwd(),
flowPath: path.relative(process.cwd(), flowPath),
includeTags,
excludeTags,
logger,
});
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
}
describe('Directory-based flow discovery', () => {
it('should discover all flows when no tags are specified', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir);
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by both include and exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should not include nested flows in subdirectories', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should exclude config files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
});
it('should exclude non-YAML files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).not.toContain('non-flow-file');
});
});
describe('Single file scenarios', () => {
it('should handle single file input', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
const maestroFlows = await getMaestroFlowList(singleFile);
const ourFlows = await getOurFlowList(singleFile);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
});
it('should handle single file with tag filtering (include)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be included
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
it('should handle single file with tag filtering (exclude)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be excluded
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
});
describe('Edge cases', () => {
it('should handle non-existent paths gracefully', async () => {
const nonExistentPath = '/path/that/does/not/exist';
// Our implementation should return empty array for non-existent paths
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
});
it('should handle empty directories', async () => {
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
await fs.mkdir(emptyDir, { recursive: true });
try {
const ourFlows = await getOurFlowList(emptyDir);
expect(ourFlows).toEqual([]);
} finally {
await fs.rmdir(emptyDir);
}
});
it('should handle flows that cannot be parsed', async () => {
// The invalid-flow.yaml has extra fields that might cause parsing issues
// but should still be discovered if tag filtering passes
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
// The important thing is that it doesn't crash
expect(Array.isArray(ourFlows)).toBe(true);
});
});
describe('Performance comparison', () => {
it('should complete discovery in reasonable time', async () => {
const startTime = Date.now();
await getOurFlowList(fixturesDir);
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
});
});
describe('Detailed flow content verification', () => {
it('should correctly identify flows with specific tag combinations', async () => {
// Test complex tag filtering scenarios
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
// Verify specific files are included/excluded correctly
expect(authFlows).toContain('tagged-flow-auth');
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
expect(smokeFlows).toContain('tagged-flow-smoke');
expect(criticalFlows).toContain('tagged-flow-smoke');
});
it('should handle flows without tags correctly', async () => {
// When no filters are applied, flows without tags should be included
const allFlows = await getOurFlowList(fixturesDir);
expect(allFlows).toContain('no-tags-flow');
expect(allFlows).toContain('basic-flow');
// When include tags are specified, flows without tags should be excluded
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(taggedFlows).not.toContain('no-tags-flow');
expect(taggedFlows).not.toContain('basic-flow');
});
});
describe('config.yaml flow patterns', () => {
it('should discover flows using config.yaml patterns', async () => {
const maestroFlows = await getMaestroFlowList(configFlowsDir);
const ourFlows = await getOurFlowList(configFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Verify specific files are included based on config patterns
expect(ourFlows).toContain('login'); // features/auth/login.yaml
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
// Verify top-level file is excluded (not matching patterns)
expect(ourFlows).not.toContain('ignored-top-level');
});
it('should handle tag filtering with config.yaml patterns', async () => {
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
expect(authFlows).toEqual(maestroAuthFlows);
expect(authFlows).toContain('login');
expect(authFlows).toContain('logout');
expect(authFlows).not.toContain('navigation');
expect(authFlows).not.toContain('api.spec');
});
it('should handle exclude tags with config.yaml patterns', async () => {
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
});
it('should handle multiple include tags with config.yaml patterns', async () => {
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
'auth',
'integration',
]);
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
expect(multiTagFlows).toContain('login'); // auth tag
expect(multiTagFlows).toContain('logout'); // auth tag
expect(multiTagFlows).toContain('api.spec'); // integration tag
expect(multiTagFlows).toContain('ui.spec'); // integration tag
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
});
});
describe('config.yaml includeTags/excludeTags merging', () => {
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir);
const ourFlows = await getOurFlowList(configTagsDir);
expect(ourFlows).toEqual(maestroFlows);
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-cli-include');
});
it('should merge CLI includeTags with config includeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
expect(ourFlows).toEqual(maestroFlows);
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).toContain('flow-cli-include');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-cli-exclude');
});
it('should merge CLI excludeTags with config excludeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
expect(ourFlows).toEqual(maestroFlows);
// Exclude both cfgExclude and cliExclude
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-inc-and-exc');
});
it('should apply both merged include and exclude tags correctly', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
});
});
describe('backward compatibility (no config.yaml)', () => {
it('should use default "*" pattern when no config.yaml exists', async () => {
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
const ourFlows = await getOurFlowList(noConfigFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Should include top-level files
expect(ourFlows).toContain('top-level-flow');
// Should exclude subdirectory files (default "*" behavior)
expect(ourFlows).not.toContain('nested-flow');
});
it('should handle tag filtering without config.yaml', async () => {
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
expect(basicFlows).toEqual(maestroBasicFlows);
expect(basicFlows).toContain('top-level-flow');
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
expect(noIncludeTagsFlows).toContain('top-level-flow');
expect(noIncludeTagsFlows).not.toContain('nested-flow');
});
});
});
@@ -1,3 +0,0 @@
flows:
- 'features/**/*.yaml'
- 'specs/*.spec.yaml'
@@ -1,5 +0,0 @@
appId: com.example.app
name: "login"
tags: ["auth", "smoke"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "logout"
tags: ["auth"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "navigation"
tags: ["core", "smoke"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "ignored-top-level"
tags: ["ignored"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "api.spec"
tags: ["api", "integration"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "ui.spec"
tags: ["ui", "integration"]
---
- assertTrue: true
@@ -1,4 +0,0 @@
appId: com.example.app
name: "basic-flow"
---
- assertTrue: true
@@ -1,6 +0,0 @@
appId: com.example.app
name: "invalid-flow"
tags:
- invalid
---
- assertTrue: true
@@ -1,4 +0,0 @@
appId: com.example.app
name: "no-tags-flow"
---
- assertTrue: true
@@ -1 +0,0 @@
This is not a YAML file and should be ignored by flow discovery.
@@ -1,7 +0,0 @@
appId: com.example.app
name: "nested-flow"
tags:
- nested
- auth
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-auth"
tags:
- auth
- login
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-regression"
tags:
- regression
- slow
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-smoke"
tags:
- smoke
- critical
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "nested-flow"
tags: ["nested"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "top-level-flow"
tags: ["basic"]
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "standalone-flow"
tags:
- standalone
- smoke
---
- assertTrue: true
@@ -1,120 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
describe('precompiledModules integration', () => {
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.enableNetConnect();
});
afterEach(() => {
nock.cleanAll();
});
it('merges the extracted debug and release xcframework trees', async () => {
const homeDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-home-'));
const previousHome = process.env.HOME;
process.env.HOME = homeDirectory;
try {
const fixturesDirectory = path.join(__dirname, 'fixtures', 'precompiledModules');
const debugArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Debug.zip')
);
const releaseArchive = await fs.readFile(
path.join(fixturesDirectory, 'xcframeworks-Release.zip')
);
nock('https://fixtures.expo.test')
.get('/xcframeworks-Debug.zip')
.reply(200, debugArchive, { 'Content-Type': 'application/zip' })
.get('/xcframeworks-Release.zip')
.reply(200, releaseArchive, { 'Content-Type': 'application/zip' });
jest.resetModules();
jest.doMock('os', () => ({
...jest.requireActual('os'),
homedir: () => homeDirectory,
}));
const {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} = await import('../precompiledModules');
startPreparingPrecompiledDependencies(
{
logger: createMockLogger(),
env: {},
} as any,
[
'https://fixtures.expo.test/xcframeworks-Debug.zip',
'https://fixtures.expo.test/xcframeworks-Release.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(PRECOMPILED_MODULES_PATH).toBe(
path.join(homeDirectory, '.expo', 'precompiled-modules')
);
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/debug/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-modules-core/output/release/xcframeworks/ExpoModulesCore.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-modules-core fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-camera/output/debug/xcframeworks/ExpoCamera.tar.gz'
),
'utf8'
)
).resolves.toBe('debug expo-camera fixture\n');
await expect(
fs.readFile(
path.join(
PRECOMPILED_MODULES_PATH,
'expo-application/output/release/xcframeworks/EXApplication.tar.gz'
),
'utf8'
)
).resolves.toBe('release expo-application fixture\n');
} finally {
jest.dontMock('os');
jest.resetModules();
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.remove(homeDirectory);
}
});
});
@@ -1,168 +0,0 @@
import downloadFile from '@expo/downloader';
import { mkdirp, mkdtemp, move, remove } from 'fs-extra';
import StreamZip from 'node-stream-zip';
import {
PRECOMPILED_MODULES_PATH,
startPreparingPrecompiledDependencies,
waitForPrecompiledModulesPreparationAsync,
} from '../precompiledModules';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
jest.mock('@expo/downloader');
jest.mock('fs-extra');
jest.mock('node-stream-zip', () => ({
__esModule: true,
default: {
async: jest.fn(),
},
}));
describe('precompiledModules', () => {
const extract = jest.fn();
const close = jest.fn();
const getMkdtempPath = jest.fn();
const createCtx = (env: Record<string, string | undefined> = {}): BuildContext =>
({
logger: createMockLogger(),
env,
}) as unknown as BuildContext;
beforeEach(() => {
jest.mocked(remove).mockImplementation(async () => undefined as any);
jest.mocked(mkdirp).mockImplementation(async () => undefined as any);
jest.mocked(move).mockImplementation(async () => undefined as any);
getMkdtempPath.mockReset();
getMkdtempPath
.mockReturnValueOnce('/tmp/precompiled-modules-archive')
.mockReturnValueOnce('/tmp/precompiled-modules-staging');
jest.mocked(mkdtemp).mockImplementation(async () => getMkdtempPath() as any);
jest.mocked(downloadFile).mockReset();
extract.mockReset().mockResolvedValue(undefined);
close.mockReset().mockResolvedValue(undefined);
jest.mocked(StreamZip.async).mockImplementation(
() =>
({
extract,
close,
}) as any
);
});
afterEach(() => {
jest.useRealTimers();
});
it('downloads through CocoaPods proxy when enabled', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('falls back to the direct url when CocoaPods proxy download fails', async () => {
jest
.mocked(downloadFile)
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
startPreparingPrecompiledDependencies(
createCtx({ EAS_BUILD_COCOAPODS_CACHE_URL: 'http://localhost:9001' }),
[
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]
);
await waitForPrecompiledModulesPreparationAsync();
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'http://localhost:9001/storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
});
it('extracts all configured archives into the well-known destination path', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
]);
await waitForPrecompiledModulesPreparationAsync();
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(downloadFile).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-0.zip',
{ retry: 3 }
);
expect(downloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-1.zip',
'/tmp/precompiled-modules-archive/precompiled-modules-1.zip',
{ retry: 3 }
);
expect(extract).toHaveBeenNthCalledWith(1, null, '/tmp/precompiled-modules-staging');
expect(extract).toHaveBeenNthCalledWith(2, null, '/tmp/precompiled-modules-staging');
expect(move).toHaveBeenCalledWith('/tmp/precompiled-modules-staging', PRECOMPILED_MODULES_PATH);
});
it('leaves the destination empty when preparation fails', async () => {
jest.mocked(downloadFile).mockResolvedValue(undefined);
extract.mockRejectedValueOnce(new Error('extract failed'));
startPreparingPrecompiledDependencies(createCtx(), [
'https://storage.googleapis.com/eas-build-precompiled-modules-production/ios/precompiled-modules-0.zip',
]);
await expect(waitForPrecompiledModulesPreparationAsync()).rejects.toThrow('extract failed');
expect(move).not.toHaveBeenCalled();
expect(remove).toHaveBeenCalledWith('/tmp/precompiled-modules-staging');
expect(remove).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
expect(mkdirp).toHaveBeenCalledWith(PRECOMPILED_MODULES_PATH);
});
it('throws when precompiled dependencies are still not ready after 30 seconds', async () => {
jest.useFakeTimers();
jest.mocked(downloadFile).mockImplementation(() => new Promise<void>(() => {}) as any);
startPreparingPrecompiledDependencies(createCtx(), [
'https://example.com/xcframeworks-Debug.zip',
]);
const waitPromise = waitForPrecompiledModulesPreparationAsync();
const waitExpectation = expect(waitPromise).rejects.toThrow(
'Timed out waiting for precompiled dependencies after 30 seconds'
);
await Promise.resolve();
await jest.advanceTimersByTimeAsync(30_000);
await waitExpectation;
});
});
@@ -1,230 +0,0 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fg from 'fast-glob';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { z } from 'zod';
const FlowConfigSchema = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
});
const WorkspaceConfigSchema = z.object({
flows: z.array(z.string()).optional(),
executionOrder: z.record(z.string(), z.unknown()).optional(),
includeTags: z.array(z.string()).optional(),
excludeTags: z.array(z.string()).optional(),
});
type FlowConfig = z.infer<typeof FlowConfigSchema>;
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
export async function findMaestroPathsFlowsToExecuteAsync({
workingDirectory,
flowPath,
includeTags: _includeTags,
excludeTags: _excludeTags,
logger,
}: {
workingDirectory: string;
flowPath: string;
includeTags: string[] | undefined;
excludeTags: string[] | undefined;
logger: bunyan;
}): Promise<string[]> {
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
// If it's a file, just return it (no validation needed)
const stat = await fs.stat(absoluteFlowPath);
if (stat.isFile()) {
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
return [absoluteFlowPath];
}
// It's a directory - discover flow files
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
// Check for workspace config
logger.info(`Searching for workspace config...`);
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
dirPath: absoluteFlowPath,
workingDirectory,
logger,
});
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
if (workspaceConfig?.executionOrder) {
logger.warn(`Execution order is not supported yet. Ignoring.`);
}
logger.info(`Searching for flow files...`);
const { flows } = await findAndParseFlowFilesAsync({
dirPath: absoluteFlowPath,
workingDirectory,
workspaceConfig,
logger,
});
if (flows.length === 0) {
logger.info(
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
);
return [];
}
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
if (includeTags.length === 0 && excludeTags.length === 0) {
logger.info(`No tags provided, returning all flows.`);
return flows.map(({ path }) => path);
}
logger.info(
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
JSON.stringify(excludeTags) ?? 'none'
}.`
);
return flows
.filter(({ config, path: flowPath }) => {
const shouldInclude = matchesTags({
flowTags: config?.tags ?? [],
includeTags,
excludeTags,
});
logger.info(
shouldInclude
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
);
return shouldInclude;
})
.map(({ path }) => path);
}
async function findAndParseWorkspaceConfigAsync({
dirPath,
workingDirectory,
logger,
}: {
dirPath: string;
workingDirectory: string;
logger: bunyan;
}): Promise<WorkspaceConfig | null> {
const configPaths = await fg(['config.yaml', 'config.yml'], {
cwd: dirPath,
absolute: true,
});
if (configPaths.length === 0) {
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
for (const configPath of configPaths) {
try {
const content = await fs.readFile(configPath, 'utf-8');
const configDoc = yaml.parse(content);
if (!configDoc) {
logger.warn(
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
return WorkspaceConfigSchema.parse(configDoc);
} catch (err) {
logger.warn(
{ err },
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
}
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
async function findAndParseFlowFilesAsync({
workingDirectory,
dirPath,
workspaceConfig,
logger,
}: {
workingDirectory: string;
dirPath: string;
workspaceConfig: WorkspaceConfig | null;
logger: bunyan;
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
const flows: { config: FlowConfig; path: string }[] = [];
// Determine flow patterns from config or use default
const flowPatterns = workspaceConfig?.flows ?? ['*'];
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
// Use fast-glob to find matching files
const matchedFiles = await fg(flowPatterns, {
cwd: dirPath,
absolute: true,
onlyFiles: true,
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
});
logger.info(`Found ${matchedFiles.length} potential flow files`);
// Parse each matched file
for (const filePath of matchedFiles) {
// Skip non-YAML files
const ext = path.extname(filePath);
if (ext !== '.yaml' && ext !== '.yml') {
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
continue;
}
const result = await asyncResult(parseFlowFile(filePath));
if (result.ok) {
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
flows.push({ config: result.value, path: filePath });
} else {
logger.info(
{ err: result.reason },
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
);
}
}
return { flows };
}
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
const content = await fs.readFile(filePath, 'utf-8');
const documents = yaml.parseAllDocuments(content);
const configDoc = documents[0];
if (!configDoc) {
throw new Error(`No config section found in ${filePath}`);
}
return FlowConfigSchema.parse(configDoc.toJS());
}
function matchesTags({
flowTags,
includeTags,
excludeTags,
}: {
flowTags: string[];
includeTags: string[];
excludeTags: string[];
}): boolean {
// Include logic: if includeTags is empty OR flow has any of the include tags
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
return includeMatch && excludeMatch;
}
@@ -1,174 +0,0 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import StreamZip from 'node-stream-zip';
import { BuildContext } from '../context';
let precompiledModulesPreparationPromise: Promise<void> | null = null;
const PRECOMPILED_MODULES_WAIT_TIMEOUT_MS = 30_000;
export const PRECOMPILED_MODULES_PATH = path.join(os.homedir(), '.expo', 'precompiled-modules');
export function shouldUsePrecompiledDependencies(env: Record<string, string | undefined>): boolean {
return env.EAS_USE_PRECOMPILED_MODULES === '1';
}
export function maybeStartPreparingPrecompiledModules(
ctx: BuildContext,
config: { precompiledModulesUrls: string[] }
): void {
if (!shouldUsePrecompiledDependencies(ctx.env)) {
return;
}
startPreparingPrecompiledDependencies(ctx, config.precompiledModulesUrls);
}
export function startPreparingPrecompiledDependencies(ctx: BuildContext, urls: string[]): void {
precompiledModulesPreparationPromise = preparePrecompiledDependenciesAsync({
logger: ctx.logger,
urls,
destinationDirectory: PRECOMPILED_MODULES_PATH,
cocoapodsProxyUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
}
export async function waitForPrecompiledModulesPreparationAsync(): Promise<void> {
if (!precompiledModulesPreparationPromise) {
return;
}
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
precompiledModulesPreparationPromise,
new Promise<void>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(
new Error(
`Timed out waiting for precompiled dependencies after ${PRECOMPILED_MODULES_WAIT_TIMEOUT_MS / 1000} seconds`
)
);
}, PRECOMPILED_MODULES_WAIT_TIMEOUT_MS);
timeoutHandle.unref?.();
}),
]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function preparePrecompiledDependenciesAsync({
logger,
urls,
destinationDirectory,
cocoapodsProxyUrl,
}: {
logger: bunyan;
urls: string[];
destinationDirectory: string;
cocoapodsProxyUrl?: string;
}): Promise<void> {
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'precompiled-modules-'));
const destinationParentDirectory = path.dirname(destinationDirectory);
await fs.mkdirp(destinationParentDirectory);
const stagingDirectory = await fs.mkdtemp(
path.join(destinationParentDirectory, `${path.basename(destinationDirectory)}-staging-`)
);
logger.info('');
logger.info(
{
destinationDirectory,
urls,
},
'Starting precompiled dependencies download'
);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
try {
const archives = urls.map((url, index) => ({
archiveName: path.basename(new URL(url).pathname),
url,
archivePath: path.join(archiveDirectory, `precompiled-modules-${index}.zip`),
}));
await Promise.all(
archives.map(async ({ url, archivePath }) => {
await downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
});
})
);
for (const { archiveName, url, archivePath } of archives) {
logger.info({ archivePath, url }, `Downloaded ${archiveName}, extracting archive`);
await extractZipAsync(archivePath, stagingDirectory);
logger.info(
{ destinationDirectory: stagingDirectory },
`Extracted ${archiveName} into staging precompiled dependencies directory`
);
}
await fs.remove(destinationDirectory);
await fs.move(stagingDirectory, destinationDirectory);
logger.info({ destinationDirectory }, `Precompiled modules ready in ${destinationDirectory}`);
} catch (error) {
await fs.remove(stagingDirectory);
await fs.remove(destinationDirectory);
await fs.mkdirp(destinationDirectory);
throw error;
} finally {
await fs.remove(archiveDirectory);
}
}
async function downloadPrecompiledModulesAsync({
url,
archivePath,
cocoapodsProxyUrl,
logger,
}: {
url: string;
archivePath: string;
cocoapodsProxyUrl?: string;
logger: bunyan;
}): Promise<void> {
const proxiedUrl = cocoapodsProxyUrl
? (() => {
const parsedUrl = new URL(url);
return `${cocoapodsProxyUrl}/${parsedUrl.hostname}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
})()
: null;
if (proxiedUrl) {
try {
await downloadFile(proxiedUrl, archivePath, { retry: 3 });
return;
} catch (err) {
logger.warn(
{ err, proxiedUrl },
'Failed to download through CocoaPods proxy, retrying directly'
);
}
}
await downloadFile(url, archivePath, { retry: 3 });
}
async function extractZipAsync(archivePath: string, destinationDirectory: string): Promise<void> {
const zip = new StreamZip.async({ file: archivePath });
try {
await zip.extract(null, destinationDirectory);
} finally {
await zip.close();
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.4.0",
"version": "18.5.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.4.0"
"@expo/steps": "18.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "18.0.1",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "18.0.2",
"version": "18.5.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.4.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -24,6 +24,98 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect build_upload context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: 'not-a-uuid',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app and account context', () => {
const context = {
after: {},
+13
View File
@@ -259,6 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
app_store_connect: z
.looseObject({
app: z.looseObject({
id: z.string(),
}),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
})
.optional(),
})
.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+92 -90
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,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.4.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.5.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -422,7 +422,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -442,7 +442,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -460,7 +460,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.4.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.5.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -501,7 +501,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.4.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.5.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -522,7 +522,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -560,7 +560,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.4.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.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -600,7 +600,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.4.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.5.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -630,7 +630,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.4.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.5.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -656,7 +656,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.4.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.5.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -708,7 +708,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.4.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.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -726,7 +726,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.4.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.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -744,7 +744,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.4.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.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -761,7 +761,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.4.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.5.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -782,7 +782,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -803,7 +803,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -825,7 +825,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.4.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.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -845,7 +845,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -867,7 +867,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.4.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.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -889,7 +889,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.4.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.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -924,7 +924,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.4.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.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -947,7 +947,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -968,7 +968,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.4.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.5.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -985,7 +985,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1003,7 +1003,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.4.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.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1032,7 +1032,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1057,7 +1057,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1078,7 +1078,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.4.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.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1099,7 +1099,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1136,7 +1136,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.4.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.5.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1156,7 +1156,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.4.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.5.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1177,7 +1177,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.4.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.5.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1198,7 +1198,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1212,7 +1212,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.4.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.5.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1226,7 +1226,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1258,7 +1258,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.4.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.5.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1284,7 +1284,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.4.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.5.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1306,7 +1306,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.4.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.5.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1334,7 +1334,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.4.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.5.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1362,7 +1362,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.4.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.5.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1385,7 +1385,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.4.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.5.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1407,7 +1407,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.4.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.5.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1441,7 +1441,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.4.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.5.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1485,7 +1485,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.4.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.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1516,7 +1516,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.4.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.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1625,7 +1625,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.4.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.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1633,16 +1633,17 @@ generate the local store configuration from the app stores
```
USAGE
$ eas metadata:pull [-e <value>]
$ eas metadata:pull [-e <value>] [--non-interactive]
FLAGS
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
--non-interactive Run the command in non-interactive mode.
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.4.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.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1650,16 +1651,17 @@ sync the local store configuration to the app stores
```
USAGE
$ eas metadata:push [-e <value>]
$ eas metadata:push [-e <value>] [--non-interactive]
FLAGS
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
--non-interactive Run the command in non-interactive mode.
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.4.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.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1711,7 +1713,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.4.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.5.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1725,7 +1727,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.4.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.5.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1748,7 +1750,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1772,7 +1774,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1790,7 +1792,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1823,7 +1825,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1866,7 +1868,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1887,7 +1889,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.4.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.5.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1908,7 +1910,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.4.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.5.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1933,7 +1935,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.4.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.5.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1960,7 +1962,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1998,7 +2000,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.4.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.5.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2026,7 +2028,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.4.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.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2056,7 +2058,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.4.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.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2077,7 +2079,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.4.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.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2097,7 +2099,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2118,7 +2120,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.4.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.5.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2140,7 +2142,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2160,7 +2162,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2179,7 +2181,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2202,7 +2204,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2219,7 +2221,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2340,7 +2342,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.4.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.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2360,7 +2362,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.4.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.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2384,7 +2386,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.4.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.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2424,7 +2426,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.4.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.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2446,7 +2448,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.4.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.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2474,7 +2476,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.4.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.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2494,7 +2496,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.4.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.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2516,5 +2518,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.4.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.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.4.0",
"version": "18.5.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -43,15 +43,15 @@
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
},
"dependencies": {
"@expo/apple-utils": "2.1.15",
"@expo/apple-utils": "2.1.16",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-json": "18.4.0",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "18.5.0",
"@expo/eas-json": "18.5.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.4.0",
"@expo/steps": "18.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
+36
View File
@@ -315,6 +315,42 @@
"liveEdits": true,
"optional": true
}
},
"screenshots": {
"type": "object",
"description": "Screenshots for this locale, organized by display type (e.g., APP_IPHONE_67, APP_IPAD_PRO_129)",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"previews": {
"type": "object",
"description": "Video previews for this locale, organized by display type (e.g., IPHONE_67, IPAD_PRO_129)",
"additionalProperties": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Video file path (relative to project root)"
},
"previewFrameTimeCode": {
"type": "string",
"description": "Optional preview frame time code (e.g., '00:05:00' for 5 seconds)"
}
},
"required": ["path"],
"additionalProperties": false
}
]
}
}
}
},
+1 -1
View File
@@ -107,7 +107,7 @@ async function validateAndroidPNGsAsync(ctx: CommonContext<Platform.ANDROID>): P
},
{
configPath: 'exp.notification.icon',
pngPath: ctx.exp.notification?.icon,
pngPath: (ctx.exp as Record<string, any>).notification?.icon,
},
];
await validatePNGsAsync(pngs);
@@ -16,7 +16,7 @@ import {
import uniq from '../../../utils/expodash/uniq';
import { AttributesOf } from '../../utils/asc';
import { removeDatePrecision } from '../../utils/date';
import { AppleMetadata } from '../types';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
type PartialExcept<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;
@@ -211,4 +211,14 @@ export class AppleConfigReader {
// TODO: add attachment
};
}
/** Get screenshots configuration for a specific locale */
public getScreenshots(locale: string): AppleScreenshots | null {
return this.schema.info?.[locale]?.screenshots ?? null;
}
/** Get video previews configuration for a specific locale */
public getPreviews(locale: string): ApplePreviews | null {
return this.schema.info?.[locale]?.previews ?? null;
}
}
@@ -13,7 +13,7 @@ import {
} from '@expo/apple-utils';
import { AttributesOf } from '../../utils/asc';
import { AppleMetadata } from '../types';
import { AppleMetadata, ApplePreviews, AppleScreenshots } from '../types';
/**
* Serializes the Apple ASC entities into the metadata configuration schema.
@@ -186,6 +186,21 @@ export class AppleConfigWriter {
// TODO: add attachment
};
}
/** Set screenshots for a specific locale */
public setScreenshots(locale: string, screenshots: AppleScreenshots): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].screenshots =
Object.keys(screenshots).length > 0 ? screenshots : undefined;
}
/** Set video previews for a specific locale */
public setPreviews(locale: string, previews: ApplePreviews): void {
this.schema.info = this.schema.info ?? {};
this.schema.info[locale] = this.schema.info[locale] ?? { title: '' };
this.schema.info[locale].previews = Object.keys(previews).length > 0 ? previews : undefined;
}
}
/** Helper function to convert `T | null` to `T | undefined`, required for the entity properties */
+11 -2
View File
@@ -4,16 +4,25 @@ import type { AgeRatingData } from './tasks/age-rating';
import type { AppInfoData } from './tasks/app-info';
import type { AppReviewData } from './tasks/app-review-detail';
import type { AppVersionData } from './tasks/app-version';
import type { PreviewsData } from './tasks/previews';
import type { ScreenshotsData } from './tasks/screenshots';
/**
* The fully prepared apple data, used within the `downloadAsync` or `uploadAsync` tasks.
* It contains references to each individual models, to either upload or download App Store data.
*/
export type AppleData = { app: App } & AppInfoData & AppVersionData & AgeRatingData & AppReviewData;
export type AppleData = { app: App; projectDir: string } & AppInfoData &
AppVersionData &
AgeRatingData &
AppReviewData &
ScreenshotsData &
PreviewsData;
/**
* The unprepared partial apple data, used within the `prepareAsync` tasks.
* It contains a reference to the app, each task should populate the necessary data.
* If an entity fails to prepare the data, individual tasks should raise errors about the missing data.
*/
export type PartialAppleData = { app: App } & Partial<Omit<AppleData, 'app'>>;
export type PartialAppleData = { app: App; projectDir: string } & Partial<
Omit<AppleData, 'app' | 'projectDir'>
>;
@@ -1,8 +1,14 @@
import { truthy } from '../../../utils/expodash/filter';
import { IssueRule } from '../../config/issue';
import { AppleInfo } from '../types';
const RESTRICTED_PROPERTIES: (keyof AppleInfo)[] = ['title', 'subtitle', 'description', 'keywords'];
/** Only check text properties that may contain restricted words */
type AppleInfoTextProperty = 'title' | 'subtitle' | 'description' | 'keywords';
const RESTRICTED_PROPERTIES: AppleInfoTextProperty[] = [
'title',
'subtitle',
'description',
'keywords',
];
const RESTRICTED_WORDS = {
beta: 'Apple restricts the word "beta" and synonyms implying incomplete functionality.',
};
@@ -1,4 +1,4 @@
import { AgeRatingDeclaration, AppStoreVersion, Rating } from '@expo/apple-utils';
import { AgeRatingDeclaration, AppInfo, Rating } from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
@@ -12,13 +12,13 @@ jest.mock('../../config/writer');
describe(AgeRatingTask, () => {
describe('prepareAsync', () => {
it('loads age rating from app version', async () => {
it('loads age rating from app info', async () => {
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersion.type}/stub-id/ageRatingDeclaration`)
.get(`/v1/${AppInfo.type}/stub-id/ageRatingDeclaration`)
.reply(200, require('./fixtures/appStoreVersions/get-ageRatingDeclaration-200.json'));
const context: any = {
version: new AppStoreVersion(requestContext, 'stub-id', {} as any),
info: new AppInfo(requestContext, 'stub-id', {} as any),
};
await new AgeRatingTask().prepareAsync({ context });
@@ -26,6 +26,16 @@ describe(AgeRatingTask, () => {
expect(context.ageRating).toBeInstanceOf(AgeRatingDeclaration);
expect(scope.isDone()).toBeTruthy();
});
it('skips when info is not available', async () => {
const context: any = {
info: undefined,
};
await new AgeRatingTask().prepareAsync({ context });
expect(context.ageRating).toBeUndefined();
});
});
describe('downloadAsync', () => {
@@ -54,13 +64,13 @@ describe(AgeRatingTask, () => {
});
describe('uploadAsync', () => {
it('aborts when age rating is not loaded', async () => {
const promise = new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
});
await expect(promise).rejects.toThrow('rating not initialized');
it('skips when age rating is not loaded', async () => {
await expect(
new AgeRatingTask().uploadAsync({
config: new AppleConfigReader({}),
context: { ageRating: undefined } as any,
})
).resolves.not.toThrow();
});
it('skips updating age rating when not configured', async () => {
@@ -23,6 +23,7 @@ describe(AppInfoTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppInfoTask().prepareAsync({ context });
@@ -72,20 +72,20 @@ describe(AppReviewDetailTask, () => {
nock.cleanAll();
});
it('aborts when version is not loaded', async () => {
const promise = new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: 'review@example.com',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
});
await expect(promise).rejects.toThrow('version not init');
it('skips when version is not loaded', async () => {
await expect(
new AppReviewDetailTask().uploadAsync({
config: new AppleConfigReader({
review: {
firstName: 'Evan',
lastName: 'Bacon',
email: '[email protected]',
phone: '+1 555 555 5555',
},
}),
context: {} as any,
})
).resolves.not.toThrow();
});
it('updates review details when loaded', async () => {
@@ -44,6 +44,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -83,6 +84,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ editLive: true }).prepareAsync({ context });
@@ -118,6 +120,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -153,6 +156,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask().prepareAsync({ context });
@@ -194,6 +198,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '2.0' }).prepareAsync({ context });
@@ -243,6 +248,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
};
await new AppVersionTask({ version: '3.0' }).prepareAsync({ context });
@@ -371,6 +377,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
};
@@ -399,6 +406,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
versionPhasedRelease: null, // Not enabled yet
};
@@ -417,6 +425,7 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
versionPhasedRelease: new AppStoreVersionPhasedRelease(
@@ -440,8 +449,9 @@ describe(AppVersionTask, () => {
const context: PartialAppleData = {
app: new App(requestContext, 'stub-id', {} as any),
projectDir: '/test/project',
version: new AppStoreVersion(requestContext, 'APP_STORE_VERSION_1', {} as any),
// Enabled, and not completed yet
// Enabled, and completed
versionPhasedRelease: new AppStoreVersionPhasedRelease(
requestContext,
'APP_STORE_VERSION_PHASED_RELEASE_1',
@@ -0,0 +1,405 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PreviewsTask } from '../previews';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 10240 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(PreviewsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty preview sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(0);
});
it('fetches preview sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppPreviewSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new PreviewsTask().prepareAsync({ context });
expect(context.previewSets).toBeDefined();
expect(context.previewSets!.size).toBe(1);
expect(context.previewSets!.get('en-US')).toBeDefined();
expect(context.previewSets!.get('en-US')!.has(PreviewType.IPHONE_67)).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new PreviewsTask().downloadAsync({
config: writer,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('skips locales with no preview sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const previewSets = new Map();
previewSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
} as any,
});
expect(writer.setPreviews).not.toBeCalled();
});
it('downloads previews and sets config with simple path', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
});
});
it('downloads previews with previewFrameTimeCode as object config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const preview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
videoUrl: 'https://example.com/demo.mp4',
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
jest.spyOn(preview, 'getVideoUrl').mockReturnValue('https://example.com/demo.mp4');
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
const previewSet = new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [preview],
} as any);
previewTypeMap.set(PreviewType.IPHONE_67, previewSet);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-video-data')),
});
await new PreviewsTask().downloadAsync({
config: writer,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setPreviews).toBeCalledWith('en-US', {
[PreviewType.IPHONE_67]: {
path: 'store/apple/preview/en-US/IPHONE_67/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
});
});
});
describe('uploadAsync', () => {
it('skips when preview sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: { previewSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without previews in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new preview for configured locale with string path', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: './previews/demo.mp4',
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
})
);
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
IPHONE_67: {
path: './previews/demo.mp4',
previewFrameTimeCode: '00:05:00',
},
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newPreview = new AppPreview(requestContext, 'NEW_PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: '00:05:00',
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
// Create preview set
.post(`/v1/${AppPreviewSet.type}`)
.reply(201, {
data: {
id: 'NEW_PSET_1',
type: AppPreviewSet.type,
attributes: {
previewType: PreviewType.IPHONE_67,
appPreviews: [],
},
},
});
// Mock AppPreview.uploadAsync to avoid real file access
jest.spyOn(AppPreview, 'uploadAsync').mockResolvedValue(newPreview);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppPreview.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_PSET_1',
filePath: '/test/project/previews/demo.mp4',
waitForProcessing: true,
previewFrameTimeCode: '00:05:00',
})
);
});
});
});
@@ -0,0 +1,303 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import nock from 'nock';
import { requestContext } from './fixtures/requestContext';
import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { ScreenshotsTask } from '../screenshots';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
__esModule: true,
default: (...args: any[]) => mockFetch(...args),
}));
import fs from 'fs';
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
jest.spyOn(fs, 'statSync').mockReturnValue({ size: 1024 } as any);
jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined);
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
describe(ScreenshotsTask, () => {
beforeEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
describe('prepareAsync', () => {
it('initializes empty screenshot sets when no locales are available', async () => {
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: undefined,
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(0);
});
it('fetches screenshot sets for each locale', async () => {
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const scope = nock('https://api.appstoreconnect.apple.com')
.get(`/v1/${AppStoreVersionLocalization.type}/LOC_1/${AppScreenshotSet.type}`)
.query(true)
.reply(200, {
data: [
{
id: 'SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
],
});
const context: PartialAppleData = {
app: {} as any,
projectDir: '/test/project',
versionLocales: [locale],
};
await new ScreenshotsTask().prepareAsync({ context });
expect(context.screenshotSets).toBeDefined();
expect(context.screenshotSets!.size).toBe(1);
expect(context.screenshotSets!.get('en-US')).toBeDefined();
expect(
context.screenshotSets!.get('en-US')!.has(ScreenshotDisplayType.APP_IPHONE_67)
).toBeTruthy();
expect(scope.isDone()).toBeTruthy();
});
});
describe('downloadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const writer = jest.mocked(new AppleConfigWriter());
await new ScreenshotsTask().downloadAsync({
config: writer,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('skips locales with no screenshot sets', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
} as any,
});
expect(writer.setScreenshots).not.toBeCalled();
});
it('downloads screenshots and sets config', async () => {
const writer = jest.mocked(new AppleConfigWriter());
const screenshot = new AppScreenshot(requestContext, 'SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: {
state: 'COMPLETE',
errors: [],
warnings: [],
},
} as any);
// Mock getImageAssetUrl
jest.spyOn(screenshot, 'getImageAssetUrl').mockReturnValue('https://example.com/home.png');
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
const screenshotSet = new AppScreenshotSet(requestContext, 'SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [screenshot],
} as any);
displayTypeMap.set(ScreenshotDisplayType.APP_IPHONE_67, screenshotSet);
const screenshotSets = new Map();
screenshotSets.set('en-US', displayTypeMap);
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
// Mock fetch response
mockFetch.mockResolvedValue({
ok: true,
buffer: () => Promise.resolve(Buffer.from('fake-image-data')),
});
await new ScreenshotsTask().downloadAsync({
config: writer,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(writer.setScreenshots).toBeCalledWith('en-US', {
[ScreenshotDisplayType.APP_IPHONE_67]: [
'store/apple/screenshot/en-US/APP_IPHONE_67/home.png',
],
});
});
});
describe('uploadAsync', () => {
it('skips when screenshot sets are not prepared', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: { screenshotSets: undefined, versionLocales: undefined } as any,
});
// Should not throw, just log and return
});
it('skips when no locales are configured', async () => {
const config = new AppleConfigReader({});
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [],
} as any,
});
// Should not throw, just log and return
});
it('skips locales without screenshots in config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No screenshots
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets: new Map(),
versionLocales: [locale],
} as any,
});
// Should complete without uploading
});
it('uploads new screenshots for configured locale', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
screenshots: {
APP_IPHONE_67: ['./screenshots/home.png'],
},
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const newScreenshot = new AppScreenshot(requestContext, 'NEW_SS_1', {
fileName: 'home.png',
fileSize: 1024,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
// Mock createAppScreenshotSetAsync on the locale
const scope = nock('https://api.appstoreconnect.apple.com')
// Create screenshot set
.post(`/v1/${AppScreenshotSet.type}`)
.reply(201, {
data: {
id: 'NEW_SET_1',
type: AppScreenshotSet.type,
attributes: {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [],
},
},
});
// Mock AppScreenshot.uploadAsync to avoid real file access
jest.spyOn(AppScreenshot, 'uploadAsync').mockResolvedValue(newScreenshot);
// Mock AppScreenshotSet.infoAsync for reorder step
jest.spyOn(AppScreenshotSet, 'infoAsync').mockResolvedValue(
new AppScreenshotSet(requestContext, 'NEW_SET_1', {
screenshotDisplayType: ScreenshotDisplayType.APP_IPHONE_67,
appScreenshots: [newScreenshot],
} as any)
);
// Mock reorderScreenshotsAsync
const reorderMock = jest.fn().mockResolvedValue([]);
AppScreenshotSet.prototype.reorderScreenshotsAsync = reorderMock;
const screenshotSets = new Map();
screenshotSets.set('en-US', new Map());
await new ScreenshotsTask().uploadAsync({
config,
context: {
screenshotSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(scope.isDone()).toBeTruthy();
expect(AppScreenshot.uploadAsync).toHaveBeenCalledWith(
requestContext,
expect.objectContaining({
id: 'NEW_SET_1',
filePath: '/test/project/screenshots/home.png',
waitForProcessing: true,
})
);
// Reorder is skipped because the current order already matches
expect(reorderMock).not.toHaveBeenCalled();
});
});
});
@@ -1,5 +1,4 @@
import { AgeRatingDeclaration } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -7,7 +6,7 @@ import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
export type AgeRatingData = {
/** The app age rating declaration for the app version */
/** The app age rating declaration for the app info */
ageRating: AgeRatingDeclaration;
};
@@ -15,7 +14,22 @@ export class AgeRatingTask extends AppleTask {
public name = (): string => 'age rating declarations';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.ageRating = (await context.version!.getAgeRatingDeclarationAsync()) ?? undefined;
if (!context.info) {
return;
}
// The ageRatingDeclaration relationship is on appInfos (not appStoreVersions).
try {
context.ageRating = (await context.info.getAgeRatingDeclarationAsync()) ?? undefined;
} catch (error: any) {
// Gracefully handle cases where the relationship is not available
if (error?.message?.includes('ageRatingDeclaration')) {
Log.warn(
chalk`{yellow Skipped age rating - not available for this app. This may require updating through App Store Connect directly.}`
);
return;
}
throw error;
}
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -25,7 +39,10 @@ export class AgeRatingTask extends AppleTask {
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
assert(context.ageRating, `Age rating not initialized, can't update age rating`);
if (!context.ageRating) {
Log.log(chalk`{dim - Skipped age rating update, not available}`);
return;
}
const ageRating = config.getAgeRating();
if (!ageRating) {
@@ -1,5 +1,4 @@
import { AppStoreReviewDetail } from '@expo/apple-utils';
import assert from 'assert';
import chalk from 'chalk';
import Log from '../../../log';
@@ -16,7 +15,10 @@ export class AppReviewDetailTask extends AppleTask {
public name = (): string => 'app review detail';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
context.reviewDetail = (await context.version!.getAppStoreReviewDetailAsync()) ?? undefined;
if (!context.version) {
return;
}
context.reviewDetail = (await context.version.getAppStoreReviewDetailAsync()) ?? undefined;
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
@@ -32,7 +34,10 @@ export class AppReviewDetailTask extends AppleTask {
return;
}
assert(context.version, `App version not initialized, can't upload store review details`);
if (!context.version) {
Log.log(chalk`{dim - Skipped store review details, no version available}`);
return;
}
const { versionString } = context.version.attributes;
if (!context.reviewDetail) {
@@ -2,6 +2,8 @@ import { AgeRatingTask } from './age-rating';
import { AppInfoTask } from './app-info';
import { AppReviewDetailTask } from './app-review-detail';
import { AppVersionOptions, AppVersionTask } from './app-version';
import { PreviewsTask } from './previews';
import { ScreenshotsTask } from './screenshots';
import { AppleTask } from '../task';
type AppleTaskOptions = {
@@ -17,5 +19,7 @@ export function createAppleTasks({ version }: AppleTaskOptions = {}): AppleTask[
new AppInfoTask(),
new AgeRatingTask(),
new AppReviewDetailTask(),
new ScreenshotsTask(),
new PreviewsTask(),
];
}
@@ -0,0 +1,297 @@
import {
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
PreviewType,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { ApplePreviewConfig, ApplePreviews } from '../types';
/** Locale -> PreviewType -> AppPreviewSet */
export type PreviewSetsMap = Map<string, Map<PreviewType, AppPreviewSet>>;
export type PreviewsData = {
/** Map of locales to their preview sets */
previewSets: PreviewSetsMap;
};
/**
* Normalize preview config to always return an object with path and optional previewFrameTimeCode.
*/
function normalizePreviewConfig(config: ApplePreviewConfig): {
path: string;
previewFrameTimeCode?: string;
} {
if (typeof config === 'string') {
return { path: config };
}
return config;
}
/**
* Task for managing App Store video previews.
* Downloads existing previews and uploads new ones based on store configuration.
*/
export class PreviewsTask extends AppleTask {
public name = (): string => 'video previews';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
// Initialize the preview sets map
context.previewSets = new Map();
if (!context.versionLocales) {
return;
}
// Fetch preview sets for all locales in parallel
await Promise.all(
context.versionLocales.map(async locale => {
const sets = await locale.getAppPreviewSetsAsync();
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
for (const set of sets) {
previewTypeMap.set(set.attributes.previewType, set);
}
context.previewSets!.set(locale.attributes.locale, previewTypeMap);
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.previewSets || !context.versionLocales) {
return;
}
for (const locale of context.versionLocales) {
const localeCode = locale.attributes.locale;
const previewTypeMap = context.previewSets.get(localeCode);
if (!previewTypeMap || previewTypeMap.size === 0) {
continue;
}
const previews: ApplePreviews = {};
for (const [previewType, set] of previewTypeMap) {
const previewModels = set.attributes.appPreviews;
if (!previewModels || previewModels.length === 0) {
continue;
}
// For now, we only handle the first preview per set (App Store allows up to 3)
// We can extend this later to support multiple previews
const preview = previewModels[0];
const relativePath = await downloadPreviewAsync(
context.projectDir,
localeCode,
previewType,
preview,
0
);
if (relativePath) {
// Include preview frame time code if available
if (preview.attributes.previewFrameTimeCode) {
previews[previewType] = {
path: relativePath,
previewFrameTimeCode: preview.attributes.previewFrameTimeCode,
};
} else {
previews[previewType] = relativePath;
}
}
}
if (Object.keys(previews).length > 0) {
config.setPreviews(localeCode, previews);
}
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.previewSets || !context.versionLocales) {
Log.log(chalk`{dim - Skipped video previews, no version available}`);
return;
}
const locales = config.getLocales();
if (locales.length <= 0) {
Log.log(chalk`{dim - Skipped video previews, no locales configured}`);
return;
}
for (const localeCode of locales) {
const previews = config.getPreviews(localeCode);
if (!previews || Object.keys(previews).length === 0) {
continue;
}
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
Log.warn(chalk`{yellow Skipping video previews for ${localeCode} - locale not found}`);
continue;
}
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
context.previewSets.get(localeCode)
);
}
}
}
}
/**
* Sync a preview set - upload new preview, delete old one if changed.
*/
async function syncPreviewSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
previewType: PreviewType,
previewConfig: { path: string; previewFrameTimeCode?: string },
existingSets: Map<PreviewType, AppPreviewSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
const absolutePath = path.resolve(projectDir, previewConfig.path);
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Video preview not found: ${absolutePath}}`);
return;
}
// Get or create the preview set
let previewSet = existingSets?.get(previewType);
if (!previewSet) {
previewSet = await logAsync(
() =>
localization.createAppPreviewSetAsync({
previewType,
}),
{
pending: `Creating preview set for ${chalk.bold(previewType)} (${locale})...`,
success: `Created preview set for ${chalk.bold(previewType)} (${locale})`,
failure: `Failed creating preview set for ${chalk.bold(previewType)} (${locale})`,
}
);
}
const existingPreviews = previewSet.attributes.appPreviews || [];
// Check if we need to update (different filename, size, or no existing preview)
const existingPreview = existingPreviews.find(p => p.attributes.fileName === fileName);
const localSize = fs.statSync(absolutePath).size;
if (
existingPreview &&
existingPreview.isComplete() &&
existingPreview.attributes.fileSize === localSize
) {
// Preview with same filename exists, check if we need to update preview frame time code
if (
previewConfig.previewFrameTimeCode &&
existingPreview.attributes.previewFrameTimeCode !== previewConfig.previewFrameTimeCode
) {
await logAsync(
() =>
existingPreview.updateAsync({
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Updating preview frame time code for ${chalk.bold(fileName)} (${locale})...`,
success: `Updated preview frame time code for ${chalk.bold(fileName)} (${locale})`,
failure: `Failed updating preview frame time code for ${chalk.bold(fileName)} (${locale})`,
}
);
}
Log.log(chalk`{dim Preview ${fileName} already exists, skipping upload}`);
return;
}
// Delete existing previews that don't match
for (const preview of existingPreviews) {
if (preview.attributes.fileName !== fileName) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
}
// Upload new preview
await logAsync(
() =>
AppPreview.uploadAsync(localization.context, {
id: previewSet!.id,
filePath: absolutePath,
waitForProcessing: true,
previewFrameTimeCode: previewConfig.previewFrameTimeCode,
}),
{
pending: `Uploading video preview ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded video preview ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading video preview ${chalk.bold(fileName)} (${locale})`,
}
);
}
/**
* Download a video preview to the local filesystem.
* Returns the relative path to the downloaded file.
*/
async function downloadPreviewAsync(
projectDir: string,
locale: string,
previewType: PreviewType,
preview: AppPreview,
index: number
): Promise<string | null> {
const videoUrl = preview.getVideoUrl();
if (!videoUrl) {
Log.warn(chalk`{yellow Could not get download URL for preview ${preview.attributes.fileName}}`);
return null;
}
// Create directory structure: store/apple/preview/{locale}/{previewType}/
const previewsDir = path.join(projectDir, 'store', 'apple', 'preview', locale, previewType);
await fs.promises.mkdir(previewsDir, { recursive: true });
// Use original filename for matching during sync
const fileName = preview.attributes.fileName || `${String(index + 1).padStart(2, '0')}.mp4`;
const outputPath = path.join(previewsDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(videoUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded video preview: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(chalk`{yellow Failed to download video preview ${fileName}: ${error.message}}`);
return null;
}
}
@@ -0,0 +1,314 @@
import {
AppScreenshot,
AppScreenshotSet,
AppStoreVersionLocalization,
ScreenshotDisplayType,
} from '@expo/apple-utils';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import fetch from '../../../fetch';
import Log from '../../../log';
import { logAsync } from '../../utils/log';
import { AppleTask, TaskDownloadOptions, TaskPrepareOptions, TaskUploadOptions } from '../task';
import { AppleScreenshots } from '../types';
/** Locale -> ScreenshotDisplayType -> AppScreenshotSet */
export type ScreenshotSetsMap = Map<string, Map<ScreenshotDisplayType, AppScreenshotSet>>;
export type ScreenshotsData = {
/** Map of locales to their screenshot sets */
screenshotSets: ScreenshotSetsMap;
};
/**
* Task for managing App Store screenshots.
* Downloads existing screenshots and uploads new ones based on store configuration.
*/
export class ScreenshotsTask extends AppleTask {
public name = (): string => 'screenshots';
public async prepareAsync({ context }: TaskPrepareOptions): Promise<void> {
// Initialize the screenshot sets map
context.screenshotSets = new Map();
if (!context.versionLocales) {
return;
}
// Fetch screenshot sets for all locales in parallel
await Promise.all(
context.versionLocales.map(async locale => {
const sets = await locale.getAppScreenshotSetsAsync();
const displayTypeMap = new Map<ScreenshotDisplayType, AppScreenshotSet>();
for (const set of sets) {
displayTypeMap.set(set.attributes.screenshotDisplayType, set);
}
context.screenshotSets!.set(locale.attributes.locale, displayTypeMap);
})
);
}
public async downloadAsync({ config, context }: TaskDownloadOptions): Promise<void> {
if (!context.screenshotSets || !context.versionLocales) {
return;
}
for (const locale of context.versionLocales) {
const localeCode = locale.attributes.locale;
const displayTypeMap = context.screenshotSets.get(localeCode);
if (!displayTypeMap || displayTypeMap.size === 0) {
continue;
}
const screenshots: AppleScreenshots = {};
for (const [displayType, set] of displayTypeMap) {
const screenshotModels = set.attributes.appScreenshots;
if (!screenshotModels || screenshotModels.length === 0) {
continue;
}
// Download screenshots and save to local filesystem
const paths: string[] = [];
for (let i = 0; i < screenshotModels.length; i++) {
const screenshot = screenshotModels[i];
const relativePath = await downloadScreenshotAsync(
context.projectDir,
localeCode,
displayType,
screenshot,
i
);
if (relativePath) {
paths.push(relativePath);
}
}
if (paths.length > 0) {
screenshots[displayType] = paths;
}
}
if (Object.keys(screenshots).length > 0) {
config.setScreenshots(localeCode, screenshots);
}
}
}
public async uploadAsync({ config, context }: TaskUploadOptions): Promise<void> {
if (!context.screenshotSets || !context.versionLocales) {
Log.log(chalk`{dim - Skipped screenshots, no version available}`);
return;
}
const locales = config.getLocales();
if (locales.length <= 0) {
Log.log(chalk`{dim - Skipped screenshots, no locales configured}`);
return;
}
for (const localeCode of locales) {
const screenshots = config.getScreenshots(localeCode);
if (!screenshots || Object.keys(screenshots).length === 0) {
continue;
}
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
Log.warn(chalk`{yellow Skipping screenshots for ${localeCode} - locale not found}`);
continue;
}
for (const [displayType, paths] of Object.entries(screenshots)) {
if (!paths || paths.length === 0) {
continue;
}
await syncScreenshotSetAsync(
context.projectDir,
localization,
displayType as ScreenshotDisplayType,
paths,
context.screenshotSets.get(localeCode)
);
}
}
}
}
/**
* Sync a screenshot set - upload new screenshots, delete removed ones, reorder if needed.
*/
async function syncScreenshotSetAsync(
projectDir: string,
localization: AppStoreVersionLocalization,
displayType: ScreenshotDisplayType,
paths: string[],
existingSets: Map<ScreenshotDisplayType, AppScreenshotSet> | undefined
): Promise<void> {
const locale = localization.attributes.locale;
// Get or create the screenshot set
let screenshotSet = existingSets?.get(displayType);
if (!screenshotSet) {
screenshotSet = await logAsync(
() =>
localization.createAppScreenshotSetAsync({
screenshotDisplayType: displayType,
}),
{
pending: `Creating screenshot set for ${chalk.bold(displayType)} (${locale})...`,
success: `Created screenshot set for ${chalk.bold(displayType)} (${locale})`,
failure: `Failed creating screenshot set for ${chalk.bold(displayType)} (${locale})`,
}
);
}
const existingScreenshots = screenshotSet.attributes.appScreenshots || [];
// Build a map of existing screenshots by filename for comparison
const existingByFilename = new Map<string, AppScreenshot>();
for (const screenshot of existingScreenshots) {
existingByFilename.set(screenshot.attributes.fileName, screenshot);
}
// Track which screenshots to keep, upload, and delete
const screenshotIdsToKeep: string[] = [];
const pathsToUpload: string[] = [];
for (const relativePath of paths) {
const absolutePath = path.resolve(projectDir, relativePath);
const fileName = path.basename(absolutePath);
// Check if screenshot already exists with same name and file size
const existing = existingByFilename.get(fileName);
const localSize = fs.existsSync(absolutePath) ? fs.statSync(absolutePath).size : null;
if (
existing &&
existing.isComplete() &&
(localSize === null || existing.attributes.fileSize === localSize)
) {
screenshotIdsToKeep.push(existing.id);
existingByFilename.delete(fileName);
} else {
pathsToUpload.push(absolutePath);
}
}
// Delete screenshots that are no longer in config
for (const screenshot of existingByFilename.values()) {
await logAsync(() => screenshot.deleteAsync(), {
pending: `Deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})...`,
success: `Deleted screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
failure: `Failed deleting screenshot ${chalk.bold(screenshot.attributes.fileName)} (${locale})`,
});
}
// Upload new screenshots
for (const absolutePath of pathsToUpload) {
const fileName = path.basename(absolutePath);
if (!fs.existsSync(absolutePath)) {
Log.warn(chalk`{yellow Screenshot not found: ${absolutePath}}`);
continue;
}
const newScreenshot = await logAsync(
() =>
AppScreenshot.uploadAsync(localization.context, {
id: screenshotSet!.id,
filePath: absolutePath,
waitForProcessing: true,
}),
{
pending: `Uploading screenshot ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded screenshot ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading screenshot ${chalk.bold(fileName)} (${locale})`,
}
);
screenshotIdsToKeep.push(newScreenshot.id);
}
// Reorder screenshots to match config order
if (screenshotIdsToKeep.length > 0) {
const refreshedSet = await AppScreenshotSet.infoAsync(localization.context, {
id: screenshotSet.id,
});
const refreshedScreenshots = refreshedSet.attributes.appScreenshots || [];
const screenshotsByFilename = new Map<string, AppScreenshot>();
for (const s of refreshedScreenshots) {
screenshotsByFilename.set(s.attributes.fileName, s);
}
// Build the desired order based on config paths
const orderedIds: string[] = [];
for (const relativePath of paths) {
const fileName = path.basename(relativePath);
const screenshot = screenshotsByFilename.get(fileName);
if (screenshot) {
orderedIds.push(screenshot.id);
}
}
// Only call reorder if the order actually differs from current
const currentIds = refreshedScreenshots.map(s => s.id);
if (
orderedIds.length > 0 &&
(orderedIds.length !== currentIds.length || orderedIds.some((id, i) => id !== currentIds[i]))
) {
await screenshotSet.reorderScreenshotsAsync({ appScreenshots: orderedIds });
}
}
}
/**
* Download a screenshot to the local filesystem.
* Returns the relative path to the downloaded file.
*/
async function downloadScreenshotAsync(
projectDir: string,
locale: string,
displayType: ScreenshotDisplayType,
screenshot: AppScreenshot,
index: number
): Promise<string | null> {
const imageUrl = screenshot.getImageAssetUrl({ type: 'png' });
if (!imageUrl) {
Log.warn(
chalk`{yellow Could not get download URL for screenshot ${screenshot.attributes.fileName}}`
);
return null;
}
// Create directory structure: store/apple/screenshot/{locale}/{displayType}/
const screenshotsDir = path.join(projectDir, 'store', 'apple', 'screenshot', locale, displayType);
await fs.promises.mkdir(screenshotsDir, { recursive: true });
// Use original filename for matching during sync
const fileName = screenshot.attributes.fileName || `${String(index + 1).padStart(2, '0')}.png`;
const outputPath = path.join(screenshotsDir, fileName);
const relativePath = path.relative(projectDir, outputPath);
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.buffer();
await fs.promises.writeFile(outputPath, buffer);
Log.log(chalk`{dim Downloaded screenshot: ${relativePath}}`);
return relativePath;
} catch (error: any) {
Log.warn(chalk`{yellow Failed to download screenshot ${fileName}: ${error.message}}`);
return null;
}
}
+45 -1
View File
@@ -1,7 +1,46 @@
import type { AgeRatingDeclarationProps } from '@expo/apple-utils';
import type {
AgeRatingDeclarationProps,
PreviewType,
ScreenshotDisplayType,
} from '@expo/apple-utils';
export type AppleLocale = string;
/** Screenshot display type enum values from App Store Connect API */
export type AppleScreenshotDisplayType = `${ScreenshotDisplayType}`;
/** Preview display type enum values from App Store Connect API */
export type ApplePreviewType = `${PreviewType}`;
/**
* Screenshots organized by display type.
* Key is the display type (e.g., 'APP_IPHONE_67'), value is array of file paths.
* @example { "APP_IPHONE_67": ["./screenshots/home.png", "./screenshots/profile.png"] }
*/
export type AppleScreenshots = Partial<Record<AppleScreenshotDisplayType, string[]>>;
/**
* Video preview configuration - either a simple path string or an object with options.
* @example "./previews/demo.mp4"
* @example { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" }
*/
export type ApplePreviewConfig =
| string
| {
/** Video file path (relative to project root) */
path: string;
/** Optional preview frame time code (e.g., '00:05:00' for 5 seconds) */
previewFrameTimeCode?: string;
};
/**
* Video previews organized by display type.
* Key is the display type (e.g., 'IPHONE_67'), value is the preview config.
* @example { "IPHONE_67": "./previews/demo.mp4" }
* @example { "IPHONE_67": { path: "./previews/demo.mp4", previewFrameTimeCode: "00:05:00" } }
*/
export type ApplePreviews = Partial<Record<ApplePreviewType, ApplePreviewConfig>>;
export interface AppleMetadata {
version?: string;
copyright?: string;
@@ -9,6 +48,7 @@ export interface AppleMetadata {
categories?: AppleCategory;
release?: AppleRelease;
advisory?: AppleAdvisory;
/** @deprecated Use screenshots/previews in AppleInfo instead */
preview?: Record<string, string[]>;
review?: AppleReview;
}
@@ -40,6 +80,10 @@ export interface AppleInfo {
privacyPolicyText?: string;
privacyChoicesUrl?: string;
supportUrl?: string;
/** Screenshots for this locale, organized by display type */
screenshots?: AppleScreenshots;
/** Video previews for this locale, organized by display type */
previews?: ApplePreviews;
}
export interface AppleReview {
+1 -1
View File
@@ -77,7 +77,7 @@ export async function downloadMetadataAsync({
const errors: Error[] = [];
const config = createAppleWriter();
const tasks = createAppleTasks();
const taskCtx = { app };
const taskCtx = { app, projectDir };
for (const task of tasks) {
try {
+1 -1
View File
@@ -69,7 +69,7 @@ export async function uploadMetadataAsync({
version: config.getVersion()?.versionString,
});
const taskCtx = { app };
const taskCtx = { app, projectDir };
for (const task of tasks) {
try {
@@ -2,7 +2,11 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { resolveVcsClient } from '../../vcs';
import { resolveWorkflowAsync, resolveWorkflowPerPlatformAsync } from '../workflow';
import {
hasIgnoredIosProjectAsync,
resolveWorkflowAsync,
resolveWorkflowPerPlatformAsync,
} from '../workflow';
jest.mock('fs');
@@ -86,3 +90,21 @@ describe(resolveWorkflowPerPlatformAsync, () => {
});
});
});
describe(hasIgnoredIosProjectAsync, () => {
beforeEach(() => {
vol.reset();
});
test('returns true when ios project is ignored', async () => {
vol.fromJSON(
{
'./ios/helloworld.xcodeproj/project.pbxproj': 'fake',
'./.easignore': 'ios/\n',
},
projectDir
);
await expect(hasIgnoredIosProjectAsync(projectDir, vcsClient)).resolves.toBe(true);
});
});
@@ -137,81 +137,21 @@ describe(ensureApplicationIdIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('auto-generates application id in non-interactive mode when suggestion is available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { android: { package: 'com.jester.myapp' } },
});
});
it('uses ios.bundleIdentifier as application id in non-interactive mode when available', async () => {
it('throws an error in non-interactive mode', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
'app.json': '{ "blah": {} }',
},
'/app'
);
const result = await ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', ios: { bundleIdentifier: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureApplicationIdIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: '' } as any,
projectId: '',
exp: {} as any,
vcsClient,
nonInteractive: true,
})
@@ -148,8 +148,15 @@ async function configureApplicationIdAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
const paths = getConfigFilePaths(projectDir);
if (nonInteractive) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -159,50 +166,25 @@ async function configureApplicationIdAsync({
assert(paths.staticConfigPath, 'app.json must exist');
let packageName: string;
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
if (nonInteractive) {
let suggestedAndroidApplicationId: string | undefined;
try {
suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedAndroidApplicationId) {
throw new Error(
`The "android.package" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#package'
)}`
);
}
packageName = suggestedAndroidApplicationId;
Log.log(`Using automatically generated Android application id: ${chalk.bold(packageName)}`);
} else {
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 Android application id`)} ${chalk.dim(
learnMore('https://expo.fyi/android-package')
)}`
);
const result = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
packageName = result.packageName;
}
const suggestedAndroidApplicationId = await getSuggestedApplicationIdAsync(
graphqlClient,
exp,
projectId
);
const { packageName } = await promptAsync({
name: 'packageName',
type: 'text',
message: `What would you like your Android application id to be?`,
initial: suggestedAndroidApplicationId,
validate: value => (isApplicationIdValid(value) ? true : INVALID_APPLICATION_ID_MESSAGE),
});
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
@@ -115,81 +115,20 @@ describe(ensureBundleIdentifierIsDefinedForManagedProjectAsync, () => {
})
).rejects.toThrowError(/we can't update this file programmatically/);
});
it('auto-generates bundle identifier in non-interactive mode when suggestion is available', async () => {
it('throws an error in non-interactive mode', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
'app.json': '{ "blah": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp' } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.jester.myapp');
expect(promptAsync).not.toHaveBeenCalled();
const appJson = JSON.parse(await fs.readFile('/app/app.json', 'utf-8'));
expect(appJson).toMatchObject({
expo: { ios: { bundleIdentifier: 'com.jester.myapp' } },
});
});
it('uses android.package as bundle identifier in non-interactive mode when available', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
const result = await ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: 'myapp', android: { package: 'com.example.myapp' } } as any,
vcsClient,
nonInteractive: true,
});
expect(result).toBe('com.example.myapp');
expect(promptAsync).not.toHaveBeenCalled();
});
it('throws an error in non-interactive mode when no valid suggestion can be generated', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
jest.mocked(AppQuery.byIdAsync).mockRejectedValue(new Error('Not found'));
vol.fromJSON(
{
'app.json': '{ "expo": {} }',
},
'/app'
);
await expect(
ensureBundleIdentifierIsDefinedForManagedProjectAsync({
graphqlClient,
projectDir: '/app',
projectId: '1234',
exp: { slug: '' } as any,
exp: {} as any,
vcsClient,
nonInteractive: true,
})
@@ -7,6 +7,23 @@ import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../exe
jest.mock('fs');
jest.mock('../../../prompts');
// @expo/require-utils uses require() to load JS configs, which bypasses the
// mocked fs. Override loadModuleSync to read from the mocked fs and evaluate.
jest.mock('@expo/require-utils', () => {
const actual = jest.requireActual('@expo/require-utils');
return {
...actual,
loadModuleSync: (filename: string) => {
const fs = require('fs');
const content = fs.readFileSync(filename, 'utf-8');
const mod = { exports: {} as any };
// eslint-disable-next-line no-new-func
new Function('module', 'exports', content)(mod, mod.exports);
return mod.exports;
},
};
});
beforeEach(async () => {
jest.resetAllMocks();
vol.reset();
@@ -137,8 +137,15 @@ async function configureBundleIdentifierAsync({
exp: ExpoConfig;
nonInteractive: boolean;
}): Promise<string> {
const paths = getConfigFilePaths(projectDir);
if (nonInteractive) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
const paths = getConfigFilePaths(projectDir);
// we can't automatically update app.config.js
if (paths.dynamicConfigPath) {
throw new Error(
@@ -148,51 +155,26 @@ async function configureBundleIdentifierAsync({
assert(paths.staticConfigPath, 'app.json must exist');
let bundleIdentifier: string;
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
if (nonInteractive) {
let suggestedBundleIdentifier: string | undefined;
try {
suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
} catch {
// If we can't get a suggestion, we'll throw the non-interactive error below
}
if (!suggestedBundleIdentifier) {
throw new Error(
`The "ios.bundleIdentifier" is required to be set in app config when running in non-interactive mode. ${learnMore(
'https://docs.expo.dev/versions/latest/config/app/#bundleidentifier'
)}`
);
}
bundleIdentifier = suggestedBundleIdentifier;
Log.log(`Using automatically generated iOS bundle identifier: ${chalk.bold(bundleIdentifier)}`);
} else {
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
Log.addNewLineIfNone();
Log.log(
`${chalk.bold(`📝 iOS Bundle Identifier`)} ${chalk.dim(
learnMore('https://expo.fyi/bundle-identifier')
)}`
);
const suggestedBundleIdentifier = await getSuggestedBundleIdentifierAsync(
graphqlClient,
exp,
projectId
);
const result = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value =>
isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE,
});
bundleIdentifier = result.bundleIdentifier;
}
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
type: 'text',
message: `What would you like your iOS bundle identifier to be?`,
initial: suggestedBundleIdentifier,
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
const rawStaticConfig = readAppJson(paths.staticConfigPath);
rawStaticConfig.expo = {
+1 -1
View File
@@ -55,7 +55,7 @@ export async function hasIgnoredIosProjectAsync(
try {
const pbxProjectPath = IOSConfig.Paths.getPBXProjectPath(projectDir);
return await vcsClient.isFileIgnoredAsync(path.relative(vcsRootPath, pbxProjectPath));
} finally {
} catch {
return false;
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "18.4.0",
"version": "18.5.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "18.4.0",
"@expo/eas-build-job": "18.5.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.4.0",
"version": "18.5.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,11 +26,11 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "18.4.0",
"@expo/eas-build-job": "18.4.0",
"@expo/logger": "18.0.1",
"@expo/build-tools": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "18.0.1",
"@expo/turtle-spawn": "18.5.0",
"bunyan": "^1.8.15",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "18.0.1",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.4.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "18.4.0",
"@expo/logger": "18.0.1",
"@expo/eas-build-job": "18.5.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fs-extra": "^11.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "18.0.1",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "18.0.1",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.4.0",
"version": "18.5.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,18 +19,18 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.4.0",
"@expo/config-plugins": "8.0.8",
"@expo/config-types": "52.0.1",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.4.0",
"@expo/logger": "18.0.1",
"@expo/build-tools": "18.5.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/logger": "18.5.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.4.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@hapi/boom": "10.0.1",
"@sentry/node": "7.77.0",
"check-disk-space": "3.4.0",
+2 -3
View File
@@ -1,4 +1,3 @@
import { PRECOMPILED_MODULES_PATH } from '@expo/build-tools';
import { Platform, Workflow } from '@expo/eas-build-job';
import config from '../config';
@@ -35,7 +34,7 @@ describe(getBuildEnv.name, () => {
expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn);
});
it('enables precompiled modules env vars for flagged iOS jobs', () => {
it('passes through EXPO_USE_PRECOMPILED_MODULES for flagged iOS jobs', () => {
const env = getBuildEnv({
job: {
platform: Platform.IOS,
@@ -56,6 +55,6 @@ describe(getBuildEnv.name, () => {
});
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBe(PRECOMPILED_MODULES_PATH);
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
});
+1 -8
View File
@@ -1,10 +1,4 @@
import {
Artifacts,
BuildContext,
Builders,
maybeStartPreparingPrecompiledModules,
runGenericJobAsync,
} from '@expo/build-tools';
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
import {
Android,
BuildJob,
@@ -47,7 +41,6 @@ export async function build({
{ job: omit(ctx.job, 'secrets', 'projectArchive') },
'Builder is ready, starting build'
);
maybeStartPreparingPrecompiledModules(ctx, config);
},
{ doNotMarkStart: true }
);
-4
View File
@@ -98,10 +98,6 @@ export default {
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
defaultValue: null,
}),
precompiledModulesUrls: [
'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Debug.zip',
'https://storage.googleapis.com/turtle-v2/precompiled-modules/db65b4afac835ff71269ce53937fb20627b133c0/xcframeworks-Release.zip',
],
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('runMetricsServer'),
defaultValue: null,
+1 -3
View File
@@ -1,4 +1,3 @@
import { PRECOMPILED_MODULES_PATH, shouldUsePrecompiledDependencies } from '@expo/build-tools';
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { spawnSync } from 'child_process';
import micromatch from 'micromatch';
@@ -54,9 +53,8 @@ export function getBuildEnv({
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (shouldUsePrecompiledDependencies(job.builderEnvironment?.env ?? {})) {
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
setEnv(env, 'EXPO_PRECOMPILED_MODULES_PATH', PRECOMPILED_MODULES_PATH);
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
+13 -4
View File
@@ -82,6 +82,7 @@ export async function logProjectDependenciesAsync(
});
} catch (error: any) {
sentry.handleError('Failed to report project dependencies metrics', error, {
level: 'warning',
tags: {
errorCode: 'FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT',
},
@@ -133,10 +134,15 @@ async function resolveNewArchEnabled(
}
if (
appConfig?.[job.platform]?.newArchEnabled !== undefined ||
appConfig.newArchEnabled !== undefined
(appConfig?.[job.platform] as unknown as Record<string, unknown>)?.newArchEnabled !==
undefined ||
(appConfig as unknown as Record<string, unknown>).newArchEnabled !== undefined
) {
return appConfig?.[job.platform]?.newArchEnabled ?? appConfig.newArchEnabled;
return (
((appConfig?.[job.platform] as unknown as Record<string, unknown>)
?.newArchEnabled as boolean) ??
((appConfig as unknown as Record<string, unknown>).newArchEnabled as boolean)
);
}
if (job.type === Workflow.GENERIC) {
@@ -220,7 +226,10 @@ async function getPackageManagerVersion(
}
): Promise<string | undefined> {
try {
const { stdout } = await spawnAsync(packageManager, ['--version'], { cwd, env });
const { stdout } = await spawnAsync(packageManager, ['--version'], {
cwd,
env,
});
return stdout.toString().trim();
} catch {
// Some package managers can fail version checks in project dirs with mismatched packageManager fields.
Generated
+294 -142
View File
@@ -844,6 +844,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.20.0, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/code-frame@npm:7.29.0"
dependencies:
"@babel/helper-validator-identifier": "npm:^7.28.5"
js-tokens: "npm:^4.0.0"
picocolors: "npm:^1.1.1"
checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.22.13":
version: 7.22.13
resolution: "@babel/code-frame@npm:7.22.13"
@@ -911,6 +922,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/compat-data@npm:^7.28.6":
version: 7.29.0
resolution: "@babel/compat-data@npm:7.29.0"
checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94
languageName: node
linkType: hard
"@babel/core@npm:7.24.7":
version: 7.24.7
resolution: "@babel/core@npm:7.24.7"
@@ -1026,6 +1044,29 @@ __metadata:
languageName: node
linkType: hard
"@babel/core@npm:^7.25.2":
version: 7.29.0
resolution: "@babel/core@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-compilation-targets": "npm:^7.28.6"
"@babel/helper-module-transforms": "npm:^7.28.6"
"@babel/helpers": "npm:^7.28.6"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/traverse": "npm:^7.29.0"
"@babel/types": "npm:^7.29.0"
"@jridgewell/remapping": "npm:^2.3.5"
convert-source-map: "npm:^2.0.0"
debug: "npm:^4.1.0"
gensync: "npm:^1.0.0-beta.2"
json5: "npm:^2.2.3"
semver: "npm:^6.3.1"
checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa
languageName: node
linkType: hard
"@babel/generator@npm:^7.14.0, @babel/generator@npm:^7.16.5":
version: 7.16.5
resolution: "@babel/generator@npm:7.16.5"
@@ -1107,6 +1148,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/generator@npm:^7.29.0":
version: 7.29.1
resolution: "@babel/generator@npm:7.29.1"
dependencies:
"@babel/parser": "npm:^7.29.0"
"@babel/types": "npm:^7.29.0"
"@jridgewell/gen-mapping": "npm:^0.3.12"
"@jridgewell/trace-mapping": "npm:^0.3.28"
jsesc: "npm:^3.0.2"
checksum: 10c0/349086e6876258ef3fb2823030fee0f6c0eb9c3ebe35fc572e16997f8c030d765f636ddc6299edae63e760ea6658f8ee9a2edfa6d6b24c9a80c917916b973551
languageName: node
linkType: hard
"@babel/generator@npm:^7.7.2":
version: 7.15.0
resolution: "@babel/generator@npm:7.15.0"
@@ -1216,6 +1270,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-compilation-targets@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-compilation-targets@npm:7.28.6"
dependencies:
"@babel/compat-data": "npm:^7.28.6"
"@babel/helper-validator-option": "npm:^7.27.1"
browserslist: "npm:^4.24.0"
lru-cache: "npm:^5.1.1"
semver: "npm:^6.3.1"
checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50
languageName: node
linkType: hard
"@babel/helper-create-class-features-plugin@npm:^7.12.1":
version: 7.12.1
resolution: "@babel/helper-create-class-features-plugin@npm:7.12.1"
@@ -1368,6 +1435,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-imports@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-module-imports@npm:7.28.6"
dependencies:
"@babel/traverse": "npm:^7.28.6"
"@babel/types": "npm:^7.28.6"
checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.12.1":
version: 7.12.1
resolution: "@babel/helper-module-transforms@npm:7.12.1"
@@ -1461,6 +1538,19 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-module-transforms@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-module-transforms@npm:7.28.6"
dependencies:
"@babel/helper-module-imports": "npm:^7.28.6"
"@babel/helper-validator-identifier": "npm:^7.28.5"
"@babel/traverse": "npm:^7.28.6"
peerDependencies:
"@babel/core": ^7.0.0
checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b
languageName: node
linkType: hard
"@babel/helper-optimise-call-expression@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/helper-optimise-call-expression@npm:7.10.4"
@@ -1498,6 +1588,13 @@ __metadata:
languageName: node
linkType: hard
"@babel/helper-plugin-utils@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/helper-plugin-utils@npm:7.28.6"
checksum: 10c0/3f5f8acc152fdbb69a84b8624145ff4f9b9f6e776cb989f9f968f8606eb7185c5c3cfcf3ba08534e37e1e0e1c118ac67080610333f56baa4f7376c99b5f1143d
languageName: node
linkType: hard
"@babel/helper-replace-supers@npm:^7.12.1":
version: 7.12.5
resolution: "@babel/helper-replace-supers@npm:7.12.5"
@@ -1760,6 +1857,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/helpers@npm:^7.28.6":
version: 7.29.2
resolution: "@babel/helpers@npm:7.29.2"
dependencies:
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
checksum: 10c0/dab0e65b9318b2502a62c58bc0913572318595eec0482c31f0ad416b72636e6698a1d7c57cd2791d4528eb8c548bca88d338dc4d2a55a108dc1f6702f9bc5512
languageName: node
linkType: hard
"@babel/highlight@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/highlight@npm:7.10.4"
@@ -1922,6 +2029,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0":
version: 7.29.2
resolution: "@babel/parser@npm:7.29.2"
dependencies:
"@babel/types": "npm:^7.29.0"
bin:
parser: ./bin/babel-parser.js
checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317
languageName: node
linkType: hard
"@babel/plugin-proposal-class-properties@npm:^7.0.0":
version: 7.12.1
resolution: "@babel/plugin-proposal-class-properties@npm:7.12.1"
@@ -2278,6 +2396,18 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-transform-modules-commonjs@npm:^7.24.8":
version: 7.28.6
resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6"
dependencies:
"@babel/helper-module-transforms": "npm:^7.28.6"
"@babel/helper-plugin-utils": "npm:^7.28.6"
peerDependencies:
"@babel/core": ^7.0.0-0
checksum: 10c0/7c45992797c6150644c8552feff4a016ba7bd6d59ff2b039ed969a9c5b20a6804cd9d21db5045fc8cca8ca7f08262497e354e93f8f2be6a1cdf3fbfa8c31a9b6
languageName: node
linkType: hard
"@babel/plugin-transform-object-super@npm:^7.0.0":
version: 7.12.1
resolution: "@babel/plugin-transform-object-super@npm:7.12.1"
@@ -2457,6 +2587,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/template@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/template@npm:7.28.6"
dependencies:
"@babel/code-frame": "npm:^7.28.6"
"@babel/parser": "npm:^7.28.6"
"@babel/types": "npm:^7.28.6"
checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5
languageName: node
linkType: hard
"@babel/traverse@npm:^7.12.1, @babel/traverse@npm:^7.12.5, @babel/traverse@npm:^7.14.0, @babel/traverse@npm:^7.16.5, @babel/traverse@npm:^7.16.7, @babel/traverse@npm:^7.16.8, @babel/traverse@npm:^7.17.10, @babel/traverse@npm:^7.17.3":
version: 7.23.2
resolution: "@babel/traverse@npm:7.23.2"
@@ -2508,6 +2649,21 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/traverse@npm:7.29.0"
dependencies:
"@babel/code-frame": "npm:^7.29.0"
"@babel/generator": "npm:^7.29.0"
"@babel/helper-globals": "npm:^7.28.0"
"@babel/parser": "npm:^7.29.0"
"@babel/template": "npm:^7.28.6"
"@babel/types": "npm:^7.29.0"
debug: "npm:^4.3.1"
checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.4, @babel/types@npm:^7.10.5, @babel/types@npm:^7.11.0, @babel/types@npm:^7.12.1, @babel/types@npm:^7.12.5, @babel/types@npm:^7.3.0, @babel/types@npm:^7.3.3":
version: 7.12.6
resolution: "@babel/types@npm:7.12.6"
@@ -2623,7 +2779,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.8.3":
"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.8.3":
version: 7.29.0
resolution: "@babel/types@npm:7.29.0"
dependencies:
@@ -2691,33 +2847,33 @@ __metadata:
languageName: node
linkType: hard
"@expo/apple-utils@npm:2.1.15":
version: 2.1.15
resolution: "@expo/apple-utils@npm:2.1.15"
"@expo/apple-utils@npm:2.1.16":
version: 2.1.16
resolution: "@expo/apple-utils@npm:2.1.16"
bin:
apple-utils: bin.js
checksum: 10c0/956abdb77d1d3bcb09d42b70745705305d23b9aa4ed50e3da795e93e50dc3ada126cc16f73a55b49538f0d793ccdc455bad0caf7aaadbadd5ee7a3d0f3c69465
checksum: 10c0/9d3915f3f25c32e1f1ab323676cabccc2998046be6f30deebc68a0706836bd50bc5145662fa13441e0283f82c56e50f55002d50dcc43aa78e9fa7327d1676a54
languageName: node
linkType: hard
"@expo/build-tools@npm:18.4.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:18.5.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:10.0.6"
"@expo/config-plugins": "npm:9.0.12"
"@expo/downloader": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.5.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:18.0.1"
"@expo/logger": "npm:18.5.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.2.0"
"@expo/repack-app": "npm:~0.4.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.4.0"
"@expo/template-file": "npm:18.0.1"
"@expo/turtle-spawn": "npm:18.0.1"
"@expo/steps": "npm:18.5.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/xcpretty": "npm:^4.3.1"
"@google-cloud/storage": "npm:^7.11.2"
"@types/fs-extra": "npm:^11.0.4"
@@ -2792,48 +2948,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-plugins@npm:8.0.8":
version: 8.0.8
resolution: "@expo/config-plugins@npm:8.0.8"
"@expo/config-plugins@npm:55.0.7, @expo/config-plugins@npm:~55.0.7":
version: 55.0.7
resolution: "@expo/config-plugins@npm:55.0.7"
dependencies:
"@expo/config-types": "npm:^51.0.0-unreleased"
"@expo/json-file": "npm:~8.3.0"
"@expo/plist": "npm:^0.1.0"
"@expo/sdk-runtime-versions": "npm:^1.0.0"
chalk: "npm:^4.1.2"
debug: "npm:^4.3.1"
find-up: "npm:~5.0.0"
getenv: "npm:^1.0.0"
glob: "npm:7.1.6"
resolve-from: "npm:^5.0.0"
semver: "npm:^7.5.4"
slash: "npm:^3.0.0"
slugify: "npm:^1.6.6"
xcode: "npm:^3.0.1"
xml2js: "npm:0.6.0"
checksum: 10c0/4d403ce9df0cd694cd30f1a914c4e4529c529e9b5f572ddc3cdf88f80d4c47a8ba8336da78ae3f9c67966a7fa1e02d87ea2dff95102a0a337004d118ebc32d63
languageName: node
linkType: hard
"@expo/config-plugins@npm:9.0.12":
version: 9.0.12
resolution: "@expo/config-plugins@npm:9.0.12"
dependencies:
"@expo/config-types": "npm:^52.0.0"
"@expo/json-file": "npm:~9.0.0"
"@expo/plist": "npm:^0.2.0"
"@expo/config-types": "npm:^55.0.5"
"@expo/json-file": "npm:~10.0.12"
"@expo/plist": "npm:^0.5.2"
"@expo/sdk-runtime-versions": "npm:^1.0.0"
chalk: "npm:^4.1.2"
debug: "npm:^4.3.5"
getenv: "npm:^1.0.0"
glob: "npm:^10.4.2"
getenv: "npm:^2.0.0"
glob: "npm:^13.0.0"
resolve-from: "npm:^5.0.0"
semver: "npm:^7.5.4"
slash: "npm:^3.0.0"
slugify: "npm:^1.6.6"
xcode: "npm:^3.0.1"
xml2js: "npm:0.6.0"
checksum: 10c0/a27f085caf3d9ce29ca8b532425dd409054d8ef449b5d5338db2a2b1ead8051073e4f155691f6c2dc40b46816229fffbd09ef51c84eb87aa95eefaea9c810d40
checksum: 10c0/46cea118d9a780ac367862ef136aafa65063a3bc9a9864ece2a18d4b9e66e64d2bb19f0c0972e07f1106a58203f65671208bebcf5e207953f54217210807746c
languageName: node
linkType: hard
@@ -2859,7 +2991,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-plugins@npm:~9.0.0, @expo/config-plugins@npm:~9.0.10, @expo/config-plugins@npm:~9.0.17":
"@expo/config-plugins@npm:~9.0.0, @expo/config-plugins@npm:~9.0.17":
version: 9.0.17
resolution: "@expo/config-plugins@npm:9.0.17"
dependencies:
@@ -2881,17 +3013,10 @@ __metadata:
languageName: node
linkType: hard
"@expo/config-types@npm:52.0.1":
version: 52.0.1
resolution: "@expo/config-types@npm:52.0.1"
checksum: 10c0/f06287eb20d599fbc422bf58dc97a803d8f9242699ce43188b90bfd92d54da27f83ca1225bf1ec61a5c30e74c57e47e7bfba2ef3cada38983622657658fafe4e
languageName: node
linkType: hard
"@expo/config-types@npm:^51.0.0-unreleased":
version: 51.0.3
resolution: "@expo/config-types@npm:51.0.3"
checksum: 10c0/bd87a729da985b0097ab29367c0473a2bced5ff211d416f342729e1b2631a7c00d62878e05cdee49ece7e3b65d3296957917f24380d57e2faef2cf220194fdec
"@expo/config-types@npm:55.0.5, @expo/config-types@npm:^55.0.5":
version: 55.0.5
resolution: "@expo/config-types@npm:55.0.5"
checksum: 10c0/24ce0481cc465ddd3b53cfdde099ef4e899b1f8fff224a0f249b88c93e6c98930e99a55f3929eb53d08138b1b66102ece7b76e16f4e5fadcdf5bbac26c9c3d7e
languageName: node
linkType: hard
@@ -2909,27 +3034,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/config@npm:10.0.6":
version: 10.0.6
resolution: "@expo/config@npm:10.0.6"
dependencies:
"@babel/code-frame": "npm:~7.10.4"
"@expo/config-plugins": "npm:~9.0.10"
"@expo/config-types": "npm:^52.0.0"
"@expo/json-file": "npm:^9.0.0"
deepmerge: "npm:^4.3.1"
getenv: "npm:^1.0.0"
glob: "npm:^10.4.2"
require-from-string: "npm:^2.0.2"
resolve-from: "npm:^5.0.0"
resolve-workspace-root: "npm:^2.0.0"
semver: "npm:^7.6.0"
slugify: "npm:^1.3.4"
sucrase: "npm:3.35.0"
checksum: 10c0/1fdf47ea0e268b475e30e02ebdc1f6739c0b426a4c6c695a7b4ee680109258b4ccfbd3010d3bd031a6efcd7a861670be84d10cd0019d0f5d14dc669a5039b860
languageName: node
linkType: hard
"@expo/config@npm:11.0.10":
version: 11.0.10
resolution: "@expo/config@npm:11.0.10"
@@ -2951,6 +3055,25 @@ __metadata:
languageName: node
linkType: hard
"@expo/config@npm:55.0.10":
version: 55.0.10
resolution: "@expo/config@npm:55.0.10"
dependencies:
"@expo/config-plugins": "npm:~55.0.7"
"@expo/config-types": "npm:^55.0.5"
"@expo/json-file": "npm:^10.0.12"
"@expo/require-utils": "npm:^55.0.3"
deepmerge: "npm:^4.3.1"
getenv: "npm:^2.0.0"
glob: "npm:^13.0.0"
resolve-from: "npm:^5.0.0"
resolve-workspace-root: "npm:^2.0.0"
semver: "npm:^7.6.0"
slugify: "npm:^1.3.4"
checksum: 10c0/a2e0a100c8d472e56e9ad649a8852eddca76ab64503475c803b4fc372df08e303169243b6a782fe150c54212ba02f47c40c4f0e365c13e77e70f5d777aa8d704
languageName: node
linkType: hard
"@expo/config@npm:~10.0.4":
version: 10.0.11
resolution: "@expo/config@npm:10.0.11"
@@ -2972,7 +3095,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:18.0.1, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:18.5.0, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -2988,11 +3111,11 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:18.4.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:18.5.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:18.0.1"
"@expo/logger": "npm:18.5.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
jest: "npm:^29.7.0"
@@ -3005,12 +3128,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:18.4.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:18.5.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/eas-build-job": "npm:18.5.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3074,7 +3197,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:8.3.3, @expo/json-file@npm:~8.3.0":
"@expo/json-file@npm:8.3.3":
version: 8.3.3
resolution: "@expo/json-file@npm:8.3.3"
dependencies:
@@ -3085,6 +3208,16 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:^10.0.12, @expo/json-file@npm:~10.0.12":
version: 10.0.12
resolution: "@expo/json-file@npm:10.0.12"
dependencies:
"@babel/code-frame": "npm:^7.20.0"
json5: "npm:^2.2.3"
checksum: 10c0/52131a6426e96208ff1b295d580fc70eebb8e292b29fde1db016b2f21a0942a7521feec96b3f58efe5b32dcc1642d569b4211d651146fcdb9bf7e5f08b635878
languageName: node
linkType: hard
"@expo/json-file@npm:^10.0.8":
version: 10.0.8
resolution: "@expo/json-file@npm:10.0.8"
@@ -3126,7 +3259,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/json-file@npm:~9.0.0, @expo/json-file@npm:~9.0.2":
"@expo/json-file@npm:~9.0.2":
version: 9.0.2
resolution: "@expo/json-file@npm:9.0.2"
dependencies:
@@ -3137,7 +3270,7 @@ __metadata:
languageName: node
linkType: hard
"@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"
dependencies:
@@ -3205,17 +3338,6 @@ __metadata:
languageName: node
linkType: hard
"@expo/plist@npm:^0.1.0":
version: 0.1.3
resolution: "@expo/plist@npm:0.1.3"
dependencies:
"@xmldom/xmldom": "npm:~0.7.7"
base64-js: "npm:^1.2.3"
xmlbuilder: "npm:^14.0.0"
checksum: 10c0/134315260a7828bc1ce4563e2af67499b498feae46c39c5c2cab9d72082402a42d3b7575f13e269022bcf3c28668948ea960dd4943bd38f52f9c01154317aac5
languageName: node
linkType: hard
"@expo/plist@npm:^0.2.0, @expo/plist@npm:^0.2.2":
version: 0.2.2
resolution: "@expo/plist@npm:0.2.2"
@@ -3238,6 +3360,17 @@ __metadata:
languageName: node
linkType: hard
"@expo/plist@npm:^0.5.2":
version: 0.5.2
resolution: "@expo/plist@npm:0.5.2"
dependencies:
"@xmldom/xmldom": "npm:^0.8.8"
base64-js: "npm:^1.5.1"
xmlbuilder: "npm:^15.1.1"
checksum: 10c0/19adae2a365ac1a12db93682fb310ff8be03c711f9173bebe5841cbe60cdfb749247bc1a95fa0977b5bac3aa6a078a0fceeafe4ff6c66d1ed67cce496679e310
languageName: node
linkType: hard
"@expo/plugin-help@npm:5.1.23":
version: 5.1.23
resolution: "@expo/plugin-help@npm:5.1.23"
@@ -3294,6 +3427,22 @@ __metadata:
languageName: node
linkType: hard
"@expo/require-utils@npm:^55.0.3":
version: 55.0.3
resolution: "@expo/require-utils@npm:55.0.3"
dependencies:
"@babel/code-frame": "npm:^7.20.0"
"@babel/core": "npm:^7.25.2"
"@babel/plugin-transform-modules-commonjs": "npm:^7.24.8"
peerDependencies:
typescript: ^5.0.0 || ^5.0.0-0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10c0/562a2dc71f983fba2215295bbcc376d6911217c3a98b96484331112ff98c7a2e979fb1904ac293008ac557114c6658c1deb9b2f441bd246764b507103d2560cd
languageName: node
linkType: hard
"@expo/results@npm:1.0.0, @expo/results@npm:^1.0.0":
version: 1.0.0
resolution: "@expo/results@npm:1.0.0"
@@ -3332,12 +3481,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:18.4.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:18.5.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:18.4.0"
"@expo/logger": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.5.0"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3362,7 +3511,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:18.0.1, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:18.5.0, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3385,11 +3534,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:18.0.1, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:18.5.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:18.0.1"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -3405,18 +3554,18 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.4.0"
"@expo/config-plugins": "npm:8.0.8"
"@expo/config-types": "npm:52.0.1"
"@expo/downloader": "npm:18.0.1"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/logger": "npm:18.0.1"
"@expo/build-tools": "npm:18.5.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.5.0"
"@expo/logger": "npm:18.5.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.4.0"
"@expo/template-file": "npm:18.0.1"
"@expo/turtle-spawn": "npm:18.0.1"
"@expo/steps": "npm:18.5.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@hapi/boom": "npm:10.0.1"
"@sentry/node": "npm:7.77.0"
"@types/babel__core": "npm:7.20.5"
@@ -5053,7 +5202,7 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/gen-mapping@npm:^0.3.12":
"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5":
version: 0.3.13
resolution: "@jridgewell/gen-mapping@npm:0.3.13"
dependencies:
@@ -5074,6 +5223,16 @@ __metadata:
languageName: node
linkType: hard
"@jridgewell/remapping@npm:^2.3.5":
version: 2.3.5
resolution: "@jridgewell/remapping@npm:2.3.5"
dependencies:
"@jridgewell/gen-mapping": "npm:^0.3.5"
"@jridgewell/trace-mapping": "npm:^0.3.24"
checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194
languageName: node
linkType: hard
"@jridgewell/resolve-uri@npm:^3.0.3":
version: 3.0.7
resolution: "@jridgewell/resolve-uri@npm:3.0.7"
@@ -10172,7 +10331,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:18.4.0"
"@expo/steps": "npm:18.5.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10785,11 +10944,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:18.4.0"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/logger": "npm:18.0.1"
"@expo/build-tools": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.5.0"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:18.0.1"
"@expo/turtle-spawn": "npm:18.5.0"
"@types/bunyan": "npm:^1.8.11"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
@@ -10839,15 +10998,15 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli@workspace:packages/eas-cli"
dependencies:
"@expo/apple-utils": "npm:2.1.15"
"@expo/apple-utils": "npm:2.1.16"
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:10.0.6"
"@expo/config-plugins": "npm:9.0.12"
"@expo/eas-build-job": "npm:18.4.0"
"@expo/eas-json": "npm:18.4.0"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:18.5.0"
"@expo/eas-json": "npm:18.5.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:18.0.1"
"@expo/logger": "npm:18.5.0"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10859,7 +11018,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.4.0"
"@expo/steps": "npm:18.5.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"
@@ -11801,7 +11960,7 @@ __metadata:
languageName: node
linkType: hard
"find-up@npm:^5.0.0, find-up@npm:~5.0.0":
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
dependencies:
@@ -12273,6 +12432,13 @@ __metadata:
languageName: node
linkType: hard
"getenv@npm:^2.0.0":
version: 2.0.0
resolution: "getenv@npm:2.0.0"
checksum: 10c0/397ff641dd70cd78e414430258651e9a2228d3c5553a8cf15ae7840f75d3f10dfcb83f668f84829e84ea665b0fce2f08a9eddda3c9dcd7faa2d3da1c182c1854
languageName: node
linkType: hard
"git-hooks-list@npm:^3.0.0":
version: 3.2.0
resolution: "git-hooks-list@npm:3.2.0"
@@ -12377,20 +12543,6 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:7.1.6":
version: 7.1.6
resolution: "glob@npm:7.1.6"
dependencies:
fs.realpath: "npm:^1.0.0"
inflight: "npm:^1.0.4"
inherits: "npm:2"
minimatch: "npm:^3.0.4"
once: "npm:^1.3.0"
path-is-absolute: "npm:^1.0.0"
checksum: 10c0/2575cce9306ac534388db751f0aa3e78afedb6af8f3b529ac6b2354f66765545145dba8530abf7bff49fb399a047d3f9b6901c38ee4c9503f592960d9af67763
languageName: node
linkType: hard
"glob@npm:^10.2.2":
version: 10.3.10
resolution: "glob@npm:10.3.10"