mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e716de0f | ||
|
|
42fc262d8d | ||
|
|
879a13990b | ||
|
|
6581bb7104 | ||
|
|
7e42b3867d | ||
|
|
5499d03b8f | ||
|
|
03c25a7a29 | ||
|
|
88edfb0729 | ||
|
|
5d8f23e666 | ||
|
|
f77cd548c3 | ||
|
|
0abcbb746d | ||
|
|
7fc29a9f18 | ||
|
|
0a39966a40 | ||
|
|
cf1d77160b | ||
|
|
f65472ae0a | ||
|
|
40f2b55f96 | ||
|
|
dc759c0854 | ||
|
|
e38a605d89 | ||
|
|
a586f0cb68 | ||
|
|
eebfa728a7 | ||
|
|
bf478c719c | ||
|
|
a7fcb5140c | ||
|
|
f0a7377069 | ||
|
|
b755469716 | ||
|
|
1830f88603 | ||
|
|
75f83829d8 | ||
|
|
08c92bf937 | ||
|
|
1615d36cf9 | ||
|
|
88e23aaa09 | ||
|
|
0a274a6fef | ||
|
|
78f6782970 | ||
|
|
5757091b33 | ||
|
|
dd6ad17179 | ||
|
|
695f567760 | ||
|
|
15e2302575 | ||
|
|
ee4f03d44e | ||
|
|
5e192ecba6 | ||
|
|
864b11effe | ||
|
|
62df1bae1c | ||
|
|
8070663faa | ||
|
|
e38a5a88d2 | ||
|
|
82845def18 | ||
|
|
476b4e9b09 | ||
|
|
9315aebef5 | ||
|
|
786d8bac85 | ||
|
|
8f67105e5a | ||
|
|
3bfb405c87 | ||
|
|
73335e8233 | ||
|
|
9af03f901e | ||
|
|
13ab40e271 | ||
|
|
355f3f2012 | ||
|
|
736e1aeb90 | ||
|
|
58d372a096 | ||
|
|
56d01c4129 | ||
|
|
bcb5d96ea8 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Regenerate graphql code
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain missing background task identifiers reported by App Store Connect.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain App Store Connect NOT_AUTHORIZED responses in the shared API client.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/eas-json': minor
|
||||
---
|
||||
|
||||
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Use single-device previews when the session already has a valid device ID.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry transient failures when reporting that a device run session has started.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
'@expo/steps': patch
|
||||
---
|
||||
|
||||
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.
|
||||
@@ -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).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Run package-manager version queries in parallel during worker preparation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Remove the Free plan build limit guidance experiment for coding agents from `eas build`.
|
||||
@@ -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
|
||||
---
|
||||
|
||||
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Import ordered action configurations with `eas simulator:control --import` and show progress for each action.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add searchable interactive simulator controls with session and device details, guided parameters, and action results.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Start simulator sessions from saved settings and apply control actions after readiness.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Unify signed URL uploading to a single implementation and use it everywhere
|
||||
@@ -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,6 +0,0 @@
|
||||
---
|
||||
'@expo/eas-build-job': major
|
||||
'@expo/build-tools': major
|
||||
---
|
||||
|
||||
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops.
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * *' # 8:00 AM UTC time every day
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
"STAGING": "27.1.0",
|
||||
"PRODUCTION": "27.1.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.3.0",
|
||||
"version": "27.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,18 +42,18 @@
|
||||
"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/xcpretty": "^4.3.1",
|
||||
"@expo/steps": "27.1.0",
|
||||
"@expo/template-file": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
@@ -59,6 +60,47 @@ describe(XcodeBuildLogger, () => {
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes the final incomplete compiler error when the log reader is interrupted', async () => {
|
||||
const { ExpoRunFormatter: RealFormatter } =
|
||||
jest.requireActual<typeof import('@expo/xcpretty')>('@expo/xcpretty');
|
||||
jest.mocked(ExpoRunFormatter.create).mockImplementation(RealFormatter.create);
|
||||
|
||||
let rejectReader!: (error: Error) => void;
|
||||
const spawnPromise = Object.assign(
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectReader = reject;
|
||||
}),
|
||||
{
|
||||
child: {
|
||||
stdout,
|
||||
kill: () =>
|
||||
rejectReader(Object.assign(new Error('tail interrupted'), { signal: 'SIGINT' })),
|
||||
},
|
||||
}
|
||||
);
|
||||
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
|
||||
const diagnostics =
|
||||
'/project/First.swift:12:3: error: first compiler error\n' +
|
||||
' /project/Last.swift:20:4: error: final compiler error\n';
|
||||
vol.fromJSON({ '/logs/build.log': diagnostics });
|
||||
const logger = createMockLogger();
|
||||
const buildLogger = new XcodeBuildLogger(logger, '/project');
|
||||
await buildLogger.watchLogFiles('/logs');
|
||||
stdout.emit('data', diagnostics);
|
||||
|
||||
await buildLogger.flush();
|
||||
|
||||
const output = jest
|
||||
.mocked(logger.info)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join('\n');
|
||||
expect(output).toContain('first compiler error');
|
||||
expect(output).toContain('final compiler error');
|
||||
expect(output).toContain('Last.swift:20:4');
|
||||
expect(output).toContain('2 error(s)');
|
||||
expect(output.match(/final compiler error/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to raw logs when the formatter throws', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -80,9 +80,12 @@ export class XcodeBuildLogger {
|
||||
this.logger.info(data.toString());
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
try {
|
||||
await this.logReaderPromise;
|
||||
} finally {
|
||||
// flush() interrupts tail, but the formatter may still hold a final compiler diagnostic.
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!this.flushing) {
|
||||
this.loggerError = err;
|
||||
|
||||
@@ -34,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';
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+11
-1
@@ -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',
|
||||
|
||||
+13
-1
@@ -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);
|
||||
@@ -309,6 +382,29 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('passes the existing Android selection to the preview host', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await runSessionAsync(logger);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ previewDeviceId: 'emulator-5554' })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the selected iOS preview target without enabling Simulator startup', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid }, { udid: 'BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB' }] as Awaited<
|
||||
ReturnType<typeof IosSimulatorUtils.getAvailableDevicesAsync>
|
||||
>);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN);
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options).toHaveProperty('previewDeviceId', udid);
|
||||
expect(options.iosSimulatorUdid).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
@@ -361,6 +457,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 +544,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',
|
||||
});
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+43
-1
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -198,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toContain('HTTP 404');
|
||||
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
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,
|
||||
isMissingAppIconsError,
|
||||
isMissingBackgroundTaskIdentifiersError,
|
||||
isMissingPurposeStringError,
|
||||
isRedundantBinaryUploadError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
@@ -45,6 +55,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);
|
||||
@@ -73,6 +97,41 @@ describe(isSdkVersionIssueError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingBackgroundTaskIdentifiersError, () => {
|
||||
it('returns true when all errors are missing BG task identifiers codes', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90771' }])).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90062' }])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingAppIconsError, () => {
|
||||
it.each([['90391', '90713', '90713'], ['90713', '90022', '90023'], ['90022'], ['90023']])(
|
||||
'recognizes missing-icon validation codes %j',
|
||||
(...codes) => {
|
||||
expect(isMissingAppIconsError(codes.map(code => ({ code })))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingAppIconsError([{ code: '90391' }, { code: '90771' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingAppIconsError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
@@ -110,3 +169,176 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple background-task details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [
|
||||
{
|
||||
code: '90771',
|
||||
description:
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing.',
|
||||
},
|
||||
],
|
||||
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_MISSING_BG_TASK_IDENTIFIERS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing. (90771)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple validation details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [{ code: '90713', description: 'Missing CFBundleIconName in Watch.app.' }],
|
||||
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_MISSING_APP_ICONS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing CFBundleIconName in Watch.app. (90713)'
|
||||
);
|
||||
} 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
@@ -156,6 +157,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
@@ -225,9 +227,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 +247,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,
|
||||
@@ -266,8 +277,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
// The preview host uses the same device selected for Appium.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
@@ -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();
|
||||
|
||||
@@ -262,6 +262,45 @@ 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 (isMissingBackgroundTaskIdentifiersError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS',
|
||||
'Build upload was rejected by App Store Connect because Info.plist is missing `BGTaskSchedulerPermittedIdentifiers` while `UIBackgroundModes` includes `processing`. ' +
|
||||
'In an Expo project, add `BGTaskSchedulerPermittedIdentifiers` to `ios.infoPlist` in your app config and include every background task identifier the app schedules. ' +
|
||||
'If you do not use Continuous Native Generation (CNG), add the same key to the native target Info.plist. Then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/versions/latest/config/app/#infoplist',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingAppIconsError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS',
|
||||
'Build upload was rejected by App Store Connect because required app icons are missing or misconfigured in one of the iOS bundles. ' +
|
||||
'In an Expo project, check your icon config (`expo.icon`, `ios.icon`) and any custom native targets or plugins that create extra bundles, such as watch extensions. ' +
|
||||
'Make sure the affected target has the required icon assets and Info.plist icon keys (for example `CFBundleIconName` / `CFBundleIconFiles`), then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
@@ -344,6 +383,21 @@ 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 isMissingBackgroundTaskIdentifiersError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90771');
|
||||
}
|
||||
|
||||
export function isMissingAppIconsError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90022', '90023', '90391', '90713'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
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]',
|
||||
|
||||
+53
-2
@@ -170,6 +170,53 @@ describe('with a device run session token', () => {
|
||||
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
|
||||
@@ -364,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(
|
||||
@@ -589,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(
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
@@ -68,6 +69,7 @@ jest.mock('../remoteDeviceRunSession', () => ({
|
||||
jest.mock('../deviceRunSessionPreview', () => ({
|
||||
...jest.requireActual('../deviceRunSessionPreview'),
|
||||
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
|
||||
captureDeviceRunSessionPreviewAsync: jest.fn(),
|
||||
startDeviceRunSessionPreview: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
@@ -191,6 +193,91 @@ afterEach(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, '', 'invalid serial', 'all', 'booted'])(
|
||||
'starts Hub without adding a device restriction for %j',
|
||||
async previewDeviceId => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId,
|
||||
});
|
||||
try {
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].args).not.toContain('--single-device');
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('uses the supplied Android serial for Hub and thumbnails without selecting another device', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'no-device-id' }),
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: 'emulator-5556',
|
||||
});
|
||||
try {
|
||||
const args = jest.mocked(spawnDetached).mock.calls[0][0].args;
|
||||
expect(
|
||||
args.slice(args.indexOf('--single-device'), args.indexOf('--single-device') + 2)
|
||||
).toEqual(['--single-device', 'emulator-5556']);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await jest
|
||||
.mocked(startDeviceRunSessionPreview)
|
||||
.mock.calls[0][0].captureAsync(new AbortController().signal);
|
||||
expect(captureDeviceRunSessionPreviewAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ device: 'emulator-5556' })
|
||||
);
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins an already-booted iOS preview without taking over boot or setup', async () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: udid }),
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: udid,
|
||||
});
|
||||
try {
|
||||
const args = jest.mocked(spawnDetached).mock.calls[0][0].args;
|
||||
expect(
|
||||
args.slice(args.indexOf('--single-device'), args.indexOf('--single-device') + 2)
|
||||
).toEqual(['--single-device', udid]);
|
||||
expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled();
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects readiness on a different Simulator from the selected preview target', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA',
|
||||
})
|
||||
).rejects.toThrow('but this session requested AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA');
|
||||
expect(stopServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('targets the selected Simulator in the serve-sim invocation', () => {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: 'session-udid' }).slice(0, 4)).toEqual([
|
||||
'@expo/serve-sim@latest',
|
||||
@@ -1413,7 +1500,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 +1555,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 +1771,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 +1786,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');
|
||||
|
||||
@@ -530,6 +530,22 @@ describe(describeServeSimLaunch, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('only pins hosts when a valid device ID is supplied', () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: udid })).toEqual(
|
||||
expect.arrayContaining(['--single-device', udid])
|
||||
);
|
||||
for (const iosSimulatorUdid of [undefined, '', 'booted', 'iPhone 17']) {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid })).not.toContain('--single-device');
|
||||
}
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device: 'emulator-5554' })).toEqual(
|
||||
expect.arrayContaining(['--single-device', 'emulator-5554'])
|
||||
);
|
||||
for (const device of [undefined, '', 'all', 'booted', 'invalid serial']) {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device })).not.toContain('--single-device');
|
||||
}
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -144,6 +144,7 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
const attempts: unknown[] = [];
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
@@ -161,6 +162,7 @@ export namespace GooglePlayUtils {
|
||||
throw new SystemError('Google did not confirm the committed edit.');
|
||||
}
|
||||
} catch (error) {
|
||||
attempts.push(...(error instanceof AggregateError ? error.errors : [error]));
|
||||
if (error instanceof GooglePlayAuthRequestError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -168,8 +170,14 @@ export namespace GooglePlayUtils {
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
const attemptMessages = attempts
|
||||
.map(
|
||||
(attempt, index) =>
|
||||
`${index + 1}. ${attempt instanceof Error ? attempt.message : String(attempt)}`
|
||||
)
|
||||
.join('\n');
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.\n\nCommit attempts:\n${attemptMessages}`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,6 +84,41 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('shows failed commit attempts in order across processing, HTTP, and network retries', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.replyWithError(Object.assign(new Error('Lost commit response'), { code: 'ECONNRESET' }));
|
||||
const message = 'You must let us know whether your app includes any health features.';
|
||||
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
|
||||
const error = await commit().catch(error => error);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
|
||||
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
expect((mapped as Error).message).toContain(`Google Play request failed (HTTP 403): ${message}`);
|
||||
expect((mapped as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect(mapped).toMatchObject({
|
||||
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
|
||||
cause: expect.any(AggregateError),
|
||||
});
|
||||
const attempts = (mapped as Error).message.split('Commit attempts:\n')[1]?.split('\n');
|
||||
expect(attempts).toEqual([
|
||||
`1. Google Play request failed (HTTP 400): ${pendingUploadMessage}`,
|
||||
'2. Google Play request failed (HTTP 503).',
|
||||
'3. Google Play request failed before a response was received.',
|
||||
`4. Google Play request failed (HTTP 403): ${message}`,
|
||||
]);
|
||||
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(3);
|
||||
expect(((mapped as Error).cause as AggregateError).errors[1]).toMatchObject({
|
||||
cause: { code: 'ECONNRESET' },
|
||||
});
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled before commit');
|
||||
@@ -250,7 +285,9 @@ it('keeps the last Google Play error response when the commit outcome is unknown
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(503, { error: { message: 'Backend error' } });
|
||||
await expect(commit()).rejects.toMatchObject({
|
||||
const error = await commit().catch(error => error);
|
||||
expect(error.message).toContain('Google Play request failed (HTTP 503): Backend error');
|
||||
expect(error).toMatchObject({
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
|
||||
|
||||
@@ -91,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';
|
||||
@@ -267,7 +268,9 @@ 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) {
|
||||
|
||||
@@ -55,6 +55,8 @@ import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulat
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const IOS_SIMULATOR_UDID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
||||
const ANDROID_DEVICE_SERIAL = /^(?!all$|booted$)[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
@@ -157,6 +159,9 @@ export function createServeSimArgs({
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
|
||||
...(iosSimulatorUdid && IOS_SIMULATOR_UDID.test(iosSimulatorUdid)
|
||||
? ['--single-device', iosSimulatorUdid]
|
||||
: []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -198,6 +203,7 @@ export function createServeSimArgs({
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
device,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
@@ -206,6 +212,7 @@ export function createExpoDeviceHubArgs({
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
device?: string;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
@@ -215,6 +222,7 @@ export function createExpoDeviceHubArgs({
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
...(device && ANDROID_DEVICE_SERIAL.test(device) ? ['--single-device', device] : []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -387,6 +395,8 @@ type DeviceSessionHostOptions = {
|
||||
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
|
||||
corsOrigins?: string[];
|
||||
iosSimulatorUdid?: string;
|
||||
/** Selected preview target; does not enable Simulator boot or setup. */
|
||||
previewDeviceId?: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
@@ -431,6 +441,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
signal,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
previewDeviceId,
|
||||
installAppPath,
|
||||
application,
|
||||
onStartupError,
|
||||
@@ -479,6 +490,10 @@ async function startDeviceSessionHostInternalAsync(
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const deviceIdPattern = isAndroid ? ANDROID_DEVICE_SERIAL : IOS_SIMULATOR_UDID;
|
||||
const validPreviewDevice =
|
||||
previewDeviceId && deviceIdPattern.test(previewDeviceId) ? previewDeviceId : undefined;
|
||||
const previewDevice = isAndroid ? validPreviewDevice : (iosSimulatorUdid ?? validPreviewDevice);
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
@@ -506,6 +521,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
@@ -517,7 +533,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
@@ -577,7 +593,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 +686,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 +727,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 +766,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', {
|
||||
@@ -762,9 +778,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = (await readiness).enforceValue();
|
||||
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
|
||||
if (!isAndroid && previewDevice && device.toLowerCase() !== previewDevice.toLowerCase()) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
|
||||
`serve-sim became ready on ${device}, but this session requested ${previewDevice}.`
|
||||
);
|
||||
}
|
||||
hostReady = true;
|
||||
@@ -807,7 +823,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
captureAsync: signal =>
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
device: previewDevice ?? device,
|
||||
env,
|
||||
signal,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import * as jose from 'jose';
|
||||
@@ -617,6 +617,24 @@ export class AscApiClient {
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
const errors = parsedAscErrorResponse.value.errors.map(ascError => {
|
||||
if (response.status === 401 && ascError.code === 'NOT_AUTHORIZED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_INVALID_AUTH',
|
||||
'App Store Connect rejected the API authentication credentials. ' +
|
||||
'Check that the API key is active and belongs to the correct App Store Connect account. ' +
|
||||
'If the key is stored in EAS credentials, replace or reconfigure it. ' +
|
||||
'For a local key, check `key_id` and `key`, and `issuer_id` for a team key. Then try again.',
|
||||
{ cause: ascError, docsUrl: 'https://docs.expo.dev/submit/ios/' }
|
||||
);
|
||||
}
|
||||
if (response.status === 500 && ascError.code === 'UNEXPECTED_ERROR') {
|
||||
return new SystemError(
|
||||
'App Store Connect returned an unexpected server error. ' +
|
||||
'Check Apple’s service status at https://developer.apple.com/system-status/ and verify whether the requested operation completed in App Store Connect before trying again. ' +
|
||||
'If the error continues, try again later or contact Apple Developer Support.',
|
||||
{ cause: ascError, trackingCode: 'EAS_ASC_SERVER_ERROR' }
|
||||
);
|
||||
}
|
||||
if (ascError.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
@@ -85,6 +85,57 @@ describe(AscApiClient, () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'explains NOT_AUTHORIZED without losing other errors (mixed: %s)',
|
||||
async mixed => {
|
||||
const known = { status: '401', code: 'NOT_AUTHORIZED' };
|
||||
const other = { status: '401', code: 'OTHER_ERROR' };
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(401, { errors: mixed ? [known, other] : [known] });
|
||||
const error = await client
|
||||
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
|
||||
.catch(error => error);
|
||||
const mapped = mixed ? error.errors[0] : error;
|
||||
expect(mapped).toBeInstanceOf(UserError);
|
||||
expect(mapped).toMatchObject({ errorCode: 'EAS_ASC_INVALID_AUTH', cause: known });
|
||||
if (mixed) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
|
||||
expect(error.errors[1].code).toBe('OTHER_ERROR');
|
||||
}
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'explains UNEXPECTED_ERROR without losing other errors (mixed: %s)',
|
||||
async mixed => {
|
||||
const known = { status: '500', code: 'UNEXPECTED_ERROR' };
|
||||
const other = { status: '500', code: 'OTHER_ERROR' };
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(500, { errors: mixed ? [known, other] : [known] });
|
||||
const error = await client
|
||||
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
|
||||
.catch(error => error);
|
||||
const mapped = mixed ? error.errors[0] : error;
|
||||
expect(mapped).toBeInstanceOf(SystemError);
|
||||
expect(mapped).toMatchObject({
|
||||
trackingCode: 'EAS_ASC_SERVER_ERROR',
|
||||
cause: known,
|
||||
});
|
||||
if (mixed) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
|
||||
expect(error.errors[1].code).toBe('OTHER_ERROR');
|
||||
}
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('fetches app info', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = require('./fixtures/apps/get-apps-200.json');
|
||||
|
||||
@@ -391,7 +391,9 @@ describe('commitBuildUploadFileAsync', () => {
|
||||
.reply(status, { errors: [{ code }] });
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).rejects.toMatchObject({ status, code });
|
||||
).rejects.toMatchObject(
|
||||
code === 'NOT_AUTHORIZED' ? { errorCode: 'EAS_ASC_INVALID_AUTH' } : { status, code }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not commit when the initial state read fails', async () => {
|
||||
|
||||
@@ -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,7 +555,7 @@ 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}).`
|
||||
);
|
||||
@@ -564,7 +565,16 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
{ 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)
|
||||
|
||||
@@ -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/
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user