Compare commits

..
Author SHA1 Message Date
AbbanMustafa fef31f74e5 fold into existing restore/save_build_cache 2026-03-18 01:16:41 -05:00
AbbanMustafa aa77ef6485 cleanup 2026-03-17 01:55:34 -05:00
AbbanMustafa c5a12d9c26 snapshots 2026-03-17 01:51:06 -05:00
AbbanMustafa 8a9bc57e28 cleanup 2026-03-17 01:39:01 -05:00
AbbanMustafa 94bf1ca46f cleanup 2026-03-17 01:31:28 -05:00
AbbanMustafa 8671d262f7 archive & simulator build support 2026-03-16 17:00:23 -05:00
AbbanMustafa 9aeaba46eb derived_data_path to the archive Gymfile template 2026-03-12 00:44:02 -05:00
AbbanMustafa d68a7fa34a proper path 2026-03-12 00:26:32 -05:00
AbbanMustafa b6450b28b6 proper path 2026-03-12 00:16:28 -05:00
AbbanMustafa 6090749291 run build phases 2026-03-11 19:19:49 -05:00
AbbanMustafa 10c148a359 derived data cache 2026-03-10 14:13:47 -05:00
Stanisław Chmiela f3fd05d2dc [eas-build-job] Rename UserFacingError to UserError (#3468)
## Summary
- rename `UserFacingError` to `UserError` across the stack
- keep payloads and behavior unchanged
- move unknown user-visible errors under the new name

## Changes
- rename the shared user error class in `eas-build-job`
- update `build-tools` and `worker` imports and `instanceof` checks
- update tests and direct `dist/errors` imports in build-tools

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn build && npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts src/steps/utils/ios/__tests__/AscApiUtils.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-10 10:17:55 +01:00
Ash Wu 1ca478cb5e [build-tools][steps] Report step metrics after each step (#3479)
* [build-tools][steps] Report step metrics after each step instead of batching at job completion

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

* address comments

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

* move reportStepMetric to customBuildContext

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-03-10 14:57:53 +08:00
Expo CI efbb61b088 update CHANGELOG.md 2026-03-10 02:04:35 +00:00
Expo CI 3984c9a938 v18.3.0 2026-03-10 02:02:33 +00:00
Brent Vatne a3c9e6727e [eas-cli] Show user email in account:view and whoami (#3485) 2026-03-09 16:43:53 -07:00
Expo CI e06a5babcf update CHANGELOG.md 2026-03-09 22:20:37 +00:00
Expo CI 5f400d7766 v18.2.0 2026-03-09 22:17:42 +00:00
Hassan Khan 4338aea702 feat(cli): add flag to prevent uploading server sourcemaps (#3478) 2026-03-09 18:45:07 +00:00
Stanisław Chmiela ecf42e68f3 [eas-build-job] Add configure Android version build phase (#3483)
# Why

Preparation for https://github.com/expo/eas-cli/pull/3483.

# How

- add `BuildPhase.CONFIGURE_ANDROID_VERSION`
- add display metadata for the new phase
- add website ID metadata for the new phase

# Test Plan

CI should pass.
2026-03-09 16:25:01 +01:00
Stanisław Chmiela f23579fa32 [eas-cli] Upgrade eas-cli to oclif v4 (#3472)
## Why

Old task I asked Codex to do.

## How

- upgrade `eas-cli` from oclif v3 to v4 using the native v4 APIs
- migrate legacy array-style `static args = [...]` declarations to native v4 object-style `Args.*` definitions
- keep command typing fully native and type-safe, including `Flags.option(...).options` const assertions where needed for inference
- update tests and shared test helpers for the v4 config/hook behavior

Notes:
- argument order is preserved in oclif v4 because `static args = { ... }` is treated as an ordered object; positional args follow object insertion order
- the bin entrypoints now use `@oclif/core` `execute(...)`, which is the supported v4 boot path
- `execute(...)` already performs the old `run().then(flush).catch(handle)` flow internally, so the explicit `flush` and `handle` chaining was removed as redundant rather than as a behavior change
- `bin/dev` keeps the dev entrypoint behavior through `execute({ development: true, ... })`, which enables the development boot path and verbose debugging behavior in v4

## Test plan

- `./node_modules/.bin/tsc --project packages/eas-cli/tsconfig.build.json`
- `node packages/eas-cli/bin/run --help`
- `node packages/eas-cli/bin/dev --help`
- `npx -y @yarnpkg/[email protected].0 workspace eas-cli test --runInBand`
2026-03-09 11:18:15 +01:00
Stanisław Chmiela afdc626fcb [build-tools] Introduce ExpoError as the shared build error base (#3470)
## Summary
- add `ExpoError` as the shared base for build-related errors
- centralize shared metadata and formatting there
- keep runtime behavior unchanged

## Changes
- introduce `ExpoError` with `errorCode`, `trackingCode`, `docsUrl`, `cause`, and `buildPhase`
- make `BuildError` and `UserFacingError` extend `ExpoError`
- add unit coverage for the shared base behavior

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn build && npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-09 10:57:56 +01:00
Evan BaconandClaude Opus 4.5 f9f4583a39 [eas-cli] Auto-generate Android keystore in non-interactive mode (#3477)
Instead of throwing an error when running `eas build -p android --non-interactive`
without existing credentials, automatically generate a keystore using the existing
`generateRandomKeystoreAsync()` function.

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-03-07 13:11:03 -08:00
Krystof WoldrichandClaude Opus 4.6 2d955440ca feat(cli): enable --non-interactive when --json (#3476)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-07 11:45:07 +01:00
Mustafa Abban 642bb6fa4d [build-tools] Remove cache support check in restoreCache for build jobs (#3475) 2026-03-06 14:42:30 -06:00
Douglas Lowder fb1f2d2add [cli] CI check and environment flag override for update (#3442)
# Why

Provide a way to override the new requirement for the `--environment` flag in `eas update`.

# How

Modify the check to skip when the `EAS_UPDATE_SKIP_ENVIRONMENT_CHECK` variable is set to a boolish value (1 or true).

If the command is run interactively, we now prompt the user for the environment, instead of throwing an error. This is skipped if the `--auto` flag is set. A missing environment flag already throws an error if `--non-interactive` is set.

Add the `CI` flag to the tests for whether we should skip the check in a CI environment.

# Test Plan

New unit tests added.
2026-03-06 06:55:52 -08:00
Stanisław Chmiela dbcca53f1d [worker] Add HttpLogStream (#3391)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We want to push logs to a logs service.

# How

Initially I wanted to set up a WebSocket connection between worker and the logs service to reduce the HTTP overhead, but I found having a persistent/reconnecting WebSocket connection is not trivial and HTTP should be ok.

Confirmed failure to upload is not logged in the GCS log.

# Test Plan

Tested manually.


https://github.com/user-attachments/assets/f80f0482-73f5-4dfb-855f-2361ed04aa4f
2026-03-06 14:43:58 +01:00
Stanisław Chmiela 4a0dc9f426 [worker] Remove duplicated user-facing fields from build errors (#3469)
## Summary
- remove the duplicated `userFacingMessage` and `userFacingErrorCode` fields from the build error flow
- keep current runtime behavior and unknown-error semantics
- preserve the current worker protocol shape

Stanley: I don't want to have separate `userFacingMessage` and `message` / `userFacingErrorCode` and `errorCode`. It looks like the reason we had them separate was to log "UNKNOWN_ERROR" to the user if we encounter an internal issue like "NPM_CORRUPTED_PACKAGE". This pull request inverts the logic — we're going to have a `trackingCode` so we keep logging "NPM_CORRUPTED_PACKAGE" to Sentry and the default `errorCode` is the one we log to the user.

## Changes
- make `BuildError.format()` use canonical `message` and `errorCode`
- make `build-tools` store internal-only classification in `trackingCode` instead of parallel public fields
- make worker derive `internalErrorCode` from `trackingCode ?? errorCode`
- delete the obsolete `userFacing...` fields from `BuildError`

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-06 14:11:10 +01:00
Stanisław Chmiela 146656c7f7 [build-tools] Handle ASC 90478 as closed version train (#3460)
## Why
`submit_ios_from_linux` UNKNOWN_ERROR analysis showed App Store Connect responses that clearly indicate the same version-train failure class but with mixed codes (`90062`, `90186`, `90478`).

Current handling recognizes only `90062` and `90186`, so runs that include `90478` fall back to `UNKNOWN_ERROR`.

## How
- extend `isClosedVersionTrainError` to include ASC code `90478` while keeping strict `.every` matching
- broaden `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` user-facing message to cover both closed train and lower-than-approved version cases
- update unit test to include `90478` in positive closed-train detection

## Examples
Existing `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` runs:
- https://expo.dev/uuid/019cbe83-ac3b-756f-b7ef-447bae5ef4dc
- https://expo.dev/uuid/019cbe66-4f9b-7d95-a35e-06886b646434

Would-be closed-train runs currently persisted as `UNKNOWN_ERROR` (log errors include `90478` with `90062`/`90186`):
- https://expo.dev/uuid/019cbe71-62fa-7fee-9e77-bdc051cc6e8b
- https://expo.dev/uuid/019cbe44-5c75-7211-af66-35fe96a86903

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-05 16:29:48 +01:00
Phil Pluckthun e37411040f [build-tools] Add --include=dev for npm install and --production false for Yarn classic (#3459)
* Add missing `--include=dev` for regular npm install

* Override NODE_ENV globally for yarn

* Add `--production false` for Yarn Classic and remove NODE_ENV change

* Add CHANGELOG entry
2026-03-05 14:39:03 +00:00
Mustafa Abban b71226d90f [worker] Handle non-OK HTTP responses before JSON parsing in upload session (#3457) 2026-03-04 15:35:09 -06:00
Stanisław Chmiela 9a9fe11f73 [build-tools] Handle ASC missing purpose strings as user-facing error (#3456)
## Why
Uploads to App Store Connect can fail with code `90683` when required `*UsageDescription` privacy keys are missing in `Info.plist`.

Without dedicated handling this ends up as a generic upload failure, even though ASC already returns structured details that can guide the user.

## How
- add ASC error recognition for `90683` in `uploadToAsc` failed-state handling
- throw a specific `UserFacingError` with code `EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING`
- parse ASC error descriptions to extract and list missing `*UsageDescription` keys
- provide concrete remediation for both CNG (`ios.infoPlist`) and non-CNG (`Info.plist`) workflows
- set `docsUrl` on the user-facing error to Expo permissions docs
- add unit tests for detection and missing-key parsing helpers

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-04 21:27:59 +01:00
Stanisław Chmiela 1b62b6feae [build-tools] Add ASC invalid bundle ID user-facing error (#3455)
## Why
Some App Store Connect uploads currently end up as generic failures even when ASC returns explicit bundle identifier mismatch validation ([`90054`](https://expo.dev/accounts/dobrokruh/projects/dobrokruh/job-runs/019cb865-ef97-71d1-9137-f6ae4c05d94d)/[`90055`](https://expo.dev/accounts/central-studio/projects/hfc-fuels/job-runs/019cb882-1550-7801-9b4b-9647e1bce364)). We want a user-facing error that explains the mismatch and shows both identifiers so users can quickly determine whether they selected the wrong app or the wrong build profile/build.

## How
- add `isInvalidBundleIdentifierError` detector in `uploadToAsc` for ASC error codes `90054`/`90055`
- when matched, throw `UserFacingError` with code `EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID`
- include both identifiers in message:
  - IPA bundle identifier (best-effort read via `readIpaInfoAsync`)
  - selected ASC app bundle identifier
- provide remediation covering both paths:
  - wrong selected app identifier in submit profile
  - wrong build selected / rebuild with different profile
- use `asyncResult` for best-effort IPA introspection (no nested catch needed)
- add unit tests for invalid bundle-id detector

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
2026-03-04 13:58:10 +01:00
Stanisław Chmiela ebb215db1a [build-tools] Remove -z from nc (#3454)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Older (default) Android system image package does not support `-z`.

```
  Forward stdin/stdout to a file or network connection.

  -4	Force IPv4
  -6	Force IPv6
  -L	Listen for multiple incoming connections (server mode)
  -U	Use a UNIX domain socket
  -W	SECONDS timeout for more data on an idle connection
  -f	Use FILENAME (ala /dev/ttyS0) instead of network
  -l	Listen for one incoming connection
  -p	Local port number
  -q	Quit SECONDS after EOF on stdin, even if stdout hasn't closed yet
  -s	Local source address
  -t	Allocate tty (must come before -l or -L)
  -u	Use UDP
  -w	SECONDS timeout to establish connection
```

# How

Replaced `-z` with a closed stdin.

# Test Plan

System tests should work.

Deployed worker from this branch at https://github.com/expo/eas-cli/actions/runs/22666833774/job/65700594158.

Confirmed works at https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/workflows/019cb88e-f710-74d3-87ba-3c17bfe45c00.
2026-03-04 12:31:52 +01:00
Stanisław Chmiela cdf49377cd [build-tools] Replace ping with nc for Android network readiness check (#3453)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

`ping` is blocked on Linux VMs. https://expo.dev/accounts/layerztec/projects/layerzwallet/workflows/019cb835-cf4a-7b9f-af32-db474797eb85

# How

Replaced `ping` check with `nc`.

# Test Plan

Tested manually on staging that it works. https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019cb862-4ea6-7966-9148-5cb8dbfb1724
2026-03-04 10:50:17 +00:00
Stanisław Chmiela 8fb84e86bb [build-tools] Harden Android emulator startup readiness and retries (#3450)
# Why

Android emulators started for workflow/Maestro runs can report boot completion while still lacking functional networking. This makes Maestro tests more flaky.

# How

- Extend `AndroidEmulatorUtils.waitForReadyAsync` to require both:
  - `sys.boot_completed == 1`
  - successful guest-side network probe (`ping 1.1.1.1`)
- Add reusable `AndroidEmulatorUtils.stopAsync` and compose `deleteAsync` from stop+delete logic.
- Add startup retries with escalating readiness timeouts (60s, 120s, 180s) in:
  - `eas/start_android_emulator` base emulator startup
  - `eas/start_android_emulator` clone startup path
  - Android startup path inside `eas/__maestro_test` (`withCleanDeviceAsync`)
- Keep retries at 3 total attempts (initial + 2 retries).

# Test Plan

Tested with integration tests.
2026-03-04 10:18:43 +01:00
Stanisław ChmielaandCopilot d660e3602d [worker] Preserve UserFacingError instead of UNKNOWN_ERROR (#3445)
## Why
Generic/custom workflow steps can throw `UserFacingError`, but worker-level error handling currently maps any non-`BuildError` to `UnknownBuildError` / `UnknownCustomBuildError`. That causes actionable step errors to be persisted and exposed as `UNKNOWN_ERROR`.

## How
- add a `toBuildError` conversion in worker service catch path
- preserve existing `BuildError` values
- convert `UserFacingError` into `BuildError` with the same code/message/docsUrl
- keep unknown fallbacks only for truly unstructured errors
- add integration coverage in `stateSync` to assert generic job `UserFacingError` is exposed in `externalBuildError` and `internalErrorCode`

We should make a bigger refactor to error handling, but it's more involved — https://github.com/expo/eas-cli/pull/3447.

## Test Plan

CI should pass.


```
 PASS  src/__integration__/stateSync.test.ts
  State sync mechanism
    query new worker
      ✓ should return 'new' state (210 ms)
    query worker after dispatch
      ✓ should return 'in-progress' state (170 ms)
    query worker after build
      ✓ should return 'success' state (2062 ms)
    query worker after error
      ✓ should return 'error' state with artifacts from error (88 ms)
      ✓ should expose user-facing error from generic job (60 ms)
```

Co-authored-by: Copilot <[email protected]>
2026-03-04 09:38:36 +01:00
Expo CI 5b9d47c02b update CHANGELOG.md 2026-03-04 08:36:00 +00:00
190 changed files with 6353 additions and 4439 deletions

No files matched your search

@@ -12,6 +12,8 @@ jobs:
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
+32 -2
View File
@@ -8,6 +8,38 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
### 🧹 Chores
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
@@ -15,8 +47,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
### 🧹 Chores
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
+3 -1
View File
@@ -296,9 +296,11 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated with TypeScript and the linter before committing:
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.1.0",
"version": "18.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -26,7 +26,7 @@
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "9.0.3",
"oclif": "^3.9.0",
"oclif": "^4.22.85",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.1.0",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,14 @@
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.0.2",
"@expo/eas-build-job": "18.2.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@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.0.2",
"@expo/steps": "18.2.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/xcpretty": "^4.3.1",
@@ -19,3 +19,7 @@ export enum LoggerLevel {
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
@@ -1,9 +1,14 @@
import { BuildTrigger, Ios, Metadata } from '@expo/eas-build-job';
import { StepMetric } from '@expo/steps';
import { createTestIosJob } from './utils/job';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { uploadStepMetricsToWwwAsync } from '../utils/stepMetrics';
jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('should not lose workflowInterpolationContext', () => {
@@ -34,4 +39,122 @@ describe(CustomBuildContext, () => {
foo: 'bar',
});
});
describe('reportStepMetric', () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
const workflowJobId = 'test-workflow-job-id';
const robotAccessToken = 'test-robot-token';
const sampleMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'success',
durationMs: 1000,
platform: 'linux',
};
function createCustomBuildContext(
overrides: {
expoApiV2BaseUrl?: string | undefined;
includeRobotAccessToken?: boolean;
includeWorkflowJobId?: boolean;
} = {}
): CustomBuildContext {
const job = createTestIosJob();
if (overrides.includeRobotAccessToken !== false) {
(job.secrets as any).robotAccessToken = robotAccessToken;
}
const env: Record<string, string> = {
__API_SERVER_URL: 'http://api.expo.test',
};
if (overrides.includeWorkflowJobId !== false) {
env.__WORKFLOW_JOB_ID = workflowJobId;
}
const ctx = new BuildContext(job, {
env,
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '/workingdir',
expoApiV2BaseUrl:
'expoApiV2BaseUrl' in overrides ? overrides.expoApiV2BaseUrl : expoApiV2BaseUrl,
});
return new CustomBuildContext(ctx);
}
beforeEach(() => {
jest.clearAllMocks();
mockUploadStepMetricsToWwwAsync.mockResolvedValue(undefined);
});
it('calls uploadStepMetricsToWwwAsync when all required fields are present', () => {
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledTimes(1);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledWith(
expect.objectContaining({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [sampleMetric],
})
);
});
it('does not call upload when workflowJobId is missing', () => {
const customCtx = createCustomBuildContext({ includeWorkflowJobId: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when robotAccessToken is missing', () => {
const customCtx = createCustomBuildContext({ includeRobotAccessToken: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when expoApiV2BaseUrl is missing', () => {
const customCtx = createCustomBuildContext({ expoApiV2BaseUrl: undefined });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('drainPendingMetricUploads awaits all pending uploads', async () => {
let resolveUpload!: () => void;
const uploadPromise = new Promise<void>(resolve => {
resolveUpload = resolve;
});
mockUploadStepMetricsToWwwAsync.mockReturnValue(uploadPromise);
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
let drained = false;
const drainPromise = customCtx.drainPendingMetricUploads().then(() => {
drained = true;
});
await new Promise(r => setImmediate(r));
expect(drained).toBe(false);
resolveUpload();
await drainPromise;
expect(drained).toBe(true);
});
it('drainPendingMetricUploads settles even if an upload rejects', async () => {
mockUploadStepMetricsToWwwAsync.mockRejectedValue(new Error('upload failed'));
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
await expect(customCtx.drainPendingMetricUploads()).resolves.not.toThrow();
});
});
});
@@ -0,0 +1,128 @@
import { BuildPhase } from '@expo/eas-build-job';
import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
import { runGenericJobAsync } from '../generic';
import { CustomBuildContext } from '../customBuildContext';
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
jest.mock('../customBuildContext');
jest.mock('../utils/outputs');
jest.mock('../common/projectSources');
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
jest.mock('../steps/easFunctionGroups', () => ({
getEasFunctionGroups: jest.fn().mockReturnValue([]),
}));
jest.mock('@expo/steps');
jest.mock('fs/promises');
const mockUploadJobOutputsToWwwAsync = jest.mocked(uploadJobOutputsToWwwAsync);
describe(runGenericJobAsync, () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
let mockDrainPendingMetricUploads: jest.Mock;
let mockCtx: any;
beforeEach(() => {
jest.clearAllMocks();
mockDrainPendingMetricUploads = jest.fn().mockResolvedValue(undefined);
(CustomBuildContext as unknown as jest.Mock).mockImplementation(() => ({
projectSourceDirectory: '/tmp/src',
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
drainPendingMetricUploads: mockDrainPendingMetricUploads,
}));
(BuildStepGlobalContext as unknown as jest.Mock).mockImplementation(() => ({
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
}));
mockCtx = {
expoApiV2BaseUrl,
job: {
secrets: { robotAccessToken: 'test-token' },
steps: [],
},
logger: {
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn().mockReturnThis(),
},
runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise<any>) => fn()),
};
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
});
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runGenericJobAsync(mockCtx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
});
it('awaits drainPendingMetricUploads even when outputs upload fails', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockUploadJobOutputsToWwwAsync.mockRejectedValue(new Error('outputs upload failed'));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let rejected = false;
const resultPromise = runGenericJobAsync(mockCtx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('outputs upload failed');
expect(rejected).toBe(true);
});
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
mockCtx.expoApiV2BaseUrl = undefined;
const mockWorkflow = { executeAsync: jest.fn() };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await expect(runGenericJobAsync(mockCtx)).rejects.toThrow(
'expoApiV2BaseUrl is required for generic jobs'
);
expect(CustomBuildContext).not.toHaveBeenCalled();
});
});
@@ -26,8 +26,14 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.metadata).toEqual({
packageName: '@typescript-eslint/[email protected]',
});
});
it('detects log for invalid bundler and reports it to user', async () => {
@@ -45,7 +51,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.message).toBe(
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.metadata).toBeUndefined();
});
it('does not detect errors if they show up in different build phase', async () => {
@@ -63,7 +73,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects npm cache error if cache is enabled', async () => {
@@ -82,8 +95,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
},
'/fake/path'
);
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
});
it('does not detect npm cache error if cache is disabled', async () => {
@@ -103,7 +119,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects xcode line error', async () => {
@@ -126,7 +145,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/path/to/'
);
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects minimum deployment target error correctly', async () => {
@@ -144,7 +163,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -162,7 +181,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects generic "Run Fastlane" error for resign correctly', async () => {
@@ -178,7 +197,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects build error in "Run Fastlane" phase correctly', async () => {
@@ -194,7 +213,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
@@ -240,8 +259,8 @@ note`;
'/path/to'
);
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
expect(err.userFacingMessage)
expect(err.trackingCode).toBeUndefined();
expect(err.message)
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
- Some other error
@@ -263,9 +282,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
'/fake/path'
);
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -285,8 +304,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.userFacingMessage).toBe(
expect(err.trackingCode).toBeUndefined();
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
@@ -6,7 +6,8 @@ import { ErrorContext, ErrorHandler } from './errors.types';
export class TrackedBuildError extends Error {
constructor(
public errorCode: string,
public message: string
public message: string,
public metadata?: Record<string, unknown>
) {
super(message);
}
@@ -154,7 +155,9 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
createError: (match: RegExpMatchArray) =>
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
packageName: match[1],
}),
})),
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
@@ -73,34 +73,22 @@ export async function resolveBuildPhaseErrorAsync(
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userFacingError =
error instanceof errors.UserFacingError
const userError =
error instanceof errors.UserError
? error
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const isUnknownUserError =
!userFacingError ||
(
[
errors.ErrorCode.UNKNOWN_ERROR,
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
] as string[]
).includes(userFacingError.errorCode);
const message =
(isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message;
const errorCode =
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
userFacingError.errorCode;
const trackingCode =
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
return new errors.BuildError(message, {
errorCode,
userFacingErrorCode: userFacingError.errorCode,
userFacingMessage: userFacingError.message,
docsUrl: userFacingError.docsUrl,
innerError: error,
return new errors.BuildError(userError.message, {
errorCode: userError.errorCode,
trackingCode,
docsUrl: userError.docsUrl,
cause: error,
buildPhase: phase,
metadata: buildError?.metadata,
});
}
@@ -2,9 +2,9 @@ import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-bui
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import UserFacingError = errors.UserFacingError;
import UserError = errors.UserError;
export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
export const userErrorHandlers: ErrorHandler<UserError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
@@ -12,7 +12,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// example log:
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_UNSUPPORTED_COCOAPODS_VERSION_ERROR',
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
- changing the current version under key "cocoapods"
@@ -27,7 +27,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// example log:
// /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
@@ -40,7 +40,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-5787e6af-3002-4cb7-8a57-3e73d13313c2.jks": Invalid keystore format
regexp: /Invalid keystore format/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_INVALID_KEYSTORE_FORMAT_ERROR',
'The keystore used in this build is malformed or it has an unsupported type. Make sure you provided the correct file.'
),
@@ -52,7 +52,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-286069a8-4bb9-48a6-add9-acf6b58ea06d.jks": null
regexp: /Failed to read key[^\n]+from store/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
),
@@ -67,7 +67,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// [11:17:29] Error: [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist.
regexp: /Cannot copy google-services\.json/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -82,7 +82,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
regexp:
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -95,7 +95,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// [08:44:18] ENOENT: no such file or directory, copyfile '/Users/expo/workingdir/build/managed/abc' -> '/Users/expo/workingdir/build/managed/ios/testapp/GoogleService-Info.plist'
regexp: /ENOENT: no such file or directory, copyfile .*GoogleService-Info.plist/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -113,7 +113,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
regexp:
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
createError: (_, { job }) => {
return new UserFacingError(
return new UserError(
'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR',
`Some pods require a higher minimum deployment target.
${
@@ -141,7 +141,7 @@ ${
// You should run `pod update Firebase/Core` to apply changes you've made.
regexp: /CocoaPods could not find compatible versions for pod /,
createError: () => {
return new UserFacingError(
return new UserError(
'EAS_BUILD_INCOMPATIBLE_PODS_ERROR',
`Compatible versions of some pods could not be resolved.
You are seeing this error because either:
@@ -161,7 +161,7 @@ You are seeing this error because either:
/Fix the upstream dependency conflict, or retry.*\s.*this command with --force, or --legacy-peer-deps/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserFacingError(
return new UserError(
'EAS_BUILD_NPM_CONFLICTING_PEER_DEPENDENCIES',
`Some of your peer dependencies are not compatible. The recommended approach is to fix your dependencies by resolving any conflicts listed by "npm install". As a temporary workaround you can:
- Add ".npmrc" file with "legacy-peer-deps=true" and commit that to your repo.
@@ -179,7 +179,7 @@ You are seeing this error because either:
regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserFacingError(
return new UserError(
'EAS_BUILD_YARN_LOCK_CHECKSUM_ERROR',
`Checksum for package "${matchResult[1]}" does not match value in registry. To fix that:
- run "yarn cache clean"
@@ -203,7 +203,7 @@ You are seeing this error because either:
/\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages...\s*\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages.../,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult) {
return new UserFacingError(
return new UserError(
'EAS_BUILD_YARN_MULTIPLE_INSTANCES_ERROR',
`One of project dependencies is starting new install process while the main one is still in progress, which might result in corrupted packages. Most likely the reason for error is "prepare" script in git-referenced dependency of your project. Learn more: https://github.com/yarnpkg/yarn/issues/7212#issuecomment-493720324`
);
@@ -230,7 +230,7 @@ You are seeing this error because either:
regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/,
createError: (_, { job }) =>
'type' in job && job.type === Workflow.MANAGED
? new UserFacingError(
? new UserError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
@@ -239,7 +239,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: new UserFacingError(
: new UserError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
@@ -254,7 +254,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
phase: BuildPhase.RUN_GRADLEW,
regexp: /.*/,
createError: () =>
new UserFacingError(
new UserError(
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
),
@@ -265,7 +265,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
mode: BuildMode.RESIGN,
regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR',
`The bundle identifier in provisioning profile used to resign the app does not match the bundle identifier of the app selected to be resigned. See logs above for more information.`
),
@@ -276,7 +276,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
mode: BuildMode.RESIGN,
regexp: /.*/,
createError: () =>
new UserFacingError(
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
`The "Run fastlane" step failed with an unknown error.`
),
@@ -297,7 +297,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
regexp: /error: .+/g,
createError: matchResult =>
new UserFacingError(
new UserError(
'XCODE_BUILD_ERROR',
`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:\n${matchResult
.map(match => `- ${match.replace('error: ', '')}`)
@@ -309,7 +309,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
phase: BuildPhase.RUN_FASTLANE,
regexp: /.*/,
createError: () =>
new UserFacingError(
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
`The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.`
),
@@ -1,10 +1,12 @@
import { BuildJob } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { vol } from 'memfs';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -74,4 +76,60 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runCustomBuildAsync(ctx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
drainSpy.mockRestore();
});
it('awaits drainPendingMetricUploads even when workflow throws', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
const executeSpy = jest
.spyOn(BuildWorkflow.prototype, 'executeAsync')
.mockRejectedValue(new Error('workflow failed'));
let rejected = false;
const resultPromise = runCustomBuildAsync(ctx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('workflow failed');
expect(rejected).toBe(true);
drainSpy.mockRestore();
executeSpy.mockRestore();
});
});
@@ -94,6 +94,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
// do nothing, it's a non-breaking error.
}
}
await customBuildCtx.drainPendingMetricUploads();
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
+28 -2
View File
@@ -16,8 +16,13 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
patchPodsXcodeprojAsync,
restoreCcacheAsync,
restoreXcodeCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveXcodeCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -51,6 +56,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let xcodeCacheHit = false;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -87,10 +93,23 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
xcodeCacheHit = await restoreXcodeCacheAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
simulator: ctx.job.simulator,
});
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
await runInstallPodsAsync(ctx);
await patchPodsXcodeprojAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
cacheHit: xcodeCacheHit,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -188,6 +207,13 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
ctx.logger.info('Local builds do not support saving cache.');
return;
}
await saveXcodeCacheAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
simulator: ctx.job.simulator,
});
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
@@ -24,7 +24,8 @@ export async function installDependenciesAsync({
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
break;
}
case PackageManager.PNPM: {
@@ -44,7 +45,12 @@ export async function installDependenciesAsync({
];
}
} else {
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
}
break;
}
+10 -4
View File
@@ -1,7 +1,13 @@
import { ExpoConfig } from '@expo/config';
import { BuildJob, BuildPhase, Ios, Job, Platform } from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
BuildJob,
BuildPhase,
BuildTrigger,
Ios,
Job,
Platform,
UserError,
} from '@expo/eas-build-job';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
@@ -281,7 +287,7 @@ async function validateAppConfigAsync(
extraMessage =
'If you are using environment variables to switch between projects in app.config.js/app.config.ts, make sure those variables are also set inside EAS Build. You can do that using "env" field in eas.json or EAS environment variables. ';
}
throw new UserFacingError(
throw new UserError(
'EAS_BUILD_PROJECT_ID_MISMATCH',
`The value of the "extra.eas.projectId" field (${appConfig.extra.eas.projectId}) in the app config does not match the current project id (${ctx.env.EAS_BUILD_PROJECT_ID}). ${extraMessage}Learn more: https://expo.fyi/eas-config-mismatch.`
);
+4 -1
View File
@@ -67,6 +67,7 @@ export interface BuildContextOptions {
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
}
export class SkipNativeBuildError extends Error {}
@@ -82,6 +83,7 @@ export class BuildContext<TJob extends Job = Job> {
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -110,6 +112,7 @@ export class BuildContext<TJob extends Job = Job> {
this._job = job;
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
@@ -299,7 +302,7 @@ export class BuildContext<TJob extends Job = Job> {
// leaving message empty, website will display err.stack which already includes err.message
this.logger.error({ err }, '');
} else {
this.logger.error(`Error: ${buildError.userFacingMessage}`);
this.logger.error(`Error: ${buildError.message}`);
}
return buildError;
}
+27 -1
View File
@@ -9,12 +9,13 @@ import {
StaticJobInterpolationContext,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
import { ArtifactToUpload, BuildContext } from './context';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
[Platform.ANDROID]: BuildRuntimePlatform.LINUX,
@@ -60,6 +61,8 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
this._env = buildCtx.env;
@@ -75,6 +78,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
@@ -142,4 +146,26 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
};
this.metadata = metadata;
}
public reportStepMetric(metric: StepMetric): void {
const workflowJobId = this._env.__WORKFLOW_JOB_ID;
const robotAccessToken = this.job.secrets?.robotAccessToken;
if (!workflowJobId || !robotAccessToken || !this.expoApiV2BaseUrl) {
return;
}
const p = uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl: this.expoApiV2BaseUrl,
stepMetrics: [metric],
logger: this.logger,
});
this.pendingMetricUploads.push(p);
}
public async drainPendingMetricUploads(): Promise<void> {
await Promise.allSettled(this.pendingMetricUploads);
}
}
+17 -18
View File
@@ -2,6 +2,7 @@ import { BuildPhase, Generic } from '@expo/eas-build-job';
import { Result, asyncResult } from '@expo/results';
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
import { prepareProjectSourcesAsync } from './common/projectSources';
import { BuildContext } from './context';
@@ -10,12 +11,17 @@ import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
import { retryAsync } from './utils/retry';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
export async function runGenericJobAsync(
ctx: BuildContext<Generic.Job>,
{ expoApiV2BaseUrl }: { expoApiV2BaseUrl: string }
ctx: BuildContext<Generic.Job>
): Promise<{ runResult: Result<void>; buildWorkflow: BuildWorkflow }> {
// expoApiV2BaseUrl is empty when running a local build, but generic jobs
// are never executed locally, so it is always present here.
const expoApiV2BaseUrl = nullthrows(
ctx.expoApiV2BaseUrl,
'expoApiV2BaseUrl is required for generic jobs'
);
const customBuildCtx = new CustomBuildContext(ctx);
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
@@ -60,22 +66,15 @@ export async function runGenericJobAsync(
const runResult = await asyncResult(workflow.executeAsync());
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
await uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
expoApiV2BaseUrl,
});
const workflowJobId = globalContext.env.__WORKFLOW_JOB_ID;
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
if (workflowJobId && robotAccessToken) {
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: globalContext.stepMetrics,
const results = await Promise.allSettled([
uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
});
expoApiV2BaseUrl,
}),
customBuildCtx.drainPendingMetricUploads(),
]);
if (results[0].status === 'rejected') {
throw results[0].reason;
}
});
@@ -19,6 +19,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains/login.keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -42,6 +44,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -67,6 +71,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -94,6 +100,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -57,6 +57,7 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
buildConfiguration: 'Release',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: true,
logsDirectory: '/tmp/logs',
@@ -93,6 +94,7 @@ describe('gymfile', () => {
outputFile,
credentials: mockCredentials,
scheme: 'TestScheme',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: false,
logsDirectory: '/tmp/logs',
@@ -130,6 +132,7 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
buildConfiguration: 'Release',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: true,
logsDirectory: '/tmp/logs',
@@ -191,6 +194,7 @@ describe('gymfile', () => {
outputFile,
credentials: mockCredentials,
scheme: 'MyApp',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: true,
logsDirectory: '/tmp/logs',
+1
View File
@@ -141,6 +141,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
credentials: nullthrows(credentials, 'credentials must exist for non-simulator builds'),
scheme,
buildConfiguration,
derivedDataPath: './build',
outputDirectory: './build',
clean: false,
logsDirectory,
+3
View File
@@ -10,6 +10,7 @@ interface ArchiveBuildOptions {
credentials: Credentials;
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
outputDirectory: string;
clean: boolean;
logsDirectory: string;
@@ -32,6 +33,7 @@ export async function createGymfileForArchiveBuild({
credentials,
scheme,
buildConfiguration,
derivedDataPath,
entitlements,
outputDirectory,
logsDirectory,
@@ -58,6 +60,7 @@ export async function createGymfileForArchiveBuild({
KEYCHAIN_PATH: credentials.keychainPath,
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
DERIVED_DATA_PATH: derivedDataPath,
OUTPUT_DIRECTORY: outputDirectory,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
@@ -74,6 +74,8 @@ function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
@@ -106,6 +108,11 @@ function createStepsForIosSimulatorBuild({
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
calculateEASUpdateRuntimeVersion,
installPods,
createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: Platform.IOS,
},
}),
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
@@ -122,6 +129,11 @@ function createStepsForIosSimulatorBuild({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: Platform.IOS,
},
}),
];
}
@@ -195,9 +207,9 @@ function createStepsForIosBuildWithCredentials({
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
restoreCache,
calculateEASUpdateRuntimeVersion,
installPods,
restoreCache,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
@@ -1,11 +1,14 @@
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', () => ({
@@ -13,11 +16,22 @@ jest.mock('@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);
@@ -28,8 +42,21 @@ describe(createInternalEasMaestroTestFunction, () => {
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,
]);
@@ -39,6 +66,18 @@ describe(createInternalEasMaestroTestFunction, () => {
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']);
});
@@ -95,4 +134,77 @@ describe(createInternalEasMaestroTestFunction, () => {
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('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,
})
);
});
});
@@ -0,0 +1,177 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../../utils/retry';
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
AndroidEmulatorUtils: {
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
getAvailableDevicesAsync: jest.fn(),
createAsync: jest.fn(),
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
}
function createStartResult(serialId: string) {
return {
serialId: serialId as any,
emulatorPromise: Promise.resolve({}) as any,
} as any;
}
describe(createStartAndroidEmulatorBuildFunction, () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
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;
});
});
it('retries base emulator startup with increasing readiness timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-1111'))
.mockResolvedValueOnce(createStartResult('emulator-2222'));
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
await createStep().executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-1111',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-2222',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
})
);
});
it('retries clone startup independently and cleans up failed clone attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-base'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
mockedAndroidUtils.waitForReadyAsync
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('clone network unavailable'))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
await createStep({ count: 2 }).executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
4,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-1',
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
})
);
});
it('fails after exhausting all startup attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
});
@@ -1,9 +1,19 @@
import { isClosedVersionTrainError } from '../uploadToAsc';
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '90062' }])
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
@@ -17,3 +27,71 @@ describe(isClosedVersionTrainError, () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -56,7 +56,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const interpolationContext = stepsCtx.global.getInterpolationContext();
if (!('workflow' in interpolationContext)) {
throw new UserFacingError(
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
'No workflow found in the interpolation context.'
);
@@ -64,7 +64,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
throw new UserFacingError(
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
'No robot access token found in the job secrets.'
);
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -134,7 +134,7 @@ export async function downloadBuildAsync({
});
if (matchingFiles.length === 0) {
throw new UserFacingError(
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES',
`No ${extensions.map(ext => `.${ext}`).join(', ')} entries found in the archive.`
);
@@ -24,6 +24,7 @@ import {
} 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 {
@@ -413,6 +414,9 @@ const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
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,
@@ -434,7 +438,7 @@ async function withCleanDeviceAsync<TResult>({
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
@@ -458,28 +462,81 @@ async function withCleanDeviceAsync<TResult>({
break;
}
case 'android': {
logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(`Starting Android Emulator ${localDeviceName}...`);
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
localDeviceIdentifier = serialId;
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 }));
@@ -510,14 +567,14 @@ async function withCleanDeviceAsync<TResult>({
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -51,10 +51,7 @@ export function createReadIpaInfoBuildFunction(): BuildFunction {
const ipaPathInput = z.string().parse(inputs.ipa_path.value);
const ipaPath = path.resolve(stepCtx.workingDirectory, ipaPathInput);
if (!(await fs.pathExists(ipaPath))) {
throw new UserFacingError(
'EAS_READ_IPA_INFO_FILE_NOT_FOUND',
`IPA file not found: ${ipaPath}`
);
throw new UserError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
}
const ipaInfo = await readIpaInfoAsync(ipaPath);
@@ -72,7 +69,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
const bundleIdentifier = infoPlist.CFBundleIdentifier;
if (typeof bundleIdentifier !== 'string') {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist'
);
@@ -80,7 +77,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
const bundleShortVersion = infoPlist.CFBundleShortVersionString;
if (typeof bundleShortVersion !== 'string') {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist'
);
@@ -88,7 +85,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
const bundleVersion = infoPlist.CFBundleVersion;
if (typeof bundleVersion !== 'string') {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist'
);
@@ -100,11 +97,11 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
bundleVersion,
};
} catch (error) {
if (error instanceof UserFacingError) {
if (error instanceof UserError) {
throw error;
}
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_FAILED',
`Failed to read IPA info: ${(error as Error).message}`,
{ cause: error }
@@ -118,10 +115,7 @@ function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
const parsedBinaryPlists = bplistParser.parseBuffer(data);
const parsedBinaryPlist = parsedBinaryPlists[0];
if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_BINARY_PLIST',
'Invalid binary plist in IPA'
);
throw new UserError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
}
return parsedBinaryPlist as Record<string, unknown>;
}
@@ -135,7 +129,7 @@ async function readInfoPlistBufferFromIpaAsync(ipaPath: string): Promise<Buffer>
const entries = Object.values(await zip.entries());
const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
if (!infoPlistEntry) {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND',
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
);
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Ios, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,10 @@ import {
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs';
import nullthrows from 'nullthrows';
import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -17,6 +20,11 @@ import {
getCcachePath,
} from '../../utils/cacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
import { XCODE_CACHE_KEY_PREFIX, generateXcodeCacheKeyAsync } from '../../utils/xcodeCacheKey';
// Stable path outside DerivedData where cached pod products are restored.
// xcodebuild archive wipes ArchiveIntermediates, so products must live elsewhere.
export const PODS_CACHE_DIR = '/tmp/pods-cache';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -50,6 +58,23 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.IOS) {
const simulator = (stepCtx.global.staticContext.job as Ios.Job).simulator;
const cacheHit = await restoreXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
simulator,
});
await patchPodsXcodeprojAsync({
logger,
workingDirectory,
env,
cacheHit,
});
}
},
});
}
@@ -176,6 +201,191 @@ export async function restoreCcacheAsync({
}
}
export async function restoreXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets,
simulator,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
simulator?: boolean;
}): Promise<boolean> {
if (env.EAS_XCODE_CACHE !== '1') {
return false;
}
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
try {
const cacheKey = await generateXcodeCacheKeyAsync(workingDirectory, simulator);
logger.info(`Restoring Xcode cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: ['pods-products-v2'],
key: cacheKey,
keyPrefixes: [`${XCODE_CACHE_KEY_PREFIX}${simulator ? 'sim' : 'device'}-`],
platform: Platform.IOS,
});
await fs.promises.mkdir(PODS_CACHE_DIR, { recursive: true });
await spawn('tar', ['xzf', archivePath, '-C', PODS_CACHE_DIR]);
// Modulemap files contain hardcoded absolute paths from the original build.
// Rewrite them to point to the cache directory instead.
await rewriteModulemapPathsAsync(PODS_CACHE_DIR, logger);
logger.info(
`Xcode cache restored to ${PODS_CACHE_DIR} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
);
return true;
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Xcode cache found for this key');
} else {
logger.warn('Failed to restore Xcode cache: ', err);
}
return false;
}
}
async function rewriteModulemapPathsAsync(cacheDir: string, logger: bunyan): Promise<void> {
let modulemapFiles: string;
try {
const result = await spawn('find', [cacheDir, '-name', '*.modulemap'], { stdio: 'pipe' });
modulemapFiles = result.stdout.trim();
} catch {
return;
}
if (!modulemapFiles) {
return;
}
let patchCount = 0;
for (const filePath of modulemapFiles.split('\n')) {
if (!filePath) continue;
const content = await fs.promises.readFile(filePath, 'utf-8');
// Match absolute paths ending with /Release-<platform>/ (iphoneos, iphonesimulator, etc.)
const replaced = content.replace(/"[^"]*\/Release-[a-z]+\//g, `"${cacheDir}/`);
if (replaced !== content) {
await fs.promises.writeFile(filePath, replaced);
patchCount++;
}
}
logger.info(`Rewrote paths in ${patchCount} modulemap files`);
}
const PATCH_RUBY_SCRIPT = `
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/Pods/Pods.xcodeproj')
umbrella_targets = project.native_targets.select { |t| t.name.start_with?('Pods-') }
abort "ERROR: No Pods-* umbrella targets found!" if umbrella_targets.empty?
umbrella_names = umbrella_targets.map(&:name)
puts "Keeping umbrella targets: #{umbrella_names.join(', ')}"
pod_targets = project.native_targets.reject { |t| t.name.start_with?('Pods-') }
puts "Found #{pod_targets.size} pod targets to remove"
umbrella_targets.each do |umbrella|
deps = umbrella.dependencies.to_a
deps.each { |dep| dep.remove_from_project }
puts "Cleared #{deps.size} dependencies from #{umbrella.name}"
end
pod_targets.each do |target|
target.remove_from_project
end
project.save
puts "Saved — #{project.native_targets.size} target(s) remaining"
`;
export async function patchPodsXcodeprojAsync({
logger,
workingDirectory,
env,
cacheHit,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
cacheHit: boolean;
}): Promise<void> {
if (env.EAS_XCODE_CACHE !== '1' || !cacheHit) {
return;
}
logger.info('Patching Pods.xcodeproj to remove pod targets...');
await spawnAsync('ruby', ['-e', PATCH_RUBY_SCRIPT], {
cwd: workingDirectory,
logger,
env,
stdio: 'pipe',
});
logger.info('Pods.xcodeproj patched successfully');
// Point PODS_CONFIGURATION_BUILD_DIR to the stable cache directory
// so Xcode finds pre-built products there instead of in DerivedData
await patchPodXcconfigAsync(workingDirectory, logger);
logger.info('Pods xcconfig patched successfully');
}
async function patchPodXcconfigAsync(workingDirectory: string, logger: bunyan): Promise<void> {
const targetSupportDir = path.join(workingDirectory, 'ios', 'Pods', 'Target Support Files');
let patchCount = 0;
const entries = await fs.promises.readdir(targetSupportDir);
for (const entry of entries) {
if (!entry.startsWith('Pods-')) {
continue;
}
const dir = path.join(targetSupportDir, entry);
const stat = await fs.promises.stat(dir);
if (!stat.isDirectory()) {
continue;
}
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (!file.endsWith('.xcconfig')) {
continue;
}
const filePath = path.join(dir, file);
let content = await fs.promises.readFile(filePath, 'utf-8');
if (content.includes('PODS_CONFIGURATION_BUILD_DIR')) {
content = content.replace(
/PODS_CONFIGURATION_BUILD_DIR\s*=\s*.*/g,
`PODS_CONFIGURATION_BUILD_DIR = ${PODS_CACHE_DIR}`
);
await fs.promises.writeFile(filePath, content);
patchCount++;
logger.info(`Patched ${filePath}`);
}
}
}
logger.info(`Patched ${patchCount} xcconfig files`);
}
export async function cacheStatsAsync({
logger,
env,
@@ -58,11 +58,6 @@ export function createRestoreCacheFunction(): BuildFunction {
const { logger } = stepsCtx;
try {
if (stepsCtx.global.staticContext.job.platform) {
logger.error('Caches are not supported in build jobs yet.');
return;
}
const paths = z
.array(z.string())
.parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/))
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Ios, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -9,9 +9,11 @@ import {
} from '@expo/steps';
import fs from 'fs';
import nullthrows from 'nullthrows';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateXcodeCacheKeyAsync } from '../../utils/xcodeCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
return new BuildFunction({
@@ -38,6 +40,17 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
if (platform === Platform.IOS) {
const simulator = (stepCtx.global.staticContext.job as Ios.Job).simulator;
await saveXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
simulator,
});
}
await saveCcacheAsync({
logger,
workingDirectory,
@@ -135,3 +148,121 @@ export async function saveCcacheAsync({
logger.error({ err }, 'Failed to save cache');
}
}
export async function saveXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets,
simulator,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
simulator?: boolean;
}): Promise<void> {
if (env.EAS_XCODE_CACHE !== '1') {
return;
}
const productsPath = await findBuildProductsPathAsync(workingDirectory, logger);
if (!productsPath) {
logger.warn('No build products found, skipping Xcode cache save');
return;
}
logger.info(`Found build products at: ${productsPath}`);
const cachePaths = ['pods-products-v2'];
try {
const cacheKey = await generateXcodeCacheKeyAsync(workingDirectory, simulator);
logger.info(`Saving Xcode cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing Xcode build products...');
const { archivePath } = await compressCacheAsync({
paths: ['.'],
workingDirectory: productsPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const { size } = await fs.promises.stat(archivePath);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key: cacheKey,
paths: cachePaths,
size,
platform: Platform.IOS,
});
logger.info('Xcode cache saved successfully');
} catch (err) {
logger.error({ err }, 'Failed to save Xcode cache');
}
}
async function findBuildProductsPathAsync(
workingDirectory: string,
logger: bunyan
): Promise<string | null> {
const iosBuildDir = path.join(workingDirectory, 'ios', 'build');
// Archive build: Build/Intermediates.noindex/ArchiveIntermediates/<AppName>/BuildProductsPath/Release-iphoneos/
const archiveIntermediatesDir = path.join(
iosBuildDir,
'Build',
'Intermediates.noindex',
'ArchiveIntermediates'
);
try {
const entries = await fs.promises.readdir(archiveIntermediatesDir);
for (const entry of entries) {
const buildProductsPath = path.join(archiveIntermediatesDir, entry, 'BuildProductsPath');
try {
const configs = await fs.promises.readdir(buildProductsPath);
const releaseDir = configs.find(c => c.startsWith('Release-'));
if (releaseDir) {
const fullPath = path.join(buildProductsPath, releaseDir);
logger.info(`Found archive build products: ${fullPath}`);
return fullPath;
}
} catch {
// No BuildProductsPath in this entry
}
}
} catch {
// No ArchiveIntermediates directory
}
// Simulator build: Build/Products/Release-iphonesimulator/
const productsDir = path.join(iosBuildDir, 'Build', 'Products');
try {
const configs = await fs.promises.readdir(productsDir);
const releaseDir = configs.find(c => c.startsWith('Release-'));
if (releaseDir) {
const fullPath = path.join(productsDir, releaseDir);
logger.info(`Found simulator build products: ${fullPath}`);
return fullPath;
}
} catch {
// No Products directory
}
logger.info('No build products found');
return null;
}
@@ -9,6 +9,9 @@ import {
} from '../../utils/AndroidEmulatorUtils';
import { retryAsync } from '../../utils/retry';
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -72,25 +75,78 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
}
);
logger.info('Creating emulator device');
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
let emulatorPromise = null;
let serialId = null;
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let attemptSerialId = null;
logger.info('Starting emulator device');
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId,
});
logger.info(`${deviceName} is ready.`);
try {
logger.info(`Creating emulator device${attemptSuffix}.`);
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage,
deviceIdentifier: deviceIdentifier ?? null,
env,
logger,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env,
});
attemptSerialId = startResult.serialId;
await AndroidEmulatorUtils.waitForReadyAsync({
env,
serialId: attemptSerialId,
timeoutMs,
logger,
});
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
emulatorPromise = startResult.emulatorPromise;
} catch (err) {
logger.warn(
{ err },
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (attemptSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: attemptSerialId,
deviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
if (!serialId || !emulatorPromise) {
throw new Error(`Failed to start emulator ${deviceName}.`);
}
const count = Number(inputs.count.value ?? 1);
if (count > 1) {
@@ -105,27 +161,70 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
for (let i = 0; i < count; i++) {
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
let cloneSerialId = null;
try {
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: deviceName,
destinationDeviceName: cloneIdentifier,
env,
logger,
});
logger.info('Starting emulator device');
const { serialId } = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
logger.info(`Starting emulator device${attemptSuffix}.`);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: cloneIdentifier,
env,
});
cloneSerialId = startResult.serialId;
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
});
logger.info('Waiting for emulator to become ready');
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: cloneSerialId,
env,
timeoutMs,
logger,
});
logger.info(`${cloneIdentifier} is ready.`);
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
logger.warn(
{ err },
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
);
try {
if (cloneSerialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId: cloneSerialId,
deviceName: cloneIdentifier,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: cloneIdentifier,
env,
});
}
} catch (cleanupErr) {
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
}
}
},
@@ -1,4 +1,5 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
BuildStepInput,
@@ -14,6 +15,7 @@ import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -103,10 +105,13 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const client = new AscApiClient({ token, logger: stepsCtx.logger });
stepsCtx.logger.info('Reading App information...');
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
);
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
stepsCtx.logger.info(
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
);
stepsCtx.logger.info('Creating Build Upload...');
@@ -261,11 +266,48 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} version train is closed. ` +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version and submit again.'
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
@@ -284,10 +326,34 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90062', '90186'].includes(message.code))
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import {
AscApiClient,
@@ -39,7 +39,7 @@ export namespace AscApiUtils {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
@@ -47,8 +47,8 @@ export namespace AscApiUtils {
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
: ''),
{
cause: error,
docsUrl: 'https://expo.fyi/asc-app-id',
cause: error,
}
);
}
@@ -94,14 +94,14 @@ export namespace AscApiUtils {
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
if (isDuplicateVersionError) {
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.',
{
cause: error,
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: error,
}
);
}
@@ -24,7 +24,7 @@ describe('AscApiUtils', () => {
).resolves.toEqual(response);
});
it('throws UserFacingError with visible apps when app id is not found', async () => {
it('throws UserError with visible apps when app id is not found', async () => {
const notFoundPayload = {
errors: [
{
@@ -97,7 +97,7 @@ describe('AscApiUtils', () => {
});
describe('createBuildUploadAsync', () => {
it('throws UserFacingError when ASC duplicate version error is returned', async () => {
it('throws UserError when ASC duplicate version error is returned', async () => {
const payload = {
errors: [
{
@@ -17,6 +17,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
derived_data_path("<%- DERIVED_DATA_PATH %>")
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
@@ -1,4 +1,5 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import FastGlob from 'fast-glob';
@@ -17,6 +18,8 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
export namespace AndroidEmulatorUtils {
const RETRY_INTERVAL_MS = 1_000;
export const defaultSystemImagePackage = `system-images;android-30;default;${
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
}`;
@@ -352,10 +355,15 @@ export namespace AndroidEmulatorUtils {
export async function waitForReadyAsync({
serialId,
env,
timeoutMs = 3 * 60 * 1_000,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
timeoutMs?: number;
logger?: bunyan;
}): Promise<void> {
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
await retryAsync(
async () => {
const { stdout } = await spawn(
@@ -367,17 +375,48 @@ export namespace AndroidEmulatorUtils {
if (!stdout.startsWith('1')) {
throw new Error(`Emulator (${serialId}) boot has not completed.`);
}
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
if (!hasNetworkConnection) {
throw new Error(`Emulator (${serialId}) network is not ready.`);
}
},
{
// Retry every second for 3 minutes.
retryOptions: {
retries: 3 * 60,
retryIntervalMs: 1_000,
retries,
retryIntervalMs: RETRY_INTERVAL_MS,
},
logger,
}
);
}
async function hasNetworkConnectionAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<boolean> {
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
if (networkReadyCheckCommand) {
const customNetworkCheckResult = await asyncResult(
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
);
return customNetworkCheckResult.ok;
}
const netcatResult = await asyncResult(
spawn(
'adb',
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
)
);
return netcatResult.ok;
}
export async function collectLogsAsync({
serialId,
env,
@@ -419,18 +458,13 @@ export namespace AndroidEmulatorUtils {
return { outputPath };
}
export async function deleteAsync({
export async function stopAsync({
serialId,
env,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
await retryAsync(
@@ -443,12 +477,40 @@ export namespace AndroidEmulatorUtils {
{
retryOptions: {
retries: 3 * 60,
retryIntervalMs: 1_000,
retryIntervalMs: RETRY_INTERVAL_MS,
},
}
);
}
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
export async function deleteAsync({
serialId,
deviceName,
env,
}: {
serialId?: AndroidDeviceSerialId;
deviceName?: AndroidVirtualDeviceName;
env: NodeJS.ProcessEnv;
}): Promise<void> {
let resolvedDeviceName = deviceName;
if (!resolvedDeviceName && serialId) {
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
env,
});
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
| AndroidVirtualDeviceName
| undefined;
}
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
const serialIdToStop =
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
if (serialIdToStop) {
await stopAsync({ serialId: serialIdToStop, env });
}
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
}
export async function startScreenRecordingAsync({
@@ -9,6 +9,7 @@ import {
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
// We need to use real fs for cloning devices to work.
jest.unmock('fs');
@@ -104,7 +105,10 @@ describe('AndroidEmulatorUtils', () => {
});
expect(stdout).toContain('data');
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
await AndroidEmulatorUtils.stopAsync({
serialId,
env: process.env,
});
await asyncResult(emulatorPromise);
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
@@ -131,12 +135,133 @@ describe('AndroidEmulatorUtils', () => {
expect(stdoutClone).toContain('data');
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await asyncResult(emulatorPromiseClone);
}, 60_000);
it('should delete a running emulator by device name', async () => {
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: process.env,
logger: createMockLogger({ logToConsole: true }),
});
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
deviceName,
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
});
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: process.env,
});
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
await asyncResult(emulatorPromise);
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
expect(devices.map(device => device.serialId)).not.toContain(serialId);
await expect(fs.promises.access(avdPath)).rejects.toThrow();
}, 60_000);
it('should retry when first startup forces network check command failure', async () => {
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
let attemptCounter = 0;
let sawNetworkNotReadyError = false;
await retryAsync(
async attemptCount => {
attemptCounter += 1;
const shouldFailNetworkCheck = attemptCount === 0;
const envForAttempt: NodeJS.ProcessEnv = {
...process.env,
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
? 'echo "network not ready" && exit 1'
: 'echo "network ready"',
};
let serialId: AndroidDeviceSerialId | null = null;
let emulatorPromise: Promise<unknown> | null = null;
try {
await AndroidEmulatorUtils.createAsync({
deviceName,
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
deviceIdentifier: null,
env: envForAttempt,
logger: createMockLogger({ logToConsole: true }),
});
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName,
env: envForAttempt,
});
serialId = startResult.serialId;
emulatorPromise = asyncResult(startResult.emulatorPromise);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env: envForAttempt,
timeoutMs: 30_000,
logger: createMockLogger({ logToConsole: true }),
});
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
await emulatorPromise;
} catch (err: any) {
if (err?.message?.includes('network is not ready')) {
sawNetworkNotReadyError = true;
}
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName,
env: process.env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName,
env: process.env,
});
}
} catch (cleanupError) {
console.warn('Cleanup failed during retry test', cleanupError);
}
if (emulatorPromise) {
await emulatorPromise;
}
throw err;
}
},
{
logger: createMockLogger({ logToConsole: true }),
retryOptions: {
retries: 1,
retryIntervalMs: 1_000,
},
}
);
expect(sawNetworkNotReadyError).toBe(true);
expect(attemptCounter).toBe(2);
}, 360_000);
it('should work with screen recording', async () => {
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
await AndroidEmulatorUtils.createAsync({
@@ -0,0 +1,161 @@
import spawn from '@expo/turtle-spawn';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../retry', () => ({
retryAsync: jest.fn(async fn => await fn(0)),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
describe('AndroidEmulatorUtils', () => {
beforeEach(() => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedRetryAsync.mockImplementation(async fn => await fn(0));
});
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
timeoutMs: 60_000,
});
expect(mockedRetryAsync).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
retryOptions: expect.objectContaining({
retries: 59,
retryIntervalMs: 1_000,
}),
})
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
});
it('throws when network is unavailable despite completed boot', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'nc') {
throw new Error('network unreachable');
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await expect(
AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: process.env,
})
).rejects.toThrow('network is not ready');
});
it('uses overridden network readiness command when provided', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[3] === 'getprop') {
return { stdout: '1\n', stderr: '' } as any;
}
if (args[3] === 'exit 0') {
return { stdout: '', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId: 'emulator-5554' as any,
env: {
...process.env,
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
},
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'exit 0'],
expect.objectContaining({
env: expect.objectContaining({
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
}),
})
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
expect.anything()
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
if (args[2] === 'emu' && args[3] === 'kill') {
return { stdout: '', stderr: '' } as any;
}
if (args[0] === 'devices') {
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
}
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
}) as any);
await AndroidEmulatorUtils.stopAsync({
serialId: 'emulator-5554' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
env: process.env,
});
});
});
describe(AndroidEmulatorUtils.deleteAsync, () => {
it('stops by serial id and then deletes the AVD', async () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await AndroidEmulatorUtils.deleteAsync({
serialId: 'emulator-5554' as any,
deviceName: 'eas-simulator-1' as any,
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
env: process.env,
});
expect(mockedSpawn).toHaveBeenCalledWith(
'avdmanager',
['delete', 'avd', '-n', 'eas-simulator-1'],
{
env: process.env,
}
);
});
});
});
@@ -1,10 +1,13 @@
import { StepMetricsCollection } from '@expo/steps';
import { StepMetric } from '@expo/steps';
import { randomUUID } from 'crypto';
import { uploadStepMetricsToWwwAsync } from '../stepMetrics';
import { turtleFetch } from '../turtleFetch';
jest.mock('../turtleFetch');
jest.mock('../turtleFetch', () => ({
...jest.requireActual('../turtleFetch'),
turtleFetch: jest.fn(),
}));
const mockTurtleFetch = turtleFetch as jest.MockedFunction<typeof turtleFetch>;
describe(uploadStepMetricsToWwwAsync, () => {
@@ -23,44 +26,52 @@ describe(uploadStepMetricsToWwwAsync, () => {
jest.clearAllMocks();
});
it('uploads step metrics to www', async () => {
const stepMetrics: StepMetricsCollection = [
{ metricsId: 'eas/checkout', result: 'success', durationMs: 1000, platform: 'linux' },
{
metricsId: 'eas/install_node_modules',
result: 'failed',
durationMs: 5000,
platform: 'linux',
},
];
it('uploads step metrics', async () => {
const stepMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'success',
durationMs: 1000,
platform: 'linux',
};
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics,
stepMetrics: [stepMetric],
logger: mockLogger,
});
expect(mockTurtleFetch).toHaveBeenCalledTimes(1);
expect(mockTurtleFetch).toHaveBeenCalledWith(
`${expoApiV2BaseUrl}workflows/${workflowJobId}/metrics`,
'POST',
expect.objectContaining({
json: { stepMetrics },
json: { stepMetrics: [stepMetric] },
headers: { Authorization: `Bearer ${robotAccessToken}` },
retries: 2,
})
);
});
it('does nothing when stepMetrics is empty', async () => {
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [],
logger: mockLogger,
});
it('silently gives up on failure', async () => {
mockTurtleFetch.mockRejectedValue(new Error('persistent failure'));
expect(mockTurtleFetch).not.toHaveBeenCalled();
const stepMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'failed',
durationMs: 500,
platform: 'darwin',
};
await expect(
uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [stepMetric],
logger: mockLogger,
})
).resolves.not.toThrow();
});
});
+6 -13
View File
@@ -1,5 +1,5 @@
import { bunyan } from '@expo/logger';
import { StepMetricsCollection } from '@expo/steps';
import { StepMetric } from '@expo/steps';
import { turtleFetch } from './turtleFetch';
@@ -13,29 +13,22 @@ export async function uploadStepMetricsToWwwAsync({
workflowJobId: string;
robotAccessToken: string;
expoApiV2BaseUrl: string;
stepMetrics: StepMetricsCollection;
stepMetrics: StepMetric[];
logger: bunyan;
}): Promise<void> {
if (stepMetrics.length === 0) {
logger.debug('No step metrics to upload');
return;
}
try {
await turtleFetch(
new URL(`workflows/${workflowJobId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { stepMetrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 20000,
headers: { Authorization: `Bearer ${robotAccessToken}` },
timeout: 5000,
retries: 2,
logger,
}
);
logger.info(`Uploaded ${stepMetrics.length} step metrics`);
} catch {
// Don't display unactionable error to the user, let's send it to Sentry in the future
// Don't fail the build for metrics — silently give up
}
}
@@ -0,0 +1,23 @@
import * as PackageManagerUtils from '@expo/package-manager';
import { hashFiles } from '@expo/steps';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
export const XCODE_CACHE_KEY_PREFIX = 'ios-xcode-cache-';
export async function generateXcodeCacheKeyAsync(
workingDirectory: string,
simulator?: boolean
): Promise<string> {
const packagerRunDir = findPackagerRootDir(workingDirectory);
const manager = PackageManagerUtils.createForProject(packagerRunDir);
const lockPath = path.join(packagerRunDir, manager.lockFile);
const variant = simulator ? 'sim' : 'device';
try {
return `${XCODE_CACHE_KEY_PREFIX}${variant}-${hashFiles([lockPath])}`;
} catch (err: any) {
throw new Error(`Failed to read lockfile for Xcode cache key generation: ${err.message}`);
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.0.3",
"version": "18.2.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.0.2"
"@expo/steps": "18.2.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.0.2",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -1,30 +1,89 @@
import { UserFacingError } from '../errors';
import { BuildError, ExpoError, UserError } from '../errors';
import { BuildPhase } from '../logs';
describe(UserFacingError, () => {
class TestExpoError extends ExpoError {
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
super(message, details);
}
}
describe(ExpoError, () => {
it('stores shared metadata and formats external payload', () => {
const cause = new Error('root cause');
const error = new TestExpoError('canonical message', {
errorCode: 'ERR_CODE',
trackingCode: 'TRACKING_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@typescript-eslint/typescript-estree' },
cause,
});
expect(error.errorCode).toBe('ERR_CODE');
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({
packageName: '@typescript-eslint/typescript-estree',
});
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
expect(error.format()).toEqual({
errorCode: 'ERR_CODE',
message: 'canonical message',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
});
});
});
describe(BuildError, () => {
it('formats using canonical message and errorCode', () => {
const error = new BuildError('canonical message', {
errorCode: 'ERR_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@expo/config' },
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.metadata).toEqual({ packageName: '@expo/config' });
expect(error.format()).toEqual({
errorCode: 'ERR_CODE',
message: 'canonical message',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
});
});
});
describe(UserError, () => {
it('supports docsUrl in options', () => {
const error = new UserFacingError('ERR_CODE', 'message', {
const error = new UserError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.cause).toBeUndefined();
});
it('supports cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', { cause });
const error = new UserError('ERR_CODE', 'message', { cause });
expect(error.cause).toBe(cause);
});
it('supports docsUrl and cause in options', () => {
it('supports docsUrl, metadata, and cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', {
const error = new UserError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
metadata: { packageName: 'expo' },
cause,
});
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({ packageName: 'expo' });
expect(error.cause).toBe(cause);
});
});
+54 -31
View File
@@ -17,56 +17,83 @@ export interface ExternalBuildError {
buildPhase?: BuildPhase;
}
interface BuildErrorDetails {
export type ErrorMetadata = Record<string, unknown>;
interface ExpoErrorDetails<TMetadata extends ErrorMetadata = ErrorMetadata> {
errorCode: string;
userFacingMessage: string;
userFacingErrorCode: string;
trackingCode?: string;
docsUrl?: string;
innerError?: Error;
buildPhase?: BuildPhase;
/**
* Metadata object for the error. Used internally for Sentry/logging/debugging.
* It is not included in the external build error payload.
*/
metadata?: TMetadata;
/**
* Underlying error that caused this error to be created. Used internally to
* propagate blame stack traces to the response.
*/
cause?: unknown;
}
export class BuildError extends Error {
export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata> extends Error {
public errorCode: string;
public userFacingMessage: string;
public userFacingErrorCode: string;
// Internal-only classification used for Sentry, analytics, and worker internalErrorCode.
// The public error saved on builds and job runs is always `errorCode`.
public trackingCode?: string;
public docsUrl?: string;
public innerError?: Error;
public readonly metadata?: TMetadata;
public buildPhase?: BuildPhase;
constructor(message: string, details: BuildErrorDetails) {
super(message);
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, { cause: details.cause });
this.errorCode = details.errorCode;
this.userFacingErrorCode = details.userFacingErrorCode;
this.userFacingMessage = details.userFacingMessage;
this.trackingCode = details.trackingCode;
this.docsUrl = details.docsUrl;
this.innerError = details.innerError;
this.metadata = details.metadata;
this.buildPhase = details.buildPhase;
}
public format(): ExternalBuildError {
return {
errorCode: this.userFacingErrorCode,
message: this.userFacingMessage,
errorCode: this.errorCode,
message: this.message,
docsUrl: this.docsUrl,
buildPhase: this.buildPhase,
};
}
}
export class UserFacingError extends Error {
public docsUrl?: string;
constructor(
public errorCode: string,
public message: string,
options?: { docsUrl?: string; cause?: unknown }
) {
super(message, { cause: options?.cause });
this.docsUrl = options?.docsUrl;
export class BuildError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, details);
}
}
export class UnknownError extends UserFacingError {
export class UserError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
public errorCode: string,
public message: string,
options?: {
docsUrl?: string;
metadata?: TMetadata;
cause?: unknown;
}
) {
super(message, {
errorCode,
docsUrl: options?.docsUrl,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
export class UnknownError extends UserError {
constructor(buildPhase?: BuildPhase) {
super(
ErrorCode.UNKNOWN_ERROR,
@@ -83,8 +110,6 @@ export class UnknownBuildError extends BuildError {
const message = 'Unknown error. See logs for more information.';
super(message, {
errorCode,
userFacingMessage: message,
userFacingErrorCode: errorCode,
});
}
}
@@ -95,13 +120,11 @@ export class UnknownCustomBuildError extends BuildError {
const message = 'Unknown custom build error. See logs for more information.';
super(message, {
errorCode,
userFacingMessage: message,
userFacingErrorCode: errorCode,
});
}
}
export class CredentialsDistCertMismatchError extends UserFacingError {
export class CredentialsDistCertMismatchError extends UserError {
constructor(message: string) {
super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message);
}
+1
View File
@@ -20,6 +20,7 @@ export {
export { Metadata, sanitizeMetadata } from './metadata';
export * from './job';
export * from './logs';
export * from './errors';
export * as errors from './errors';
export * from './artifacts';
export * from './context';
+3
View File
@@ -16,6 +16,7 @@ export enum BuildPhase {
EAS_BUILD_INTERNAL = 'EAS_BUILD_INTERNAL',
PREBUILD = 'PREBUILD',
PREPARE_CREDENTIALS = 'PREPARE_CREDENTIALS',
CONFIGURE_ANDROID_VERSION = 'CONFIGURE_ANDROID_VERSION',
CALCULATE_EXPO_UPDATES_RUNTIME_VERSION = 'CALCULATE_EXPO_UPDATES_RUNTIME_VERSION',
CONFIGURE_EXPO_UPDATES = 'CONFIGURE_EXPO_UPDATES',
EAGER_BUNDLE = 'EAGER_BUNDLE',
@@ -80,6 +81,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.EAS_BUILD_INTERNAL]: 'Resolve build configuration',
[BuildPhase.PREBUILD]: 'Prebuild',
[BuildPhase.PREPARE_CREDENTIALS]: 'Prepare credentials',
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'Configure Android version',
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'Calculate expo-updates runtime version',
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'Configure expo-updates',
[BuildPhase.EAGER_BUNDLE]: 'Bundle JavaScript',
@@ -143,6 +145,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.EAS_BUILD_INTERNAL]: 'resolve-build-configuration',
[BuildPhase.PREBUILD]: 'prebuild',
[BuildPhase.PREPARE_CREDENTIALS]: 'prepare-credentials',
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'configure-android-version',
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'calculate-expo-updates-runtime-version',
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'configure-expo-updates',
[BuildPhase.EAGER_BUNDLE]: 'eager-bundle',
+511 -496
View File
File diff suppressed because it is too large. Load diff
+6 -13
View File
@@ -1,17 +1,10 @@
#!/usr/bin/env node
const oclif = require('@oclif/core');
const path = require('path');
(async () => {
const oclif = require('@oclif/core');
const project = path.join(__dirname, '..', 'tsconfig.json');
// Keep dev entrypoint failures verbose for local debugging.
oclif.settings.debug = true;
// In dev mode -> use ts-node and dev plugins
process.env.NODE_ENV = 'development';
require('ts-node').register({ project });
// In dev mode, always show stack traces
oclif.settings.disableJsonFlag = true
// Start the CLI
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
await oclif.execute({ development: true, dir: __dirname });
})();
+4 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
require('@oclif/core')
.run()
.then(require('@oclif/core/flush'))
.catch(require('@oclif/core/handle'));
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.1.0",
"version": "18.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/eas-build-job": "18.0.2",
"@expo/eas-json": "18.0.2",
"@expo/eas-build-job": "18.2.0",
"@expo/eas-json": "18.2.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.0.1",
@@ -63,10 +63,10 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.0.2",
"@expo/steps": "18.2.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^1.26.2",
"@oclif/plugin-autocomplete": "^2.3.10",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
@@ -99,11 +99,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -126,11 +122,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=ios'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=ios'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -150,11 +142,7 @@ describe(BuildVersionGetView, () => {
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
@@ -175,11 +163,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
});
@@ -218,11 +202,7 @@ describe(BuildVersionGetView, () => {
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
const cmd = mockTestCommand(BuildVersionGetView, ['--json', '--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError(
`This project is not configured for using remote version source. Add ${chalk.bold(
'{"cli": { "appVersionSource": "remote" }}'
@@ -133,11 +133,23 @@ export function mockTestCommand<T extends Command>(
argv: string[],
ctx: any
): T {
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
const config = getMockOclifConfig();
const cmd = new commandConstructor(argv, config);
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
export function getMockOclifConfig(
options: Partial<ConstructorParameters<typeof Config>[0]> = {}
): Config {
const config = new Config({ root: __dirname, ...options });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
export class NoErrorThrownError extends Error {}
export const getErrorAsync = async <TError = any>(
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { ExitError } from '@oclif/core/lib/errors';
import { Errors } from '@oclif/core';
import path from 'path';
import { instance, mock, when } from 'ts-mockito';
@@ -8,6 +8,8 @@ import { validatePNGsForManagedProjectAsync } from '../validate';
const fixturesPath = path.join(__dirname, 'fixtures');
const { ExitError } = Errors;
describe(validatePNGsForManagedProjectAsync, () => {
it('does not validate PNGs for generic projects', async () => {
const ctxMock = mock<CommonContext<Platform.ANDROID>>();
@@ -42,9 +42,9 @@ beforeEach(() => {
jest.mocked(createAnalyticsAsync).mockResolvedValue(analytics);
});
const createTestEasCommand = (): typeof EasCommand => {
const createTestEasCommand = () => {
class TestEasCommand extends EasCommand {
async runAsync(): Promise<void> {}
override async runAsync(): Promise<void> {}
}
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
@@ -109,7 +109,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const error = new Error('Unexpected, internal error message');
runAsyncMock.mockImplementation(() => {
throw error;
@@ -126,7 +126,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const graphQLErrors = ['Unexpected GraphQL error message'];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
@@ -144,7 +144,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
@@ -176,7 +176,7 @@ describe(EasCommand.name, () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
@@ -212,7 +212,7 @@ describe(EasCommand.name, () => {
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
runAsyncMock.mockImplementation(() => {
throw new Error('Error message');
});
@@ -226,7 +226,7 @@ describe(EasCommand.name, () => {
it('re-throws the error with a different default base message in case of CombinedError', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = ['Unexpected GraphQL error message'];
throw new CombinedError({ graphQLErrors });
@@ -241,7 +241,7 @@ describe(EasCommand.name, () => {
it('re-throws the error with a different default base message in case of CombinedError with multiple GraphQLErrors', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = [
new GraphQLError('Unexpected GraphQL error message', null, null, null, null, null, {
@@ -0,0 +1,45 @@
import { resolveNonInteractiveAndJsonFlags } from '../flags';
describe(resolveNonInteractiveAndJsonFlags, () => {
test('returns both false when no flags are set', () => {
expect(resolveNonInteractiveAndJsonFlags({})).toEqual({
json: false,
nonInteractive: false,
});
});
test('--json implies --non-interactive', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: true })).toEqual({
json: true,
nonInteractive: true,
});
});
test('--non-interactive alone does not enable json', () => {
expect(resolveNonInteractiveAndJsonFlags({ 'non-interactive': true })).toEqual({
json: false,
nonInteractive: true,
});
});
test('both flags explicitly set', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: true, 'non-interactive': true })).toEqual({
json: true,
nonInteractive: true,
});
});
test('both flags explicitly false', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: false, 'non-interactive': false })).toEqual({
json: false,
nonInteractive: false,
});
});
test('--json false with --non-interactive true', () => {
expect(resolveNonInteractiveAndJsonFlags({ json: false, 'non-interactive': true })).toEqual({
json: false,
nonInteractive: true,
});
});
});
@@ -37,6 +37,7 @@ describe(getProjectIdAsync, () => {
actor: {
__typename: 'User',
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
+20 -11
View File
@@ -2,14 +2,23 @@ import { Flags } from '@oclif/core';
export const EasNonInteractiveAndJsonFlags = {
json: Flags.boolean({
description: 'Enable JSON output, non-JSON messages will be printed to stderr.',
dependsOn: ['non-interactive'],
description:
'Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.',
}),
'non-interactive': Flags.boolean({
description: 'Run the command in non-interactive mode.',
}),
};
export function resolveNonInteractiveAndJsonFlags(flags: {
json?: boolean;
'non-interactive'?: boolean;
}): { json: boolean; nonInteractive: boolean } {
const json = flags.json ?? false;
const nonInteractive = flags['non-interactive'] || json;
return { json, nonInteractive };
}
export const EasEnvironmentFlagParameters = {
description: "Environment variable's environment, e.g. 'production', 'preview', 'development'",
};
@@ -28,28 +37,28 @@ export const EASMultiEnvironmentFlag = {
};
export const EASVariableFormatFlag = {
format: Flags.enum({
format: Flags.option({
description: 'Output format',
options: ['long', 'short'],
options: ['long', 'short'] as const,
default: 'short',
}),
})(),
};
export const EASVariableVisibilityFlag = {
visibility: Flags.enum<'plaintext' | 'sensitive' | 'secret'>({
visibility: Flags.option({
description: 'Visibility of the variable',
options: ['plaintext', 'sensitive', 'secret'],
}),
options: ['plaintext', 'sensitive', 'secret'] as const,
})(),
};
export type EASEnvironmentVariableScopeFlagValue = 'project' | 'account';
export const EASEnvironmentVariableScopeFlag = {
scope: Flags.enum<EASEnvironmentVariableScopeFlagValue>({
scope: Flags.option({
description: 'Scope for the variable',
options: ['project', 'account'],
options: ['project', 'account'] as const,
default: 'project',
}),
})(),
};
export const EASNonInteractiveFlag = {
@@ -1,18 +1,18 @@
import { Flags } from '@oclif/core';
import { OptionFlag } from '@oclif/core/lib/interfaces';
import { Flags, Interfaces } from '@oclif/core';
import { EasNonInteractiveAndJsonFlags } from './flags';
import { EasNonInteractiveAndJsonFlags, resolveNonInteractiveAndJsonFlags } from './flags';
export const getPaginatedQueryOptions = (
flags: Partial<
Record<keyof typeof EasPaginatedQueryFlags | keyof typeof EasNonInteractiveAndJsonFlags, any>
>
): PaginatedQueryOptions => {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
...('limit' in flags && { limit: flags.limit }),
offset: flags.offset ?? 0,
nonInteractive: flags['non-interactive'] ?? false,
json: flags.json ?? false,
nonInteractive,
json,
};
};
@@ -38,7 +38,7 @@ export const getLimitFlagWithCustomValues = ({
}: {
defaultTo: number;
limit: number;
}): OptionFlag<number | undefined> =>
}): Interfaces.OptionFlag<number | undefined> =>
Flags.integer({
description: `The number of items to fetch each query. Defaults to ${defaultTo} and is capped at ${limit}.`,
// eslint-disable-next-line async-protect/async-suffix
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
@@ -201,13 +202,12 @@ function displayUsage(data: UsageDisplayData, usageData: AccountFullUsageData):
export default class AccountUsage extends EasCommand {
static override description = 'view account usage and billing for the current cycle';
static override args = [
{
name: 'ACCOUNT_NAME',
static override args = {
ACCOUNT_NAME: Args.string({
description:
'Account name to view usage for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
},
];
}),
};
static override flags = {
...EasJsonOnlyFlag,
@@ -19,10 +19,14 @@ export default class AccountView extends EasCommand {
maybeLoggedIn: { actor, authenticationInfo },
} = await this.getContextAsync(AccountView, { nonInteractive: true });
if (actor) {
const displayName = getActorDisplayName(actor);
const loggedInAs = authenticationInfo.accessToken
? `${getActorDisplayName(actor)} (authenticated using EXPO_TOKEN)`
: getActorDisplayName(actor);
? `${displayName} (authenticated using EXPO_TOKEN)`
: displayName;
Log.log(chalk.green(loggedInAs));
if ('email' in actor) {
Log.log(actor.email);
}
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more
+6 -1
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import { getAnalyticsEnabledAsync, setAnalyticsEnabledAsync } from '../analytics/AnalyticsManager';
import EasCommand from '../commandUtils/EasCommand';
import Log from '../log';
@@ -5,7 +6,11 @@ import Log from '../log';
export default class AnalyticsView extends EasCommand {
static override description = 'display or change analytics settings';
static override args = [{ name: 'STATUS', options: ['on', 'off'] }];
static override args = {
STATUS: Args.string({
options: ['on', 'off'] as const,
}),
};
async runAsync(): Promise<void> {
const { STATUS: status } = (await this.parse(AnalyticsView)).args;
+14 -7
View File
@@ -1,9 +1,13 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
import { getDefaultBranchNameAsync } from '../../branch/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
@@ -12,13 +16,12 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BranchCreate extends EasCommand {
static override description = 'create a branch';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the branch to create',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -33,8 +36,9 @@ export default class BranchCreate extends EasCommand {
async runAsync(): Promise<void> {
let {
args: { name },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(BranchCreate);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -61,6 +65,9 @@ export default class BranchCreate extends EasCommand {
initial: (await getDefaultBranchNameAsync(vcsClient)) ?? undefined,
validate: value => (value ? true : validationMessage),
}));
if (!name) {
throw new Error(validationMessage);
}
}
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, { appId: projectId, name });
+10 -7
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import gql from 'graphql-tag';
@@ -5,7 +6,10 @@ import { scheduleBranchDeletionAsync } from '../../branch/delete';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { withErrorHandlingAsync } from '../../graphql/client';
import { GetBranchInfoQuery, GetBranchInfoQueryVariables } from '../../graphql/generated';
@@ -54,13 +58,12 @@ export default class BranchDelete extends EasCommand {
...this.ContextOptions.LoggedIn,
};
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the branch to delete',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -71,7 +74,7 @@ export default class BranchDelete extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchDelete);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
if (jsonFlag) {
+6 -2
View File
@@ -1,6 +1,9 @@
import { listAndRenderBranchesOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
@@ -19,11 +22,12 @@ export default class BranchList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(BranchList);
const { nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(BranchList, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
@@ -4,7 +4,10 @@ import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
EditUpdateBranchInput,
@@ -69,8 +72,9 @@ export default class BranchRename extends EasCommand {
async runAsync(): Promise<void> {
let {
flags: { json: jsonFlag, from: currentName, to: newName, 'non-interactive': nonInteractive },
flags: { from: currentName, to: newName, ...rawFlags },
} = await this.parse(BranchRename);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
projectId,
loggedIn: { graphqlClient },
+10 -7
View File
@@ -1,6 +1,10 @@
import { Args } from '@oclif/core';
import { selectBranchOnAppAsync } from '../../branch/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -12,13 +16,12 @@ import { enableJsonOutput } from '../../utils/json';
export default class BranchView extends EasCommand {
static override description = 'view a branch';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the branch to view',
},
];
}),
};
static override flags = {
...EasPaginatedQueryFlags,
@@ -36,7 +39,7 @@ export default class BranchView extends EasCommand {
args: { name: branchName },
flags,
} = await this.parse(BranchView);
const { 'non-interactive': nonInteractive } = flags;
const { nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -93,15 +93,17 @@ export async function selectBuildToCancelAsync(
export default class BuildCancel extends EasCommand {
static override description = 'cancel a build';
static override args = [{ name: 'BUILD_ID' }];
static override args = {
BUILD_ID: Args.string({}),
};
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.enum({
platform: Flags.option({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
}),
})(),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -140,7 +142,7 @@ export default class BuildCancel extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg;
let buildId: string | null = buildIdFromArg ?? null;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
@@ -19,11 +19,11 @@ export default class BuildConfigure extends EasCommand {
static override description = 'configure the project to support EAS Build';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
description: 'Platform to configure',
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
};
static override contextDefinition = {
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import gql from 'graphql-tag';
import EasCommand from '../../commandUtils/EasCommand';
@@ -79,14 +79,16 @@ export async function selectBuildToDeleteAsync(
export default class BuildDelete extends EasCommand {
static override description = 'delete a build';
static override args = [{ name: 'BUILD_ID' }];
static override args = {
BUILD_ID: Args.string({}),
};
static override flags = {
...EASNonInteractiveFlag,
platform: Flags.enum({
platform: Flags.option({
char: 'p',
description: 'Filter builds by the platform if build ID is not provided',
options: Object.values(RequestedPlatform),
}),
})(),
profile: Flags.string({
char: 'e',
description: 'Filter builds by build profile if build ID is not provided',
@@ -121,7 +123,7 @@ export default class BuildDelete extends EasCommand {
await ensureBuildExistsAsync(graphqlClient, buildIdFromArg);
}
let buildId: string | null = buildIdFromArg;
let buildId: string | null = buildIdFromArg ?? null;
if (!buildId) {
if (nonInteractive) {
throw new Error('Build ID must be provided in non-interactive mode');
+3 -3
View File
@@ -34,10 +34,10 @@ export default class BuildDev extends EasCommand {
'run dev client simulator/emulator build with matching fingerprint or create a new one';
static override flags = {
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
profile: Flags.string({
char: 'e',
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
@@ -5,7 +5,10 @@ import { pathExists } from 'fs-extra';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { AppPlatform, BuildFragment, BuildStatus, DistributionType } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { toAppPlatform } from '../../graphql/types/AppPlatform';
@@ -23,10 +26,10 @@ export default class Download extends EasCommand {
description: 'Fingerprint hash of the build to download',
required: true,
}),
platform: Flags.enum<Platform.IOS | Platform.ANDROID>({
platform: Flags.option({
char: 'p',
options: [Platform.IOS, Platform.ANDROID],
}),
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
'dev-client': Flags.boolean({
description: 'Filter only dev-client builds.',
allowNo: true,
@@ -42,13 +45,13 @@ export default class Download extends EasCommand {
async runAsync(): Promise<void> {
const {
flags: {
json: jsonFlag,
platform,
fingerprint,
'dev-client': developmentClient,
'non-interactive': nonInteractive,
...rawFlags
},
} = await this.parse(Download);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
loggedIn: { graphqlClient },
+13 -13
View File
@@ -10,7 +10,10 @@ import path from 'path';
import { LocalBuildMode } from '../../build/local';
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { StatuspageServiceName } from '../../graphql/generated';
import Log, { link } from '../../log';
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
@@ -45,10 +48,10 @@ export default class Build extends EasCommand {
static override description = 'start a build';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
'skip-credentials-check': Flags.boolean({
default: false,
hidden: true,
@@ -96,7 +99,7 @@ export default class Build extends EasCommand {
description:
'Specify the "What to Test" information for the build in TestFlight (iOS-only). To be used with the `auto-submit` flag',
}),
'resource-class': Flags.enum({
'resource-class': Flags.option({
options: Object.values(ResourceClass),
hidden: true,
deprecated: {
@@ -105,15 +108,15 @@ export default class Build extends EasCommand {
),
},
description: 'The instance type that will be used to run this build [experimental]',
}),
})(),
message: Flags.string({
char: 'm',
description: 'A short message describing the build',
}),
'build-logger-level': Flags.enum({
'build-logger-level': Flags.option({
description: 'The level of logs to output during the build process. Defaults to "info".',
options: Object.values(LoggerLevel),
}),
})(),
'freeze-credentials': Flags.boolean({
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
@@ -184,16 +187,13 @@ export default class Build extends EasCommand {
private sanitizeFlags(
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
const nonInteractive = flags['non-interactive'];
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
if (!flags.platform && nonInteractive) {
Errors.error('--platform is required when building in non-interactive mode', { exit: 1 });
}
if (flags.json && !nonInteractive) {
Errors.error('--json is allowed only when building in non-interactive mode', { exit: 1 });
}
const autoSubmit = flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined;
if (flags['what-to-test'] && !autoSubmit) {
Errors.error(
@@ -242,7 +242,7 @@ export default class Build extends EasCommand {
: {},
wait: flags['wait'],
clearCache: flags['clear-cache'],
json: flags['json'],
json,
autoSubmit,
submitProfile: flags['auto-submit-with-profile'] ?? profile,
resourceClass: flags['resource-class'],
@@ -28,23 +28,23 @@ export default class BuildInspect extends EasCommand {
'inspect the state of the project at specific build stages, useful for troubleshooting';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: [RequestedPlatform.Android, RequestedPlatform.Ios],
options: [RequestedPlatform.Android, RequestedPlatform.Ios] as const,
required: true,
}),
})(),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
stage: Flags.enum({
stage: Flags.option({
char: 's',
description: STAGE_DESCRIPTION,
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD],
options: [InspectStage.ARCHIVE, InspectStage.PRE_BUILD, InspectStage.POST_BUILD] as const,
required: true,
}),
})(),
output: Flags.string({
char: 'o',
description: 'Output directory.',
@@ -18,11 +18,11 @@ export default class BuildInternal extends EasCommand {
static override hidden = true;
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
options: ['android', 'ios'] as const,
required: true,
}),
})(),
profile: Flags.string({
char: 'e',
description:
+11 -9
View File
@@ -3,7 +3,10 @@ import { Flags } from '@oclif/core';
import { BUILDS_LIMIT, listAndRenderBuildsOnAppAsync } from '../../build/queries';
import { BuildDistributionType, BuildStatus } from '../../build/types';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -20,18 +23,18 @@ export default class BuildList extends EasCommand {
static override description = 'list all builds for your project';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
options: Object.values(RequestedPlatform),
char: 'p',
}),
status: Flags.enum({
})(),
status: Flags.option({
options: Object.values(BuildStatus),
description: 'Filter only builds with the specified status',
}),
distribution: Flags.enum({
})(),
distribution: Flags.option({
options: Object.values(BuildDistributionType),
description: 'Filter only builds with the specified distribution type',
}),
})(),
channel: Flags.string(),
'app-version': Flags.string({
aliases: ['appVersion'],
@@ -83,12 +86,11 @@ export default class BuildList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
json: jsonFlag,
platform: requestedPlatform,
status: buildStatus,
distribution: buildDistribution,
'non-interactive': nonInteractive,
} = flags;
if (buildDistribution === BuildDistributionType.SIMULATOR) {
Log.warn(
@@ -13,7 +13,10 @@ import { listAndSelectBuildOnAppAsync } from '../../build/queries';
import { printBuildResults, printLogsUrls } from '../../build/utils/printBuildInfo';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasPaginatedQueryFlags } from '../../commandUtils/pagination';
import { CredentialsContext } from '../../credentials/context';
import {
@@ -66,10 +69,10 @@ export default class BuildResign extends EasCommand {
static override description = 're-sign a build archive';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
'target-profile': Flags.string({
char: 'e',
description:
@@ -242,7 +245,7 @@ export default class BuildResign extends EasCommand {
}
sanitizeFlags(flags: RawBuildResignFlags): BuildResignFlags {
const nonInteractive = flags['non-interactive'];
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (nonInteractive && !flags.id) {
throw new Error(
`${chalk.bold('--id')} is required when running with ${chalk.bold(
@@ -251,7 +254,7 @@ export default class BuildResign extends EasCommand {
);
}
return {
json: flags.json,
json,
nonInteractive,
offset: flags.offset,
limit: flags.limit,
+3 -3
View File
@@ -62,10 +62,10 @@ export default class Run extends EasCommand {
description: 'ID of the simulator/emulator build to run',
exclusive: ['latest, path, url'],
}),
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
@@ -5,7 +5,10 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../../../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
@@ -22,10 +25,10 @@ export default class BuildVersionGetView extends EasCommand {
public static override description = 'get the latest version from EAS servers';
public static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
@@ -44,7 +47,8 @@ export default class BuildVersionGetView extends EasCommand {
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionGetView);
if (flags.json) {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
@@ -57,13 +61,13 @@ export default class BuildVersionGetView extends EasCommand {
withServerSideEnvironment: null,
});
if (!flags.platform && flags['non-interactive']) {
if (!flags.platform && nonInteractive) {
throw new Error('"--platform" flag is required in non-interactive mode.');
}
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const platforms = toPlatforms(requestedPlatform);
@@ -94,7 +98,7 @@ export default class BuildVersionGetView extends EasCommand {
buildProfile: profile,
platform,
vcsClient,
nonInteractive: flags['non-interactive'],
nonInteractive,
env,
});
const remoteVersions = await AppVersionQuery.latestVersionAsync(
@@ -107,7 +111,7 @@ export default class BuildVersionGetView extends EasCommand {
results[platform] = remoteVersions?.buildVersion;
}
}
if (flags.json) {
if (json) {
const jsonResults: { versionCode?: string; buildNumber?: string } = {};
if (results.android) {
jsonResults.versionCode = results.android;
@@ -26,10 +26,10 @@ export default class BuildVersionSetView extends EasCommand {
static override description = 'update version of an app';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios'],
}),
options: ['android', 'ios'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
@@ -45,10 +45,10 @@ export default class BuildVersionSyncView extends EasCommand {
'update a version in native code with a value stored on EAS servers';
public static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: ['android', 'ios', 'all'],
}),
options: ['android', 'ios', 'all'] as const,
})(),
profile: Flags.string({
char: 'e',
description:
+4 -1
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
@@ -11,7 +12,9 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class BuildView extends EasCommand {
static override description = 'view a build for your project';
static override args = [{ name: 'BUILD_ID' }];
static override args = {
BUILD_ID: Args.string({}),
};
static override flags = {
...EasJsonOnlyFlag,
@@ -1,8 +1,12 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import { createAndLinkChannelAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { enableJsonOutput } from '../../utils/json';
@@ -10,13 +14,12 @@ import { enableJsonOutput } from '../../utils/json';
export default class ChannelCreate extends EasCommand {
static override description = 'create a channel';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to create',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -30,8 +33,9 @@ export default class ChannelCreate extends EasCommand {
async runAsync(): Promise<void> {
let {
args: { name: channelName },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(ChannelCreate);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -53,6 +57,9 @@ export default class ChannelCreate extends EasCommand {
message: 'Provide a channel name:',
validate: value => (value ? true : validationMessage),
}));
if (!channelName) {
throw new Error(validationMessage);
}
}
await createAndLinkChannelAsync(graphqlClient, {
@@ -1,7 +1,11 @@
import { Args } from '@oclif/core';
import { scheduleChannelDeletionAsync } from '../../channel/delete';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { toggleConfirmAsync } from '../../prompts';
@@ -11,13 +15,12 @@ import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJ
export default class ChannelDelete extends EasCommand {
static override description = 'Delete a channel';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to delete',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -30,8 +33,9 @@ export default class ChannelDelete extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { name: nameArg },
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
flags,
} = await this.parse(ChannelDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+11 -8
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -12,7 +12,10 @@ import {
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
UpdateChannelBasicInfoFragment,
@@ -58,13 +61,12 @@ export async function updateChannelBranchMappingAsync(
export default class ChannelEdit extends EasCommand {
static override description = 'point a channel at a new branch';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to edit',
},
];
}),
};
static override flags = {
branch: Flags.string({
@@ -81,8 +83,9 @@ export default class ChannelEdit extends EasCommand {
async runAsync(): Promise<void> {
const {
args,
flags: { branch: branchFlag, json, 'non-interactive': nonInteractive },
flags: { branch: branchFlag, ...rawFlags },
} = await this.parse(ChannelEdit);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(rawFlags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,6 +1,9 @@
import { CHANNELS_LIMIT, listAndRenderChannelsOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import {
EasPaginatedQueryFlags,
getLimitFlagWithCustomValues,
@@ -25,7 +28,7 @@ export default class ChannelList extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelList);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+11 -11
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -6,7 +6,10 @@ import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
PauseUpdateChannelMutation,
@@ -50,13 +53,12 @@ export async function pauseUpdateChannelAsync(
export default class ChannelPause extends EasCommand {
static override description = 'pause a channel to stop it from sending updates';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to edit',
},
];
}),
};
static override flags = {
branch: Flags.string({
@@ -71,10 +73,8 @@ export default class ChannelPause extends EasCommand {
};
async runAsync(): Promise<void> {
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelPause);
const { args, flags } = await this.parse(ChannelPause);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+11 -11
View File
@@ -1,4 +1,4 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
@@ -6,7 +6,10 @@ import gql from 'graphql-tag';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
ResumeUpdateChannelMutation,
@@ -50,13 +53,12 @@ export async function resumeUpdateChannelAsync(
export default class ChannelResume extends EasCommand {
static override description = 'resume a channel to start sending updates';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to edit',
},
];
}),
};
static override flags = {
branch: Flags.string({
@@ -71,10 +73,8 @@ export default class ChannelResume extends EasCommand {
};
async runAsync(): Promise<void> {
const {
args,
flags: { json, 'non-interactive': nonInteractive },
} = await this.parse(ChannelResume);
const { args, flags } = await this.parse(ChannelResume);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
@@ -1,7 +1,10 @@
import { Flags } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
@@ -49,15 +52,14 @@ type ChannelRolloutArgsAndFlags = {
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = [
{
name: 'channel',
static override args = {
channel: Args.string({
description: 'channel on which the rollout should be done',
},
];
}),
};
static override flags = {
action: Flags.enum({
action: Flags.option({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
@@ -97,17 +99,17 @@ export default class ChannelRollout extends EasCommand {
],
},
],
}),
})(),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: false,
}),
outcome: Flags.enum({
outcome: Flags.option({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
}),
})(),
branch: Flags.string({
description: 'Branch to roll out. Use with --action=create',
required: false,
@@ -183,8 +185,7 @@ export default class ChannelRollout extends EasCommand {
runtimeVersion: rawFlags['runtime-version'],
privateKeyPath: rawFlags['private-key-path'] ?? null,
action: action ? this.getAction(action) : undefined,
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
...resolveNonInteractiveAndJsonFlags(rawFlags),
};
}
}
+10 -7
View File
@@ -1,3 +1,4 @@
import { Args } from '@oclif/core';
import assert from 'assert';
import {
@@ -5,20 +6,22 @@ import {
selectChannelOnAppAsync,
} from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { EasPaginatedQueryFlags, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { enableJsonOutput } from '../../utils/json';
export default class ChannelView extends EasCommand {
static override description = 'view a channel';
static override args = [
{
name: 'name',
static override args = {
name: Args.string({
required: false,
description: 'Name of the channel to view',
},
];
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
@@ -36,7 +39,7 @@ export default class ChannelView extends EasCommand {
flags,
} = await this.parse(ChannelView);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
+10 -10
View File
@@ -6,7 +6,10 @@ import chalk from 'chalk';
import { evaluateConfigWithEnvVarsAsync } from '../build/evaluateConfigWithEnvVarsAsync';
import EasCommand from '../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../commandUtils/flags';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { appPlatformEmojis } from '../platform';
@@ -17,7 +20,7 @@ export default class Config extends EasCommand {
static override description = 'display project configuration (app.json + eas.json)';
static override flags = {
platform: Flags.enum<Platform>({ char: 'p', options: [Platform.ANDROID, Platform.IOS] }),
platform: Flags.option({ char: 'p', options: [Platform.ANDROID, Platform.IOS] as const })(),
profile: Flags.string({
char: 'e',
description:
@@ -39,14 +42,11 @@ export default class Config extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(Config);
if (flags.json) {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (json) {
enableJsonOutput();
}
const {
platform: maybePlatform,
profile: maybeProfile,
'non-interactive': nonInteractive,
} = flags;
const { platform: maybePlatform, profile: maybeProfile } = flags;
const { getDynamicPublicProjectConfigAsync, projectDir, getDynamicLoggedInAsync } =
await this.getContextAsync(Config, {
nonInteractive,
@@ -80,7 +80,7 @@ export default class Config extends EasCommand {
const profile = await EasJsonUtils.getBuildProfileAsync(accessor, platform, profileName);
if (flags['eas-json-only']) {
if (flags.json) {
if (json) {
printJsonOnlyOutput({ buildProfile: profile });
} else {
const appPlatform = toAppPlatform(platform);
@@ -99,7 +99,7 @@ export default class Config extends EasCommand {
opts: { env: profile.env },
});
if (flags.json) {
if (json) {
printJsonOnlyOutput({ buildProfile: profile, appConfig });
} else {
Log.addNewLineIfNone();
@@ -10,10 +10,10 @@ export default class InitializeBuildCredentials extends EasCommand {
static override description = 'Set up credentials for building your project.';
static override flags = {
platform: Flags.enum({
platform: Flags.option({
char: 'p',
options: [Platform.ANDROID, Platform.IOS],
}),
options: [Platform.ANDROID, Platform.IOS] as const,
})(),
profile: Flags.string({
char: 'e',
description: 'The name of the build profile in eas.json.',
@@ -7,7 +7,7 @@ export default class Credentials extends EasCommand {
static override description = 'manage credentials';
static override flags = {
platform: Flags.enum({ char: 'p', options: ['android', 'ios'] }),
platform: Flags.option({ char: 'p', options: ['android', 'ios'] as const })(),
};
static override contextDefinition = {
@@ -1,8 +1,12 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import Log from '../../../log';
import { Ora, ora } from '../../../ora';
import { toggleConfirmAsync } from '../../../prompts';
@@ -22,7 +26,9 @@ export default class WorkerAliasDelete extends EasCommand {
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
static override args = [{ name: 'ALIAS_NAME' }];
static override args = {
ALIAS_NAME: Args.string({}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
@@ -105,9 +111,10 @@ export default class WorkerAliasDelete extends EasCommand {
}
private sanitizeFlags(flags: any): DeployAliasDeleteFlags {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
return {
nonInteractive: flags['non-interactive'],
json: flags['json'],
nonInteractive,
json,
};
}
}
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more