Compare commits

...
Author SHA1 Message Date
Expo CI 88e23aaa09 v27.1.0 2026-10-28 16:52:14 +00:00
Will Schurman 🦦 0a274a6fef [eas-cli] Throw when environment variable pull fails (#4774)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

With protected environment variables (and even regular usage without protected environment variables), we need to prevent the build running when they can't be pulled. Otherwise the build can end up in a unexpected state where env variables are empty or unset.

# How

Re-throw the error when environment variable pulling fails. The original PR that added this (#2495) didn't specify why it was caught and allowed to proceed.

# Test Plan

Before:

```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
[GraphQL] Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
This can possibly be a bug in EAS/EAS CLI. Report it here: https://github.com/expo/eas-cli/issues
✔ Incremented versionCode from 11 to 12.


✔ Using remote Android credentials (Expo server)
```

After:
```
$ neas build
✔ Select platform › Android

⚠️ Detected that your app uses Expo Go for development, this is not recommended when building production apps.
Learn more
To suppress this warning, set EAS_BUILD_NO_EXPO_GO_WARNING=true.

Resolved "production" environment for the build. Learn more
Failed to pull env variables for environment production from EAS servers
You don't have the required permissions to perform this operation.

This can sometimes happen if you are logged in as incorrect user or if the permissions granted during authentication are insufficient to perform the operation.
Run eas whoami to check the user you are logged in as and your permission grant.
Run eas login to change the user or permission grant.

Original error message: Entity Not Authorized: Reading this environment variable requires the Release Manager role or higher since it targets exclusively the production environment and thus is considered protected.: Entity not authorized: EnvironmentVariableEntity[d3f35233-c3b7-4ef0-98fd-bc08d576e1db] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:102f030b-3f17-44ad-9472-cf051344224b(RUN,VIEW);expo_account:df3ec58d-d9b7-4f83-afdd-dbdb8258fdbe(VIEW)], action = READ, ruleIndex = -1)
Request ID: 11ade772-f079-4515-84bc-3c5239bf35b7
```

Note that this ^^ is just the env evaluation for config, but the same function is used in `eas build:dev`, `build:resign`, `build:version:get/set/sync`, `eas config`, and `eas fingerprint:generate`.
2026-10-28 09:03:43 -07:00
Stanisław Chmiela 78f6782970 [build-tools] Handle ASC 90189 as version duplicate (#3461)
# Why

ASC error 90189 arrives after upload creation. The existing duplicate-version handler covers the creation request, so it does not handle this processing failure.

# How

Map error 90189 to the existing duplicate-build UserError. Include Apple’s original descriptions and codes in the public error message and retain the full failed-upload state as `cause`, including errors, warnings, and information messages. Keep the current SDK-version handler. Remove unrelated message edits copied into the old PR.

# Examples
- https://expo.dev/uuid/019cbf7e-e0f8-7212-9b72-37e97d43b7a2
- https://expo.dev/uuid/019cbf7d-28fa-747b-bd36-81f81b8d8d1b
- https://expo.dev/uuid/019cbf7a-b73a-7310-b845-4e2f8c58224c

# Datadog evidence

Checked on 23 October 2026. Count window: 16 October 2026 12:00 UTC to 23 October 2026 12:00 UTC (14:00 Warsaw time). Counts refer to distinct runs or builds with failed phases, not raw log lines.

187 distinct failed runs contain error 90189. In 173 runs, the validation error list has no other code, so it matches this PR’s condition. Mixed validation errors remain outside that condition.

- [Datadog: matching failed-phase logs](https://app.datadoghq.com/logs?query=service%3Aeas-build+build_phase_result%3Afailed+submission_platform%3Aios+%2290189%22&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)
- [Datadog: example failed run or build](https://app.datadoghq.com/logs?query=service%3Aeas-build+job_run_id%3A01a16866-c973-7288-b710-eb556df52e1c&from_ts=1792152000000&to_ts=1792756800000&live=false&stream_sort=desc)

# Test Plan

- `yarn --cwd packages/build-tools jest-unit --runInBand src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- Root type checks, lint, and formatting checks passed.
- All 19 focused tests passed. The new test for public error details and cause preservation fails on the previous code and passes with this change.
2026-10-28 14:21:02 +01:00
Szymon Świerk 5757091b33 [eas-cli] support nested workflow calls in local file workflow run (#4769)
* [eas-cli] Collect workflow files referenced by uses jobs

* [eas-cli] Upload referenced workflow files in workflow:run

* [eas-cli] Regenerate GraphQL types
2026-10-28 11:40:21 +01:00
Ash Wu dd6ad17179 [eas-cli] recognize queued workflow runs and report finishedAt only for final runs (#4258)
Signed-off-by: Ash Wu <[email protected]>
2026-10-28 18:01:28 +08:00
Ash Wu 695f567760 [build-tools][worker] keep NPM_TOKEN working on newer pnpm and explain pnpm Node.js mismatches (#4729)
pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc (GHSA-3qhv-2rgh-x77r) but still expand them in the npm user config. On EAS Build the NPM_TOKEN credential now goes into the file `npm config get userconfig` reports for the build env (~/.npmrc unless NPM_CONFIG_USERCONFIG points elsewhere) whenever the job has NPM_TOKEN, and the project .npmrc is left to the user. Local builds keep creating the project .npmrc, because the home directory there belongs to the developer. The legacy PREPARE_PROJECT path and the eas/use_npm_token step share one helper.

Projects that pin pnpm 11 through packageManager on a Node 20 image crash on node:sqlite right after pnpm's "requires at least Node.js v22.13" banner. A new user error names the eas.json node value to set or the pnpm version to change. The worker raises the same error when it reinstalls pnpm under a node version pinned in eas.json, instead of the generic "Failed to install pnpm" message: the typed error it threw before never reached the log handlers, and the probe's piped output never reached the log.
2026-10-28 17:19:16 +08:00
Ash Wu 15e2302575 [build-tools] install the repack package with npm in an isolated sandbox (#4728) 2026-10-28 17:10:27 +08:00
Gabe Debes ee4f03d44e [ci] Format the generated changelog before release (#4773) 2026-10-27 19:18:51 -07:00
Expo CI 5e192ecba6 v27.0.0 2026-10-27 22:10:54 +00:00
Gabe DebesandClaude Opus 5.5 864b11effe [eas-cli] Add simulator:logs for iOS sessions (#4545)
* [eas-cli] Add simulator:logs

Read buffered or live logs from a running iOS simulator session through
its preview API, or save the logs artifact once the session has stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Use oclif exclusive for simulator:logs --follow and --json

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Suggest redirecting --follow to save live simulator logs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Report log source failures while following simulator logs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Close the connection of a rejected simulator preview response

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Write simulator log health notices to stderr

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Explain that live simulator logs need an iOS session

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 15:02:52 -07:00
Gabe DebesandCodex 62df1bae1c [eas-cli] Require explicit simulator-host upload paths (#4683)
* [eas-cli] Require explicit simulator-host upload paths

* [eas-cli] Cover simulator upload path help and filesystem failures

* [eas-cli] Reject host paths for upload ID actions

Keep upload guidance consistent in text and JSON help, and explain local filenames beginning with host:. Add rejection and help regressions.

Co-authored-by: Codex <[email protected]>
2026-10-27 12:34:27 -07:00
Vojtech NovakandClaude Opus 5.5 8070663faa [eas-cli] Write the reason a command failed to stderr (#4592)
* [eas-cli] Write the reason a command failed to stderr

EasCommand.catch printed the reason with Log.error, which writes to
stdout. oclif then printed "<command> command failed." to stderr. A
program that reads stderr for the error found only that generic line.

Only the catch changes. Other Log.error and Log.warn calls stay on
stdout, because some of them are not failures and the spinner keeps
non-TTY output off stderr on purpose.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Drop the errorToStderr unit test and doc comment

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Color the failure reason by stderr's color support

chalk's default instance takes its color level from stdout, so
`eas ... 2>err.log` in a terminal wrote color codes to the file.

Also spy on errorToStderr in the already-printed catch test. The catch
no longer calls Log.error, so the test passed even without the
messageAlreadyPrinted guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Vojtech NovakandClaude Opus 5.5 e38a5a88d2 [eas-cli] Print the eas-cli dependency notice only when stderr is a terminal (#4591)
The notice is advice for a person. A program that runs eas-cli reads
stderr for the command's error and found this notice first.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-27 10:11:39 +01:00
Gabe DebesandClaude Opus 5.5 82845def18 [eas-cli] Fix simulator control action help (#4681)
* [eas-cli] Fix simulator control action help

* [eas-cli] Exercise real simulator help lookup and hooks

* [eas-cli] Batch source-loaded help lifecycle probes

* [build-tools] Expect headless Appium default on iOS in the launch test

#4622 added this test while #4699 added appium:isHeadless to the iOS
default capabilities. Each PR passed CI without the other, so main fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 17:00:03 -07:00
Gabe DebesandClaude Opus 5.5 476b4e9b09 [build-tools] Run Appium iOS sessions with a headless Simulator (#4699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-26 16:42:34 -07:00
Gabe Debes 9315aebef5 [build-tools] run apsd cleanup in parallel for iOS Simulator sessions (#4706)
* [build-tools] Keep APSD setup off preview readiness

* [build-tools] Run APSD setup alongside app launch

* [build-tools] Lowercase apsd in the changeset
2026-10-26 16:30:46 -07:00
Gabe Debes 786d8bac85 [build-tools] Start Appium with the prepared Full Disk Access helper (#4622) 2026-10-26 15:41:02 -07:00
Gabe Debes 8f67105e5a [build-tools] Improve simulator sessions failed app downloads error (#4703)
* [build-tools] Explain application archive download failures

* [build-tools] Update download error expectations

* [build-tools] Use neutral archive download advice
2026-10-26 15:06:37 -07:00
Will Schurman 🦦 3bfb405c87 [eas-cli] Fix graphql CI step on stacked PRs (#4763)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Noticed that the required "Ensure GraphQL schema and generated code" CI job wasn't running on stacked PRs, and thus prevented stacked land.

# How

Allow it to run on stacked PRs by removing the `main` condition from PR.

# Test Plan

This PR was stacked and it ran!
2026-10-26 14:59:35 -07:00
Will Schurman 🦦 73335e8233 [eas-cli] Handle unauthorized error during build command (#4760)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

As requested by @sjkim-expo in #4734, when the user has insufficient permissions to create a build due to the required role being higher than the user's granted role, we can show a better error message.

# How

Handle the `UNAUTHORIZED` error code and show a better error.

Note that this doesn't yet gracefully handle protected environment variable errors or protected credential errors.

# Test Plan

`neas build` for granted role lower than required role from eas.json

Output:
```
Cannot create an EXPO_TOKEN for the job: the job requires the "Release Manager" role on the account "real-org-will", but the credentials of the actor that started the job are scoped to the "Developer" role. Re-authenticate to grant the credentials a scope that includes the "Release Manager" role, or request a lower role.
Request ID: bd12f9af-8950-498b-a69b-5d89255ca4f4

Run eas whoami to check the user you are logged in as and your permission grant. Run eas login to change the user or permission grant. To request a lower role for the build's EXPO_TOKEN, set permissions.account.role in the build profile in eas.json.
```
2026-10-26 14:31:37 -07:00
Will Schurman 🦦 9af03f901e [eas-cli] Add better description to re-auth for scoped auth (#4759)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that there's scoping, when a permissions error occurs and the user is scoped, they are told to run `eas login`.

But, when running `eas login` the experience was suboptimal:
```
You are already logged in as wschurman.
  ✔ Do you want to continue? … yes
```

# How

Update this message to be more descriptive and instructing to continue if the user wants to change scope.

# Test Plan

`neas login`

```
You are already logged in as wschurman.

  Accounts:
  • wschurman (Role: Owner)
  • expo (Role: Admin, limited to Viewer by the current credentials)

  The permissions granted during authentication limit your access to some accounts. Continue to log in again and change the
  permission grant.
  ✔ Do you want to continue? … yes
```
2026-10-26 14:07:42 -07:00
Gabe Debes 13ab40e271 [build-tools] Support idle timeouts for iOS web preview sessions (#4731)
* [build-tools] Support idle timeouts for iOS web preview sessions

* [build-tools] Reject Android web preview idle timeouts
2026-10-26 12:23:37 -07:00
Will Schurman 🦦 355f3f2012 [eas-cli] Send requested role from eas.json to build mutation (#4734)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Builds on top of #4732 to send the new field to graphql for build creation. The eas.json field has been deployed on the server.

# How

Read the requested role from config, send to mutation if specified.

# Test Plan

Run the new test.

Manual test:
1. Add `"permissions": { "account": { "role": "runner" } }` to eas.json
2. `neas build`, see build runs!
2026-10-26 11:51:46 -07:00
Gabe Debes 736e1aeb90 [build-tools] Handle device sessions stopped during startup (#4716) 2026-10-26 11:31:14 -07:00
Expo CI 58d372a096 bump STAGING and PRODUCTION to 26.0.0 in cli-versions.json 2026-10-26 16:52:09 +00:00
Expo CI 56d01c4129 v26.0.0 2026-10-26 16:14:04 +00:00
Stanisław Chmiela bcb5d96ea8 [cocoapods-proxy] Add Sonatype snapshot CocoaPods proxy support (#3736)
## Summary

Adds `central.sonatype.com` support to the packaged `expo-cocoapods-proxy` gem used by iOS workers.

## Change

- Adds `central.sonatype.com` to the `Pod::Downloader::Http` proxy allowlist.
- Adds spec coverage for Sonatype repository artifact URLs.

## Code Requiring This

This PR is for CocoaPods `:http` pod source downloads. It does not proxy React Native's `Net::HTTP` metadata lookup.

React Native returns Sonatype artifact URLs to CocoaPods as `:http` pod sources here:

- RNCore returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rncore.rb#L170-L173
- ReactNativeDependencies returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/scripts/cocoapods/rndependencies.rb#L205-L208
- Hermes returns `{:http => url}` after resolving the Sonatype nightly artifact URL: https://github.com/facebook/react-native/blob/7bbb13c9be014c0e63f0deaad809d669c15325ad/packages/react-native/sdks/hermes-engine/hermes-utils.rb#L190-L193

CocoaPods/cocoapods-downloader then uses `Pod::Downloader::Http` for those `:http` params:

- CocoaPods passes the pod source params to `Downloader.for_target(...).download`: https://github.com/CocoaPods/CocoaPods/blob/1.12.1/lib/cocoapods/downloader.rb#L107-L110
- `cocoapods-downloader` maps `:http` to `Http`: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L22-L30
- `Downloader.for_target` instantiates that downloader with the `:http` URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader.rb#L51-L64
- `Pod::Downloader::Http#download_file` uses curl for that URL: https://github.com/CocoaPods/cocoapods-downloader/blob/1.6.3/lib/cocoapods-downloader/http.rb#L12-L22

## Validation

- `rspec` in `packages/expo-cocoapods-proxy`: 16 examples, 0 failures
- `git diff --check`
2026-10-26 15:51:09 +01:00
Will Schurman 🦦 d02de4da64 [eas-json] Add ability to specify role request/requirement to eas.json (#4732)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

This is the next part of https://github.com/expo/universe/pull/32694, which allows the requested/required permission role for the build job's EXPO_TOKEN to have to be specified in build mutation.

# How

The first step is to allow specifying this in eas.json. The proposed structure is:
```
...
{
  "build": {
    "production": {
      "permissions": { "account": { "role": "release_manager" } }
    }
  }
}
...
```

This will, upon updating eas-cli in a separate PR, set the requested role in the build creation mutation. If the creator doesn't possess the required role, it will throw.

# Test Plan

CI
2026-10-23 16:54:10 -07:00
Will Schurman 🦦 04c214be93 [eas-cli] Regenerate graphql code (#4692)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Regenerate graphql to fix CI ahead of others PRs failing due to needing to sync.

# How

regenerate.

# Test Plan

`yarn tsc`, CI
2026-10-23 16:42:14 -07:00
Gabe Debes 1d9607ca00 [build-tools] Retry critical device session startup requests (#4680)
* [build-tools] Retry transient device session worker requests

* [build-tools] Limit retries to critical device session requests

* [build-tools] Keep existing download lookup retries
2026-10-23 11:57:08 -07:00
Vojtech Novak 78ba76001b [eas-cli] Read the app config fewer times in eas build (#4649) 2026-10-23 20:31:39 +02:00
Tomasz Mazur 3c8b62fb39 [build-tools] Unify signed URL uploads (#4712)
* [build-tools] Unify signed URL uploads

* [build-tools] Upload in-memory bodies with one signed URL helper
2026-10-23 17:57:35 +02:00
Stanisław Chmiela c3a26fef34 [worker] Fall back when Node.js mirror installation fails (#3452)
# Why

Workers can set `NVM_NODEJS_ORG_MIRROR` through the Node.js cache settings. If `nvm install` fails with that mirror, runtime setup currently stops even when the default Node.js download source is available.

# How

Retry `nvm install` without the mirror if it failed with the mirror. This helps with situation when we fail to have refreshed Nodes list in our cache or our cache is down and stuff like that.

# Test Plan

CI should pass.
2026-10-23 17:12:48 +02:00
Stanisław Chmiela a2c0d0cf09 [build-tools] Default Android emulators to 4 GiB of RAM (#4727)
# Why

We recently changed the default Android system image from API 30 to API 35. Looks like API 35 needs more RAM to be working well.

<details>
<summary>Details</summary>

Android 35 Google APIs x86_64 can lose ADB during large APK transfers. Guest tracing shows `adbd` getting `ENOMEM` from `write()`, then closing VSOCK before the emulator closes host TCP. Six fresh live EAS devices with 4 GiB passed six Maestro tests and 60 additional APK downloads. Two of three corrected lower-memory control devices lost ADB.

</details>

# How

Use 4096 MB when the resolved system-image package starts with `system-images;android-35` or newer.

# Test Plan

- Existing emulator utility and startup suites: 30 tests passed. No tests added. Updated one existing API 35 AOSP RAM expectation from 2048 to 4096.
- Type checks for all 13 projects, repository lint, targeted formatting, and `git diff --check` passed.
- [Live validation of the 4 GiB setting](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a165c0-9d4d-777b-920f-3827ff2f89b4): three devices, three Maestro tests, 30 APK downloads passed.
- [Default API 30 comparison](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a16833-cf25-7eac-b472-e64dc6084c3d): 2/3/4 GiB, three fresh workers per setting, complete.
- [API 34 comparison](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a16833-cf57-70be-a4e8-72a20ca8d6b2): 2/4 GiB, three fresh workers per setting, complete.
- [API 35 comparison](https://expo.dev/accounts/sjchmiela/projects/sdk-57-production/workflows/01a16837-36ae-7bfe-9518-28fe72b9cd5c): 2/3/4 GiB, three fresh workers per setting, complete.

| Image | RAM request | Full passes (flow plus ten downloads) |
| --- | --- | --- |
| Default API 30 | 2 / 3 / 4 GiB | 3/3 / 3/3 / 3/3 |
| API 34 Google APIs x86_64 | 2 / 4 GiB | 3/3 / 3/3 |
| API 35 Google APIs x86_64 | 2 / 3 / 4 GiB | 1/3 / 0/3 / 3/3 |

API 35 at 2 and 3 GiB each had two Maestro failures with `device offline`. One 3 GiB device passed Maestro but lost ADB during extra download four. All 4 GiB devices passed all 30 extra downloads. Three devices per cell do not establish a long-term failure rate.
2026-10-23 13:09:22 +02:00
Vojtech NovakandClaude Opus 5.5 f148570a36 [eas-cli] Share one GraphQL client between the project ID check and the command (#4646)
* [eas-cli] Share one GraphQL client between the project ID check and the command

The project ID check and LoggedInContextField each built their own urql
client, so the second AppByIdQuery for the same project missed the cache.
SessionManager now keeps one client per credential.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* [eas-cli] Update test mocks for the analytics and GraphQL client changes on main

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 12:11:17 +02:00
Stanisław Chmiela e8ca106f2e [build-tools] Remove legacy artifact categories (#4719)
## Why

expo/universe#32638

## How

Removed obsolete enum that didn't mean anything.

## Test Plan

CI should pass.
2026-10-22 21:55:56 +02:00
Ash Wu 62ddc91aa4 [build-tools] stop logging workflow hook parsing in build logs (#4717)
Hook parsing in native builds runs between READ_PACKAGE_JSON and
INSTALL_DEPENDENCIES and logged through the phase-less logger, so every
native build with hooks showed an "Unknown build phase" log group.
Ignore unknown and unrun hook keys silently in both the native and the
steps parser, let the eas/checkout step print the working-directory line
itself, and drop the after-hook failure line that the failing hook step
already logs.
2026-10-22 21:56:13 +08:00
192 changed files with 9991 additions and 2538 deletions

No files matched your search

@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
-6
View File
@@ -1,6 +0,0 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
+81
View File
@@ -2,6 +2,87 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
+1 -1
View File
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Generic artifacts for any file
- Workflow artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
"STAGING": "26.0.0",
"PRODUCTION": "26.0.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.3.0",
"version": "27.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+38
View File
@@ -1,5 +1,43 @@
# @expo/build-tools
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.3.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/downloader": "27.1.0",
"@expo/eas-build-job": "27.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "25.3.0",
"@expo/logger": "27.1.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/steps": "27.1.0",
"@expo/template-file": "27.1.0",
"@expo/turtle-spawn": "27.1.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -125,6 +125,31 @@ describe(resolveBuildPhaseErrorAsync, () => {
expect(err.trackingCode).toBeUndefined();
});
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.19.4',
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
expect(err.message).toBe(
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
);
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
@@ -0,0 +1,33 @@
import { errors } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import semver from 'semver';
import { ErrorHandler } from './errors.types';
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
return new errors.UserError(
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
);
}
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
createError: createPnpmUnsupportedNodeVersionError,
};
export function detectPnpmUnsupportedNodeVersionError(
output: string
): errors.UserError | undefined {
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
}
@@ -1,6 +1,7 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
import UserError = errors.UserError;
@@ -211,6 +212,7 @@ You are seeing this error because either:
return undefined;
},
},
pnpmUnsupportedNodeVersionErrorHandler,
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
@@ -16,11 +16,13 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info: jest.fn(),
info,
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -36,7 +38,7 @@ function createCtx(
workingdir,
}
);
return { ctx, warn };
return { ctx, info, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -79,22 +81,31 @@ describe(parseJobHooksAsync, () => {
});
});
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -192,10 +203,9 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -12,6 +12,7 @@ import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { Readable } from 'stream';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -390,7 +391,13 @@ describe('projectSources', () => {
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
let uploadedBody = '';
fetchMock.mockImplementation(async (_url, request) => {
for await (const chunk of request!.body as Readable) {
uploadedBody += chunk;
}
return { ok: true } as Response;
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
@@ -414,10 +421,14 @@ describe('projectSources', () => {
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
headers: {
'x-custom-header': 'value',
'content-length': '63',
'content-type': 'text/plain;charset=UTF-8',
},
})
);
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
@@ -44,9 +44,8 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
} catch {
// The phase error wins.
}
}
throw phaseError;
@@ -4,6 +4,7 @@ import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -196,10 +197,6 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
@@ -207,24 +204,9 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+5 -16
View File
@@ -53,19 +53,17 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut(ctx.logger);
globalContext.markAsCheckedOut();
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
// a malformed `before_submit` fails a steps build, so it fails here too). An
// unknown key is inert — a key newer than this worker must be ignored, never
// fail the job.
for (const [key, steps] of Object.entries(hooks)) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
if (parseHookKey(key) === null) {
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
@@ -81,15 +79,6 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,10 +208,9 @@ async function uploadProjectMetadataAsync(
headers: Record<string, string>;
};
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
await uploadContentWithSignedUrl({
signedUrl: uploadSession,
content: JSON.stringify({ archiveContent: sortedFiles }),
});
const updateMetadataResult = await ctx.graphqlClient
+6 -1
View File
@@ -71,7 +71,12 @@ export async function setupAsync<TJob extends BuildJob>(
}
);
await setUpNpmrcAsync(ctx, ctx.logger);
await setUpNpmrcAsync({
projectTargetDirectory: ctx.buildDirectory,
workingDirectory: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
logger: ctx.logger,
});
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
+2 -4
View File
@@ -4,13 +4,11 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -42,7 +40,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: GenericArtifactType;
type: null;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -241,7 +239,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && !isGenericArtifact(artifact)) {
if (result.filename && artifact.type !== null) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
+11 -2
View File
@@ -3,8 +3,16 @@ import HttpLogStream from './logging/HttpLogStream';
import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, HttpLogStream, RemoteLoggerStream };
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from './storage/uploadWithSignedUrl';
export type {
SignedUrl,
UploadWithSignedUrlOptions,
UploadWithSignedUrlParams,
} from './storage/uploadWithSignedUrl';
export { downloadCacheAsync } from './steps/functions/restoreCache';
export { uploadCacheAsync } from './steps/functions/saveCache';
@@ -26,6 +34,7 @@ export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
return null;
}
public async init(): Promise<string> {
public async init(): Promise<void> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
return (await this.flush(true)) as string;
await this.flush(true);
}
public async cleanUp(): Promise<void> {
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
}
}
private async flush(force = false): Promise<string | void> {
private async flush(force = false): Promise<void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
return await this.flush(force);
await this.flush(force);
return;
}
return await this.flushInternal();
await this.flushInternal();
}
}
private async flushInternal(): Promise<string | void> {
private async flushInternal(): Promise<void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
return await this.upload()
.then(result => {
return result;
})
await this.upload()
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(result => {
.then(() => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<string | void> {
private async upload(): Promise<void> {
if (!this.uploadMethod) {
return;
}
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
);
};
return await uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -48,7 +48,10 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -56,6 +59,9 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -96,14 +102,11 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut(logger);
globalCtx.markAsCheckedOut();
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -98,7 +98,7 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
@@ -162,6 +162,59 @@ afterEach(() => {
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -1,4 +1,4 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -305,7 +305,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1091,7 +1090,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1420,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
@@ -176,6 +176,16 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
@@ -1,8 +1,10 @@
import { type bunyan } from '@expo/logger';
import turtleSpawnAsync from '@expo/turtle-spawn';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
import resolveFrom from 'resolve-from';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
@@ -17,17 +19,100 @@ import {
} from '../repack';
jest.mock('fs');
jest.mock('@expo/spawn-async');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/repack-app');
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
jest.mock('../../utils/ios/credentials/manager');
describe(createRepackBuildFunction, () => {
beforeEach(() => {
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
const sandbox = args[args.indexOf('--prefix') + 1];
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
vol.fromJSON({
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
version: '2.0.0-next.1',
}),
});
}) as unknown as typeof turtleSpawnAsync);
});
afterEach(() => {
jest.restoreAllMocks();
vol.reset();
});
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({
PATH: '/usr/local/bin:/usr/bin',
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
NPM_TOKEN: 'secret',
});
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
repack_package: '@scope/fork',
repack_version: 'next',
},
});
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
const npmEnv = installCall[2]?.env;
assert(npmEnv);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
expect(installCall).toEqual([
'npm',
[
'install',
'--prefix',
npmEnv.HOME,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@scope/fork@next',
],
{ env: npmEnv, logger: repackStep.ctx.logger },
]);
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
});
it('installs the repack package from the default registry when the job has no npm cache', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
},
});
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
expect(installCall[1]).not.toContain('--registry');
});
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
@@ -103,6 +103,18 @@ async function runAsync(
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
beforeEach(async () => {
jest.clearAllMocks();
@@ -147,7 +159,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -1,6 +1,7 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
@@ -202,6 +203,78 @@ describe(installAppiumAsync, () => {
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, true],
[BuildRuntimePlatform.LINUX, false],
])('starts Appium on %s with helper present=%s', async (runtimePlatform, helperPresent) => {
const helper = '/usr/local/libexec/turtle-tcc-fda';
if (helperPresent) {
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, 'prepared helper');
}
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
// Stop at the launch boundary; no readiness requests or remote session are needed.
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const env = {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
PATH: '/configured/node/bin:/usr/bin',
DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
};
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { ...env, NGROK_AUTHTOKEN: 'token' },
} as never
)
).rejects.toBe(launched);
expect(spawnDetached).toHaveBeenCalledTimes(1);
const launch = jest.mocked(spawnDetached).mock.calls[0][0];
const appiumBinPath = path.join(launch.env.APPIUM_HOME!, 'node_modules', '.bin', 'appium');
const useHelper = runtimePlatform === BuildRuntimePlatform.DARWIN && helperPresent;
expect(launch).toEqual({
command: useHelper ? helper : appiumBinPath,
args: [
...(useHelper ? [appiumBinPath] : []),
'--address',
'127.0.0.1',
'--port',
'4723',
'--base-path',
'/',
'--log-level',
'error',
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
runtimePlatform === BuildRuntimePlatform.DARWIN
? '{"appium:eventTimings":true,"appium:isHeadless":true}'
: '{"appium:eventTimings":true}',
],
env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME },
logger,
});
});
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
@@ -361,6 +434,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
@@ -447,6 +521,7 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
@@ -128,7 +128,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
@@ -84,11 +84,52 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -268,6 +309,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -121,26 +121,32 @@ describe(createUploadArtifactBuildFunction, () => {
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
'uploads %s as a workflow artifact with metadata',
async type => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
createGlobalContextMock({}),
{
callInputs: {
type,
key: 'Screen Recording',
path: '/',
metadata,
},
}
);
await buildStep.executeAsync();
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({ type: null, metadata }),
})
);
}
);
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
@@ -1,7 +1,15 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -45,6 +53,20 @@ describe(isInvalidBundleIdentifierError, () => {
});
});
describe(isRedundantBinaryUploadError, () => {
it('returns true when all errors are redundant-binary-upload codes', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isRedundantBinaryUploadError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
@@ -110,3 +132,62 @@ describe(parseMissingUsageDescriptionKeys, () => {
).toEqual([]);
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90189',
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
);
} finally {
jest.restoreAllMocks();
}
});
});
@@ -0,0 +1,39 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/project';
const homeDirectory = '/home/expo';
describe(createSetUpNpmrcBuildFunction, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
NpmrcAuthTokenLine
);
});
});
@@ -58,7 +58,11 @@ export function createCheckoutBuildFunction(): BuildFunction {
});
}
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
stepsCtx.global.markAsCheckedOut();
stepsCtx.logger.info(
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
);
},
});
}
@@ -312,8 +312,22 @@ export async function downloadBuildAsync(
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
const archiveUrl = new URL(downloadUrl);
const source = params.buildId
? `application archive for build ${params.buildId}`
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
if (response.status === 404 || response.status === 410) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
`Failed to download the ${source} (HTTP ${response.status}). ` +
'The archive is unavailable. ' +
(params.buildId
? 'Check that the archive still exists and try again.'
: 'Check that the URL is correct and the archive still exists, then try again.')
);
}
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
@@ -1,4 +1,4 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -631,7 +631,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
type: null,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
@@ -738,7 +738,7 @@ async function uploadFailureScreenshotsAsync({
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
type: null,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
@@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -121,18 +121,12 @@ export function createRepackBuildFunction(): BuildFunction {
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: resolvedRepackVersion,
versionSpec: inputs.repack_version.value as string,
pathEnv: env.PATH,
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
logger: stepsCtx.logger,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -226,16 +220,28 @@ function createOutputPath({
*/
async function installAndImportRepackAsync({
packageName,
version,
versionSpec,
pathEnv,
npmCacheUrl,
logger,
}: {
packageName: string;
version: string;
versionSpec: string;
pathEnv: string | undefined;
npmCacheUrl: string | undefined;
logger: bunyan;
}): Promise<typeof import('@expo/repack-app')> {
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
stdio: 'inherit',
cwd: sandbox,
await installIsolatedNpmPackageAsync({
packageSpec: `${packageName}@${versionSpec}`,
directory: sandbox,
npmCacheUrl,
pathEnv,
logger,
});
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
return require(resolveFrom(sandbox, packageName));
}
@@ -11,6 +11,7 @@ import path from 'path';
import * as tar from 'tar';
import z from 'zod';
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { formatBytes } from '../../utils/artifacts';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { getCacheVersion } from '../utils/cache';
@@ -161,16 +162,13 @@ export async function uploadCacheAsync({
logger.info(`Uploading cache...`);
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
method: 'PUT',
headers,
body: fs.createReadStream(archivePath),
});
if (!uploadResponse.ok) {
throw new Error(
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
);
}
await uploadWithSignedUrl(
{
signedUrl: { url, headers },
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
},
{ retries: 3, retryIntervalMs: 100 }
);
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
@@ -226,16 +224,13 @@ export async function uploadPublicCacheAsync({
logger.info(`Uploading public cache...`);
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
method: 'PUT',
headers,
body: fs.createReadStream(archivePath),
});
if (!uploadResponse.ok) {
throw new Error(
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
);
}
await uploadWithSignedUrl(
{
signedUrl: { url, headers },
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
},
{ retries: 3, retryIntervalMs: 100 }
);
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -239,6 +239,9 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
@@ -65,6 +65,7 @@ const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
const AppiumInstalledDriversSchema = z.record(
z.string(),
@@ -225,9 +226,12 @@ export function createStartAppiumRemoteSessionBuildFunction(
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: appiumBinPath,
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
@@ -242,7 +246,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
],
env: appiumEnv,
logger,
@@ -278,7 +288,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
authtoken: ngrokAuthtoken,
});
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -296,6 +306,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -360,7 +360,7 @@ export function createStartArgentRemoteSessionBuildFunction(
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -374,6 +374,9 @@ export function createStartArgentRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -57,6 +58,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -68,9 +74,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
@@ -129,7 +146,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -147,6 +164,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -154,6 +174,14 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
@@ -1,9 +1,4 @@
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -33,7 +28,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
ManagedArtifactType.APPLICATION_ARCHIVE,
ManagedArtifactType.BUILD_ARTIFACTS,
...Object.keys(artifactTypeInputToManagedArtifactType),
...Object.values(GenericArtifactType),
// Keep accepting these legacy artifact types for compatibility.
'android-apk',
'android-aab',
'ios-simulator-app',
'ios-ipa',
'other',
],
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -126,12 +126,13 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
const artifact =
artifactSpec.type === null
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
@@ -141,7 +142,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
// Ignoring error.
return;
}
@@ -158,7 +159,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
* values. This makes sure we support all:
* - kebab-case managed artifact types (the original)
* - snake-caps-case managed artifact types (the mistake)
* - generic artifact types.
* - workflow artifact types.
*/
function parseArtifactTypeInput({
inputValue,
@@ -166,19 +167,15 @@ function parseArtifactTypeInput({
}: {
inputValue: string;
platform: Job['platform'];
}): GenericArtifactType | ManagedArtifactType {
}): ManagedArtifactType | null {
if (!inputValue) {
// In build jobs the default artifact type is application-archive.
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
}
// Step's allowedValues ensures input is either
// a key of artifactTypeInputToManagedArtifactType
// or a value of an artifact type.
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
if (translatedManagedArtifactType) {
return translatedManagedArtifactType;
}
return inputValue as GenericArtifactType | ManagedArtifactType;
return (
artifactTypeInputToManagedArtifactType[inputValue] ??
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
null
);
}
@@ -262,6 +262,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
);
}
if (isRedundantBinaryUploadError(errors)) {
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.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: state,
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
@@ -344,6 +357,10 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90189');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -1,9 +1,6 @@
import { BuildFunction } from '@expo/steps';
import fs from 'fs-extra';
import path from 'path';
import { NpmrcTemplate } from '../../templates/npmrc';
import { findPackagerRootDir } from '../../utils/packageManager';
import { setUpNpmrcAsync } from '../../utils/npmrc';
export function createSetUpNpmrcBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -12,28 +9,12 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
name: 'Use NPM_TOKEN',
__metricsId: 'eas/use_npm_token',
fn: async (stepCtx, { env }) => {
const { logger } = stepCtx;
if (env.NPM_TOKEN) {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
logger.info('Creating .npmrc in your project directory with the following contents:');
logger.info(NpmrcTemplate);
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
}
} else {
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(
`.npmrc found at ${path.relative(
stepCtx.global.projectTargetDirectory,
projectNpmrcPath
)}`
);
}
}
await setUpNpmrcAsync({
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
workingDirectory: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
});
},
});
}
@@ -186,6 +186,7 @@ describe(runAgentAsync, () => {
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@anthropic-ai/[email protected]',
@@ -18,7 +18,14 @@ import {
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
...actual,
default: jest.fn(),
};
});
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
@@ -87,8 +94,8 @@ describe(startDeviceRunSessionPreview, () => {
'https://uploads.expo.test/preview',
expect.objectContaining({
method: 'PUT',
body: image,
headers: { 'content-type': 'image/webp' },
body: expect.any(Readable),
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
})
);
await jest.advanceTimersByTimeAsync(59_999);
@@ -130,9 +137,7 @@ describe(startDeviceRunSessionPreview, () => {
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledTimes(1);
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(fields.err.message).toBe(
'Session preview upload request failed (name: FetchError, type: request-timeout).'
);
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
});
@@ -1,4 +1,5 @@
import { SystemError } from '@expo/eas-build-job';
import { FetchError } from 'node-fetch';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -11,6 +12,7 @@ import {
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
jest.unmock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
type Response = InstanceType<typeof Response>;
@@ -262,12 +264,12 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
await expect(createApi().getApplicationAsync()).rejects.toThrow(
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal, timeout, and retries to the request', async () => {
it('passes the signal and timeout without enabling nested retries', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
@@ -280,7 +282,7 @@ describe(DeviceRunSessionWorkerApi, () => {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
);
});
@@ -319,7 +321,7 @@ describe(DeviceRunSessionWorkerApi, () => {
);
const error = await createApi()
.getSessionAsync()
.getSessionAsync({ retries: 0 })
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -334,7 +336,7 @@ describe(DeviceRunSessionWorkerApi, () => {
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
const error = await createApi()
.getSessionAsync()
.getSessionAsync({ retries: 0 })
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -348,7 +350,7 @@ describe(DeviceRunSessionWorkerApi, () => {
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
await expect(createApi().getSessionAsync()).rejects.toThrow(
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned an unexpected response'
);
});
@@ -356,8 +358,183 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects a successful response that is not JSON', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
await expect(createApi().getSessionAsync()).rejects.toThrow(
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
);
});
});
describe('device run session worker API retries', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(turtleFetch).mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
it.each([429, 500, 502, 503, 504])(
'recovers from HTTP %s when starting a session',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(new Response('unavailable', { status }))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const result = createApi().startAsync(
{ remoteConfig: { url: 'https://preview.test' } },
{ retries: 3, timeoutMs: 30_000 }
);
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
jest.mocked(turtleFetch).mock.calls[0]
);
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
retries: 0,
timeout: 30_000,
});
}
);
it('recovers from a network failure', async () => {
jest
.mocked(turtleFetch)
.mockRejectedValueOnce(new Error('socket hang up'))
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
build: null,
});
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('exhausts three retries with exponential backoff', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({
status: 503,
});
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1000);
expect(turtleFetch).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(4000);
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(4);
});
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
await expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({ status });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it.each([
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
[
'screenshot application metadata',
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
],
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
[
'TURN without an explicit retry option',
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
],
])('does not retry %s by default', async (_name, call) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('honors an explicit retry count', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().fetchTurnIceServersAsync({ retries: 1 })
).rejects.toMatchObject({ status: 503 });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('does not send another request after cancellation during backoff', async () => {
const controller = new AbortController();
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({ name: 'AbortError' });
await jest.advanceTimersByTimeAsync(0);
controller.abort();
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
});
describe('device run session retry safety', () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('does not replay artifact creation after a transient failure', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(
createApi().createArtifactUploadSessionAsync({
name: 'report',
filename: 'report.json',
size: 1,
})
).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not send an already cancelled request', async () => {
const controller = new AbortController();
controller.abort();
await expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({
name: 'AbortError',
});
expect(turtleFetch).not.toHaveBeenCalled();
});
it.each(['system', 'body-timeout'])(
'retries a response body transport failure (%s)',
async type => {
jest.useFakeTimers();
try {
const response = jsonResponse({});
jest
.spyOn(response, 'json')
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(response)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).resolves.toMatchObject({ status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
}
);
});
@@ -151,10 +151,11 @@ describe('with a device run session token', () => {
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('starts the session through the worker API', async () => {
it('retries a transient failure when starting the session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'Service unavailable' }] }, 503))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
await uploadRemoteSessionConfigAsync({
@@ -165,9 +166,57 @@ describe('with a device run session token', () => {
});
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
expect(turtleFetch).toHaveBeenCalledTimes(2);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('handles the structured stopped code without matching the message', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
],
},
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).resolves.toBe('stopped');
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
});
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).rejects.toBeInstanceOf(SystemError);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
@@ -213,7 +262,7 @@ describe('with a device run session token', () => {
'--turn-credential',
'credential',
]);
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 0 });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
});
@@ -362,7 +411,9 @@ describe('with a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500): stop here');
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -587,7 +638,9 @@ describe('without a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow('Unexpected response from server (500)');
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -1413,7 +1413,7 @@ it('does not read the iOS egress handoff for an Android host', async () => {
});
it.each([true, false])(
'waits for setup before launch or boot-only readiness (app: %s)',
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
async hasApp => {
const setup = deferred<void>();
const prepared = deferred<ServeSimApplicationOptions | void>();
@@ -1468,70 +1468,78 @@ it.each([true, false])(
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
: undefined
);
if (!hasApp) {
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
if (hasApp) {
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(reported).toBe(false);
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
} else {
expect(runServeSimActionAsync).not.toHaveBeenCalled();
setup.resolve();
const host = await starting;
expect(runServeSimActionAsync).not.toHaveBeenCalled();
await host.finishAsync();
return;
}
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(1);
setup.resolve();
while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) {
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(
jest
.mocked(runServeSimActionAsync)
.mock.calls.map(([options]) => [options.action, options.params])
).toEqual([
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]);
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
).toEqual(
hasApp
? [
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]
: []
);
if (hasApp) {
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
}
let queried = false;
ready.resolve({
ok: true,
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
const host = await starting;
await host.finishAsync();
await expect(access(directory)).rejects.toThrow();
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
try {
expect(reported).toBe(true);
const host = await starting;
let finished = false;
const finishing = host.finishAsync().then(() => {
finished = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
setup.resolve();
await finishing;
} finally {
setup.resolve();
await (await starting).finishAsync();
}
if (hasApp) {
await expect(access(directory)).rejects.toThrow();
}
}
);
@@ -1676,7 +1684,8 @@ it('warns and continues launching when disabling push fails', async () => {
await host.finishAsync();
});
it('cancels pending setup without launching and observes its late failure', async () => {
it('cancels pending app preparation and observes late setup failure', async () => {
const application = deferred<ServeSimApplicationOptions>();
const controller = new AbortController();
let rejectSetup!: (error: Error) => void;
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
@@ -1690,7 +1699,7 @@ it('cancels pending setup without launching and observes its late failure', asyn
logger,
timeoutMs: 2_000,
iosSimulatorUdid: 'emulator-5554',
launchAppIdentifier: 'dev.example.app',
application: application.promise,
signal: controller.signal,
});
const rejected = expect(starting).rejects.toThrow('session canceled');
@@ -2,6 +2,14 @@ jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
...actual,
default: jest.fn(),
};
});
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
@@ -310,7 +318,7 @@ describe('sandbox artifact uploads', () => {
expect(logger.error).toHaveBeenCalledTimes(1);
const logged = inspect([logger.info.mock.calls, logger.error.mock.calls], { depth: 10 });
expect(fetch).toHaveBeenCalledTimes(3);
expect(logged).toContain('Artifact PUT failed: ECONNRESET.');
expect(logged).toContain('Signed URL upload failed: ECONNRESET.');
expect(logged).not.toContain('secret-signature');
});
@@ -1,6 +1,5 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import turtleSpawn from '@expo/turtle-spawn';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
@@ -13,6 +12,7 @@ import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './age
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
import { killProcessGroup } from '../../utils/processes';
export type AgentKind = 'claude-code' | 'codex';
@@ -76,17 +76,13 @@ export async function runAgentAsync({
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
logger.info(`Installing ${packageSpec}.`);
try {
await turtleSpawn(
'npm',
[
'install',
...['--prefix', cliDirectory],
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
packageSpec,
],
{ env: minimalEnv, signal }
);
await installIsolatedNpmPackageAsync({
packageSpec,
directory: cliDirectory,
npmCacheUrl,
pathEnv,
signal,
});
} catch (error) {
signal.throwIfAborted();
throw new SystemError(
@@ -1,120 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { Headers } from 'node-fetch';
import type { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { AbortUtils } from '../../utils/AbortUtils';
export type ArtifactUploadSession = { url: string; headers: unknown };
export type ArtifactSource = { openStream: () => Readable };
export async function putArtifactWithRetriesAsync(
{
uploadSession,
source,
signal,
}: {
uploadSession: ArtifactUploadSession;
source: ArtifactSource;
signal?: AbortSignal;
},
{ attempts = 1 }: { attempts?: number } = {}
): Promise<void> {
for (let attempt = 0; attempt < attempts; attempt++) {
signal?.throwIfAborted();
const stream = source.openStream();
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
const timeout = AbortUtils.resettableTimeout(90_000);
try {
await putArtifactAsync({
uploadSession,
stream,
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
onProgress: timeout.resetDeadline,
});
return;
} catch (error) {
signal?.throwIfAborted();
const retryable =
error instanceof ArtifactPutError
? [408, 429, 500, 502, 503, 504].includes(error.status)
: timeout.signal.aborted || error instanceof ArtifactPutNetworkError;
if (!retryable || attempt + 1 === attempts) {
throw error;
}
await delay(500 * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
await finished(stream).catch(() => {});
}
}
}
async function putArtifactAsync({
uploadSession,
stream,
signal,
onProgress,
}: {
uploadSession: ArtifactUploadSession;
stream: Readable;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
try {
const responding = fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
let response;
try {
response = await responding;
} catch (error) {
signal.throwIfAborted();
throw error instanceof Error && error.name === 'FetchError'
? new ArtifactPutNetworkError(error)
: error;
}
signal.throwIfAborted();
if (!response.ok) {
throw new ArtifactPutError(response);
}
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
}
}
export class ArtifactPutError extends SystemError {
readonly status: number;
readonly reason: string;
constructor(response: { status: number; statusText: string }) {
const reason = `HTTP ${response.status} ${response.statusText}`;
super(`Artifact PUT failed: ${reason}.`);
this.status = response.status;
this.reason = reason;
}
}
// node-fetch names the request URL in its message, and here that URL is signed. The message keeps
// only the error code, and the original is not kept as the cause because loggers and Sentry print causes.
export class ArtifactPutNetworkError extends SystemError {
readonly code: string | undefined;
readonly reason: string;
constructor(error: Error & { code?: unknown }) {
const code = typeof error.code === 'string' ? error.code : undefined;
const reason = code ?? 'network error';
super(`Artifact PUT failed: ${reason}.`);
this.code = code;
this.reason = reason;
}
}
@@ -1,13 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import type { Readable } from 'node:stream';
import { CustomBuildContext } from '../../customBuildContext';
import {
ArtifactPutError,
ArtifactPutNetworkError,
type ArtifactSource,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
type SignedUrl,
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from '../../storage/uploadWithSignedUrl';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
@@ -23,7 +23,11 @@ type ArtifactMetadata = {
export async function uploadDeviceRunSessionArtifactAsync(
ctx: CustomBuildContext,
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal; attempts?: number }
options: ArtifactMetadata & {
openStream: () => Readable;
signal?: AbortSignal;
attempts?: number;
}
): Promise<void> {
try {
const uploadSession = await withDeviceRunSessionTimeoutAsync(
@@ -31,12 +35,12 @@ export async function uploadDeviceRunSessionArtifactAsync(
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
);
options.signal?.throwIfAborted();
await putArtifactWithRetriesAsync(
{ uploadSession, source: options, signal: options.signal },
{ attempts: options.attempts }
await uploadWithSignedUrl(
{ signedUrl: uploadSession, srcGeneratorAsync: async () => options.openStream() },
{ signal: options.signal, retries: (options.attempts ?? 1) - 1, retryIntervalMs: 500 }
);
} catch (error) {
if (error instanceof ArtifactPutError || error instanceof ArtifactPutNetworkError) {
if (error instanceof SignedUrlUploadApiError || error instanceof SignedUrlUploadNetworkError) {
throw new DeviceRunSessionArtifactUploadError(options.artifactId, error.reason);
}
throw error;
@@ -53,9 +57,9 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
): Promise<ArtifactUploadSession> {
): Promise<SignedUrl> {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
let uploadSession: ArtifactUploadSession;
let uploadSession: SignedUrl;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
@@ -10,6 +9,7 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadContentWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { killProcessGroup } from '../../utils/processes';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
@@ -17,31 +17,6 @@ const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
*/
async function putPreviewAsync(
uploadSession: { url: string; headers: Record<string, string> },
{ image, signal }: { image: Buffer; signal: AbortSignal }
): Promise<Awaited<ReturnType<typeof fetch>>> {
try {
return await fetch(uploadSession.url, {
method: 'PUT',
headers: uploadSession.headers,
body: image,
signal,
timeout: 30_000,
});
} catch (err) {
const { name, type, code } = err as { name?: unknown; type?: unknown; code?: unknown };
const details = Object.entries({ name, type, code })
.filter(([, value]) => typeof value === 'string' && value.length > 0)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
throw new Error(`Session preview upload request failed (${details || 'unknown error'}).`);
}
}
export function startDeviceRunSessionPreview({
ctx,
@@ -75,10 +50,10 @@ export function startDeviceRunSessionPreview({
if (controller.signal.aborted) {
return;
}
const response = await putPreviewAsync(uploadSession, { image, signal });
if (!response.ok) {
throw new Error(`Session preview upload failed: ${response.status} ${response.statusText}`);
}
await uploadContentWithSignedUrl(
{ signedUrl: uploadSession, content: image },
{ signal, retries: 0, stallTimeoutMs: 30_000 }
);
} catch (err) {
if (!controller.signal.aborted) {
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
@@ -1,9 +1,11 @@
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { FetchError } from 'node-fetch';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
import { turtleFetch } from '../../utils/turtleFetch';
/**
@@ -75,7 +77,7 @@ type RequestOptions = {
signal?: AbortSignal;
/** node-fetch timeout of each attempt, in milliseconds. */
timeoutMs?: number;
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
retries?: number;
logger?: bunyan;
};
@@ -89,7 +91,8 @@ export class DeviceRunSessionWorkerApiError extends Error {
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions
options?: ErrorOptions,
readonly code?: string
) {
super(message, options);
this.name = 'DeviceRunSessionWorkerApiError';
@@ -189,16 +192,44 @@ export class DeviceRunSessionWorkerApi {
}
private async requestAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{ retries = 0, ...options }: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const request = () => {
options.signal?.throwIfAborted();
return this.requestOnceAsync(method, route, dataSchema, options);
};
if (retries === 0) {
return await request();
}
return await promiseRetryWithCondition(
request,
error =>
!options.signal?.aborted &&
error instanceof DeviceRunSessionWorkerApiError &&
(error.status === undefined || error.status === 429 || error.status >= 500),
{ retries, factor: 2, minTimeout: 1000 },
({ attemptNumber, maxAttemptsCount }) =>
options.logger?.warn(
`Retrying ${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route} (attempt ${attemptNumber}/${maxAttemptsCount}).`
)
)();
}
private async requestOnceAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{
signal,
timeoutMs,
retries = 0,
logger,
json,
}: RequestOptions & { json?: Record<string, unknown> }
}: Omit<RequestOptions, 'retries'> & {
json?: Record<string, unknown>;
}
): Promise<T> {
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
const url = new URL(
@@ -212,7 +243,7 @@ export class DeviceRunSessionWorkerApi {
...(json ? { json } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
retries,
retries: 0,
shouldThrowOnNotOk: false,
logger,
})
@@ -237,10 +268,21 @@ export class DeviceRunSessionWorkerApi {
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status
response.status,
undefined,
apiError?.success ? apiError.data.errors[0]?.code : undefined
);
}
if (!bodyResult.ok) {
if (
bodyResult.reason instanceof FetchError &&
['system', 'body-timeout'].includes(bodyResult.reason.type)
) {
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed while reading the response body.`,
undefined
);
}
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} returned a response that is not JSON.`,
response.status
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
}
let stagedAppDirectory: string | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
@@ -670,7 +670,7 @@ async function startDeviceSessionHostInternalAsync(
onStopped: successful => onFinished(successful && !previewFailed),
})
.finally(async () => {
await beforeLaunch;
await simulatorSetup;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
@@ -711,7 +711,8 @@ async function startDeviceSessionHostInternalAsync(
);
}
signal?.throwIfAborted();
beforeLaunch = asyncResult(
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
simulatorSetup = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
@@ -749,7 +750,6 @@ async function startDeviceSessionHostInternalAsync(
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
@@ -31,14 +31,17 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<void> {
}): Promise<'started' | 'stopped'> {
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
const localEgress = await readLocalEgressHandoffAsync();
signal?.throwIfAborted();
await uploadRemoteSessionConfigAsync({
const sessionStart = await uploadRemoteSessionConfigAsync({
...options,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (sessionStart === 'stopped') {
return sessionStart;
}
if (localEgress && !signal?.aborted) {
// Guard refusals from here on show up under this step in the job log,
// alongside the monitor's reports, instead of under the boot step.
@@ -57,4 +60,5 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
});
}
return sessionStart;
}
@@ -31,6 +31,7 @@ import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
type DeviceRunSessionStatus,
DeviceRunSessionWorkerApiError,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
@@ -554,14 +555,26 @@ export async function uploadRemoteSessionConfigAsync({
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<void> {
}): Promise<'started' | 'stopped'> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync({ remoteConfig });
await workerApi.startAsync(
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
return 'started';
} catch (err) {
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
) {
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
return 'stopped';
}
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
@@ -14,12 +14,8 @@ import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
ArtifactPutError,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateSandboxArtifactUploadSession(
@@ -238,18 +234,23 @@ async function putSandboxArtifactAsync({
}: {
filePath: string;
size: number;
uploadSession: ArtifactUploadSession;
uploadSession: { url: string; headers: unknown };
signal: AbortSignal;
}): Promise<void> {
const openStream = (): Readable =>
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 });
try {
await putArtifactWithRetriesAsync(
{ uploadSession, source: { openStream }, signal },
{ attempts: 3 }
await uploadWithSignedUrl(
{
signedUrl: {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
},
srcGeneratorAsync: async () =>
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 }),
},
{ signal, retries: 2, retryIntervalMs: 500 }
);
} catch (error) {
if (!(error instanceof ArtifactPutError && error.status === 412)) {
if (!(error instanceof SignedUrlUploadApiError && error.status === 412)) {
throw error;
}
}
@@ -1,325 +1,254 @@
import { randomBytes } from 'crypto';
import fetch, { RequestInit, Response } from 'node-fetch';
import { Readable } from 'stream';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
import {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadContentWithSignedUrl,
uploadWithSignedUrl,
} from '../uploadWithSignedUrl';
jest.mock('node-fetch');
jest.unmock('node-fetch');
class ErrorWithCode extends Error {
protected readonly _code: string | undefined;
let handle: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
let server: ReturnType<typeof createServer>;
let origin: string;
let signedUrl: { url: string; headers: Record<string, string> };
let requests: number;
constructor(message: string, code?: string) {
super(message);
if (code) {
this._code = code;
}
}
public get code(): string | undefined {
return this._code;
async function readBodyAsync(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString();
}
class DNSError extends ErrorWithCode {
protected readonly _code: 'ENOTFOUND' | 'EAI_AGAIN' = 'ENOTFOUND';
}
const TEST_BUCKET = 'turtle-v2-test';
function createSignedUrl(key: string, contentType: string): SignedUrl {
return {
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
headers: { 'content-type': contentType },
function respondWithStatuses(statuses: number[]): void {
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = statuses[requests - 1];
response.end();
};
}
describe('signed URL uploader', () => {
describe('uploadWithSignedUrl', () => {
it('should throw an error if upload fails', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: false,
status: 500,
} as Response;
beforeEach(async () => {
requests = 0;
server = createServer((request, response) => {
requests++;
void handle(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
origin = `http://127.0.0.1:${address.port}`;
signedUrl = { url: `${origin}/bucket/object?signature=secret-signature`, headers: {} };
});
const key = 'cat.jpg';
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
});
const signedUrl = createSignedUrl(key, 'image/jpeg');
describe(uploadWithSignedUrl, () => {
it('sends the full body on every attempt', async () => {
const bodies: string[] = [];
handle = async (request, response) => {
bodies.push(await readBodyAsync(request));
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
};
const payload = '*'.repeat(128 * 1024);
fetchMock.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow();
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from([Buffer.from(payload)]) },
{ retryIntervalMs: 0 }
);
expect(bodies).toEqual([payload, payload]);
});
it('waits longer after each failed attempt', async () => {
respondWithStatuses([503, 503, 200]);
const startedAt = performance.now();
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ retryIntervalMs: 100 }
);
expect(requests).toBe(3);
expect(performance.now() - startedAt).toBeGreaterThanOrEqual(290);
});
it('does not retry a client error', async () => {
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = 403;
response.end('<Error><Code>AccessDenied</Code></Error>');
};
const error = await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadApiError);
expect(error).toMatchObject({
status: 403,
reason: 'HTTP 403 Forbidden',
message: 'Signed URL upload failed: HTTP 403 Forbidden.',
});
expect(requests).toBe(1);
});
it('should return stripped URL if successful', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
});
it('should retry upload on DNS error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes and DNS errors error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on DNS error more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
const lastDNSError = new DNSError('failed thrice');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementationOnce(async () => {
throw lastDNSError;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow(lastDNSError);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on retriable status code more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 504,
} as Response;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on other error codes', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
fetchMock
.mockImplementationOnce(async () => {
throw nonDNSError;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow(nonDNSError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('retries upload with full stream', async () => {
const fetchMock = jest.mocked(fetch);
const receivedBodies: Buffer[] = [];
const recordBody = async (request: RequestInit | undefined): Promise<void> => {
let body = Buffer.from([]);
for await (const chunk of request!.body as Readable) {
body = Buffer.concat([body, chunk]);
}
receivedBodies.push(body);
};
fetchMock
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
throw new DNSError('failed once');
})
.mockImplementation(async (_path, request) => {
await recordBody(request);
return {
ok: true,
status: 201,
} as Response;
});
const signedUrl = createSignedUrl('text.txt', 'text/plain');
const bufferToUpload = randomBytes(16);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(receivedBodies.length).toBe(3);
for (const body of receivedBodies) {
// Here we're testing that each body received in every retry is the same.
// If we passed the same body instance to each of the retries
// each retry would consume a chunk of the same stream,
// causing the uploaded file to be corrupted (missing first bytes).
expect(body).toStrictEqual(bufferToUpload);
it('retries a dropped connection', async () => {
handle = async (request, response) => {
if (requests === 1) {
request.socket.destroy();
return;
}
await readBodyAsync(request);
response.end();
};
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
await uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 });
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
expect(requests).toBe(2);
});
it('retries a refused connection and keeps the signed URL out of the error', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
const error = await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync },
{ retries: 1, retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
expect(error).toMatchObject({ code: 'ECONNREFUSED' });
expect(error.message).not.toContain('secret-signature');
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
});
it('does not retry a network error outside the retryable codes', async () => {
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
const error = await uploadWithSignedUrl(
{
signedUrl: { ...signedUrl, url: signedUrl.url.replace('http:', 'https:') },
srcGeneratorAsync,
},
{ retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
expect(error).toMatchObject({ code: 'EPROTO' });
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
});
it('propagates a source error without retrying, even with a retryable code', async () => {
const sourceError = Object.assign(new Error('read failed'), { code: 'ECONNRESET' });
const srcGeneratorAsync = jest.fn(async (): Promise<Readable> => {
throw sourceError;
});
await expect(
uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 })
).rejects.toBe(sourceError);
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
expect(requests).toBe(0);
});
it('retries an attempt that stalls past the stall timeout', async () => {
handle = async (request, response) => {
await readBodyAsync(request);
if (requests > 1) {
response.end();
}
};
const streams: Readable[] = [];
const srcGeneratorAsync = async (): Promise<Readable> => {
const stream = Readable.from(['body']);
streams.push(stream);
return stream;
};
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync },
{ retryIntervalMs: 0, stallTimeoutMs: 100 }
);
expect(requests).toBe(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('stops waiting for the next attempt when aborted', async () => {
const controller = new AbortController();
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = 503;
response.end();
setTimeout(() => controller.abort(), 50);
};
await expect(
uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ signal: controller.signal, retryIntervalMs: 60_000 }
)
).rejects.toMatchObject({ name: 'AbortError' });
expect(requests).toBe(1);
});
});
describe(uploadContentWithSignedUrl, () => {
let received: IncomingMessage | undefined;
let body: string;
beforeEach(() => {
received = undefined;
handle = async (request, response) => {
received = request;
body = await readBodyAsync(request);
response.end();
};
});
it('counts the content length in bytes', async () => {
await uploadContentWithSignedUrl({ signedUrl, content: 'zażółć' });
expect(received?.headersDistinct['content-length']).toEqual(['10']);
expect(body).toBe('zażółć');
});
it('keeps a content length that the signed headers already set', async () => {
signedUrl.headers = { 'Content-Length': '5' };
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
expect(received?.headersDistinct['content-length']).toEqual(['5']);
});
it('labels only string content as plain text', async () => {
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
expect(received?.headersDistinct['content-type']).toEqual(['text/plain;charset=UTF-8']);
await uploadContentWithSignedUrl({ signedUrl, content: Buffer.from('hello') });
expect(received?.headersDistinct['content-type']).toBeUndefined();
});
it('keeps a content type that the signed headers already set', async () => {
signedUrl.headers = { 'Content-Type': 'application/json' };
await uploadContentWithSignedUrl({ signedUrl, content: '{}' });
expect(received?.headersDistinct['content-type']).toEqual(['application/json']);
});
});
-70
View File
@@ -1,70 +0,0 @@
import { Response } from 'node-fetch';
type RetryOptions = {
retries: number;
retryIntervalMs: number;
shouldRetryOnError: (error: unknown) => boolean;
shouldRetryOnResponse: (response: Response) => boolean;
};
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
export async function retryOnUploadFailure(
fn: (attemptCount: number) => Promise<Response>,
{
retries,
retryIntervalMs,
}: { retries: RetryOptions['retries']; retryIntervalMs: RetryOptions['retryIntervalMs'] }
): Promise<Response> {
return await retry(fn, {
retries,
retryIntervalMs,
shouldRetryOnError: err => {
return (
isErrorWithCode(err) &&
(err.code === 'ENOTFOUND' ||
err.code === 'EAI_AGAIN' ||
err.code === 'ECONNRESET' ||
err.code === 'ETIMEDOUT' ||
err.code === 'EPIPE')
);
},
shouldRetryOnResponse: resp => {
return [408, 429, 500, 502, 503, 504].includes(resp.status);
},
});
}
function isErrorWithCode(error: unknown): error is { code: string } {
return typeof error === 'object' && error !== null && 'code' in error;
}
/**
* Wrapper used to execute an inner function and possibly retry it if it throws an error
* @param fn Function to be executed and retried in case of error
* @param retries How many times at most should the function be retried
* @param retryIntervalMs Time interval between the retries
* @param shouldRetryOnError Function that determines if the function should be retried based on the error thrown
* @param shouldRetryOnResponse Function that determines if the function should be retried based on the response
*/
async function retry(
fn: (attemptCount: number) => Promise<Response>,
{ retries, retryIntervalMs, shouldRetryOnError, shouldRetryOnResponse }: RetryOptions
): Promise<Response> {
let attemptCount = -1;
for (;;) {
try {
attemptCount += 1;
const resp = await fn(attemptCount);
if (attemptCount < retries && shouldRetryOnResponse(resp)) {
await new Promise(res => setTimeout(res, retryIntervalMs));
} else {
return resp;
}
} catch (err: unknown) {
if (attemptCount === retries || !shouldRetryOnError(err)) {
throw err;
}
await new Promise(res => setTimeout(res, retryIntervalMs));
}
}
}
@@ -1,8 +1,11 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { FetchError } from 'node-fetch';
import { Readable } from 'stream';
import { URL } from 'url';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { retryOnUploadFailure } from './retry';
import { AbortUtils } from '../utils/AbortUtils';
import { isConnectFailure, isConnectionInterruptedError } from '../utils/networkErrors';
export type SignedUrl = {
url: string;
@@ -12,46 +15,140 @@ export type SignedUrl = {
export type UploadWithSignedUrlParams = {
signedUrl: SignedUrl;
srcGeneratorAsync: () => Promise<Readable>;
retryIntervalMs?: number;
retries?: number;
};
export async function uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync,
retries = 2,
retryIntervalMs = 30_000,
}: UploadWithSignedUrlParams): Promise<string> {
let resp;
try {
resp = await retryOnUploadFailure(
async () => {
const src = await srcGeneratorAsync();
return await fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: src,
});
},
{ retries, retryIntervalMs }
);
} catch (err: unknown) {
if (err instanceof FetchError) {
throw new Error(`Failed to upload the file, reason: ${err.code}`);
}
throw err;
}
export type UploadWithSignedUrlOptions = {
signal?: AbortSignal;
retries?: number;
retryIntervalMs?: number;
stallTimeoutMs?: number;
};
if (!resp.ok) {
let body: string | undefined;
export async function uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync }: UploadWithSignedUrlParams,
{
signal,
retries = 2,
retryIntervalMs = 30_000,
stallTimeoutMs = 90_000,
}: UploadWithSignedUrlOptions = {}
): Promise<void> {
for (let attempt = 0; ; attempt++) {
signal?.throwIfAborted();
const stream = await srcGeneratorAsync();
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
const timeout = AbortUtils.resettableTimeout(stallTimeoutMs);
try {
body = await resp.text();
} catch {}
throw new Error(
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
);
await putAsync({
signedUrl,
stream,
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
onProgress: timeout.resetDeadline,
});
return;
} catch (error) {
signal?.throwIfAborted();
if (attempt >= retries || (!timeout.signal.aborted && !isRetryableError(error))) {
throw error;
}
await delay(retryIntervalMs * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
await finished(stream).catch(() => {});
}
}
}
export async function uploadContentWithSignedUrl(
{ signedUrl, content }: { signedUrl: SignedUrl; content: string | Buffer },
options?: UploadWithSignedUrlOptions
): Promise<void> {
const headers = { ...signedUrl.headers };
const headerNames = Object.keys(headers).map(name => name.toLowerCase());
if (!headerNames.includes('content-length')) {
headers['content-length'] = String(Buffer.byteLength(content));
}
if (typeof content === 'string' && !headerNames.includes('content-type')) {
headers['content-type'] = 'text/plain;charset=UTF-8';
}
await uploadWithSignedUrl(
{
signedUrl: { url: signedUrl.url, headers },
srcGeneratorAsync: async () => Readable.from([content]),
},
options
);
}
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504];
function isRetryableError(error: unknown): boolean {
if (error instanceof SignedUrlUploadApiError && RETRYABLE_STATUSES.includes(error.status)) {
return true;
}
return isConnectFailure(error) || isConnectionInterruptedError(error);
}
async function putAsync({
signedUrl,
stream,
signal,
onProgress,
}: {
signedUrl: SignedUrl;
stream: Readable;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
try {
const responsePromise = fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
const response = await responsePromise;
signal.throwIfAborted();
if (!response.ok) {
throw new SignedUrlUploadApiError(response);
}
} catch (error) {
signal.throwIfAborted();
if (error instanceof FetchError) {
throw new SignedUrlUploadNetworkError(error);
}
throw error;
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
}
}
export class SignedUrlUploadApiError extends SystemError {
readonly status: number;
readonly reason: string;
constructor(response: { status: number; statusText: string }) {
const reason = `HTTP ${response.status} ${response.statusText}`;
super(`Signed URL upload failed: ${reason}.`);
this.status = response.status;
this.reason = reason;
}
}
// node-fetch names the request URL in its message, which we want to avoid for signed urls.
export class SignedUrlUploadNetworkError extends SystemError {
readonly code: string | undefined;
readonly reason: string;
constructor(error: FetchError) {
const reason = error.code ?? 'network error';
super(`Signed URL upload failed: ${reason}.`);
this.code = error.code;
this.reason = reason;
}
const url = new URL(signedUrl.url);
return `${url.protocol}//${url.host}${url.pathname}`;
}
+3 -1
View File
@@ -1,3 +1,5 @@
export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
export const NpmrcAuthTokenLine = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}';
export const NpmrcTemplate = `${NpmrcAuthTokenLine}
registry=https://registry.npmjs.org/
`;
@@ -154,8 +154,9 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
// Give interactive sessions more headroom for guest VSOCK packet allocations.
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
// Keep the lower RAM default for older images; newer guests need more headroom for ADB.
const apiLevel = systemImagePackage.match(/^system-images;android-(\d+)/)?.[1];
const ramSize = !env.DEVICE_RUN_SESSION_ID && apiLevel && Number(apiLevel) < 35 ? 2048 : 4096;
logger.info(`Setting hw.ramSize to ${ramSize}.`);
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
@@ -121,7 +121,7 @@ describe('AndroidEmulatorUtils', () => {
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
'hw.ramSize=2048',
'hw.ramSize=4096',
'',
'hw.lcd.density=220',
'',
@@ -0,0 +1,89 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../installIsolatedNpmPackageAsync';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const spawnMock = jest.mocked(spawn);
const npmCacheUrl = 'https://npm-cache.expo.test';
describe(installIsolatedNpmPackageAsync, () => {
it('retries without the npm cache registry when the install fails against it', async () => {
const logger = createMockLogger();
const cacheFailure = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed, reason: ECONNREFUSED`,
});
spawnMock.mockRejectedValueOnce(cacheFailure).mockResolvedValueOnce({} as never);
await installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
logger,
});
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0][1]).toEqual(expect.arrayContaining(['--registry', npmCacheUrl]));
expect(spawnMock.mock.calls[1][1]).not.toContain('--registry');
expect(spawnMock.mock.calls[1][2]).toEqual({
env: { PATH: '/usr/bin', HOME: '/tmp/sandbox' },
logger,
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install @scope/fork@next using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({ name: 'NpmCacheRegistryInstallError', cause: cacheFailure }),
{ level: 'warning', extras: { packageSpec: '@scope/fork@next', npmCacheUrl } }
);
});
it('rethrows an install failure that does not mention the npm cache registry', async () => {
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: 'npm error notarget No matching version found for @scope/fork@next.',
});
spawnMock.mockRejectedValueOnce(error);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
})
).rejects.toBe(error);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('rethrows the cancellation of an install without retrying', async () => {
const controller = new AbortController();
const cancellation = new Error('cancelled');
spawnMock.mockImplementationOnce((() => {
controller.abort(cancellation);
return Promise.reject(
Object.assign(new Error('npm install was killed with SIGTERM'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed`,
})
);
}) as unknown as typeof spawn);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
signal: controller.signal,
})
).rejects.toBe(cancellation);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
@@ -20,6 +20,7 @@ it.each([
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
'ERR_SOCKET_CONNECTION_TIMEOUT',
])('recognizes wrapped %s as a connection establishment failure', code => {
const error = new Error('Request failed', {
cause: new TypeError('fetch failed', {
@@ -30,7 +31,7 @@ it.each([
expect(isConnectionInterruptedError(error)).toBe(false);
});
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
'requires caller-specific recovery for wrapped %s',
code => {
const error = new Error('Request failed', {
@@ -41,7 +42,7 @@ it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
}
);
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
it.each(['CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
const error = Object.assign(new Error('Request failed'), { code });
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(false);
@@ -0,0 +1,120 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createMockLogger } from '../../__tests__/utils/logger';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../../templates/npmrc';
import { setUpNpmrcAsync } from '../npmrc';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/build';
const homeDirectory = '/home/expo';
describe(setUpNpmrcAsync, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder to ~/.npmrc on EAS Build and leaves the project .npmrc alone', async () => {
const projectContents = '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, projectContents);
await vol.promises.writeFile(`${homeDirectory}/.npmrc`, 'fund=false');
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toBe(
`fund=false\n${NpmrcAuthTokenLine}\n`
);
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
projectContents
);
});
it('adds the NPM_TOKEN placeholder to the user config file npm resolves from the build env', async () => {
await vol.promises.mkdir(`${homeDirectory}/npm`, { recursive: true });
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${homeDirectory}/npm/npmrc\n` } as never);
const env = {
NPM_TOKEN: 'npm_secret_token',
EAS_BUILD_RUNNER: 'eas-build',
NPM_CONFIG_USERCONFIG: '~/npm/npmrc',
};
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env,
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['config', 'get', 'userconfig'],
expect.objectContaining({ cwd: projectTargetDirectory, env })
);
expect(await vol.promises.readFile(`${homeDirectory}/npm/npmrc`, 'utf8')).toBe(
`\n${NpmrcAuthTokenLine}\n`
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('creates the project .npmrc and leaves the user npmrc alone on local builds', async () => {
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
NpmrcTemplate
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('leaves an existing project .npmrc untouched on local builds', async () => {
const existingContents = 'registry=https://registry.example.com/\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, existingContents);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
existingContents
);
});
it('reports an existing .npmrc when NPM_TOKEN is not set', async () => {
const logger = createMockLogger();
await vol.promises.writeFile(
`${projectTargetDirectory}/.npmrc`,
'registry=https://registry.example.com/\n'
);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { EAS_BUILD_RUNNER: 'eas-build' },
logger,
});
expect(logger.info).toHaveBeenCalledWith('.npmrc found at .npmrc');
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
});
@@ -1,9 +1,7 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
PackageManager,
@@ -16,7 +14,6 @@ import {
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -293,33 +290,6 @@ describe(resolvePackageInstall, () => {
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as bunyan;
it('should resolve version from package name and dist tag', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'latest',
});
expect(semver.valid(version)).toBeTruthy();
});
it('should return null if version cannot be resolved', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'nonexistent-dist-tag',
});
expect(version).toBeNull();
});
});
describe(findPackagerRootDir, () => {
beforeEach(() => {
vol.reset();
@@ -0,0 +1,62 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from './npmCacheRegistry';
import { Sentry } from '../sentry';
export async function installIsolatedNpmPackageAsync({
packageSpec,
directory,
npmCacheUrl,
pathEnv,
logger,
signal,
}: {
packageSpec: string;
directory: string;
npmCacheUrl: string | undefined;
pathEnv: string | undefined;
logger?: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const installAsync = async (registryUrl: string | undefined): Promise<void> => {
await spawn(
'npm',
[
'install',
'--prefix',
directory,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
...(registryUrl ? ['--registry', registryUrl] : []),
packageSpec,
],
// npm reads the user config in $HOME from any cwd, so the install directory doubles as HOME.
{ env: { PATH: pathEnv, HOME: directory }, logger, signal }
);
};
if (!npmCacheUrl) {
await installAsync(undefined);
return;
}
try {
await installAsync(npmCacheUrl);
} catch (error) {
signal?.throwIfAborted();
if (!isNpmCacheInstallFailure(error, npmCacheUrl)) {
throw error;
}
logger?.warn(
`Failed to install ${packageSpec} using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(error), {
level: 'warning',
extras: { packageSpec, npmCacheUrl },
});
await installAsync(undefined);
}
}
@@ -8,6 +8,14 @@ const CONNECT_FAILURE_CODES = new Set([
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
'ERR_SOCKET_CONNECTION_TIMEOUT',
]);
const CONNECTION_INTERRUPTED_CODES = new Set([
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_SOCKET',
]);
export function isConnectFailure(error: unknown): boolean {
@@ -26,7 +34,11 @@ export function isConnectFailure(error: unknown): boolean {
// The server may already have applied a request. The caller must decide whether repetition is safe.
export function isConnectionInterruptedError(error: unknown): boolean {
for (let current = error; current instanceof Error; current = current.cause) {
if ('code' in current && (current.code === 'ECONNRESET' || current.code === 'UND_ERR_SOCKET')) {
if (
'code' in current &&
typeof current.code === 'string' &&
CONNECTION_INTERRUPTED_CODES.has(current.code)
) {
return true;
}
}
@@ -0,0 +1,20 @@
import { SystemError } from '@expo/eas-build-job';
export function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
export class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+50 -16
View File
@@ -1,23 +1,56 @@
import { Job } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
import { BuildContext } from '../context';
import { NpmrcTemplate } from '../templates/npmrc';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../templates/npmrc';
export async function setUpNpmrcAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
if (ctx.env.NPM_TOKEN) {
await createNpmrcIfNotExistsAsync(ctx, logger);
export async function setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory,
env,
logger,
}: {
projectTargetDirectory: string;
workingDirectory: string;
env: Record<string, string | undefined>;
logger: bunyan;
}): Promise<void> {
if (!env.NPM_TOKEN) {
await logIfNpmrcExistsAsync(projectTargetDirectory, workingDirectory, logger);
return;
}
logger.info('We detected that you set the NPM_TOKEN environment variable');
// pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc but expand them in ~/.npmrc
if (env.EAS_BUILD_RUNNER === 'eas-build') {
await addNpmTokenToUserNpmrcAsync(workingDirectory, env, logger);
} else {
await logIfNpmrcExistsAsync(ctx, logger);
await createProjectNpmrcIfNotExistsAsync(projectTargetDirectory, logger);
}
}
async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc');
async function addNpmTokenToUserNpmrcAsync(
workingDirectory: string,
env: Record<string, string | undefined>,
logger: bunyan
): Promise<void> {
const userNpmrcPath = (
await spawn('npm', ['config', 'get', 'userconfig'], {
cwd: findPackagerRootDir(workingDirectory),
env,
stdio: 'pipe',
})
).stdout.trim();
logger.info(`Adding "${NpmrcAuthTokenLine}" to ${userNpmrcPath}`);
await fs.appendFile(userNpmrcPath, `\n${NpmrcAuthTokenLine}\n`);
}
async function createProjectNpmrcIfNotExistsAsync(
projectTargetDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
@@ -27,12 +60,13 @@ async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunya
}
}
async function logIfNpmrcExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
const projectNpmrcPath = path.join(
findPackagerRootDir(ctx.getReactNativeProjectDirectory()),
'.npmrc'
);
async function logIfNpmrcExistsAsync(
projectTargetDirectory: string,
workingDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(findPackagerRootDir(workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`);
logger.info(`.npmrc found at ${path.relative(projectTargetDirectory, projectNpmrcPath)}`);
}
}
@@ -1,5 +1,4 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
@@ -126,34 +125,6 @@ export function resolvePackageInstall(
}
}
/**
* Get the version of a package from the dist-tags.
* Returns null if the version cannot be resolved.
*/
export async function resolvePackageVersionAsync({
logger,
packageName,
distTag,
}: {
logger: bunyan;
packageName: string;
distTag: string;
}): Promise<string | null> {
try {
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
stdio: 'pipe',
});
const distTags = JSON.parse(stdout);
if (distTag in distTags) {
return distTags[distTag];
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
}
return null;
}
export function findPackagerRootDir(currentDir: string): string {
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
}
@@ -1,5 +1,17 @@
# create-eas-build-function
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "25.3.0",
"version": "27.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "25.3.0"
"@expo/steps": "27.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+12
View File
@@ -1,5 +1,17 @@
# @expo/downloader
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.3.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,17 @@
# eas-build-cache-provider
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "25.3.0",
"version": "27.1.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+18
View File
@@ -1,5 +1,23 @@
# @expo/eas-build-job
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.3.0",
"version": "27.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "25.3.0",
"@expo/logger": "27.1.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/turtle-spawn": "27.1.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
-21
View File
@@ -7,24 +7,3 @@ export enum ManagedArtifactType {
*/
XCODE_BUILD_LOGS = 'XCODE_BUILD_LOGS',
}
export enum GenericArtifactType {
ANDROID_APK = 'android-apk',
ANDROID_AAB = 'android-aab',
IOS_SIMULATOR_APP = 'ios-simulator-app',
IOS_IPA = 'ios-ipa',
OTHER = 'other',
}
export const isGenericArtifact = <
TSpec extends { type: GenericArtifactType | ManagedArtifactType },
>(
artifactSpec: TSpec
): artifactSpec is TSpec & { type: GenericArtifactType } => {
if (Object.values(GenericArtifactType).includes(artifactSpec.type as GenericArtifactType)) {
return true;
}
return false;
};
+45
View File
@@ -1,5 +1,50 @@
# eas-cli
## 27.1.0
### Minor Changes
- Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### Patch Changes
- Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Major Changes
- Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### Minor Changes
- Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
## 26.0.0
### Patch Changes
- Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
### Minor Changes
+268 -198
View File
File diff suppressed because it is too large. Load diff
+2941 -733
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.3.0",
"version": "27.1.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/eas-build-job": "27.1.0",
"@expo/eas-json": "27.1.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.3.0",
"@expo/logger": "27.1.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.3.0",
"@expo/steps": "27.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -198,6 +198,28 @@ export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
return getMockWorkflowRunsFragment();
}
export function getMockWorkflowRunFragment(
status: WorkflowRunStatus,
overrides?: Partial<WorkflowRunFragment>
): WorkflowRunFragment {
return {
id: `run-${status}`,
status,
triggerEventType: WorkflowRunTriggerEventType.Manual,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
...overrides,
};
}
export function getMockWorkflowRunsFragment(
params?:
| undefined
@@ -240,6 +262,7 @@ export function getMockWorkflowRunsFragment(
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
@@ -332,6 +355,7 @@ export function getMockWorkflowRunWithJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -364,6 +388,7 @@ export function getMockWorkflowRunWithBuildJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -0,0 +1,88 @@
import {
getMockWorkflowRunFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import WorkflowRunCancel from '../../commands/workflow/cancel';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { promptAsync } from '../../prompts';
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../log');
jest.mock('../../prompts');
describe(WorkflowRunCancel, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('prompts with in-progress and queued runs and cancels the selected ones', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const inProgressRun = getMockWorkflowRunFragment(WorkflowRunStatus.InProgress);
const waitingRun = getMockWorkflowRunFragment(WorkflowRunStatus.Waiting);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockImplementation(async (_graphqlClient, _appId, status) =>
status === WorkflowRunStatus.InProgress ? [inProgressRun] : [waitingRun]
);
jest.mocked(promptAsync).mockResolvedValueOnce({ selectedRuns: [waitingRun.id] });
const cmd = mockTestCommand(WorkflowRunCancel, [], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.InProgress,
50
);
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.Waiting,
50
);
const promptCall = jest.mocked(promptAsync).mock.calls[0][0] as any;
expect(promptCall.choices.map((choice: any) => choice.value)).toEqual([
inProgressRun.id,
waitingRun.id,
]);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: waitingRun.id }
);
});
test('throws in non-interactive mode when no run IDs are given', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive'], ctx);
await expect(cmd.run()).rejects.toThrow(
'Must supply workflow run IDs as arguments when in non-interactive mode'
);
expect(promptAsync).not.toHaveBeenCalled();
});
test('cancels the given run IDs without prompting in non-interactive mode', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive', 'run-id-1'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: 'run-id-1' }
);
});
});
@@ -142,6 +142,7 @@ describe(WorkflowRunList, () => {
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more