Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela 1572a6420a [build-tools] require explicit processing diagnostic arguments 2026-10-24 17:38:41 +02:00
Stanisław Chmiela 524d0222ae [build-tools] keep processing error helpers private 2026-10-24 17:32:19 +02:00
Stanisław Chmiela c0151e4ce0 [build-tools] place ASC processing wait in its step module 2026-10-24 17:27:29 +02:00
Stanisław Chmiela 44f5ec55c9 [build-tools] validate ASC wait inputs together 2026-10-24 11:40:35 +02:00
Stanisław Chmiela bae232ac30 [build-tools] preserve ASC processing timeout behavior 2026-10-24 11:37:12 +02:00
Stanisław Chmiela b5442a5bb6 [build-tools] keep timeout coverage in the base change 2026-10-24 11:34:38 +02:00
Stanisław Chmiela 547496c588 [build-tools] move ASC polling into utilities and accept the bundle identifier 2026-10-24 11:34:38 +02:00
Stanisław Chmiela 9f3ecf60c3 [build-tools] remove repeated processing error tests 2026-10-24 11:34:38 +02:00
Stanisław Chmiela 6275c1e54c [build-tools] add a separate App Store Connect processing step 2026-10-24 11:34:37 +02:00
Will Schurman 🦦 d02de4da64 [eas-json] Add ability to specify role request/requirement to eas.json (#4732)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

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

# How

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

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

# Test Plan

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

# Why

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

# How

regenerate.

# Test Plan

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

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

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

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

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

# How

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

# Test Plan

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

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

<details>
<summary>Details</summary>

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

</details>

# How

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

# Test Plan

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

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

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

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

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

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

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

---------

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

expo/universe#32638

## How

Removed obsolete enum that didn't mean anything.

## Test Plan

CI should pass.
2026-10-22 21:55:56 +02:00
Ash Wu 62ddc91aa4 [build-tools] stop logging workflow hook parsing in build logs (#4717)
Hook parsing in native builds runs between READ_PACKAGE_JSON and
INSTALL_DEPENDENCIES and logged through the phase-less logger, so every
native build with hooks showed an "Unknown build phase" log group.
Ignore unknown and unrun hook keys silently in both the native and the
steps parser, let the eas/checkout step print the working-directory line
itself, and drop the after-hook failure line that the failing hook step
already logs.
2026-10-22 21:56:13 +08:00
Ash Wu 9b54c203ee [build-tools] retry download_build network failures and report their cause (#4690) 2026-10-22 15:30:47 +08:00
Ramon Claudio a0fe3ff2c3 [eas-cli][build-tools] Bump Node.js to 22.23.4 (#4714)
* [eas-cli] Bump Node.js to 22.23.4

* [eas-cli] Add changeset

* [build-tools] Bump Node.js to 22.23.4
2026-10-21 18:38:36 -04:00
Brent VatneandClaude Opus 5.5 fb383d818b [eas-cli] make command descriptions lowercase without trailing periods (#4385)
* [eas-cli] make command descriptions lowercase without trailing periods

Most command descriptions in eas --help already start lowercase and end
without a period; align the remaining 11 capitalized and 12 period-terminated
ones with that style. Proper nouns, [EXPERIMENTAL] prefixes, and later
paragraphs of multi-line descriptions are unchanged.

* [eas-cli] Drop trailing periods from simulator command descriptions

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-21 14:27:33 -07:00
Stanisław Chmiela c07c5edba9 [build-tools] Add max_duration_seconds to sandboxes (#4702)
# Why

I want to set up billing and limits for sandboxes so we need to be able to say how long can a sandbox run for.

# How

Added simple `max_duration_seconds` like we have in agent runs and device run sessions.

# Test Plan

CI should pass.
2026-10-21 17:18:26 +02:00
Brent Vatne 56646b7436 [eas-cli] Show simulator session errors in simulator:get (#4694)
Look up why a failed simulator session failed in both simulator:start and
simulator:get. For unknown errors, read the failed step's cause from the job
run log, using only the masked log message, with the download bounded by size
and time. Strip terminal control characters before printing.
2026-10-20 14:00:29 -07:00
Brent Vatne f775b10549 [eas-cli] Show why a simulator session failed to start (#4693)
* [eas-cli] Show why a simulator session failed to start

simulator:start only printed the session status when a session errored
before it was ready. The reason is already on the session's job run, so
fetch turtleJobRun.errors and print each message (and docs link) on its
own line.

* [eas-cli] Read simulator job run errors only on failure, and from logs for unknown errors

Fetch job run errors with a separate query when a session fails instead of
on every poll of the shared session query. When the job run only reports an
unknown error, print the failed step errors from its logs. Also show the
errors when a session fails after it is ready.

* [eas-cli] Show only structured job run errors in simulator:start

Drop the job run log fallback from this change, which printed unmasked
err.message lines, read logs for stopped sessions and had no timeout or size
limit. The bounded, masked log fallback lands in the simulator:get follow-up.

* [eas-cli] Bound and quiet the simulator job run errors lookup

Give the lookup a 10s deadline and the session's Ctrl+C signal, and cancel
its request by unsubscribing from urql, so it can't hold up stopping the
session. Don't retry it or print the transient-error message, since it's
best-effort. Cap each error message at 500 characters.
2026-10-20 13:51:21 -07:00
Brent Vatne c756c5d0b5 [worker] Mask secrets in every field of uploaded log records (#4698)
* [worker] Mask secrets in logged errors

The upload transform masked only each record's msg, so a serialized err
(message, stack and other fields) was uploaded with secrets intact. That
includes unknown build phase failures, which log { err } with an empty msg.
Mask the strings inside err on a copy, since bunyan shares the record with
other streams.

* [worker] Mask secrets in every field of uploaded log records

Mask all fields except bunyan's metadata, not only err: other fields such as
error, data and the job dump also carried secrets. Round-trip each value
through JSON first so a toJSON method can't bring back the original, mask keys
and numbers, and replace values nested too deeply instead of uploading them
unmasked.
2026-10-20 13:51:17 -07:00
Brent VatneandClaude Opus 5.5 9f674b1d19 [eas-build-job][build-tools] Check Android launch inputs before the session starts (#4696)
* [build-tools] Check Android launch inputs before the emulator boots

Agent-device sessions checked Android launch inputs only when launching,
after the emulator booted and the build installed. Check NUL characters
and the command length at the start of the step, as on iOS, leaving room
for the launch component that is only known after install.

* Add changeset for early Android launch input checks

* [eas-build-job] Share the Android launch input checks

Move the Android shell quoting and the early launch input checks into
@expo/eas-build-job, so www can check launch inputs when a session is
created, for every session type.

* [eas-build-job] Check only the known part of the Android launch command early

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 13:40:44 -07:00
Expo CI 6f33a4d2a4 bump STAGING and PRODUCTION to 25.3.0 in cli-versions.json 2026-10-20 19:22:35 +00:00
Expo CI 44b63e5a7d v25.3.0 2026-10-20 19:17:05 +00:00
Krystof Woldrich 42e4fa8a7c [eas-cli] Add --system-image to eas simulator:start (#4500) 2026-10-20 19:09:09 +02:00
Ash Wu e4bb181802 [build-tools] Wait up to five minutes for Google Play to process an uploaded bundle (#4700) 2026-10-20 17:30:02 +08:00
Brent Vatne 53ac936b11 [build-tools] Quote Android launch arguments for the device shell (#4695)
* [build-tools] Quote Android launch arguments for the device shell

adb shell joins its arguments with spaces and the device shell parses the
result again, so a launch argument such as "hello world" reached am start
as two words and the launch failed. Quote each word, reject commands that
adb cannot carry, and reject empty arguments that are not extra values.

* [build-tools] Pass empty Android launch arguments through and reject NUL

Guessing which am start option an empty argument belongs to rejected
valid input such as -e key "" and -d "", and accepted input that am
start then rejects. Pass empty arguments through, and explain a failed
launch when launch_args has an empty element. Reject NUL characters,
which spawn cannot pass, with a user error.
2026-10-19 21:29:47 -07:00
Gabe DebesandGPT-6.1-Sol c0d3f4f935 [build-tools] Forward network capture through local egress (#4598)
* [build-tools] Forward network capture through local egress

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Remove unused capture proxy override

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 20:46:19 -07:00
Gabe DebesandGPT-6.1-Sol 5ec3d84fcb [build-tools] Let serve-sim start the iOS Simulator for Appium (#4603)
* [build-tools] Let serve-sim start the iOS Simulator for Appium

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Mock Appium app metadata at its own boundary

* [build-tools] Stop Appium setup when Simulator startup fails

Co-Authored-By: Codex <[email protected]>

* [build-tools] Retry cancelled shared FFmpeg setup for surviving callers

Co-Authored-By: Codex <[email protected]>

* [build-tools] Test Appium preview settings after startup rebase

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 19:12:03 -07:00
Gabe DebesandGPT-6.1-Sol d0cb5a2d67 [build-tools] Let serve-sim start the iOS Simulator for Argent (#4602)
* [build-tools] Let serve-sim start the iOS Simulator for Argent

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Use session helpers for Argent startup

* [build-tools] Mock Argent app metadata at its own boundary

* [build-tools] Stop Argent setup when Simulator startup fails

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-19 17:12:45 -07:00
Gabe Debes aae7d13ac0 [build-tools] Upload iOS Simulator session screen recording metadata (#4689)
* [build-tools] Upload screen recording metadata

* [build-tools] Name recording artifacts metadata

* [build-tools] Check companion metadata upload names

* [build-tools] Report metadata upload failures separately
2026-10-19 16:22:10 -07:00
Gabe DebesandClaude Opus 5.5 d9b1f866d4 [build-tools] Reset the session idle timer on serve-sim preview input (#4691)
Poll serve-sim's event log and reset the existing idle timeout when a
new preview input event appears.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 15:07:16 -07:00
William Grosset a03dca45eb [eas-cli] Add a checkout prompt experiment to the Free plan build limit error (#4633)
* [eas-cli] Add a checkout prompt experiment to the Free plan build limit error

* [eas-cli] Reuse the account billing URL helper in the checkout experiment
2026-10-19 10:08:05 -07:00
Expo CI f949f3430e bump STAGING and PRODUCTION to 25.2.0 in cli-versions.json 2026-10-17 23:28:11 +00:00
Expo CI e61a563b70 v25.2.0 2026-10-17 23:23:01 +00:00
150cd21174 [eas-cli] Add simulator:control for remote simulator actions (#4641)
* [eas-cli] Add simulator:action command

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

* [eas-cli] Fold simulator actions into one table

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

* [eas-cli] Give the simulator API lookup its own timeout

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

* [eas-cli] Drop event-log from simulator:action

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

* [eas-cli] Rename to simulator:control and add sidebar actions

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

* [eas-cli] Drive simulator:control from the serve-sim action catalog

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

* [eas-cli] Fix value order, upload cleanup and output handling in simulator:control

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

* [eas-cli] Check output paths and local files before simulator:control runs

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

* [eas-cli] Upload only when the catalog condition matches

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

* [eas-cli] Send upload IDs and JSON lists from simulator:control

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

* [eas-cli] Complete the simulator control session fixture

Co-authored-by: Codex <[email protected]>

* [eas-cli] Close simulator control after discovery failure

Co-authored-by: Codex <[email protected]>

* [eas-cli] Keep simulator action descriptions on one line

Co-authored-by: Codex <[email protected]>

* [eas-cli] List simulator actions by default

Co-authored-by: Codex <[email protected]>

* [eas-cli] Reject empty simulator control session IDs

Document the separator for values beginning with --.

Co-authored-by: Codex <[email protected]>

* [eas-cli] Clarify local screenshot output usage

Co-authored-by: Codex <[email protected]>

* [eas-cli] Compact simulator action discovery and add action help

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-17 15:38:09 -07:00
Gabe DebesandCodex 4b9e792e91 [eas-cli] Add remote simulator host action client (#4642)
* [eas-cli] Add remote simulator host action client

* [eas-cli] Secure simulator action setup and close rejected upgrades

Co-authored-by: Codex <[email protected]>

* [eas-cli] Clarify alternative simulator action inputs

Co-authored-by: Codex <[email protected]>

* [eas-cli] Show each alternative action input group once

Co-authored-by: Codex <[email protected]>

* [eas-cli] Explain token failures during simulator connection setup

Co-authored-by: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
2026-10-17 15:38:09 -07:00
William Grosset 0199cd9331 [eas-cli] Add an agent guidance experiment to the Free plan build limit error (#4631) 2026-10-16 16:27:33 -07:00
William Grosset ffe6bda88d [eas-cli] Add experimentation client (#4558)
* [eas-cli] Add experimentation client

* [eas-cli] Document experimentation context order and override semantics

* [eas-cli] Take the account unit from the command instead of the app config

* [eas-cli] Skip experiments in CI

* [eas-cli] Make the GraphQL request timeout take effect

* [eas-cli] Document which experiment units are shared across surfaces

* [eas-cli] Test that non-timeout fetch errors pass through the timeout wrapper

* [eas-cli] Fetch the experimentation config lazily on first variant read

* [eas-cli] Cap the actor lookup in the experimentation context field
2026-10-16 15:22:39 -07:00
Gabe DebesandGPT-6.1-Sol 904859b824 [build-tools] Let serve-sim start the iOS Simulator for agent-device (#4544)
* [build-tools] Let serve-sim start the iOS Simulator for agent-device

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Use session helpers for agent-device startup

* [build-tools] Read agent-device app metadata from the artifact helper

* [build-tools] Pass the preview token in the startup regression

Co-Authored-By: Codex <[email protected]>

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-16 14:45:27 -07:00
Expo CI 3fd966bb9e bump STAGING and PRODUCTION to 25.1.1 in cli-versions.json 2026-10-16 21:44:06 +00:00
Expo CI aa9f5370d4 v25.1.1 2026-10-16 21:35:07 +00:00
Gabe Debes 60a0b483eb [ci] Generate build-tools GraphQL types before typechecking (#4663) 2026-10-16 23:17:55 +02:00
Szymon Dziedzic 070f15c633 [build-tools] Remove the GraphQL fallbacks for device run session jobs (#4638) 2026-10-16 23:00:28 +02:00
Expo CI c8aa5eb05d v25.1.0 2026-10-16 19:51:06 +00:00
Krystof WoldrichandClaude Fable 5.1 68596f8c0c [eas-cli] add --cors-origin to simulator:start (#4658)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 1e38ada929 [eas-cli] add --frame-ancestor to simulator:start (#4656)
* [eas-cli] add --frame-ancestor to simulator:start and print the embed URL

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] cover the embed output fallbacks

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] format the embed output tests

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 d52629e9f3 [build-tools] pass cors_origins session input to the preview hosts (#4657)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:40:20 +02:00
Krystof WoldrichandClaude Fable 5.1 bed6354ef5 [build-tools] pass frame_ancestors session input to the preview hosts (#4655)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:05:51 +02:00
Stanisław Chmiela 9aac1a0dff [build-tools] Retry commits while Google Play processes bundles (#4651)
# Why

https://sentry.io/organizations/expoio/issues/7792509578/events/f6692b78705a40ab9380fd177c265b35/

# How

Supposedly Fastlane retries committing.

# Test Plan

CI should pass. Land and see that the error no longer occurs.
2026-10-16 15:26:32 +02:00
Stanisław Chmiela c1c2b748b4 [build-tools] Report Google Play error responses to Sentry (#4652)
# Why

Google Play submissions sometimes fail with generic errors like `Precondition check failed.` (HTTP 400, `FAILED_PRECONDITION`). We currently drop everything except `error.message` and `errors[].reason`, so neither the user nor we can tell what Google actually objected to.

This replaces #4650, which also put Google's details into user-facing messages and structured logs, and added a redacting sanitizer. That made the code a lot more complex. This PR keeps build logs unchanged and only makes the raw response available in Sentry. Once real payloads show up there, we can add targeted classifiers to `mapGooglePlayError` with clear user-facing messages.

# How

- `GooglePlayApiError` now keeps:
  - the JSON error response body, as a string capped at 8 KB
  - the request URL path, without the query (the upload session query contains a secret)

  Non-JSON bodies are still dropped, as before.
- `GooglePlayApiError.getErrorMetadata()` finds the API error in the cause chain, through retry `AggregateError`s and commit-outcome-unknown wrappers. It returns `googlePlayRequestPath` and `googlePlayErrorResponse`.
- `mapGooglePlayError` and `GooglePlayCommitOutcomeUnknownError` attach that as `ExpoError.metadata`. The worker already sends it to Sentry as `extras.errorMetadata`, and it is not part of the external build error.
- The body is stored as a string because `@sentry/node` 7 normalizes extras to depth 3, so nested `details` would otherwise show up as `[Object]`.

User-facing messages are unchanged.

# Test Plan

- New tests in `GooglePlaySubmission.test.ts`:
  - a `FAILED_PRECONDITION` commit with `details` ends up in metadata but not in the message
  - metadata survives retries when the commit outcome is unknown
  - the upload-session query and non-JSON bodies are not stored
- `yarn test src/steps/utils/android src/steps/functions/__tests__/submitToGooglePlay.test.ts` (153 passed), plus `yarn typecheck`, `yarn lint`, `yarn fmt:check`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-16 15:17:05 +02:00
Gabe Debes 7b39b92df8 [build-tools] Experiment with 4 GiB RAM for Android device sessions (#4640) 2026-10-16 12:57:51 +02:00
Stanisław Chmiela 929905ba7e [build-tools] Retry transient Google Play API requests (#4629)
# Why

While testing submissions on staging I found Google APIs flake sometimes.

# How

Wrapped requests with auto-retry when we can be sure we can retry.

# Test Plan

CI should pass.
2026-10-16 12:01:48 +02:00
Philippe Loulidi ef7fceba86 [eas-cli] ENG-27937 Show EAS Simulator minutes in eas account:usage (#4583)
* [eas-cli] Show EAS Simulator minutes in eas account:usage

* [eas-cli] report unavailable Simulator usage estimates

* [eas-cli] Replace Simulator usage changelog entry with changeset
2026-10-15 17:21:22 -06:00
Expo CI c2dc766c38 bump STAGING and PRODUCTION to 25.0.0 in cli-versions.json 2026-10-15 23:01:41 +00:00
Expo CI 785cfe2d53 v25.0.0 2026-10-15 22:42:24 +00:00
Gabe DebesandCodex 18a008a39a [eas-cli] Default simulator previews to authenticated links and add --public-link option (#4619)
* [eas-cli] default simulator previews to authenticated links

Co-authored-by: Codex <[email protected]>

* [eas-cli] Persist simulator preview sharing preference

* [eas-cli] omit direct preview API addresses from private JSON

* [eas-cli] use saved simulator sharing preference in get

Co-authored-by: Codex <[email protected]>

* [eas-cli] warn before printing public simulator links

Edited-by: Codex

---------

Co-authored-by: Codex <[email protected]>
2026-10-15 15:34:10 -07:00
Will Schurman 🦦 f5be6b8d88 [eas-cli] Use effectivePermissions for logic about capabilities and account:view display (#4637) 2026-10-15 15:28:36 -07:00
Gabe DebesandCodex c4064ea9ba [build-tools] Use private links for private simulator sessions (#4620)
* [build-tools] Respect simulator preview sharing preferences

* [build-tools] Add PR attribution to changelog entry

* [build-tools] clarify preview Share fallback and verify probe cleanup

* [build-tools] pass preview sharing flags directly

Co-authored-by: Codex <[email protected]>

* [build-tools] clarify public preview logging coverage

Co-authored-by: Codex <[email protected]>

* [build-tools] redact preview tokens from host diagnostics

Edited-by: Codex

---------

Co-authored-by: Codex <[email protected]>
2026-10-15 14:25:11 -07:00
Will Schurman 🦦 f1dda5df94 [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build (#4636)
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->

# Why

Now that we're testing out permission scoping for tokens, EAS CLI needs to handle these cases more gracefully. First step is this error message, which previously assumed scoped tokens would only be used within build jobs.

# How

Update it to have a separate error message for scoped tokens outside of a build job. Also correct "user"/"account" terminology discrepancy.

# Test Plan

`yarn start`
`export EXPO_STAGING=1`
`neas login`, select viewer scope
run `neas update`
see
```
You don't have the required permissions to perform this operation.

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

Original error message: Entity Not Authorized: Entity not authorized: AppFingerprintEntity[00000000-0000-0000-0000-000000000000] (viewer = ScopedAccountActorViewerContext[RegularUserViewerContext[b99c08be-7584-4358-b8e4-aef050972708], scopes=expo_account:c344bcd9-c79d-4c0b-96fb-940d947cac42(RUN,VIEW);expo_account:55ebf7e2-f986-4b54-950d-55d38f355e29(RUN,VIEW)], action = CREATE, ruleIndex = -1)
Request ID: 410d2f30-c4fa-4575-b21a-6012f6c1e93f
```
2026-10-15 13:21:59 -07:00
Szymon Dziedzic 4ec2a5fe9e [build-tools] Call the device run session worker API with the session token (#4615)
* [build-tools] Call the device run session worker API with the session token

When a device run session job has the EAS_DEVICE_RUN_SESSION_TOKEN environment
secret, send every session-related www call to /v2/device-run-session-worker/*
with the expo-device-run-session-token header instead of GraphQL or the
account-wide REST routes. Without the token, keep the current calls.

The worker also reports Datadog metrics for these jobs through
/v2/device-run-session-worker/metrics.

* [build-tools] Download session builds without the robot token

In a device run session job with a session token, the build archive URL comes
from the device run session worker API and points to an artifact route that
needs no auth. After www stops minting EXPO_TOKEN for session jobs,
robotAccessToken is the session token, and www rejects it as a bearer
credential on every route. Send no Authorization header in that case.

* [build-tools] Match the device run session worker API response contract

www returns entity values from the worker API: session statuses are
lowercase and hyphenated, and GET /application returns
{ platform: 'ios' | 'android', build: { id, gitCommitHash,
applicationArchiveUrl } | null } without app slugs. Map the statuses to the
GraphQL values that callers compare, and name screenshots without an app
slug in worker API mode.

The worker now resolves duplicate EAS_DEVICE_RUN_SESSION_TOKEN secrets like
the build env does (the last one wins), so metrics and steps use the same
token. Add tests for the token propagation from job secrets to the worker API
client and for the download_build and start_agent_device_session call sites.

* [build-tools] Take the device run session platform from the worker runtime

* [build-tools] Report device run session build lookups as system errors and time out hung session requests
2026-10-15 19:24:06 +02:00
Szymon Dziedzic 1068fab6ac [build-tools] Keep NGROK_AUTHTOKEN out of device run session child processes (#4614) 2026-10-15 19:11:11 +02:00
Gabe DebesandGPT-6.1-Sol 3e00e86e5a [build-tools] Let serve-sim start the iOS Simulator for web preview (#4601)
* [build-tools] Let serve-sim start the iOS Simulator for web preview

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Keep web-preview startup inside the session host

* [build-tools] Mock app metadata at its own boundary

* [build-tools] Stop startup when preview readiness fails

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-15 09:28:11 -07:00
Gabe DebesandGPT-6.1-Sol 69a4a06320 [build-tools] Add serve-sim Simulator startup helpers (#4605)
* [build-tools] Add serve-sim Simulator startup helpers

Co-Authored-By: GPT-6.1-Sol <[email protected]>

* [build-tools] Keep generic boot and install steps independent

* [build-tools] Keep iOS app metadata independent of serve-sim

* [build-tools] Cover serve-sim startup validation and failures

---------

Co-authored-by: GPT-6.1-Sol <[email protected]>
2026-10-15 09:28:10 -07:00
Gabe DebesandGPT-6.1 Sol 219df86f88 [build-tools] Separate Argent and preview logs (#4587)
Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-15 09:28:10 -07:00
Gabe DebesandGPT-6.1 Sol 50e1f2aea5 [build-tools] Separate Appium and preview logs (#4586)
Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-15 09:28:09 -07:00
Krystof WoldrichandClaude Opus 5.5 4400b93ed3 [build-tools] Start expo-device-hub with a session token and the website origins (#4578)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:37:44 +02:00
Ash Wu 52c1a4ce54 [build-tools][worker] Mask the credentials eas/run_agent leases with the build log secrets filter (#4624)
The worker's secrets filter takes secrets added while the job runs through
addBuildLogMask, which reaches steps via the build context's runtimeApi.
eas/run_agent passes each leased token to it and drops its own masking.

Masking marks every occurrence of every secret on the original message and
masks once, so a secret that is a substring of another no longer breaks the
longer match. The log buffer that feeds error detection masks with the same
patterns.
2026-10-15 20:47:34 +08:00
Stanisław Chmiela f97e6ecfa8 [build-tools] Polish some Google Play submission messages (#4625)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to polish Google Play submission messages a bit.

# How

Tested the code with a few more edge cases and handled them a bit better.

# Test Plan

CI should pass.
2026-10-15 10:36:30 +02:00
Gabe DebesandGPT-6.1 Sol 6d8c29cc34 [build-tools] Split agent-device and simulator preview logs (#4585)
* [build-tools] Separate agent-device and preview logs

* [build-tools] Link agent-device changelog entry

Co-Authored-By: GPT-6.1 Sol <[email protected]>

* [build-tools] Drain preview encoder setup before ending logs

* [build-tools] Move preview encoder scripts into test fixtures

* [build-tools] Keep encoder shutdown tests focused

---------

Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-10-14 22:09:29 -07:00
Gabe Debes 2630625440 [build-tools] Stream preview output into simulator session step (#4525) 2026-10-14 20:34:18 -07:00
Gabe Debes 7ebd9f1a97 [build-tools] Avoid cold runtime scans with preinstalled WDA (#4618)
* [build-tools] Launch preinstalled WDA in iOS remote sessions

Edited-by: Codex

* [build-tools] Add preinstalled WDA changelog entry

Edited-by: Codex

* [build-tools] Bound the WDA downloader process tree

* [build-tools] Move WDA startup release note to a changeset
2026-10-14 15:30:48 -07:00
Douglas LowderandClaude Opus 5.5 8545feb433 [ci] Version packages and generate changelogs with changesets (#4580)
* [ci] Version packages and generate changelogs with changesets

PRs now add a changeset in .changeset/ instead of editing CHANGELOG.md.
The manual release workflow runs scripts/src/versionPackages.ts, which
runs `changeset version` for one fixed group of all packages, writes the
root CHANGELOG.md section with the existing emoji categories, and then
commits, tags, and pushes vX.Y.Z. The tag-driven publish is unchanged,
except that it reads the changelog section from the tagged commit.

The PR check validates changesets with @changesets/parse and rejects
direct CHANGELOG.md edits. The "no changelog" label still skips it.

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

* Correct "no changelog" documentation in .changeset/README.md

* [ci] Fail on CHANGELOG.md edits even with the "no changelog" label

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

* [ci] Skip the changeset check for Dependabot PRs

Dependabot PRs only update dependencies, so they need no changeset, and
their read-only token cannot post the PR comment. The job skips both for
them. The CHANGELOG.md check still runs. The workflow now sets explicit
token permissions.

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

* Remove the "## main" block from CHANGELOG.md

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 14:57:15 -07:00
Expo CI 14e14e3017 bump STAGING and PRODUCTION to 24.14.0 in cli-versions.json 2026-10-14 21:18:01 +00:00
Expo CI 338d367282 update CHANGELOG.md for v24.14.0 2026-10-14 21:10:57 +00:00
Expo CI 102f06cc50 v24.14.0 2026-10-14 21:08:28 +00:00
Gabe Debes f96c266645 [build-tools] Allow iOS recordings to finish before shutdown (#4626) 2026-10-14 13:19:11 -07:00
Tomasz Mazur 5739556d67 [build-tools] download the upterm binary through build cache (#4442) 2026-10-14 14:20:52 +00:00
Ash Wu 821dcd1a96 [eas-cli] add a platform filter and per-platform totals to workflow:insights:maestro (#4613)
* [eas-cli] regenerate GraphQL types

* [eas-cli] add a platform filter and per-platform totals to workflow:insights:maestro
2026-10-14 16:36:04 +08:00
Byron Karlen 3b59a744da [docs] Add protected resources page (#4616) 2026-10-13 15:56:16 -07:00
Szymon DziedzicandClaude Opus 5.5 73edec4d68 [build-tools] Disable Argent video watermark in remote sessions (#4574)
* [build-tools] Disable Argent video watermark in remote sessions

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

* [build-tools] Add changelog entry for Argent watermark change

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

* [build-tools] Continue the Argent session when disabling the video watermark fails

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 16:05:03 +02:00
Stanisław Chmiela 13fe586f3f [build-tools] Add native Google Play submission function (#4512)
# Why

To remove submission service altogether we also need to support Google Play uploads here.

# How

Adds `eas/submit_to_google_play`: load the service account, create an edit, upload the APK/AAB, update the track, and commit. Returns `version_code` after the commit succeeds.

The binary, its type, and package name come from the caller. We'll use `eas/download_build` and `eas/read_android_app_info` for that.

# Test Plan

- CI should pass.
- Full live submission passed on October 8 with current source `5915d062d`: uploaded an AAB for `com.exposervices.workflowstesting` to `internal` as draft version `146`. The default review setting worked without a retry. A fresh edit confirmed the draft and release notes. Temporary credentials and binaries were removed.
2026-10-13 15:40:19 +02:00
Stanisław Chmiela c3413315d1 [build-tools] Add resumable Google Play uploads (#4516)
# Why

When uploading files to Google Play it's best we do this thing called "resumable uploads".

# How

Added more utils and a separate namespace that handles:
- creating the upload session
- uploading to the upload session in chunks.

It tries to be smart about errors and retries. Mostly AI-driven code.

# Test Plan

CI should pass. Also tested live with staging key.
2026-10-13 12:23:56 +02:00
Kudo Chien 6c256fd9d2 [build-tools] Open Android URLs in the app package instead of the launch activity (#4559) 2026-10-13 13:10:46 +08:00
Expo CI 6683492955 bump STAGING and PRODUCTION to 24.13.0 in cli-versions.json 2026-10-12 17:49:43 +00:00
Expo CI 964b4ed93f update CHANGELOG.md for v24.13.0 2026-10-12 17:47:09 +00:00
Expo CI 34e8b45bad v24.13.0 2026-10-12 17:44:06 +00:00
Tomasz Mazur d1de958201 [build-tools] Close artifact upload streams before an upload finishes (#4609) 2026-10-12 17:58:38 +02:00
Tomasz Mazur 2b672fc07f [build-tools] Add a sandbox uploadArtifact command (#4563)
* [build-tools] Add a sandbox uploadArtifact command

* [build-tools] Add changelog entry for sandbox uploadArtifact

* [build-tools] Return BAD_REQUEST for refused sandbox artifact uploads

* [build-tools] Let sandbox uploadArtifact wait for the upload and report its status and download URL

* [build-tools] Wait up to 20 seconds for sandbox artifact uploads by default

* [build-tools] Count upload session creation toward the sandbox upload yield time

* [build-tools] Replace the shared withTimeoutAsync with abort signal timeouts for sandbox artifact uploads
2026-10-12 15:56:21 +02:00
Tomasz Mazur 8453efcfb6 [eas-build-job] Add uploadArtifact to the sandbox daemon protocol (#4562)
* [eas-build-job] Add uploadArtifact to the sandbox daemon protocol

* [eas-build-job] Add changelog entry for sandbox uploadArtifact

* [build-tools] Reject sandbox uploadArtifact calls until uploads are implemented

* [eas-build-job] Add yieldTimeMs, status and downloadUrl to sandbox uploadArtifact
2026-10-12 15:56:21 +02:00
Stanisław Chmiela 0456e8eea6 [build-tools] Download submission archives with fresh URLs (#4539)
* [build-tools] request fresh submission archive URLs

* [build-tools] link submission archive changelog entry

* [build-tools] simplify submission archive downloads

* [build-tools] validate submission download inputs together

* [build-tools] retry submission archive URL requests

* [build-tools] infer submission archive mutation types from schema
2026-10-12 12:20:34 +02:00
Szymon Świerk 0ec9a81552 [eas-cli] Don't hide inputting individual ASC API keys for submissions behind a flag (#4589)
* [eas-cli] Remove EAS_ENABLE_INDIVIDUAL_ASC_API_KEYS and enable individual ASC API keys for submissions

* [eas-cli] changelog entry about enabling individual ASC API keys for submissions

* [eas-cli] Point changelog entry to #4589
2026-10-12 11:27:55 +02:00
Szymon Świerk 3b4fedeee5 [eas-cli] Don't check for ASC API key validity on Apple servers for individual keys (#4588)
* [eas-cli] Don't check for ASC API key validity on Apple servers for individual keys

* [eas-cli] changelog entry about not checking for individual asc api keys validity on apple servers
2026-10-12 11:14:15 +02:00
Ash Wu 570617f467 [eas-cli] show avg and P75 run duration in workflow:insights (#4566)
* [eas-cli] regenerate GraphQL types

* [eas-cli] share the nullable insights metric helpers

Maestro insights kept NullableMetricSummary and its duration-with-trend
formatter private. Workflows insights needs the same pair for run durations,
so they move to insights/metrics.ts as toNullableMetricSummary and
formatNullableMetricWithTrend, which takes the value formatter as an
argument. Maestro output is unchanged.

* [eas-cli] show run durations in workflow:insights

A customer doing weekly CI health reporting asked for the average and P75
end-to-end workflow run duration, excluding canceled runs (ENG-27927). The
server now exposes avgDurationMs and p75DurationMs on the Workflows Insights
overview metrics and on each workflow node.

eas workflow:insights requests both, shows "Avg duration" and "P75 duration"
in the overview with a lower-is-better trend, adds the two columns to the
workflows table, and includes them in --json. Durations render as h/m/s;
a window with no non-canceled run shows n/a.
2026-10-12 16:51:41 +08:00
Ash Wu 8262aa34c6 [build-tools] Write readable lines to the eas/run_agent job log instead of raw JSON events (#4568) 2026-10-12 16:24:28 +08:00
1f72736126 [build-tools] Update Appium command list (#4607)
* [build-tools] Update Appium command list

* [build-tools] Add summaries for simulated hinge angle commands

---------

Co-authored-by: Expo Bot <[email protected]>
Co-authored-by: Stanisław Chmiela <[email protected]>
2026-10-12 08:41:42 +02:00
Ash Wu 3328b60f85 [build-tools] Support Codex in eas/run_agent (#4553)
Codex has no single switch for its built-in tools, so its shell, image,
apps, plugins and subagent features are turned off by name, it runs in a
read-only sandbox, and the MCP server's tools are pre-approved. Resuming
uses exec resume --last, which starts a new session when none was saved,
so the continuation prompt now repeats the task.
2026-10-12 14:39:11 +08:00
Ash Wu 4abb8601d5 [build-tools] Add the eas/run_agent step function (#4552)
www creates an agent run as a generic job with this single step. The step
validates its inputs, reads the prompt and the job token from the job
environment, takes the API and MCP URLs from the worker config, and hands
over to the agent runner.
2026-10-12 14:39:10 +08:00
Ash Wu 9a881d8366 [build-tools] Add the agent runner for Claude Code (#4551)
Installs the pinned Claude Code CLI into a private run directory, points
it at the Expo MCP server with no built-in tools and a minimal environment,
enforces the maximum duration, and resumes the CLI's session when its
credentials expire mid-run. Nothing calls it yet.
2026-10-12 14:39:10 +08:00
Ash Wu 657034a1a6 [build-tools] Add the agent run lease client (#4550)
Leases the provider credentials of an agent run from www with the job
token. Network failures and 5xx responses are retried; every 4xx is final,
as the route's contract says. Nothing calls it yet.
2026-10-12 14:39:10 +08:00
Douglas LowderandClaude Opus 5.5 bb98f5beef [eas-cli][local-build-plugin] Deprecate Node 20, use Node 22 in CI (#4579)
* [eas-cli][local-build-plugin] Deprecate Node 20, use Node 22 in CI

Print a deprecation warning from the eas-cli and local-build-plugin
bin scripts when running on Node.js < 22. Develop and test on Node.js
22: bump mise, drop 20.18.3 from the test matrix, and move to
@tsconfig/node22 and @types/node 22. Published engines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:45:17 -07:00
Gabe DebesandClaude Opus 5.5 4aca8b1cc4 [build-tools] Look up ffmpeg on PATH instead of running it (#4582)
The first run of the preinstalled ffmpeg-full on
macos-tahoe-26.6-xcode-27.1 takes about 13 s while it loads its
libraries, and the ffmpeg setup waited for that `ffmpeg -version`.
Find ffmpeg on PATH the way device-session tools do, and run it once in
the background so its first real use does not pay the cold start.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:31:50 -07:00
Tomasz Mazur 800a0e0893 [build-tools] Add a sandbox readFile command for text and image files (#4555) 2026-10-09 17:23:40 +02:00
Tomasz Mazur 2911b04ea1 [build-tools] Return sandbox daemon error codes (#4571) 2026-10-09 17:02:27 +02:00
Tomasz Mazur 50b381abe0 [eas-build-job] Add sandbox daemon error codes (#4570) 2026-10-09 17:02:26 +02:00
Gabe Debes c8696f6012 [build-tools] Install ffmpeg before starting Appium sessions (#4577) 2026-10-08 16:12:29 -07:00
Brent Vatne 45d795e076 [eas-cli] Remove obsolete appVersion runtime policy warning (#4375) 2026-10-08 15:02:35 -07:00
Expo CI 8e98a915b9 bump STAGING and PRODUCTION to 24.12.1 in cli-versions.json 2026-10-08 21:54:04 +00:00
Expo CI 73c65d3edb update CHANGELOG.md for v24.12.1 2026-10-08 21:51:34 +00:00
Expo CI 01e3be71aa v24.12.1 2026-10-08 21:49:04 +00:00
SJ Kim 991bbeae8c [eas-cli] Move the #4522 and #4483 changelog entries to their releases (#4576) 2026-10-08 17:43:35 -04:00
SJ Kim 6c342abb97 [eas-cli] Stop a simulator session created after Ctrl+C during create (ENG-27606) (#4522)
* [eas-cli] Log client request events for simulator sessions (ENG-27138)

* [eas-cli] Flush analytics and exit with 130 when Ctrl+C cancels a simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for simulator request events (ENG-27138)

* [eas-cli] Bound the Ctrl+C analytics flush and tighten its tests (ENG-27138)

* [eas-cli] Stop a session created after Ctrl+C during create (ENG-27138)

* [eas-cli] Don't count an HTTP error answer as a failed simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for #4522 (ENG-27606)

* [eas-cli] Keep the simulator request analytics wrapper with its only caller (ENG-27138)

* [eas-cli] Keep a failed Ctrl+C stop test from hanging later simulator tests (ENG-27606)

* [eas-cli] Accept an unsent create request when stopping a session after Ctrl+C
2026-10-08 16:28:09 -04:00
Stanisław Chmiela 1016aa35f3 [build-tools] Skip WDA cache for DeviceLab Runner versions (#4560)
# Why

<img width="487" height="329" alt="Zrzut ekranu 2026-10-7 o 10 29 04" src="https://github.com/user-attachments/assets/a63f0550-bf93-46ed-83c7-187faa2ac20b" />

# How

Skip WDA cache preparation for detected Runner versions 1.1.28 and later.

# Test Plan

CI should pass.
2026-10-08 14:16:53 +02:00
Stanisław Chmiela ceb8f846c2 [build-tools] Address Android app info review feedback (#4567)
# Why

I merged pull request without noticing review comments. #4514.

# How

Adhered to review comments. Added integration tests using real APK/AAB and real tools, fixed error code.

# Test Plan

CI should pass. Validated locally.
2026-10-08 12:10:43 +02:00
Stanisław Chmiela 53cb3a7263 [build-tools] Add typed Google Play API client (#4515)
* [build-tools] Add authenticated Google Play API client

* [build-tools] Remove Android fixture dependency from client tests

* [build-tools] Add typed Google Play API methods with separate OAuth transport

* [build-tools] Simplify Google Play client and remove retry changes

* [build-tools] Improve Google Play client error details

* [build-tools] Load Google Play tokens with service account keys

* [build-tools] Extract Google Play assertion helper

* [build-tools] Move token creation into GooglePlayAuthUtils

* [build-tools] Make assertion helper private and capitalize utility filename

* [build-tools] Remove upload support from Google Play client PR

* [build-tools] Improve Google Play client schema spacing

* [build-tools] Reuse Google Play release schema
2026-10-08 10:39:56 +02:00
Stanisław Chmiela 09188ab271 [build-tools] Add typed Google Play OAuth API client (#4542)
* [build-tools] Add typed Google Play OAuth API client

* [build-tools] Align Google Play OAuth errors and response parsing

* [build-tools] Include Google Play OAuth error diagnostics

* [build-tools] Keep OAuth diagnostics without response secrets
2026-10-08 10:30:18 +02:00
Stanisław Chmiela c4ffc1605e [build-tools] Add Android artifact inspection function (#4514)
* [build-tools] Add Android artifact inspection function

* [build-tools] Use Android tools to inspect app artifacts

* [build-tools] Read Android info from downloaded binary paths

* [build-tools] Detect Android type before reading package name

* [build-tools] Read APK package name with aapt2 packagename

* [build-tools] Keep Android package readers explicit

* [build-tools] Pass step cancellation to Android app inspection

* [build-tools] Simplify Android type dispatch and ZIP lookup

* [build-tools] use caller cancellation for Android artifact inspection

* [build-tools] Run checks after rebasing Android artifact inspection
2026-10-08 10:23:15 +02:00
Expo CI d90f022966 bump STAGING and PRODUCTION to 24.12.0 in cli-versions.json 2026-10-07 21:20:27 +00:00
Expo CI 8b8362e4ae update CHANGELOG.md for v24.12.0 2026-10-07 21:17:55 +00:00
Expo CI b5ac163b37 v24.12.0 2026-10-07 21:15:21 +00:00
Douglas LowderandClaude Opus 5 92c8944cda [eas-cli] Include sourceMapUrl and assetMapUrl in eas update:view --json (#4534)
For update:view, add both asset map and source map URLs to the returned results when --json is used. This allows CLI users to retrieve source maps needed for symbolicating JS crashes.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-10-07 21:06:12 +00:00
Douglas LowderandClaude Opus 5.5 96581b8094 [ci] Move ffmpeg-dependent tests to a separate workflow (#4565)
For better performance of the main test workflow, move ffmpeg-dependent tests to a separate workflow, with a restricted set of source files in the workflow trigger, and ensure that this workflow is not required for PRs.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:00:32 -07:00
Gabe Debes e229f5378f [build-tools] Show eas update:embedded:upload output in build logs (#4557) 2026-10-07 13:38:05 -07:00
Gabe Debes 19657cc7ff [build-tools] Fix unnotarized mitmproxy cask install on iOS network capture sessions (#4556) 2026-10-07 11:04:55 -07:00
Tomasz Mazur be7acb7ab5 [eas-cli] Fix workflow:ssh lookup for job run ids (#4529) 2026-10-07 17:42:49 +02:00
Gabe DebesandClaude 3c302a055d [build-tools] Upload embedded bundle from custom builds (#4494)
* [build-tools] Upload embedded bundle from eas/build custom builds

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

* [build-tools] Add ignore_error to eas/upload_embedded_bundle and share archive path resolvers

Co-Authored-By: Claude <[email protected]>
2026-10-06 15:06:48 -07:00
Samuel Newman 449bd4981e Select containing iOS app when archive includes an App Clip (#4541)
For build:download, exclude nested app bundles (like app clips) from the installable candidates. Return the sole outer app without prompting, and keep the selection prompt when there are genuinely separate apps.
2026-10-06 09:58:17 -07:00
Szymon Dziedzic 4fd36987ba [build-tools] Encode macOS session previews with cwebp (#4549)
* [build-tools] Encode macOS session previews with cwebp

Homebrew's FFmpeg is built without libwebp, so every macOS preview failed
with "Encoder not found" (exit code 8). Resize the screenshot with sips and
encode it with cwebp from Homebrew's webp package instead. Android keeps
using FFmpeg.

* [build-tools] Add changelog entry for macOS session preview encoding

* [build-tools] Format the changelog entry
2026-10-06 18:14:16 +02:00
Szymon Dziedzic 01c1ffcaf4 [build-tools] Fix the session preview device reference on main (#4548)
#4398 was based on #4499, which renamed device to readyDevice in
startDeviceSessionHostAsync. #4540 reverted #4499 before #4398 merged, so
main referred to a readyDevice name that no longer exists and the
build-tools typecheck failed. Use device instead.
2026-10-06 14:10:28 +02:00
Szymon Dziedzic b10cc829bd [build-tools] Refresh simulator session preview artifacts (#4398)
Capture a WebP thumbnail of the session device every minute while the
session host runs, and upload it to one reusable preview artifact. Upload
request errors no longer include the signed upload URL in logs.
2026-10-06 13:54:42 +02:00
SJ Kim 3d8ed4c81f [eas-cli] Log client request events for simulator sessions (#4483)
* [eas-cli] Log client request events for simulator sessions (ENG-27138)

* [eas-cli] Flush analytics and exit with 130 when Ctrl+C cancels a simulator session request (ENG-27138)

* [eas-cli] Add changelog entry for simulator request events (ENG-27138)

* [eas-cli] Bound the Ctrl+C analytics flush and tighten its tests (ENG-27138)

* [eas-cli] Don't count an HTTP error answer as a failed simulator session request (ENG-27138)

* [eas-cli] Keep the simulator request analytics wrapper with its only caller (ENG-27138)
2026-10-05 23:10:34 -04:00
Expo CI a344c0e1e8 bump STAGING and PRODUCTION to 24.11.0 in cli-versions.json 2026-10-05 22:05:17 +00:00
Expo CI f80f548e2f update CHANGELOG.md for v24.11.0 2026-10-05 22:03:02 +00:00
Expo CI c72ae9f9f6 v24.11.0 2026-10-05 22:00:12 +00:00
SJ Kim bf0420cd29 [eas-cli] Send the request origin and coding agent when creating a simulator session (#4523)
* [eas-cli] Regenerate GraphQL types (ENG-27135)

* [eas-cli] Send the request origin when eas simulator creates a session (ENG-26853)

* [eas-cli] Send the coding agent when eas simulator creates a session (ENG-27135)

* [eas-cli] Add the PR number to the changelog entry (ENG-27135)

* [eas-cli] Leave out the coding agent when analytics are disabled (ENG-27135)
2026-10-05 13:49:07 -04:00
Szymon Dziedzic 738ccb1ff6 Revert "[build-tools] Confine agent-device sessions to the session device (#4…" (#4540)
This reverts commit c487bcf1df.
2026-10-05 19:03:12 +02:00
Szymon DziedzicandClaude Opus 5.5 c487bcf1df [build-tools] Confine agent-device sessions to the session device (#4499)
* [build-tools] Confine agent-device sessions to the session device

Launch the agent-device daemon with a daemon policy
(AGENT_DEVICE_DAEMON_POLICY, agent-device 0.21.17 and later) that allows
only the booted device and denies booting and shutting down devices.

agent-device still installs during the boot. The daemon launches when
the boot reports the device. The policy file is removed when the daemon
stops or fails to launch. A daemon without `policyDigest` in daemon.json
does not enforce the policy, so the step logs a warning.

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

* [build-tools] Serve the booted Simulator in agent-device sessions

Pass the booted UDID to serve-sim, so the web preview shows the device
that the daemon policy allows. Without it, serve-sim picks a simulator
itself and can show another booted one. The session host fails when
serve-sim reports another device.

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

* [build-tools] Launch the agent-device daemon during the iOS Simulator boot

Look up the Simulator UDID before the boot and boot that UDID. The
daemon policy needs only the UDID, so on iOS the daemon launches during
the boot again, as before the policy. On Android the emulator serial is
known only after the boot, so the daemon still launches after it.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 16:25:51 +02:00
Szymon DziedzicandClaude Opus 5.5 bcaaaddab8 [build-tools] Report the web preview as webPreviewUrl and webPreviewToken (#4467)
* [build-tools] Report the web preview as webPreviewUrl and webPreviewToken

Also keep the legacy previewUrl and previewToken keys, because SERVE_SIM
sessions still read them.

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

* [build-tools] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 16:23:24 +02:00
Szymon DziedzicandClaude Opus 5.5 4e432fa537 [build-tools] Upload the agent-device screenshot display rotation as artifact metadata (#4508)
* [build-tools] Upload the agent-device screenshot display rotation as artifact metadata

agent-device reports displayRotation on screenshot entries in its /artifacts
inventory. Read it and pass it to the device run session artifact upload as
metadata.displayRotation. An unknown value is dropped so it cannot block the
upload, and an artifact without the field uploads no metadata, as before.

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

* [changelog] Add entry for #4508

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 15:49:47 +02:00
Szymon DziedzicandClaude Opus 5.5 817d06e66e [build-tools] Remove the eas/start_agent_device_remote_session step (#4518)
* [build-tools] Remove the eas/start_agent_device_remote_session step

The server now submits the single `eas/start_agent_device_session` step
for every agent-device session (expo/universe#31785), so remove the old
`eas/start_agent_device_remote_session` step.

- `runAgentDeviceRemoteSessionAsync` and the daemon helpers stay; the
  single step uses them. Its `launch` option goes away: only the old step
  passed serve-sim launch inputs, and the single step launches the app
  with simctl / adb.
- The tests that ran the shared session code through the old step now
  call `runAgentDeviceRemoteSessionAsync`, or run the single step with a
  mocked boot.

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

* [build-tools] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 15:19:54 +02:00
Stanisław Chmiela 76a9d17ea7 [build-tools] Diagnose TestFlight group assignment failures (#4521)
## Why

Group assignment can fail after upload processing completes because the build needs export compliance information. The error should identify the Apple state and explain how to resolve it on the already-uploaded build.

Example: https://expo.dev/uuid/01a0f7f6-42df-7673-af23-da067af13847

## How

After any failed group assignment, call `verifyInternalBuildStateValidForGroupAssignmentAsync` to fetch `buildBetaDetail`, log the internal TestFlight state, and capture a Sentry event. Cache its promise so subsequent failures do not repeat the lookup, log, or capture. The function throws a specific error for missing or pending export compliance. Otherwise it returns, and each group retains its own assignment error and the existing internal-group guidance.

Capture all internal states before throwing. Include the state in the Sentry message title so states have separate message groups, with the build ID and original assignment error as context. Use UNKNOWN when the diagnostic lookup fails.

Continue trying other groups before failing the job. Successful assignments do not fetch the state. There is no polling loop.

## Test Plan

- All 32 metadata tests passed, including two failed assignments followed by a successful assignment with one shared lookup, log, and capture. Export-compliance failures share the rejection; other failures retain each group's original error.
- Build-tools typecheck, repository lint, formatting, and diff checks passed.
- No new live Apple test for this change.
2026-10-05 11:36:22 +02:00
Hirbod 8ce87bfa56 [build-tools] allow expo.test subdomains for simulator previews (#4480) 2026-10-03 14:31:52 +02:00
2b7e8454e3 [build-tools] Replace record-sim with serve-sim recording (#4474)
* [build-tools] Replace record-sim with serve-sim recording

Co-authored-by: Codex <[email protected]>
Co-Authored-By: Claude <[email protected]>

* [build-tools] Avoid failure warnings for saved iOS recordings
2026-10-02 15:33:19 -07:00
Stanisław Chmiela 962a2a733e [build-tools] Retry temporary Apple request failures safely (#4520)
* [build-tools] retry temporary Apple request failures safely

* [build-tools] distinguish complete assets from uploaded chunks

* [build-tools] document Apple request recovery

* [build-tools] use connection retry policy and isolate commit recovery

* [build-tools] simplify upload commit recovery and limit reset retries to GET

* [build-tools] accept object-only retry options

* [build-tools] define simple explicit retry options

* [build-tools] adapt commit tests to the key-based ASC client

* [build-tools] keep Apple retry changes in the unreleased changelog

* [build-tools] recover uncertain UPLOAD_COMPLETE commits

* [build-tools] require upload commit logger

* [build-tools] retry uncertain upload commits through the outer retry

* [build-tools] check upload state after the final uncertain commit

* [build-tools] use try catch and classify pending commits as system errors

* [build-tools] limit upload recovery to interrupted connections
2026-10-02 21:40:18 +00:00
Eliot Hertenstein 9e13c1e820 [eas-cli] Add resumable device login (#4502) 2026-10-02 14:37:02 -07:00
Expo CI cadb7b2d1e bump STAGING and PRODUCTION to 24.10.0 in cli-versions.json 2026-10-02 20:58:58 +00:00
Expo CI 718c82d8e1 update CHANGELOG.md for v24.10.0 2026-10-02 20:55:55 +00:00
Expo CI 5d31ecc2bc v24.10.0 2026-10-02 20:53:41 +00:00
Gabe DebesandClaude Opus 5.5 890286a08d [eas-cli] add --network-capture to eas simulator (#4308)
* [eas-cli] add network capture flags to eas simulator

* [eas-cli] regenerate GraphQL for network capture inputs

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

* [eas-cli] move the network capture changelog entry to main

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 13:31:43 -07:00
Expo CI 33ff59a75e bump STAGING and PRODUCTION to 24.9.0 in cli-versions.json 2026-10-02 17:41:52 +00:00
Expo CI c2bbba6e78 update CHANGELOG.md for v24.9.0 2026-10-02 17:35:45 +00:00
Expo CI 86b85e7d8d v24.9.0 2026-10-02 17:32:47 +00:00
Gabe DebesandClaude 03a7d3a6ee [eas-cli] Only forward --non-interactive from update:rollback in non-interactive mode (#4504)
* [eas-cli] only forward --non-interactive from update:rollback when it was passed

* [eas-cli] fix the changelog entry

Co-Authored-By: Claude <[email protected]>
2026-10-02 10:15:42 -07:00
Gabe DebesandClaude Opus 5.5 a21db49184 [eas-cli] Allow new updates to end active rollouts (#4233)
* [eas-cli] allow publishing over an in-progress rollout

* [eas-cli] support superseding a rollout with a new rollout

* [eas-cli] prompt before starting the publish spinner

* [eas-cli] group the active rollout warning by runtime version

* [eas-cli] cover the active rollout paths

* [eas-cli] explain why the superseded rollout serves more users

* [eas-cli] cover publishing without the rollout check

* [eas-cli] refuse rolling out over a rollout in progress

* [eas-cli] rewrite the rollout publish errors as what, why, how

* [eas-cli] render the active rollout warning with renderTextTable

* [eas-cli] regenerate the GraphQL schema against the deployed API

* [eas-cli] add the new required event name fields to observe test fixtures

* [eas-cli] preserve interactive rollout confirmation during rollback

* [eas-cli] tweak changelog

* [eas-cli] require --force-end-active-rollout instead of prompting

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-02 10:04:41 -07:00
Stanisław Chmiela 6dd6da083d [build-tools] Pass cancellation signal to download_build (#4511)
## Why

Just a small improvement.

## How

Added `signal` from step to `downloadBuild` func.

## Test Plan

CI should pass.
2026-10-02 16:39:52 +00:00
Stanisław Chmiela 656921d776 [build-tools] Cache and refresh ASC tokens for long submissions (#4497)
* [build-tools] refresh ASC tokens for long submissions

* [build-tools] link token fix changelog

* [build-tools] own ASC token caching in the API client

* [build-tools] load ASC keys outside the API client

* [build-tools] extract metadata key and client variables

* [build-tools] extract upload API key variable

* [build-tools] name ASC token lifetime and refresh margin
2026-10-02 18:34:24 +02:00
Szymon Świerk fabde0c2bb [build-tools] ASC API client: in beta group response, allow hasAccessToAllBuilds to be nullish (#4528)
* [build-tools] ASC API client: in beta group response, allow hasAccessToAllBuilds to be nullish

* [build-tools] Fill in PR number in changelog
2026-10-02 15:42:24 +02:00
Stanisław Chmiela 7680f33879 [build-tools] Explain missing App Store Connect agreements (#4513)
* [build-tools] Explain missing App Store Connect agreements

* [build-tools] Preserve extra Apple error fields and add changelog

* [build-tools] Handle aggregate App Store Connect errors in callers

* [build-tools] Give each App Store Connect request error a single code
2026-10-02 12:39:13 +02:00
Stanisław Chmiela 199a996b38 [eas-cli] Fix simulator status compatibility with the live GraphQL schema (#4527)
GraphQL got outdated.
2026-10-02 12:28:27 +02:00
Brent Vatne 4728c64d3f [eas-cli] Correct simulator:start --max-duration-minutes help text (#4524) 2026-10-01 19:27:23 -07:00
Brent Vatne 0fdf96e795 Support --egress local for Android emulator sessions (#4519) 2026-10-01 18:14:11 -07:00
Gabe DebesandClaude 04f3bee0c4 [build-tools] install mitmproxy and pass network capture options to iOS simulator sessions (#4307)
Co-authored-by: Claude <[email protected]>
2026-10-01 16:56:04 -07:00
Gabe DebesandClaude 140cfe445a [build-tools] Poll the simulator sessions web preview readiness every 250ms (#4496)
The host answers /readyz locally within milliseconds, but the step
slept 1 s between polls, so a ready host waited up to a second more
before the session went live.

Co-authored-by: Claude <[email protected]>
2026-10-01 16:30:53 -07:00
Yuval GnessinandClaude Opus 5.5 da55f26cac [eas-cli] Fix eas workflow:validate crashes and exit code (#4507)
* [eas-cli] Fix eas workflow:validate crashes and exit code

- Skip job-schema variants without a `type` (reusable-workflow calls)
  when listing valid job types; the live schema now includes one, which
  crashed every validation with "reading 'const'".
- Check `repack` job profiles only when `params.profile` is set; it is
  optional, and the unset case crashed with "reading 'includes'".
- Set a non-zero exit code when validation fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015v5pSST6sHZiUiPkU7sq47
2026-10-01 15:00:18 -07:00
Douglas LowderandClaude Opus 5 e24130c6e0 [eas-cli] Add --upload-source-maps to eas update (#4436)
* [eas-cli] Add --upload-source-maps to eas update

Upload the source map that expo export emits for each native platform,
so EAS Observe can symbolicate stack traces from an update.

The maps are not listed in metadata.json, so each one is located from the
bundle that metadata.json does name, falling back to a single sibling
.map file. sourcesContent is always stripped, which keeps application
source text out of EAS storage and gives the map a chance of fitting
under the 5 MiB upload limit. A map that is still too large is skipped
with a warning; publishing never fails because of a source map.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-01 14:05:49 -07:00
Brent Vatne 2a0fe12836 [build-tools] Upload embedded bundle by default on SDK 58+ and rename env var (#4433) 2026-10-01 20:16:44 +00:00
SJ Kim 4c153da14d [eas-cli] Keep following simulator sessions in any status that isn't final (#4506)
* [eas-cli] Keep following simulator sessions in any status that isn't final (ENG-26991)

* [eas-cli] Add changelog entry for #4506 (ENG-26991)
2026-10-01 15:56:24 -04:00
Vojtech NovakandClaude Opus 5.5 81166df987 [build-tools] Upload manual preview screenshots as session artifacts (#4481)
* [build-tools] Upload manual preview screenshots as session artifacts

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Address review findings on preview screenshot uploads

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Report retained preview screenshots to Sentry once and throttle retry warnings

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Remove duplicated preview screenshot tests

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Give the host test's spawn mock the getExitError member main added

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Report preview-side screenshot save failures to Sentry

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Report preview screenshot save failures from the host's failure records

The host now writes a structured record into the watched directory instead of
the worker scanning its stderr at shutdown.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Name the patterns used to build screenshot artifact names

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Split the preview screenshot collector into single-purpose functions

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Keep the preview screenshot directory while the session host still runs

The collector finished and removed an empty directory even when the host could not be stopped,
so later captures went into a directory no one watched. It now takes hostStopped, warns and keeps
the directory when the host is still running, and uses rmdir so a capture that lands after the
final scan is retained instead of deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Upload fresh preview screenshots before retrying failed ones

Each pass uploaded files in readdir order, so on macOS the oldest failed capture went first every
time and a stalled one held back newer captures and could use the whole shutdown budget. Captures
that never failed now go first, oldest first. The symlink test uses fixed names so the link sorts
before its target and a symlink-following regression always fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Keep signed upload URLs out of device run session artifact errors

node-fetch puts the request URL in its error message, and the artifact PUT goes to a signed URL,
so a dropped connection wrote the signature to the job log and, for retained screenshots, to
Sentry. Network errors from the PUT are now rethrown with the artifact ID and error code only,
without the original as the cause. They stay retryable.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Tag preview screenshot artifacts with __eas_type

Recordings and performance metrics set __eas_type to their kind; screenshots did not.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Make two preview screenshot tests assert what their names claim

The session-lookup fallback test now checks the fallback name and metadata, and the host test
checks that the screenshot directory still exists when the host is stopped, which fails if the
collector finishes first.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Format the preview screenshot changes

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013nLsuzD7ThA1uv3dVHUFQp

* [build-tools] Call leftover preview screenshots "not uploaded"

"Retained" suggested the files were kept safe, but they are destroyed with the worker. The
shutdown report now says "N preview screenshots were not uploaded.".

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-01 14:23:59 +02:00
Stanisław Chmiela ef189b00be [build-tools] Nicer TestFlight metadata update output (#4503)
# Why

While expanding `eas/update_testflight_metadata` to more users I kept a close eye on the rollout and the results. I think there's a few improvements we can make to the output of metadata updates.

# How

- moved group verification into the p-limited group-managing function so it doesn't block changelog from being updated
- made lack of primary locale NOT a blocker -- we now fall back to en-US
- changed processing logic so one failure does not abort all updates
- if a user lists a group that is going to get the build automatically distributed to them, we mark it as "ok" instead of throwing

# Test Plan

Tested live.
2026-10-01 14:20:50 +02:00
Szymon Świerk 27e56afd12 [eas-build-job] Add schema for workflow Sentry interpolation context (#4453)
* [eas-build-job] Add schema for workflow Sentry interpolation context

* [eas-build-job] Add assignee, actor, priority, and count fields to Sentry context schema
2026-10-01 14:18:29 +02:00
Szymon DziedzicandClaude Opus 5.5 1c342621c0 [build-tools] Add a single step for agent-device sessions that starts independent parts in parallel (#4491)
* [build-tools] Add a single step for agent-device sessions that starts independent parts in parallel

Add `eas/start_agent_device_session`. It replaces the boot step,
`eas/download_build`, `eas/install_build`, `eas/launch_application` and
`eas/start_agent_device_remote_session` for agent-device sessions, so the
parts that do not depend on each other run at the same time: the device
boot, the build download, ffmpeg on Android, and the agent-device daemon
and its tunnel. The session host starts after the boot, and the install
after the boot and the download. Each part logs its duration, and a
summary line is logged at ready.

The old steps keep their behavior and share the code:
- `bootIosSimulatorAsync` and `startAndroidEmulatorAsync` are extracted
  from the boot steps.
- `runAgentDeviceRemoteSessionAsync` is extracted from
  `eas/start_agent_device_remote_session`. It also starts the daemon and
  the session host in parallel there, and it now stops the daemon when
  its credentials never appear.

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

* [build-tools] Add changelog entry

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

* [build-tools] Abort agent-device session startup on the first failure

Review follow-ups for `eas/start_agent_device_session`:

- `StartupTasks` now has an abort signal. The first failed task aborts
  it. The download passes it to the request and the file stream, the
  install task checks it before the install and the launch, and the
  session host stops waiting for the boot when it fires. So a failed
  startup stops quickly, its teardown cannot wait for a stalled download,
  and nothing installs or launches after a failure.
- The install task waits for the download to settle, so a failed step no
  longer returns while the download still runs.
- Remove the ffmpeg prefetch: the Linux image now ships ffmpeg, and the
  session host still checks for it.
- Each startup task logs through a child logger with a `startupTask`
  field, so the lines of parallel tasks can be told apart.

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

* [build-tools] Cancel the build lookup, extraction and daemon setup on startup abort

More review follow-ups for `eas/start_agent_device_session`. The startup
abort signal now reaches the work that can stall, not only the checks
between stages:

- The build-ID GraphQL lookup gets it through `graphqlAbortContext`.
- `decompressTarAsync` takes an optional signal. With one, it feeds the
  archive to a `tar.Unpack` that the abort stops. Without one, callers
  keep `tar.extract`. The archive scan does not start after an abort.
- The agent-device package install, the git clone and the git install
  get it, so an abort kills them. An abort does not trigger the git
  fallback, and the daemon does not start after one.
- The session stops waiting for the daemon credentials on an abort. The
  daemon is already stored, so the teardown stops it.

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

* [build-tools] Let a started build extraction finish before reporting the abort

`tar.Unpack.abort()` rejects at once but cannot stop the file writes that
the extraction already started, so the download could settle while tar
still wrote files. `decompressTarAsync` goes back to `tar.extract`
unchanged. `downloadBuildAsync` checks the startup signal right before
and right after the extraction instead: an abort during the extraction
lets this local, bounded work finish and is reported right after it, so
no file is written after the download settles.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-01 12:52:20 +02:00
Expo Bot 9b7098cb58 [license] Update BSL Change Date to 2029-10-01 2026-10-01 00:59:39 +00:00
SJ Kim 9b247a6197 [build-tools] Collect shared simulator logs (#4463)
* [build-tools] Collect shared simulator logs (ENG-26642)

* [build-tools] Link simulator log changelog entry (ENG-26642)

* [build-tools] Require user-app scope for session logs (ENG-26642)

* [build-tools] Name simulator log artifacts app-logs.ndjson (ENG-26642)

* [build-tools] Time out simulator log uploads per device (ENG-26642)

* [build-tools] Report simulator log failures to Sentry (ENG-26642)

* [build-tools] Use a type import for the log stream body (ENG-26642)

* [build-tools] Validate serve-sim log envelopes and report malformed records (ENG-26642)
2026-09-30 16:25:48 -04:00
Stanisław Chmiela 862d0a8f09 [build-tools] Clear old Maestro HTML reports before each attempt (#4409)
It seems when auto-retrying an old HTML report may be uploaded instead of a more recent one. This clears reports between attempts.

(I think behavior of auto-retry + relying on HTML reports is a bit wonky so I guess users should then use the "retry-all" retry behavior.)
2026-09-30 09:15:04 +02:00
Szymon Dziedzic 417996ade0 [eas-cli] Keep the simulator session spinner on one line (#4488) 2026-09-29 21:41:58 +02:00
Stanisław Chmiela 1c6161432a [build-tools] Add "native" TestFlight metadata step (#4475)
# Why

While working on moving off of Submission Service to job runs I found a feature discrepancy — Submission Service supported changelog and assigning to groups, but job runs did not.

# How

Added an EAS function that uses ASC API to add changelog to build and build to groups. It more or less follows what Fastlane does.

See https://github.com/expo/universe/pull/31473.

# Test Plan

- The deployed worker ran the built-in step against an existing Apple build: [staging workflow](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a0d929-8130-74b2-b014-0b95431907c2).
- Full end-to-end staging test passed with a [fresh signed IPA](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/builds/45c69b90-649f-4908-a50a-49044e92b18d): [workflow](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/workflows/01a0d934-b692-71ad-9aba-f9acec8d3888). `eas/upload_to_asc` returned upload ID `8ee25971-3463-4d6d-856f-947645988a7a`. The built-in step selected Apple build `3880e9fc-bf7b-430c-a865-e399a1398667`. Separate Apple API reads confirmed the exact changelog, test-group membership, and no beta review submission.
2026-09-29 13:51:40 +00:00
Stanisław Chmiela 3f667ae4a8 [build-tools] Support maestro-runner HTML and Allure reports (#4406)
We're seeing customers have good experience with `maestro-runner`. Let's add some support for exporting HTML and Allure reports from it.

Runner report generation: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/cli/test.go
2026-09-29 15:17:03 +02:00
Szymon DziedzicandClaude Opus 5.5 4fb6de564c [build-tools] Disable agent-device internal idle timeouts in remote sessions (#4485)
* [build-tools] Disable agent-device internal idle timeouts in remote sessions

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

* [build-tools] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-09-29 12:29:30 +02:00
SJ Kim 975693e96e [build-tools] Detect early Argent startup exits (#4454)
* [build-tools] Detect early Argent startup exits (ENG-22026)

* [build-tools] Observe startup exit before pipe closure (ENG-22026)
2026-09-28 23:10:40 -04:00
Brent Vatne b84233bf07 [eas-cli] Recount spinner lines when the terminal resizes (#4470) 2026-09-28 18:14:11 -07:00
Stanisław Chmiela 6dec2f9a13 [eas-cli] Allow preparing submission credentials without loading app config (#4471)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Having to load app config requires installing node modules which takes time and requires thinking about Node.js version, images etc.

# How

Added two optional parameters to `submit:internal` — `project-id` and `application-identifier`. With them provided we don't need to load project config, so we don't need to install dependencies!

# Test Plan

I'll land this, then land https://github.com/expo/universe/pull/31467 in staging and see if it works!
2026-09-28 21:15:19 +02:00
Vojtech NovakandClaude Fable 5.1 c15e184bb8 [ci] Install ffmpeg so the recording recovery test runs (#4441)
* [ci] Install ffmpeg so the recording recovery test runs

The build-tools test that recovers unlisted recordings from a killed
host cuts a real MP4 and needs ffprobe. It skipped on every CI run.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Fail the recording recovery test loudly without ffmpeg

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [ci] Update the ffmpeg install comment

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:58 +02:00
Vojtech NovakandClaude Fable 5.1 89740dbbfd [build-tools] Share the urql abort-signal wrapper (#4435)
* [build-tools] Add one helper that joins a caller's signal into urql fetch

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Use the shared urql abort helper in both call sites

The artifact upload-session mutation and the sandbox-ready mutation each
wrapped fetch by hand to join a cancellation signal after urql set its own.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:58 +02:00
Vojtech NovakandClaude Fable 5.1 390a165c57 [build-tools] Share remote session teardown across the automation steps (#4431)
* [build-tools] Cover a teardown failure after the session body failed

The error that ended the session must reach the step, not the teardown error.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Share remote session teardown across the automation steps

The Agent Device, Appium and Argent steps each ran Promise.allSettled over
their teardown and rethrew the first rejection from a finally block, which
replaced the error that had ended the session. One helper now runs every
teardown, logs each failure, and rethrows only when the session body succeeded.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Attempt every remote session teardown step independently

A rejected event collection stop no longer leaves the automation process
running. The tunnel close, the collector-then-process pair and the host
finish are separate teardown entries; the process stop sits in a finally.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Name each remote session teardown step in its failure log

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Report teardown failures swallowed after a failed session to Sentry

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:57 +02:00
Vojtech NovakandClaude Fable 5.1 f7fb43b974 [build-tools] Only stop a session host that never became ready (#4430)
A readiness timeout ran the full finish pipeline: a stop request to a server
that never came up and a read of a recordings.json that did not exist, each
logging a warning unrelated to the real cause. finishAsync now skips
finalization and upload for a host that never answered /readyz. Finalization
and upload failures are reported to Sentry like the per-recording upload
failures one layer down, and the finishAsync contract states that it never
rejects.


Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:57 +02:00
Vojtech NovakandClaude Fable 5.1 a32080fadf [build-tools] Bound recording shutdown and artifact uploads (#4420)
* [build-tools] Bound session shutdown and recording uploads

* [build-tools] Close HTTP requests after each artifact PUT

* [build-tools] Separate artifact retries from PUT cleanup

* [build-tools] Cover a session host that ignores SIGTERM

stopAsync must not resolve while the killed process still answers kill(pid, 0).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Wait for a killed session host to be reaped

After SIGKILL the child is a zombie until libuv reaps it on a later loop turn,
so the running check right after stopAsync always saw it alive and the
recording upload was skipped.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Cover uploads that progress past the PUT deadline

A body that keeps flowing for longer than the per-attempt cap must still upload.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Bound artifact upload stalls instead of total time

The 90 s PUT deadline now restarts on every chunk the request consumes, and the
300 s artifact and 305 s recording deadlines are gone. Upload time scales with
file size, so a whole-request cap failed every recording a slow link could not
send in time, including iOS and agent artifacts through the shared uploader.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Cover uploading the partial recording a killed Hub leaves

A killed Hub lists no recording; the host must find the fragmented partial,
skip one ffprobe cannot read, upload it flagged partial and remove the root.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Upload the partial recording a killed Hub leaves behind

When recordings.json is empty after the host stops, look for session
directories whose manifest is still at "recording" and whose fragmented
.partial ffprobe can read, then upload them through the shared recording
uploader with partial: true in the artifact metadata. The Hub writes that
manifest at the first keyframe, so the website can still place the video.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Log the Hub's reason when recording finalization is refused

The Hub answers 409 with why nothing was recorded, such as the booted-emulator
count. Read the body instead of throwing on the status so the reason reaches
the job log.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Cover recovering a failed partial recording with its reason

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Upload failed partial recordings with the Hub's reason

A Hub that failed or timed out while finalizing leaves the same fragmented
partial as a killed one, and its manifest now names the file and the error.
Recover "failed" manifests whose file decodes and pass the reason to the
website as partialReason.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Type the deadline error and simplify artifact PUT cleanup

The retry predicate matched the PUT deadline by message text and carried an
unreachable AbortError branch. A DeviceRunSessionTimeoutError replaces both.
node-fetch destroys the request body on abort and the retry loop destroys the
stream on every exit, so the extra abort listener and destroy call go. The
PUT error names the artifact and the status text again.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Do not warn about a preview tunnel that never opened

A preview whose open failed, or that its opener closed after finish began,
rejects previewTask. Retirement awaited that rejection and logged a deadline
warning for a tunnel that no longer existed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Recover completed recordings a killed Hub never listed

recordings.json is written at finish. A recording that reached its duration
limit is complete on disk but unlisted until the session ends, so a Hub killed
in that window left a finished video the scan skipped. The scan now accepts
"complete" manifests as well and uploads them without the partial flag.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Log the session host's output when a recording is lost

The Hub reports capture failures on stderr, but the worker logged only the
stop route's one-line summary. When finalization fails or nothing is
uploaded, log the last 8 000 characters of the host's output.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Tell a Hub that never recorded apart from a failed finalization

A 409 from the stop route means nothing was captured, and the Hub says why.
Log that reason and skip the upload and the host output dump, which stay
for real capture failures.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Bound the finalization request once, through the deadline wrapper

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Ignore a deadline reset after the bounded operation settled

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Say when ffprobe is missing instead of calling a recording undecodable

Also log the recording by its session directory name rather than the full
temp path.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:57 +02:00
Vojtech NovakandClaude Fable 5.1 55017fbf70 [build-tools] Record Android sessions by default through a session host (#4418)
* [build-tools] Add opt-in Android session recording upload

* [build-tools] Document opt-in Android session recording

* [build-tools] Record Android device sessions by default

* [build-tools] Separate session recording from preview lifetime

* [build-tools] Stop automation alongside recording cleanup

* [build-tools] Fix session host formatting and add lifecycle release note

* [build-tools] Keep session host guidance in the README

* [build-tools] Extract session host shutdown operations

* [build-tools] Keep one changelog entry for the recording stack

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D29AEWN9ajZXbzobHUH4fF

* [build-tools] Type the session host variables with DeviceSessionHost

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Throw SystemError when recording finalization fails

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Keep the recording upload step Darwin-only

Android recordings are uploaded by the session host, and nothing on Linux
produces recordings_json.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Drop the session host section from the README

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [build-tools] Name the recording stop grace period after the Hub's SIGTERM deadline

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-28 12:57:56 +02:00
Byron Karlen 7fd8a3a75b [eas-cli] Remove unnecessary confirm_account query param (#4368) 2026-09-24 19:40:24 -07:00
Szymon DziedzicandClaude Opus 5.5 0f38c2ad82 [eas-cli] Include legacy sessions in the web-preview-only filter (#4466)
* [eas-cli] Include legacy sessions in the web-preview-only filter

`simulator:list --type web-preview-only` now also matches sessions with the
deprecated SERVE_SIM type. The --type flags list each value once.

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

* [eas-cli] Add changelog entry

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 00:21:04 +02:00
Expo CI 5198a2cc9c bump STAGING and PRODUCTION to 24.8.0 in cli-versions.json 2026-09-24 21:22:09 +00:00
Expo CI ca5f0d2306 update CHANGELOG.md for v24.8.0 2026-09-24 21:17:13 +00:00
Expo CI 9eefd5a5c4 v24.8.0 2026-09-24 21:13:58 +00:00
Tomek Czajecki a797caaa8a [eas-cli] Add --build-fingerprint to eas simulator (#4472)
* [eas-cli] Add --build-fingerprint to eas simulator

* [eas-cli] Add changelog entry
2026-09-25 00:05:35 +03:00
Gabe Debes 11323be04b [build-tools] boot the Android emulator headless from argent (#4465)
* [build-tools] start argent with ARGENT_EMULATOR_NO_WINDOW

* [build-tools] add the PR number to the changelog
2026-09-24 12:44:25 -07:00
David Mokos b1f886ef8d allows interpolated URIs in workflow validation (#4329) 2026-09-24 04:57:53 -07:00
David MokosandDavid Mokos 14e75df9e5 [eas-cli] Stop creating CLAUDE.md in new apps (#4446)
* [eas-cli] Stop creating CLAUDE.md in new apps

* [eas-cli] Link changelog entry to PR

---------

Co-authored-by: David Mokos <[email protected]>
2026-09-24 12:19:18 +02:00
Szymon Świerk dd88335654 [eas-cli] Gate the individual ASC API key prompt (#4464) 2026-09-24 01:01:57 +02:00
Gabe Debes 5183a6581a [build-tools] pass the website origin to serve-sim (#4437)
* [build-tools] pass the website origin to serve-sim

* [build-tools] allow deploy previews to reach staging sessions

* [build-tools] allow the website dev ports on staging sessions

* [build-tools] tidy the website origin helper and its tests

* [build-tools] name only https website dev origins
2026-09-23 13:43:06 -07:00
Gabe Debes fb7c812e50 [build-tools] raise the simulator preview stream to 1600px and 10 Mbps (#4439) 2026-09-23 13:01:19 -07:00
Gabe Debes e690ac0060 [build-tools] hand the iOS application launch to serve-sim (#4324) 2026-09-23 09:38:36 -07:00
Szymon Świerk c2190ca74f [eas-cli] Add individual key type prompt for submission keys (#4250) 2026-09-23 16:34:34 +02:00
Szymon Świerk 157b7cb79f [eas-cli] Accept individual ASC API keys for submissions, guard provisioning (#4249)
* [eas-cli] Allow resolving an ASC API key without an issuer ID

* [eas-cli] Accept individual ASC API keys during authentication

* [eas-cli] Guard credential provisioning operations against individual ASC API keys

* [eas-cli] Allow individual ASC API keys for metadata and TestFlight setup

* [eas-cli] Accept individual ASC API keys in EAS Submit

* [eas-cli] Reject individual ASC API keys in the ASC integration flow

* [eas-cli] Update CHANGELOG for individual ASC API key support
2026-09-23 16:21:16 +02:00
Tomasz Mazur 0d2b1c8d88 [eas-cli] add support for build id in workflow:ssh (#4452) 2026-09-23 10:48:18 +02:00
Szymon Dziedzic 788cb57ff1 [eas-cli] Exclude simulator queue time from startup timeout (#4411)
* [eas-cli] Exclude simulator queue time from startup timeout

* [eas-cli] Add PR attribution to simulator changelog

* [eas-cli] Clarify simulator queue wait message
2026-09-22 23:31:13 +02:00
Krystof WoldrichandClaude Opus 5 1634a50b6d [eas-cli] Make sim a hidden alias of simulator in the help print (#4457)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-22 20:04:39 +02:00
Szymon DziedzicandClaude Fable 5.1 805b403ae3 [eas-cli] Make eas simulator commands visible (#4456)
* [eas-cli] Make eas simulator commands visible

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] Add PR number to changelog entry

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-22 19:28:46 +02:00
Byron Karlen 1672bcdadd [eas-cli] Recognize new Release Manager role (#4300) 2026-09-21 11:32:53 -07:00
Juwan WheatleyandClaude Fable 5.1 30b1eb2dda [eas-cli] add integrations:supabase:advisors command (#4342)
* [eas-cli] add integrations:supabase:advisors command

* [eas-cli] prompt to re-authorize Supabase from integrations:supabase:advisors

* [eas-cli] link each Supabase advisor finding to its dashboard entry

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] verify Supabase advisors against deployed API and preserve reauthorization state

* [eas-cli] make Supabase advisor titles clickable

* [eas-cli] remove advisor remediation links from terminal output

* [eas-cli] move Supabase advisor links below issue descriptions

* [eas-cli] print Supabase dashboard URL above advisor findings

* [eas-cli] tighten spacing within Supabase advisor findings

* [eas-cli] address Supabase advisors review feedback

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 13:20:28 -04:00
Stanisław Chmiela 99ce876d50 [build-tools] Preserve safe certificate import diagnostics (#4447)
* [build-tools] Preserve safe certificate import diagnostics

* [build-tools] Keep certificate import diagnostics in Keychain

* [build-tools] Simplify import error handling and log diagnostics as errors

* [build-tools] Narrow import failures to Error with optional process fields

* [build-tools] Name import diagnostics and simplify their tests

* [build-tools] Avoid duplicate certificate import diagnostics
2026-09-21 17:40:34 +02:00
Stanisław Chmiela 602502e502 [build-tools] Unify iOS keychain implementations (#4450)
# Why

We had two copies of the Keychain class (from the time period of migrating "legacy build process" to "step-based build process" and copying stuff "not to break anything").

# How

Unified the implementations. The difference between classes was whether the class received `BuildContext` as constructor param or not.

The unified interface requires `logger` being passed in to every command (since we're using the same `Keychain` instance in multiple build phases).

# Test Plan

CI should pass.
2026-09-21 17:14:23 +02:00
Gabe Debes 30a3924f43 [build-tools] pass --share-url so serve-sim copies the expo.dev preview page (#4434)
* [build-tools] pass --share-url so serve-sim copies the expo.dev preview page

* [build-tools] changelog for serve-sim --share-url

* [build-tools] reuse previewPageUrl for serve-sim --share-url
2026-09-17 15:48:10 -07:00
Douglas Lowder d2e6b4c45e [docs] Update RELEASING.md (#4417) 2026-09-17 12:28:30 -07:00
Douglas LowderandClaude Opus 5 419efa485f [ci] Fix and harden release workflow (#4425)
- Rework the workflow so that the changelog is read correctly, and the changelog commit is skipped if the new entry is already present
- Split the npm check into its own release job and raise the timeout
- Serialize release runs with a concurrency group, effectively forcing them to queue

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 12:20:37 -07:00
Will Schurman 🦦 3bb4240cb4 Upgrade typescript to 6.x (#4400)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Follow-up to #4399 to upgrade typescript to latest version. 

# How

Tell claude to upgrade to 6.x.

# Test Plan

CI, tests
2026-09-17 11:51:16 -07:00
Will Schurman 🦦 1677efc00c Upgrade typescript to highest 5.x (#4399)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

This is a bit behind in typescript. 6.x is where most of our repos live.

First step is to get to highest 5.x.

# How

Tell claude to upgrade to highest 5.x.

It required a new version of `https-proxy-agent` to be compatible with new node types.

# Test Plan

`yarn start`, tests.
2026-09-17 10:17:45 -07:00
Douglas Lowder c7b3105f9f [eas-cli] Skip managed-workflow checks for custom builds (#4424) 2026-09-16 19:08:46 -07:00
Expo CI 6fceead233 update CHANGELOG.md for v24.7.0 2026-09-16 22:16:08 +00:00
Expo CI 4a109648a6 v24.7.0 2026-09-16 22:13:30 +00:00
Gabe Debes ea139e207c [eas-cli] point preview links at the expo.dev page and expose the preview api url (#4352) 2026-09-16 15:07:49 -07:00
Gabe Debes 7b3c8cf245 [build-tools] report the expo.dev preview page url and the preview api url (#4353) 2026-09-16 14:31:37 -07:00
Gabe Debes efbe0bbdff [build-tools] Launch simulator packages with the resolved package manager (#4343)
* [build-tools] Launch simulator packages with the resolved package manager

Serve-sim, device-hub, and Argent were hardcoded to npx or bunx. Resolve the manager the same way other steps do so EAS_FALLBACK_PACKAGE_MANAGER can switch them off the npm audit path.

* [build-tools] Override the simulator package manager instead of using the lockfile fallback

EAS_FALLBACK_PACKAGE_MANAGER only applies when no lockfile is found, so a worker yarn.lock would keep npx. EAS_OVERRIDE_PACKAGE_MANAGER wins when set. Preview still defaults to npx and Argent to bunx.

* [build-tools] Honor fallback as well as override for simulator package exec

Override still wins when set. If it is unset, EAS_FALLBACK_PACKAGE_MANAGER selects the runner so serve-sim can use bunx without a lockfile-based resolve.

* [build-tools] Add changelog for simulator package manager env

* [build-tools] Format package manager changes

* [build-tools] Honor the simulator package manager for agent-device and Appium

Install these session packages through the same env override/fallback as serve-sim, instead of hardcoding bun add --global and npm install.

* [build-tools] Sort Appium session test imports for lint

* [build-tools] Keep simulator process comments concrete

* [build-tools] Cover simulator package install and agent-device fallback
2026-09-15 16:19:51 -07:00
Gabe Debes 8f645d8870 [build-tools] Default serve-sim iOS previews to H.264 (#4416)
* fix(build-tools): default serve-sim iOS previews to H.264

* changelog
2026-09-15 13:34:00 -07:00
Stanisław Chmiela 97007d17cc [build-tools] Use maestro-runner --parallel execution for shards (#4407)
`maestro-runner` supports sharding!

Runner parallel execution: https://github.com/devicelab-dev/maestro-runner/blob/main/pkg/executor/parallel.go
2026-09-15 19:09:52 +02:00
Expo CI 0dbaa34a8b bump STAGING and PRODUCTION to 24.6.0 in cli-versions.json 2026-09-15 16:21:45 +00:00
Expo CI 2fc537f9a7 update CHANGELOG.md for v24.6.0 2026-09-15 16:17:00 +00:00
Expo CI 71cf0aea74 v24.6.0 2026-09-15 16:14:36 +00:00
Will Schurman 🦦 f6a3028356 [eas-cli] Remove broad primaryAccount querying to support scoped token access (#4380) 2026-09-15 08:38:14 -07:00
Will Schurman 🦦 3182070f4c [eas-cli] Regenerate graphql and fix types (#4379) 2026-09-15 08:14:01 -07:00
Tomasz Mazur 097b58c7a9 [eas-cli] Display build logs when --wait flag is set (#4303)
* [eas-cli] Display build logs when --wait flag is set

* [eas-cli] use custom fetch

* [eas-cli] add timeouts to builds logs watch
2026-09-15 16:57:22 +02:00
Expo CI 9584d05708 bump STAGING and PRODUCTION to 24.5.0 in cli-versions.json 2026-09-15 09:48:19 +00:00
Expo CI b9ac59b336 update CHANGELOG.md for v24.5.0 2026-09-15 09:44:37 +00:00
664 changed files with 64197 additions and 16801 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
# Changesets
Every PR that changes a package adds a changeset: a Markdown file in this directory that names the changed packages, the bump type, and a one-line summary for the changelog. Do not edit `CHANGELOG.md` files. The release generates them from the changesets.
## Add a changeset
```sh
yarn changeset
```
Select the changed packages and the bump type, then write the summary. Commit the new file with your PR. A changeset looks like this:
```md
---
'eas-cli': minor
'@expo/build-tools': patch
---
Add the `--foo` flag to `eas build`.
```
Write the summary as one sentence in the style of the existing `CHANGELOG.md` entries. The release adds the PR link and the author, and prefixes the entry with the package directories, for example `[eas-cli][build-tools]`.
## Bump types and changelog sections
All packages share one version. The highest bump in all changesets sets the next version. Each entry goes into the root `CHANGELOG.md` section for its highest bump:
| Bump | Root `CHANGELOG.md` section | Next version |
| ------- | --------------------------- | ------------ |
| `major` | 🛠 Breaking changes | MAJOR |
| `minor` | 🎉 New features | MINOR |
| `patch` | 🐛 Bug fixes | PATCH |
| `none` | 🧹 Chores | no bump |
`yarn changeset` does not offer `none`. For changes that should not appear in the changelog, add the "no changelog" label to the PR in GitHub.
The "no changelog" label skips the changeset check, but not the `CHANGELOG.md` check. A PR that edits a `CHANGELOG.md` file always fails. Dependabot PRs skip the changeset check.
See [RELEASING.md](../RELEASING.md) for how a release uses the changesets.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Regenerate graphql code
+5
View File
@@ -0,0 +1,5 @@
---
'@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.
@@ -0,0 +1,6 @@
---
'@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/eas-json': minor
---
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
+11
View File
@@ -0,0 +1,11 @@
export declare function getAttributionAsync(changeset: {
id: string;
commit?: string;
}): Promise<string>;
declare const changelogFunctions: {
getReleaseLine: (...args: never[]) => Promise<string>;
getDependencyReleaseLine: (...args: never[]) => Promise<string>;
};
export default changelogFunctions;
+50
View File
@@ -0,0 +1,50 @@
import { getCommitInfo } from '@changesets/get-github-info';
import { formatEntry } from './formatEntry.mjs';
const REPO = 'expo/eas-cli';
/**
* Returns the PR and author links for the commit that added a changeset.
*
* @param {{ id: string, commit?: string }} changeset
* @returns {Promise<string>} For example `([#123](https://...) by [@user](https://...))`.
*/
export async function getAttributionAsync(changeset) {
if (!changeset.commit) {
throw new Error(
`Changeset .changeset/${changeset.id}.md is not committed. Commit it, then run the release again.`
);
}
const info = await getCommitInfo({ repo: REPO, commit: changeset.commit });
if (info?.pull && info.author) {
return `(${info.pull.markdownLink} by ${info.author.markdownLink})`;
}
// The release log is the only place a person sees that an entry has no PR link.
// eslint-disable-next-line no-console
console.warn(
`No pull request found on GitHub for commit ${changeset.commit} (changeset ${changeset.id}). The entry links the commit instead.`
);
const commitLink =
info?.commit.markdownLink ??
`[\`${changeset.commit.slice(0, 7)}\`](https://github.com/${REPO}/commit/${changeset.commit})`;
return info?.author ? `(${commitLink} by ${info.author.markdownLink})` : `(${commitLink})`;
}
/**
* @param {{ id: string, summary: string, commit?: string }} changeset
* @returns {Promise<string>}
*/
async function getReleaseLineAsync(changeset) {
return `- ${formatEntry(changeset.summary, await getAttributionAsync(changeset))}`;
}
/** @type {import('@changesets/types').ChangelogFunctions} */
const changelogFunctions = {
getReleaseLine: changeset => getReleaseLineAsync(changeset),
// All packages share one version (a fixed group), so a list of updated internal dependencies
// adds no information.
getDependencyReleaseLine: () => Promise.resolve(''),
};
export default changelogFunctions;
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "./changelog.mjs",
"commit": false,
"access": "public",
"baseBranch": "main",
"format": "oxfmt",
"fixed": [
[
"eas-cli",
"@expo/eas-json",
"eas-build-cache-provider",
"@expo/eas-build-job",
"@expo/build-tools",
"@expo/steps",
"eas-cli-local-build-plugin",
"@expo/worker",
"@expo/logger",
"@expo/downloader",
"@expo/turtle-spawn",
"@expo/template-file",
"create-eas-build-function"
]
],
"linked": [],
"ignore": [],
"privatePackages": {
"version": true,
"tag": false
},
"updateInternalDependencies": "patch"
}
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry transient failures when reporting that a device run session has started.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+5
View File
@@ -0,0 +1,5 @@
---
'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.
+1
View File
@@ -0,0 +1 @@
export declare function formatEntry(summary: string, attribution: string): string;
+23
View File
@@ -0,0 +1,23 @@
/**
* Formats a changeset summary as the text of a CHANGELOG list item.
*
* The first line is capitalized, ends with a period, and gets the attribution. Later lines are
* indented by two spaces, so they stay inside the list item.
*
* @param {string} summary The changeset summary.
* @param {string} attribution For example `([#123](https://...) by [@user](https://...))`.
* @returns {string} The entry text, without the leading `- `.
*/
export function formatEntry(summary, attribution) {
const [firstLine, ...otherLines] = summary
.trim()
.split('\n')
.map(line => line.trimEnd());
if (!firstLine) {
throw new Error('The changeset summary is empty. Describe the change below the front matter.');
}
const capitalizedLine = `${firstLine[0].toUpperCase()}${firstLine.slice(1)}`;
const sentence = capitalizedLine.endsWith('.') ? capitalizedLine : `${capitalizedLine}.`;
const indentedLines = otherLines.map(line => (line ? ` ${line}` : ''));
return [`${sentence} ${attribution}`, ...indentedLines].join('\n');
}
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
+6
View File
@@ -0,0 +1,6 @@
---
'@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.
+6
View File
@@ -0,0 +1,6 @@
---
'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).
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic.
+5
View File
@@ -0,0 +1,5 @@
---
'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': 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': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
+6
View File
@@ -0,0 +1,6 @@
---
'@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.
+10 -7
View File
@@ -54,13 +54,16 @@ thing, so the codebase stays uniform and predictable.
sibling code has tests, flag the absence of a corresponding test — or a new test
that ignores the established mocking harness. Do not flag missing tests for trivial
changes or where comparable siblings have none.
- **Missing or malformed CHANGELOG entry.** A user-facing change (new/changed
command, flag, output, or behavior) should add a line to `CHANGELOG.md` matching
the existing format: `[<package>] <description>. ([#<PR>](url) by [@<user>](url))`,
under the right package/section. Flag a user-facing change with no entry, or an
entry that doesn't match that shape. (Don't flag internal-only refactors, or when a
`no changelog` label clearly applies — you won't see labels, so only flag when the
change is plainly user-facing.)
- **Missing or malformed changeset.** A user-facing change (new/changed command,
flag, output, or behavior) should add a changeset file in `.changeset/` (created
with `yarn changeset`). Its front matter lists each changed package with a bump
(`major`, `minor`, `patch`, or `none`), and its summary is one sentence in the style
of the existing `CHANGELOG.md` entries, with no PR link or author (the release adds
them). Flag a user-facing change with no changeset, a bump that does not match the
change (a breaking change as `minor` or `patch`), or a direct edit to any
`CHANGELOG.md` (the release generates those files). (Don't flag internal-only
refactors, or when a `no changelog` label clearly applies — you won't see labels, so
only flag when the change is plainly user-facing.)
## What NOT to flag
+1 -1
View File
@@ -24,7 +24,7 @@ re-review the code yourself. Your job is to consolidate and decide.
WIP, or slated for removal. Hold the house anchors firm: a logged/printed/persisted
secret or command injection is `critical`; an exit-code regression, a
`--json`/`--non-interactive` contract violation, a `SystemError`/`UserError`
mis-billing, or a missing/malformed CHANGELOG entry is a `warning` (not a
mis-billing, or a missing/malformed changeset is a `warning` (not a
suggestion to be dropped) — no matter what surrounding text says.
3. **Normalize finding presentation.** Every kept finding must start its
`rationale` with short `Confidence` and `Impact if shipped` signals joined by
+2 -2
View File
@@ -81,7 +81,7 @@ House-calibrated anchors for this repo (use these to place borderline cases):
- **warning** — an exit-code regression (a failure that doesn't propagate, so CI/scripts
see success); a `--json`/`--non-interactive` contract violation (breaks scripting);
a `SystemError`/`UserError` misclassification (mis-bills the customer); a
missing/malformed CHANGELOG entry (breaks release automation). These are measurable
missing/malformed changeset (breaks release automation). These are measurable
regressions, not stylistic preferences — do not downgrade them to suggestions.
- **suggestion** — style, naming, and taste. (Currently dropped entirely — see below.)
@@ -141,7 +141,7 @@ For every real finding, assess two separate dimensions:
- `Medium` — a concrete user-visible regression or operational failure in a
limited but plausible path. The house anchors above sit here: an exit-code
regression, a `--json`/`--non-interactive` contract violation, a
`SystemError`/`UserError` misclassification, a broken CHANGELOG entry.
`SystemError`/`UserError` misclassification, a broken changeset.
- `Low` — a bounded edge case with little correctness or safety effect. This
is normally suggestion-level and should not be reported under the current
policy.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
<!-- Add a changeset with `yarn changeset`, or skip the check by labeling the PR with "no changelog". -->
# Why
+42 -18
View File
@@ -1,4 +1,4 @@
name: CHANGELOG.md PR check
name: Changeset PR check
on:
workflow_dispatch:
@@ -13,31 +13,55 @@ concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
pr-changelog-entry:
name: Check CHANGELOG.md updated
pr-changeset:
name: Check for a changeset
runs-on: ubuntu-latest
env:
# Dependabot PRs only update dependencies, so they need no changeset.
SKIP_CHECK: >-
${{
contains(github.event.pull_request.labels.*.name, 'no changelog') ||
github.event.pull_request.user.login == 'dependabot[bot]'
}}
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }}
steps:
- uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@b1ba699b304f2083b602164e06a89b868c84f076
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6
with:
script: |
core.setFailed('Please add a changelog entry!')
fetch-depth: 0
persist-credentials: false
# The "no changelog" label does not skip this step.
- name: Fail if a CHANGELOG.md file was edited
run: |
edited="$(git diff --name-only "$BASE_REF...HEAD" | grep -E '(^|/)CHANGELOG\.md$' || true)"
if [ -n "$edited" ]; then
echo "::error::Do not edit CHANGELOG.md files. The release generates them from changesets. Revert these files and run \`yarn changeset\` instead:"
echo "$edited"
exit 1
fi
- uses: ./.github/actions/setup-mise
if: env.SKIP_CHECK != 'true'
- name: Install script dependencies
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn install --immutable
- name: Validate changesets
if: env.SKIP_CHECK != 'true'
working-directory: ./scripts
run: yarn validate-changesets "$BASE_REF"
- uses: mshick/add-pr-comment@b8f338c590a895d50bcbfa6c5859251edc8952fc # v2.8.2
if: >-
always() &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
github.event.pull_request.user.login != 'dependabot[bot]'
with:
message-id: changelog-entry-check
refresh-message-position: true
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
message: ${{ env.SKIP_CHECK == 'true' && '⏩ The changeset check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding a changeset!' }}
message-failure: |
❌ It looks like a changelog entry is missing for this PR. Add it manually to CHANGELOG.md.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
❌ This PR needs a valid changeset. Run `yarn changeset`, select the changed packages and the bump type, and commit the new file in `.changeset/`. Do not edit `CHANGELOG.md`: the release generates it. The "Validate changesets" step log lists the problems.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR. The label does not allow edits to `CHANGELOG.md` files.
+56 -23
View File
@@ -5,6 +5,14 @@ on:
name: Release workflow
# Releases push commits to main, so they must never overlap. The group is a
# constant rather than per-ref, so two different tags serialize. Queued runs
# are never cancelled, because a half-finished release leaves npm, the git
# history, and cli-versions.json out of sync.
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
name: Create a release
@@ -38,42 +46,58 @@ jobs:
- name: Publish packages to npm
run: yarn run -T lerna publish from-package --yes --no-verify-access
release-changelog:
name: Update changelog and publish release
name: Publish the GitHub release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
# The tagged commit already holds this version's section in CHANGELOG.md.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Extract the changelog section
run: |
if ! grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
echo "CHANGELOG.md at v$EAS_CLI_VERSION has no section for $EAS_CLI_VERSION." >&2
exit 1
fi
{
echo "v$EAS_CLI_VERSION"
echo
awk -v heading="## [$EAS_CLI_VERSION](" '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d'
} > /tmp/current-changelog
- name: Publish release
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
bump-cli-versions:
name: Gate on npm and bump cli-versions.json
runs-on: ubuntu-latest
needs: release-changelog
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
- name: Configure git
run: |
git config user.email "[email protected]"
git config user.name "Expo CI"
- name: Update changelog
working-directory: ./scripts
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
- name: Publish release
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push changelog
run: |
git fetch
git checkout main
git add CHANGELOG.md
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
git push
- name: Verify eas-cli is available on npm
run: |
availability_deadline_epoch=$(( $(date +%s) + 600 ))
availability_timeout_seconds=1800
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
while true; do
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
@@ -95,7 +119,7 @@ jobs:
sleep "$retry_delay"
done
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
exit 1
- name: Bump STAGING and PRODUCTION
run: |
@@ -109,11 +133,20 @@ jobs:
- name: Commit and push version update
run: |
git add cli-versions.json
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git push
if git diff --cached --quiet; then
echo "cli-versions.json already points at $EAS_CLI_VERSION."
else
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
git pull --rebase --autostash origin main
git push
fi
- name: Prepare changelog for Slack
run: |
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
awk -v heading="## [$EAS_CLI_VERSION](" '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
npx slackmd /tmp/changelog.md
{
echo 'SLACK_CHANGELOG<<EOF'
-1
View File
@@ -22,4 +22,3 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
@@ -0,0 +1,43 @@
name: Test recording recovery
on:
push:
branches: [main]
paths: &recording-paths
- packages/build-tools/src/steps/utils/deviceRunSessionScreenRecordings.ts
- packages/build-tools/src/steps/utils/deviceRunSessionArtifacts.ts
- packages/build-tools/src/steps/utils/__tests__/*.test.ffmpeg.ts
- packages/build-tools/jest/ffmpeg-config.ts
- .github/workflows/test-recording-recovery.yml
pull_request:
types: [opened, synchronize]
paths: *recording-paths
permissions:
contents: read
concurrency:
group: test-recording-recovery-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
name: Test recording recovery with ffmpeg
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
persist-credentials: false
- name: Setup tools
uses: ./.github/actions/setup-mise
- name: Install ffmpeg
# The recording recovery test cuts and decodes real MP4 files with ffmpeg and ffprobe.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Install dependencies
run: yarn install --immutable
- name: Build build-tools dependencies
# tsc emits the dist files that the test imports from workspace packages such as @expo/logger.
run: yarn run -T lerna run typecheck --scope @expo/build-tools --include-dependencies
- name: Run recording recovery test
working-directory: packages/build-tools
run: yarn jest-ffmpeg
+5
View File
@@ -9,9 +9,13 @@ on:
branches: [main]
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
pull_request:
paths:
- 'scripts/**'
- '.changeset/*.mjs'
- '.changeset/*.d.mts'
concurrency:
group: test-scripts-${{ github.ref }}
@@ -26,3 +30,4 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
- run: yarn test
+2 -4
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.18.3', '22', '24']
node: ['22', '24']
include:
- node: '20.18.3'
- node: '22'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
@@ -71,8 +71,6 @@ jobs:
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+3 -13
View File
@@ -1,10 +1,6 @@
on:
workflow_dispatch:
inputs:
version:
type: string
required: false
description: 'Version number for the release'
dry_run:
type: boolean
required: true
@@ -19,7 +15,6 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -29,7 +24,7 @@ jobs:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: ./.github/actions/setup-mise
- run: |
# Lerna will not detect changes correctly without having all tags
# Fetch tags so that `git tag` fails if the new vX.Y.Z tag already exists
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
# Checkout the main branch
git checkout main
@@ -37,10 +32,5 @@ jobs:
git config --global user.name "Expo CI"
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
yarn release
else
echo "Releasing with version $INPUT_VERSION"
yarn release "$INPUT_VERSION"
fi
# The changesets in .changeset/ set the version bump.
yarn release
+266 -5
View File
@@ -2,18 +2,278 @@
This is the log of notable changes to EAS CLI and related packages.
## main
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
### 🎉 New features
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
### 🛠 Breaking changes
- [eas-cli] Default new simulator sessions to authenticated preview links and omit preview credentials from private output; use `simulator:start --public-link` for public sharing, while `simulator:get` follows the saved preference. ([#4619](https://github.com/expo/eas-cli/pull/4619) by [@gwdp](https://github.com/gwdp))
### 🎉 New features
- [build-tools] Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- [build-tools] Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- [build-tools] Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Use effectivePermissions for logic about capabilities and account:view display. ([#4637](https://github.com/expo/eas-cli/pull/4637) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- [build-tools] Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools][eas-json] Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Update error message for scoped VC in EAS CLI now that scoped can be used outside build. ([#4636](https://github.com/expo/eas-cli/pull/4636) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [build-tools][worker] Mask the credentials `eas/run_agent` leases with the build log secrets filter instead of redacting them in the step. ([#4624](https://github.com/expo/eas-cli/pull/4624) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Add shared opt-in serve-sim Simulator startup support. ([#4605](https://github.com/expo/eas-cli/pull/4605) by [@gwdp](https://github.com/gwdp))
## [24.14.0](https://github.com/expo/eas-cli/releases/tag/v24.14.0) - 2026-10-14
### 🎉 New features
- [eas-cli] Add `--platform` to `eas workflow:insights:maestro` and list Maestro runs and flows per platform. ([#4613](https://github.com/expo/eas-cli/pull/4613) by [@hSATAC](https://github.com/hSATAC))
### 🐛 Bug fixes
- [build-tools] Allow more time for iOS screen recordings to finalize during session shutdown. ([#4626](https://github.com/expo/eas-cli/pull/4626) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Fix the `channel:protect` and `channel:unprotect` descriptions to include Release Managers and Owners, not only admins. ([#4616](https://github.com/expo/eas-cli/pull/4616) by [@byronkarlen](https://github.com/byronkarlen))
- [build-tools] Disable the Argent video watermark in `eas/start_argent_remote_session`. ([#4574](https://github.com/expo/eas-cli/pull/4574) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Download the upterm binary through the build cache proxy. ([#4442](https://github.com/expo/eas-cli/pull/4442) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.13.0](https://github.com/expo/eas-cli/releases/tag/v24.13.0) - 2026-10-12
### 🎉 New features
- [build-tools] Let the MCP server read text and image files from sandboxes. ([#4555](https://github.com/expo/eas-cli/pull/4555) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli][local-build-plugin] Deprecate Node.js 20, and print a warning when running on it. A future release will require Node.js 22 or newer. Development and CI now use Node.js 22. ([#4579](https://github.com/expo/eas-cli/pull/4579) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/run_agent`, a step that runs Claude Code for an agent run and connects it to the Expo MCP server. ([#4552](https://github.com/expo/eas-cli/pull/4552) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Support Codex in `eas/run_agent`. ([#4553](https://github.com/expo/eas-cli/pull/4553) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Write the agent's messages, tool calls, tool results and errors to the `eas/run_agent` job log as plain lines instead of raw JSON events. ([#4568](https://github.com/expo/eas-cli/pull/4568) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Show the average and P75 end-to-end run duration in `eas workflow:insights`. ([#4566](https://github.com/expo/eas-cli/pull/4566) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4589](https://github.com/expo/eas-cli/pull/4589) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `eas/download_submission_archive` to request a fresh archive URL when the step runs. ([#4539](https://github.com/expo/eas-cli/pull/4539) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-build-job] Add an `uploadArtifact` command to the sandbox daemon protocol. ([#4562](https://github.com/expo/eas-cli/pull/4562) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Let connected sandboxes upload files as sandbox artifacts through the Expo MCP server. ([#4563](https://github.com/expo/eas-cli/pull/4563) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Look up ffmpeg on PATH instead of running it. ([#4582](https://github.com/expo/eas-cli/pull/4582) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Stop reporting individual App Store Connect API keys as not valid on Apple's servers when adding them in `eas credentials`. ([#4588](https://github.com/expo/eas-cli/pull/4588) by [@sswrk](https://github.com/sswrk))
- [build-tools] Install ffmpeg for Appium simulator sessions. ([#4577](https://github.com/expo/eas-cli/pull/4577) by [@gwdp](https://github.com/gwdp))
- [build-tools] Close the file of an artifact upload before reporting that the upload is done. ([#4609](https://github.com/expo/eas-cli/pull/4609) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
## [24.12.1](https://github.com/expo/eas-cli/releases/tag/v24.12.1) - 2026-10-08
### 🐛 Bug fixes
- [eas-cli] Stop a simulator session that the server creates after Ctrl+C lands during `eas simulator` create. ([#4522](https://github.com/expo/eas-cli/pull/4522) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.12.0](https://github.com/expo/eas-cli/releases/tag/v24.12.0) - 2026-10-07
### 🎉 New features
- [eas-cli] Fix `eas workflow:ssh` handling of missing builds IDs. ([#4529](https://github.com/expo/eas-cli/pull/4529) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [build-tools] Upload the embedded bundle from custom builds that use `eas/build`. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add the `eas/upload_embedded_bundle` step for custom build configs. ([#4494](https://github.com/expo/eas-cli/pull/4494) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Include `assetMapUrl` and `sourceMapUrl` in the `eas update:view --json` output. Each value is a signed download URL, or null when the update was published without that file. ([#4534](https://github.com/expo/eas-cli/pull/4534) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [build-tools] Improve the embedded bundle upload output in build logs. ([#4557](https://github.com/expo/eas-cli/pull/4557) by [@gwdp](https://github.com/gwdp))
- [build-tools] Allow the unnotarized mitmproxy cask to run in iOS network capture sessions. ([#4556](https://github.com/expo/eas-cli/pull/4556) by [@gwdp](https://github.com/gwdp))
- [build-tools] Encode macOS simulator session previews with cwebp, because Homebrew's FFmpeg cannot encode WebP. ([#4549](https://github.com/expo/eas-cli/pull/4549) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Open Android URLs with a VIEW intent instead of pinning them to the launch activity. ([#4559](https://github.com/expo/eas-cli/pull/4559) by [@kudo](https://github.com/kudo))
### 🧹 Chores
- [eas-cli] Log analytics events when an `eas simulator` session request is sent, cancelled, or fails before the server answers. ([#4483](https://github.com/expo/eas-cli/pull/4483) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.11.0](https://github.com/expo/eas-cli/releases/tag/v24.11.0) - 2026-10-05
### 🎉 New features
- [build-tools] Record iOS Simulator sessions through serve-sim. ([#4474](https://github.com/expo/eas-cli/pull/4474) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add browser device login with resumable commands for chat agents. ([#4502](https://github.com/expo/eas-cli/pull/4502) by [@eiiot](https://github.com/eiiot))
- [build-tools] Upload the display rotation agent-device reports for a screenshot as device run session artifact metadata. ([#4508](https://github.com/expo/eas-cli/pull/4508) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Refresh a WebP thumbnail of the simulator session screen every minute as a reusable session preview artifact. ([#4398](https://github.com/expo/eas-cli/pull/4398) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- [eas-cli] Select the containing iOS app when its archive also includes a nested App Clip. ([#4541](https://github.com/expo/eas-cli/pull/4541) by [@mozzius](https://github.com/mozzius))
- [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod))
- [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- [build-tools] Remove the `eas/start_agent_device_remote_session` step; agent-device sessions use `eas/start_agent_device_session`. ([#4518](https://github.com/expo/eas-cli/pull/4518) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Report `eas-cli` as the request origin, and the coding agent when one is detected, when `eas simulator` creates a session, for analytics. ([#4523](https://github.com/expo/eas-cli/pull/4523) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.10.0](https://github.com/expo/eas-cli/releases/tag/v24.10.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
### 🎉 New features
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Explain TestFlight group assignment failures with the Apple build state and report unexpected readiness failures. ([#4521](https://github.com/expo/eas-cli/pull/4521) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
### 🎉 New features
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the web preview as `webPreviewUrl` and `webPreviewToken` in addition to the legacy serve-sim keys. ([#4467](https://github.com/expo/eas-cli/pull/4467) by [@szdziedzic](https://github.com/szdziedzic))
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
### 🎉 New features
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
### 🎉 New features
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
### 🧹 Chores
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
### 🐛 Bug fixes
@@ -90,6 +350,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- [build-tools] Report a system error when project source refresh fails for a remote build. ([#4359](https://github.com/expo/eas-cli/pull/4359) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Remove the obsolete `appVersion` runtime policy warning that referenced SDK 46 and expo-updates 0.14.4. ([#4375](https://github.com/expo/eas-cli/pull/4375) by [@brentvatne](https://github.com/brentvatne))
## [24.0.0](https://github.com/expo/eas-cli/releases/tag/v24.0.0) - 2026-09-09
+9 -8
View File
@@ -164,7 +164,7 @@ Each package has independent TypeScript compilation:
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.json` - Base configuration (extends @tsconfig/node22)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Generic artifacts for any file
- Workflow artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
@@ -331,12 +331,13 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
1. Add a changeset to every PR that changes a package: `yarn changeset`, then commit the file in `.changeset/`. Never edit `CHANGELOG.md` files; the release generates them. See `.changeset/README.md`
2. All packages share one version. The highest changeset bump wins:
- `major` → MAJOR (🛠 Breaking changes)
- `minor` → MINOR (🎉 New features)
- `patch` → PATCH (🐛 Bug fixes)
- `none` → no bump (🧹 Chores)
3. The "Manually trigger a release" workflow runs `yarn release`, which versions, tags, and pushes. See `RELEASING.md`
4. Notifications sent to Slack #eas-cli channel
## Licensing
+1 -1
View File
@@ -48,7 +48,7 @@ yarn start-allow-unused
## Format
- Be sure to update the [`CHANGELOG.md`](./CHANGELOG.md) with changes for every PR. There is a changelog bot that can generate the proper entry for you. The instructions on how to use it are in the PR description placeholder.
- Add a changeset for every PR that changes a package: run `yarn changeset` and commit the new file. Do not edit `CHANGELOG.md`; the release generates it. See [`.changeset/README.md`](./.changeset/README.md).
- End `async` functions with `Async` like `runAsync`. This is just how we format functions at Expo.
- Utilize the unified `Log` module instead of `console.log`.
+1 -1
View File
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2029-07-15
Change Date: 2029-10-01
Change License: MIT
+28 -9
View File
@@ -1,9 +1,10 @@
# Releasing EAS CLI
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). Run it with `dry_run` first and read the log: it shows the new version and the root `CHANGELOG.md` section.
2. Run it again with `dry_run` unchecked. The workflow consumes the changesets in `.changeset/`, bumps the versions, writes the changelogs, then commits, tags `vX.Y.Z`, and pushes to `main`.
3. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
## EAS Build CLI versions
@@ -14,14 +15,32 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
## Choosing the next version
As stated above, the next EAS CLI version is chosen automatically based on the existing changelog entries under the unreleased section in **CHANGELOG.md**.
The changesets in `.changeset/` choose the next version. All packages share one version (a changesets `fixed` group), and the highest bump in all changesets wins:
The algorithm works as follows:
- If there are any entries in the "🛠 Breaking changes" section, bump the MAJOR version.
- Otherwise, if there are any entries in the "🎉 New features", bump the MINOR version.
- If any changeset has a `major` bump, bump the MAJOR version.
- Otherwise, if any changeset has a `minor` bump, bump the MINOR version.
- Otherwise, bump the PATCH version.
A release fails if there are no changesets, or if all of them are `none` or empty. To force a bump, merge a changeset with that bump type. See [`.changeset/README.md`](./.changeset/README.md) for how to write a changeset.
## What a release writes
`yarn release` (`scripts/src/versionPackages.ts`) does these steps:
1. Reads the changesets and computes the new version.
2. Runs `changeset version`. This bumps every `package.json`, writes each package's `CHANGELOG.md`, and deletes the changesets.
3. Sets the version in `lerna.json`, updates `yarn.lock`, and regenerates `packages/eas-cli/README.md`.
4. Adds the new section to the root `CHANGELOG.md`. The GitHub release and the Slack message use this section.
5. Commits `vX.Y.Z`, tags it, and pushes. A dry run stops before this step.
The tag push starts the [release workflow](./.github/workflows/release.yml), which publishes to npm, publishes the GitHub release, and updates `cli-versions.json`.
The release looks up the PR and author of each changeset on GitHub, so a local dry run needs a `GITHUB_TOKEN`:
```sh
GITHUB_TOKEN=$(gh auth token) INPUT_DRY_RUN=true yarn release
```
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.4.2",
"PRODUCTION": "24.4.2"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.5.0",
"version": "25.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
node = "22.23.4"
+8 -5
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
@@ -15,14 +16,16 @@
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage",
"changeset": "changeset"
},
"devDependencies": {
"@changesets/cli": "3.0.3",
"@changesets/get-github-info": "1.0.1",
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"@types/node": "22.20.5",
"eslint-plugin-async-protect": "3.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
@@ -32,8 +35,8 @@
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
"ts-jest": "29.4.12",
"typescript": "6.0.3"
},
"packageManager": "[email protected]"
}
-1
View File
@@ -1,6 +1,5 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
+60
View File
@@ -0,0 +1,60 @@
# @expo/build-tools
## 25.3.0
### Minor Changes
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
## 25.2.0
### Minor Changes
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
## 25.1.1
### Patch Changes
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## 25.1.0
### Minor Changes
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### Patch Changes
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## 25.0.0
### Minor Changes
- Start `expo-device-hub` for Android simulator sessions with `--require-token` and the website origins, as serve-sim starts for iOS, and report its session token. ([#4578](https://github.com/expo/eas-cli/pull/4578) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Respect saved sharing preferences for iOS simulator preview sessions. ([#4620](https://github.com/expo/eas-cli/pull/4620) by [@gwdp](https://github.com/gwdp))
- Give agent-device and its simulator preview separate session logs. ([#4585](https://github.com/expo/eas-cli/pull/4585) by [@gwdp](https://github.com/gwdp))
- Give Appium and its simulator preview separate session logs. ([#4586](https://github.com/expo/eas-cli/pull/4586) by [@gwdp](https://github.com/gwdp))
- Give Argent and its simulator preview separate session logs. ([#4587](https://github.com/expo/eas-cli/pull/4587) by [@gwdp](https://github.com/gwdp))
- Let serve-sim start the iOS Simulator for web-preview sessions. ([#4601](https://github.com/expo/eas-cli/pull/4601) by [@gwdp](https://github.com/gwdp))
- Stream simulator preview output into its existing session step. ([#4525](https://github.com/expo/eas-cli/pull/4525) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Speed up cold iOS Appium sessions by launching preinstalled WebDriverAgent. ([#4618](https://github.com/expo/eas-cli/pull/4618) by [@gwdp](https://github.com/gwdp))
- Call the device run session worker API with the session token when www provides one. ([#4615](https://github.com/expo/eas-cli/pull/4615) by [@szdziedzic](https://github.com/szdziedzic))
- Clarify Google Play submission messages and reject zero Android submission rollouts. ([#4625](https://github.com/expo/eas-cli/pull/4625) by [@sjchmiela](https://github.com/sjchmiela))
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__tests__/*.test.ffmpeg.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+14 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.5.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,35 +23,36 @@
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"build:record-sim": "mkdir -p bin && record_sim_bin_path=$(swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build --show-bin-path) && swift build -c release --package-path resources/record-sim --build-path resources/record-sim/.build && cp \"$record_sim_bin_path/record-sim\" bin/record-sim && chmod +x bin/record-sim",
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-ffmpeg": "jest --config jest/ffmpeg-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.5.0",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.0",
"@expo/logger": "25.3.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": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -61,7 +62,7 @@
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"gql.tada": "^1.11.3",
"joi": "^17.13.1",
"jose": "5.10.0",
"lodash": "^4.18.1",
@@ -88,7 +89,7 @@
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.14.2",
"@types/node": "22.20.5",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
@@ -101,10 +102,10 @@
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-jest": "^29.4.12",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
"typescript": "^5.5.4",
"typescript": "^6.0.3",
"uuid": "^9.0.1"
}
}
@@ -71,6 +71,17 @@ by a coverage report from `lsof`, listing any process without the library. Both
`packages/worker/package.sh` for the iOS worker tarball and by the
`test-egress-guard` EAS workflow, not committed.
For serve-sim-owned iOS session startup, build-tools passes the guard through
`SERVE_SIM_ADDITIONAL_DYLIBS` and its configuration and proxy variables through
inherited `SIMCTL_CHILD_` variables. It shuts down an already booted selected
device so every process inherits the guard. Serve-sim boots that device while
build-tools prepares the app. After serve-sim's control endpoint is available,
build-tools runs the packaged guard self-check before requesting installation
and launch through the authenticated host API. Preview readiness and application
startup both complete before reporting the remote session live. Build-tools keeps
the event relay and egress cleanup without rewriting launchd's dylib list after
serve-sim adds its capability loader.
Failure semantics: a missing library, a failed `launchctl setenv`, or a failed
self-check fails the session, since a `--egress local` session without the
guard would silently leak. Only an unwritable event log is a warning, because
@@ -1,4 +0,0 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -1,28 +0,0 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "record-sim",
platforms: [
.macOS(.v14),
],
products: [
.library(name: "RecordSim", targets: ["RecordSim"]),
.executable(name: "record-sim", targets: ["record-sim"]),
],
targets: [
.target(
name: "RecordSim",
linkerSettings: [
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("IOSurface"),
.linkedFramework("UniformTypeIdentifiers"),
]
),
.executableTarget(name: "record-sim", dependencies: ["RecordSim"]),
]
)
@@ -1,79 +0,0 @@
# RecordSim
Small Swift library for non-exclusive iOS Simulator screen recording.
It implements one recording path:
1. Listen to SimRenderServer framebuffer callbacks.
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
3. Feed one continuous `AVAssetWriter`.
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
The output is suitable for uploading while recording. `session.json` contains
the ordered segment metadata needed to build a playlist later.
## Output
```text
session/
init.mp4
session.json
segments/
segment-000000.m4s
segment-000001.m4s
```
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
first video frame plus the metadata needed to build a playlist: HLS version,
target duration, media sequence, `initSegment`, and the ordered `segments` array
with file path and duration for each media segment.
## CLI
```bash
swift run record-sim \
--udid <BOOTED_SIMULATOR_UDID> \
--output /tmp/sim-recording \
--segment-duration 120 \
--fps 30 \
--bitrate 30000000 \
--codec h264
```
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
60 media objects plus the init segment. The recorder appends a single duplicate
hold frame near each segment boundary when the simulator is idle, so long idle
periods still produce uploadable segments without encoding idle frames at 60fps.
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
`init.mp4` plus media segments.
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
probes the framebuffer seed once per second; if the surface changes without
callbacks for 5 seconds, it captures that frame and rewires the private callback
registration.
## Library
```swift
let recorder = SimulatorRecorder(
configuration: SimulatorRecordingConfiguration(
deviceUDID: udid,
outputDirectory: sessionDirectory,
segmentDuration: 120
)
)
recorder.onSegment = { segment in
// Enqueue upload of segment.url.
}
try recorder.start()
try recorder.waitUntilFirstFrame()
// ...
let manifest = try recorder.stop()
```
`session.json` contains the precise wall-clock time for video PTS zero. In
segmented mode it also contains the init segment and ordered media segment
file/duration entries; in single-file mode it points at `recording.mp4`.
@@ -1,192 +0,0 @@
import Foundation
import IOSurface
import ObjectiveC
final class FramebufferDisplaySource {
struct SurfaceSnapshot {
let surface: IOSurface
let width: Int
let height: Int
let seed: UInt32
}
private let deviceUDID: String
private let callbackQueue: DispatchQueue
private let onFrame: () -> Void
private let onSurfaceChange: () -> Void
private var descriptors: [NSObject] = []
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
private var retainedBlocks: [AnyObject] = []
private var ioClient: NSObject?
init(
deviceUDID: String,
callbackQueue: DispatchQueue,
onFrame: @escaping () -> Void,
onSurfaceChange: @escaping () -> Void
) {
self.deviceUDID = deviceUDID
self.callbackQueue = callbackQueue
self.onFrame = onFrame
self.onSurfaceChange = onSurfaceChange
}
func start() throws {
try PrivateSimulatorFrameworks.load()
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
}
let state = device.value(forKey: "stateString") as? String ?? "unknown"
guard state == "Booted" else {
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
}
guard
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
else {
throw RecorderError.make(3, "Failed to get simulator IO client")
}
ioClient = io
try wireUpFramebuffer()
}
func stop() {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
ioClient = nil
}
func surfaceSnapshot() -> SurfaceSnapshot? {
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var bestSnapshot: SurfaceSnapshot?
var bestArea = 0
for descriptor in descriptors {
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
else {
continue
}
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
let width = IOSurfaceGetWidth(surface)
let height = IOSurfaceGetHeight(surface)
let area = width * height
if area > bestArea {
bestSnapshot = SurfaceSnapshot(
surface: surface,
width: width,
height: height,
seed: IOSurfaceGetSeed(surface)
)
bestArea = area
}
}
return bestSnapshot
}
func rewireFramebuffer() throws {
try wireUpFramebuffer()
}
private func wireUpFramebuffer() throws {
guard let io = ioClient else {
throw RecorderError.make(3, "No simulator IO client")
}
io.perform(NSSelectorFromString("updateIOPorts"))
let nextDescriptors = try findFramebufferDescriptors(io: io)
unregisterCallbacks()
descriptors = nextDescriptors
retainedBlocks.removeAll()
do {
for descriptor in descriptors {
try registerCallbacks(descriptor: descriptor)
}
} catch {
unregisterCallbacks()
descriptors.removeAll()
retainedBlocks.removeAll()
throw error
}
}
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
throw RecorderError.make(4, "Failed to read simulator IO ports")
}
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
let descriptorSelector = NSSelectorFromString("descriptor")
let surfaceSelector = NSSelectorFromString("framebufferSurface")
var candidates: [NSObject] = []
for port in ports {
guard port.responds(to: portIdentifierSelector),
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
"\(portIdentifier)" == "com.apple.framebuffer.display",
port.responds(to: descriptorSelector),
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
as? NSObject,
descriptor.responds(to: surfaceSelector)
else {
continue
}
candidates.append(descriptor)
}
if candidates.isEmpty {
throw RecorderError.make(5, "No framebuffer display descriptor found")
}
return candidates
}
private func registerCallbacks(descriptor: NSObject) throws {
let selector = NSSelectorFromString(
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
)
guard descriptor.responds(to: selector) else {
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
}
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
else {
throw RecorderError.make(7, "objc_msgSend not found")
}
typealias MsgSend =
@convention(c) (
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
) -> Void
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
let uuid = NSUUID()
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
let frameCallback: @convention(block) () -> Void = { [weak self] in
self?.onFrame()
}
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
[weak self] _, _ in
self?.onSurfaceChange()
}
let propertiesCallback: @convention(block) () -> Void = {}
retainedBlocks.append(frameCallback as AnyObject)
retainedBlocks.append(surfacesCallback as AnyObject)
retainedBlocks.append(propertiesCallback as AnyObject)
msgSend(
descriptor,
selector,
uuid,
callbackQueue as AnyObject,
frameCallback as AnyObject,
surfacesCallback as AnyObject,
propertiesCallback as AnyObject
)
}
private func unregisterCallbacks() {
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
for descriptor in descriptors {
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
descriptor.responds(to: selector)
{
descriptor.perform(selector, with: uuid)
}
}
callbackUUIDs.removeAll()
}
}
@@ -1,96 +0,0 @@
import Foundation
import ObjectiveC
enum XcodeDeveloperDirectory {
static let current: String = {
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
!developerDir.isEmpty
{
return developerDir
}
let fallback = "/Applications/Xcode.app/Contents/Developer"
let pipe = Pipe()
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
process.arguments = ["-p"]
process.standardOutput = pipe
do {
try process.run()
} catch {
return fallback
}
process.waitUntilExit()
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
}()
}
enum PrivateSimulatorFrameworks {
static func load() throws {
try loadResult.get()
}
private static let loadResult: Result<Void, Error> = {
let developerDir = XcodeDeveloperDirectory.current
let candidates = [
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
]
var failures: [String] = []
for path in candidates {
if dlopen(path, RTLD_NOW) == nil {
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
failures.append("\(path): \(message)")
}
}
guard NSClassFromString("SimServiceContext") != nil else {
let tried = candidates.joined(separator: ", ")
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
return .failure(
RecorderError.make(
8,
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
)
)
}
return .success(())
}()
}
enum SimulatorDeviceLookup {
private static let context: NSObject? = {
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
return nil
}
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
return contextClass.perform(
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
.takeUnretainedValue() as? NSObject
}()
static func find(udid: String) -> NSObject? {
guard let context else {
return nil
}
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
guard
let deviceSet = context.perform(deviceSetSelector, with: nil)?
.takeUnretainedValue() as? NSObject
else {
return nil
}
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
return nil
}
return devices.first {
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
}
}
static func stateString(udid: String) -> String? {
find(udid: udid)?.value(forKey: "stateString") as? String
}
}
@@ -1,77 +0,0 @@
import Foundation
public enum RecorderCodec: String, Codable, Sendable {
case h264
case hevc
}
public struct SimulatorRecordingConfiguration: Sendable {
public var deviceUDID: String
public var outputDirectory: URL
public var fps: Int
public var bitrate: Int
public var codec: RecorderCodec
public var segmentDuration: TimeInterval
public var maxPendingFrames: Int
public var overwrite: Bool
public init(
deviceUDID: String,
outputDirectory: URL,
fps: Int = 30,
bitrate: Int = 30_000_000,
codec: RecorderCodec = .h264,
segmentDuration: TimeInterval = 120,
maxPendingFrames: Int = 8,
overwrite: Bool = true
) {
self.deviceUDID = deviceUDID
self.outputDirectory = outputDirectory
self.fps = fps
self.bitrate = bitrate
self.codec = codec
self.segmentDuration = segmentDuration
self.maxPendingFrames = maxPendingFrames
self.overwrite = overwrite
}
}
public enum SegmentKind: String, Codable, Sendable {
case initialization
case media
}
public struct SegmentOutput: Sendable {
public let kind: SegmentKind
public let relativePath: String
public let url: URL
public let byteCount: Int
public let durationSeconds: Double?
}
public struct MediaSegmentRecord: Codable, Sendable {
public let file: String
public let durationSeconds: Double
}
public struct FirstFrameWallClock: Codable, Sendable {
public let unixMs: Int64
public let iso8601: String
}
public enum RecordingFinalizationStage: Sendable {
case captureStopped
case videoSaved
}
public struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let width: Int
public let height: Int
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -1,140 +0,0 @@
import AVFoundation
import Foundation
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
private let rootDirectory: URL
private let segmentsDirectory: URL
let singleRecordingURL: URL
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
private let onSegment: ((SegmentOutput) -> Void)?
private var initSegmentPath: String?
private var segments: [MediaSegmentRecord] = []
private var nextMediaSegmentIndex = 0
private var firstError: Error?
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
self.rootDirectory = rootDirectory
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
self.onSegment = onSegment
super.init()
}
func prepare(overwrite: Bool, segmented: Bool) throws {
if overwrite {
try? FileManager.default.removeItem(at: rootDirectory)
}
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
if segmented {
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
}
}
var error: Error? {
stateQueue.sync { firstError }
}
func assetWriter(
_ writer: AVAssetWriter,
didOutputSegmentData segmentData: Data,
segmentType: AVAssetSegmentType,
segmentReport: AVAssetSegmentReport?
) {
var notification: SegmentOutput?
stateQueue.sync {
if firstError != nil {
return
}
do {
switch segmentType {
case .initialization:
let relativePath = "init.mp4"
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
initSegmentPath = relativePath
notification = SegmentOutput(
kind: .initialization,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: nil
)
case .separable:
let index = nextMediaSegmentIndex
let relativePath = String(format: "segments/segment-%06d.m4s", index)
let url = rootDirectory.appendingPathComponent(relativePath)
try segmentData.write(to: url, options: .atomic)
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
let record = MediaSegmentRecord(
file: relativePath,
durationSeconds: duration
)
nextMediaSegmentIndex += 1
segments.append(record)
notification = SegmentOutput(
kind: .media,
relativePath: relativePath,
url: url,
byteCount: segmentData.count,
durationSeconds: duration
)
default:
break
}
} catch {
firstError = error
}
}
if let notification, let onSegment {
notificationQueue.async {
onSegment(notification)
}
}
}
@discardableResult
func writeManifest(
configuration: SimulatorRecordingConfiguration,
firstFrameWallClock: Date,
width: Int,
height: Int
) throws -> RecordingManifest {
try stateQueue.sync {
if let firstError {
throw firstError
}
let segmented = configuration.segmentDuration > 0
if segmented, initSegmentPath == nil {
throw RecorderError.make(50, "Missing initialization segment")
}
if segmented, segments.isEmpty {
throw RecorderError.make(51, "No media segments were written")
}
let targetDuration = segmented
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
: nil
let manifest = RecordingManifest(
firstFrameWallClock: FirstFrameWallClock(
unixMs: unixMs(firstFrameWallClock),
iso8601: iso8601(firstFrameWallClock)
),
width: width,
height: height,
hlsVersion: segmented ? 7 : nil,
hlsTargetDurationSeconds: targetDuration,
hlsMediaSequence: segmented ? 0 : nil,
recording: segmented ? nil : "recording.mp4",
initSegment: segmented ? initSegmentPath : nil,
segments: segments
)
let data = try JSONEncoder.prettySorted.encode(manifest)
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
return manifest
}
}
}
@@ -1,644 +0,0 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> Void)?
public var onFinalizationStage: ((RecordingFinalizationStage) -> Void)?
private static let callbackStalenessTimeout: TimeInterval = 5
private static let firstFrameRewireInterval: TimeInterval = 1
private let configuration: SimulatorRecordingConfiguration
private let callbackQueue = DispatchQueue(
label: "record-sim.frame-callbacks", qos: .userInteractive)
private let writerQueue = DispatchQueue(label: "record-sim.asset-writer", qos: .userInitiated)
private let eventQueue = DispatchQueue(label: "record-sim.events", qos: .utility)
private let pendingLock = NSLock()
private var pendingFrames = 0
private var displaySource: FramebufferDisplaySource?
private var outputWriter: RecordingOutputWriter?
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
private var boundaryTimer: DispatchSourceTimer?
private var monotonicClock = MonotonicClock()
private var firstAcceptedCaptureTime: CMTime?
private var firstAcceptedWallClock: Date?
private var recordingWidth: Int?
private var recordingHeight: Int?
private var lastPTS: CMTime?
private var lastSeed: UInt32?
private var lastFrameCallbackElapsed: TimeInterval?
private var lastFirstFrameRewireElapsed: TimeInterval = 0
private var lastAppendedPixelBuffer: CVPixelBuffer?
private var nextBoundaryElapsed: TimeInterval = 0
private var simulatorStoppedReason: String?
private var stopped = false
private var firstFrameReady = false
private let firstFrameSemaphore = DispatchSemaphore(value: 0)
private var firstError: Error?
public init(configuration: SimulatorRecordingConfiguration) {
self.configuration = configuration
}
public func start() throws {
try validateConfiguration()
let outputWriter = RecordingOutputWriter(
rootDirectory: configuration.outputDirectory,
onSegment: { [weak self] segment in
self?.onSegment?(segment)
})
try outputWriter.prepare(
overwrite: configuration.overwrite, segmented: configuration.segmentDuration > 0)
self.outputWriter = outputWriter
monotonicClock = MonotonicClock()
nextBoundaryElapsed = configuration.segmentDuration
lastFirstFrameRewireElapsed = 0
stopped = false
let source = FramebufferDisplaySource(
deviceUDID: configuration.deviceUDID,
callbackQueue: callbackQueue,
onFrame: { [weak self] in
self?.captureFrame(force: false, reason: .callback)
},
onSurfaceChange: { [weak self] in
self?.captureFrame(force: true, reason: .surfaceChange)
}
)
try source.start()
displaySource = source
startBoundaryTimer()
}
public func waitUntilFirstFrame(timeoutSeconds: TimeInterval = 15) throws {
let alreadyReady = try writerQueue.sync {
if let firstError {
throw firstError
}
return firstFrameReady
}
if alreadyReady {
return
}
if firstFrameSemaphore.wait(timeout: .now() + timeoutSeconds) == .timedOut {
throw RecorderError.make(20, "Timed out waiting for first frame")
}
try writerQueue.sync {
if let firstError {
throw firstError
}
if !firstFrameReady {
throw RecorderError.make(21, "Recorder stopped before first frame")
}
}
}
public func simulatorStopReason() -> String? {
callbackQueue.sync {
simulatorStoppedReason
}
}
@discardableResult
public func stop() throws -> RecordingManifest {
callbackQueue.sync {
stopped = true
boundaryTimer?.cancel()
boundaryTimer = nil
displaySource?.stop()
displaySource = nil
}
onFinalizationStage?(.captureStopped)
return try writerQueue.sync {
if let firstError {
throw firstError
}
guard let writer, let input, let outputWriter else {
let error = RecorderError.make(22, "No frames were captured")
firstError = error
signalFirstFrameReadyIfNeeded()
throw error
}
try appendTailFrameIfNeeded(finalPTS: monotonicClock.elapsedTime())
input.markAsFinished()
let semaphore = DispatchSemaphore(value: 0)
writer.finishWriting {
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 60) == .timedOut {
throw RecorderError.make(23, "Timed out finishing AVAssetWriter")
}
if writer.status == .failed {
throw writer.error ?? RecorderError.make(24, "AVAssetWriter failed")
}
if let error = outputWriter.error {
throw error
}
onFinalizationStage?(.videoSaved)
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
guard let recordingWidth, let recordingHeight else {
throw RecorderError.make(27, "Missing recording dimensions")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock,
width: recordingWidth,
height: recordingHeight
)
}
}
private enum CaptureReason {
case callback
case surfaceChange
case boundary
case healthProbe
}
private func validateConfiguration() throws {
guard !configuration.deviceUDID.isEmpty else {
throw RecorderError.make(30, "deviceUDID must not be empty")
}
guard configuration.fps > 0, configuration.fps <= 120 else {
throw RecorderError.make(31, "fps must be between 1 and 120")
}
guard configuration.bitrate > 0 else {
throw RecorderError.make(32, "bitrate must be positive")
}
guard configuration.segmentDuration >= 0 else {
throw RecorderError.make(33, "segmentDuration must be non-negative")
}
guard configuration.maxPendingFrames > 0 else {
throw RecorderError.make(34, "maxPendingFrames must be positive")
}
}
private func startBoundaryTimer() {
let timer = DispatchSource.makeTimerSource(queue: callbackQueue)
timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
timer.setEventHandler { [weak self] in
self?.probeSimulatorState()
self?.probeFramebufferHealth()
self?.appendBoundaryFrameIfNeeded()
}
timer.resume()
boundaryTimer = timer
}
private func appendBoundaryFrameIfNeeded() {
if stopped {
return
}
guard configuration.segmentDuration > 0 else {
return
}
writerQueue.sync {
guard firstAcceptedCaptureTime != nil else {
return
}
let currentPTSSeconds = CMTimeGetSeconds(
normalizedPresentationTime(for: monotonicClock.elapsedTime()))
guard currentPTSSeconds >= nextBoundaryElapsed else {
return
}
let boundary = nextBoundaryElapsed
guard let lastPTS else {
return
}
do {
if CMTimeGetSeconds(lastPTS) < boundary {
let boundaryPTS = CMTime(seconds: boundary, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return
}
}
nextBoundaryElapsed = boundary + configuration.segmentDuration
} catch {
firstError = error
signalFirstFrameReadyIfNeeded()
}
}
}
private func captureFrame(force: Bool, reason: CaptureReason) {
if stopped {
return
}
if reason == .callback {
lastFrameCallbackElapsed = monotonicClock.elapsedSeconds()
}
guard let snapshot = displaySource?.surfaceSnapshot() else {
return
}
let surface = snapshot.surface
let surfaceWidth = snapshot.width
let surfaceHeight = snapshot.height
guard surfaceWidth > 0, surfaceHeight > 0 else {
return
}
let seed = snapshot.seed
let matchesLastAppendedSeed = writerQueue.sync { lastSeed == seed }
if !force, matchesLastAppendedSeed {
return
}
guard reservePendingFrame() else {
return
}
let capturedAt = monotonicClock.elapsedTime()
let capturedAtWallClock = Date()
do {
let pixelBuffer = try copySurfaceToOwnedPixelBuffer(
surface,
width: surfaceWidth,
height: surfaceHeight
)
appendOwned(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
releasePendingFrame()
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func probeFramebufferHealth() {
guard !stopped, let displaySource else {
return
}
let elapsed = monotonicClock.elapsedSeconds()
if !isFirstFrameReady(),
elapsed - lastFirstFrameRewireElapsed >= Self.firstFrameRewireInterval
{
lastFirstFrameRewireElapsed = elapsed
rewireFramebuffer()
captureFrame(force: true, reason: .healthProbe)
return
}
guard let snapshot = displaySource.surfaceSnapshot(),
snapshot.width > 0,
snapshot.height > 0
else {
rewireFramebuffer()
return
}
let observedSeed = snapshot.seed
let lastAppendedSeed = writerQueue.sync { lastSeed }
guard let lastAppendedSeed, observedSeed != lastAppendedSeed else {
return
}
let lastCallback = lastFrameCallbackElapsed ?? 0
if elapsed - lastCallback >= Self.callbackStalenessTimeout {
captureFrame(force: false, reason: .healthProbe)
rewireFramebuffer()
}
}
private func probeSimulatorState() {
guard !stopped, simulatorStoppedReason == nil else {
return
}
let state = SimulatorDeviceLookup.stateString(udid: configuration.deviceUDID) ?? "missing"
guard state != "Booted" else {
return
}
simulatorStoppedReason = state
let error = RecorderError.make(26, "Simulator stopped before first frame (state: \(state))")
writerQueue.async {
if self.firstError == nil, !self.firstFrameReady {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
let onSimulatorStopped = onSimulatorStopped
eventQueue.async {
onSimulatorStopped?(state)
}
}
private func rewireFramebuffer() {
guard let displaySource else {
return
}
do {
try displaySource.rewireFramebuffer()
} catch {
writerQueue.async {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func reservePendingFrame() -> Bool {
pendingLock.lock()
defer { pendingLock.unlock() }
if pendingFrames >= configuration.maxPendingFrames {
return false
}
pendingFrames += 1
return true
}
private func releasePendingFrame() {
pendingLock.lock()
pendingFrames = max(0, pendingFrames - 1)
pendingLock.unlock()
}
private func copySurfaceToOwnedPixelBuffer(
_ surface: IOSurface,
width: Int,
height: Int
) throws -> CVPixelBuffer {
let pool = try writerQueue.sync {
if let firstError {
throw firstError
}
if writer == nil {
try startWriter(width: width, height: height)
}
guard let pool = adaptor?.pixelBufferPool else {
throw RecorderError.make(40, "AVAssetWriter pixel buffer pool is unavailable")
}
return pool
}
var output: CVPixelBuffer?
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &output)
guard status == kCVReturnSuccess, let destination = output else {
throw RecorderError.make(41, "Failed to allocate pixel buffer from pool: \(status)")
}
let destinationLockStatus = CVPixelBufferLockBaseAddress(destination, [])
guard destinationLockStatus == kCVReturnSuccess else {
throw RecorderError.make(50, "Failed to lock pixel buffer: \(destinationLockStatus)")
}
let surfaceLockStatus = IOSurfaceLock(surface, .readOnly, nil)
guard surfaceLockStatus == KERN_SUCCESS else {
CVPixelBufferUnlockBaseAddress(destination, [])
throw RecorderError.make(49, "Failed to lock IOSurface: \(surfaceLockStatus)")
}
defer {
IOSurfaceUnlock(surface, .readOnly, nil)
CVPixelBufferUnlockBaseAddress(destination, [])
}
guard let destinationAddress = CVPixelBufferGetBaseAddress(destination) else {
throw RecorderError.make(42, "Pixel buffer has no base address")
}
let sourceAddress = IOSurfaceGetBaseAddress(surface)
let sourceStride = IOSurfaceGetBytesPerRow(surface)
let destinationStride = CVPixelBufferGetBytesPerRow(destination)
let copyWidth = min(width, IOSurfaceGetWidth(surface), CVPixelBufferGetWidth(destination))
let copyHeight = min(
height, IOSurfaceGetHeight(surface), CVPixelBufferGetHeight(destination))
let copyBytes = min(copyWidth * 4, sourceStride, destinationStride)
for row in 0..<copyHeight {
memcpy(
destinationAddress.advanced(by: row * destinationStride),
sourceAddress.advanced(by: row * sourceStride),
copyBytes
)
}
return destination
}
private func appendOwned(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) {
writerQueue.async {
defer { self.releasePendingFrame() }
if self.firstError != nil {
return
}
do {
try self.appendOnWriterQueue(
pixelBuffer: pixelBuffer,
seed: seed,
capturedAt: capturedAt,
capturedAtWallClock: capturedAtWallClock
)
} catch {
self.firstError = error
self.signalFirstFrameReadyIfNeeded()
}
}
}
private func appendOnWriterQueue(
pixelBuffer: CVPixelBuffer,
seed: UInt32,
capturedAt: CMTime,
capturedAtWallClock: Date
) throws {
guard let writer, let input, let adaptor else {
throw RecorderError.make(43, "AVAssetWriter is not initialized")
}
let isFirstFrame = firstAcceptedCaptureTime == nil
let pts = isFirstFrame ? .zero : normalizedPresentationTime(for: capturedAt)
if let lastPTS, CMTimeCompare(pts, lastPTS) <= 0 {
return
}
guard try appendPendingBoundaryFrames(before: pts) else {
return
}
guard input.isReadyForMoreMediaData else {
return
}
if !adaptor.append(pixelBuffer, withPresentationTime: pts) {
throw writer.error ?? RecorderError.make(44, "Failed to append pixel buffer")
}
if isFirstFrame {
firstAcceptedCaptureTime = capturedAt
firstAcceptedWallClock = capturedAtWallClock
}
lastPTS = pts
lastSeed = seed
lastAppendedPixelBuffer = pixelBuffer
signalFirstFrameReadyIfNeeded()
}
private func appendTailFrameIfNeeded(finalPTS: CMTime) throws {
let deadline = Date().addingTimeInterval(5)
while true {
if try appendTailFrame(finalPTS: finalPTS) {
return
}
if Date() >= deadline {
throw RecorderError.make(45, "Timed out waiting to append tail frame")
}
Thread.sleep(forTimeInterval: 0.05)
}
}
private func appendTailFrame(finalPTS: CMTime) throws -> Bool {
let pts = normalizedPresentationTime(for: finalPTS)
return try appendHeldFrame(at: pts)
}
private func appendPendingBoundaryFrames(before pts: CMTime) throws -> Bool {
guard configuration.segmentDuration > 0,
firstAcceptedCaptureTime != nil
else {
return true
}
while CMTimeGetSeconds(pts) > nextBoundaryElapsed {
if let lastPTS, CMTimeGetSeconds(lastPTS) >= nextBoundaryElapsed {
nextBoundaryElapsed += configuration.segmentDuration
continue
}
let boundaryPTS = CMTime(
seconds: nextBoundaryElapsed, preferredTimescale: 1_000_000_000)
guard try appendHeldFrame(at: boundaryPTS) else {
return false
}
nextBoundaryElapsed += configuration.segmentDuration
}
return true
}
private func appendHeldFrame(at pts: CMTime) throws -> Bool {
guard let input,
let adaptor,
let lastPTS,
let lastAppendedPixelBuffer
else {
return true
}
if CMTimeCompare(pts, lastPTS) <= 0 {
return true
}
guard input.isReadyForMoreMediaData else {
return false
}
if !adaptor.append(lastAppendedPixelBuffer, withPresentationTime: pts) {
throw writer?.error ?? RecorderError.make(45, "Failed to append held frame")
}
self.lastPTS = pts
return true
}
private func normalizedPresentationTime(for capturedAt: CMTime) -> CMTime {
let start = firstAcceptedCaptureTime ?? capturedAt
return CMTimeSubtract(capturedAt, start)
}
private func startWriter(width: Int, height: Int) throws {
guard let outputWriter else {
throw RecorderError.make(46, "Missing output writer")
}
let writer: AVAssetWriter
if configuration.segmentDuration > 0 {
writer = AVAssetWriter(contentType: .mpeg4Movie)
writer.delegate = outputWriter
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(
seconds: configuration.segmentDuration,
preferredTimescale: 600
)
} else {
writer = try AVAssetWriter(outputURL: outputWriter.singleRecordingURL, fileType: .mp4)
}
writer.initialSegmentStartTime = .zero
let input = AVAssetWriterInput(
mediaType: .video,
outputSettings: assetWriterSettings(width: width, height: height)
)
input.expectsMediaDataInRealTime = true
let attributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferWidthKey as String: width,
kCVPixelBufferHeightKey as String: height,
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
]
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: input,
sourcePixelBufferAttributes: attributes
)
guard writer.canAdd(input) else {
throw RecorderError.make(47, "AVAssetWriter cannot add video input")
}
writer.add(input)
guard writer.startWriting() else {
throw writer.error ?? RecorderError.make(48, "AVAssetWriter failed to start")
}
writer.startSession(atSourceTime: .zero)
self.writer = writer
self.input = input
self.adaptor = adaptor
recordingWidth = width
recordingHeight = height
}
private func assetWriterSettings(width: Int, height: Int) -> [String: Any] {
let codecType: AVVideoCodecType = configuration.codec == .hevc ? .hevc : .h264
var compression: [String: Any] = [
AVVideoAverageBitRateKey: configuration.bitrate,
AVVideoExpectedSourceFrameRateKey: configuration.fps,
AVVideoAllowFrameReorderingKey: true,
]
if configuration.segmentDuration > 0 {
compression[AVVideoMaxKeyFrameIntervalKey] = max(
1,
Int((configuration.segmentDuration * Double(configuration.fps)).rounded())
)
}
if configuration.codec == .h264 {
compression[AVVideoProfileLevelKey] = AVVideoProfileLevelH264HighAutoLevel
}
return [
AVVideoCodecKey: codecType,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
AVVideoCompressionPropertiesKey: compression,
]
}
private func signalFirstFrameReadyIfNeeded() {
if firstFrameReady {
return
}
firstFrameReady = true
firstFrameSemaphore.signal()
}
private func isFirstFrameReady() -> Bool {
writerQueue.sync {
firstFrameReady
}
}
}
@@ -1,60 +0,0 @@
import CoreMedia
import Foundation
enum RecorderError {
static func make(_ code: Int, _ message: String) -> NSError {
NSError(
domain: "RecordSim",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
extension JSONEncoder {
static var prettySorted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
func timeSeconds(_ time: CMTime) -> Double? {
guard time.isValid, !time.isIndefinite, !time.isNegativeInfinity, !time.isPositiveInfinity else {
return nil
}
return CMTimeGetSeconds(time)
}
func iso8601(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.string(from: date)
}
func unixMs(_ date: Date) -> Int64 {
Int64((date.timeIntervalSince1970 * 1000).rounded())
}
struct MonotonicClock {
private static let timescale: CMTimeScale = 1_000_000_000
let startedAtUptimeNanoseconds: UInt64
init() {
startedAtUptimeNanoseconds = DispatchTime.now().uptimeNanoseconds
}
func elapsedSeconds() -> TimeInterval {
TimeInterval(elapsedNanoseconds()) / TimeInterval(Self.timescale)
}
func elapsedTime() -> CMTime {
CMTime(value: CMTimeValue(elapsedNanoseconds()), timescale: Self.timescale)
}
private func elapsedNanoseconds() -> UInt64 {
DispatchTime.now().uptimeNanoseconds - startedAtUptimeNanoseconds
}
}
@@ -1,159 +0,0 @@
import Foundation
import RecordSim
struct CLIOptions {
var udid: String?
var output: String?
var segmentDuration: TimeInterval = 120
var fps = 30
var bitrate = 30_000_000
var codec: RecorderCodec = .h264
}
private var requestedStopSignal: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_ signal: CInt) {
requestedStopSignal = signal
}
private func isStopRequested() -> Bool {
requestedStopSignal != 0 || simulatorStopped != 0
}
func usage() -> String {
"""
Usage:
record-sim --udid <UDID> --output <session-dir> [options]
Options:
--segment-duration <seconds> Target fMP4 media segment duration. Use 0 for one MP4. Default: 120
--fps <fps> Expected source frame rate. Default: 30
--bitrate <bits/sec> AVAssetWriter average bitrate. Default: 30000000
--codec <h264|hevc> Video codec. Default: h264
-h, --help Show this help
"""
}
func parseOptions(_ args: [String]) throws -> CLIOptions {
var options = CLIOptions()
var i = 0
func next(_ flag: String) throws -> String {
i += 1
guard i < args.count else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing value for \(flag)"])
}
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "--udid", "-u":
options.udid = try next(arg)
case "--output", "--output-dir", "--out", "-o":
options.output = try next(arg)
case "--segment-duration":
options.segmentDuration = Double(try next(arg)) ?? -1
case "--fps":
options.fps = Int(try next(arg)) ?? 0
case "--bitrate":
options.bitrate = Int(try next(arg)) ?? 0
case "--codec":
let raw = try next(arg)
guard let codec = RecorderCodec(rawValue: raw) else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--codec must be h264 or hevc"])
}
options.codec = codec
case "--help", "-h":
print(usage())
exit(0)
default:
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Unknown argument: \(arg)"])
}
i += 1
}
guard options.udid != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --udid"])
}
guard options.output != nil else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing required --output"])
}
guard options.segmentDuration >= 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--segment-duration must be non-negative"])
}
guard options.fps > 0, options.fps <= 120 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--fps must be between 1 and 120"])
}
guard options.bitrate > 0 else {
throw NSError(domain: "record-sim", code: 2, userInfo: [NSLocalizedDescriptionKey: "--bitrate must be positive"])
}
return options
}
do {
signal(SIGINT, handleStopSignal)
signal(SIGTERM, handleStopSignal)
let options = try parseOptions(Array(CommandLine.arguments.dropFirst()))
let configuration = SimulatorRecordingConfiguration(
deviceUDID: options.udid!,
outputDirectory: URL(fileURLWithPath: options.output!),
fps: options.fps,
bitrate: options.bitrate,
codec: options.codec,
segmentDuration: options.segmentDuration
)
let recorder = SimulatorRecorder(configuration: configuration)
recorder.onSimulatorStopped = { state in
simulatorStopped = 1
fputs("[record-sim] simulator stopped state=\(state); finalizing recording\n", stderr)
}
recorder.onFinalizationStage = { stage in
switch stage {
case .captureStopped:
fputs("[record-sim] screen capture stopped; saving final video\n", stderr)
case .videoSaved:
fputs("[record-sim] final video saved; writing recording details\n", stderr)
}
}
recorder.onSegment = { segment in
switch segment.kind {
case .initialization:
fputs("[record-sim] init \(segment.relativePath) bytes=\(segment.byteCount)\n", stderr)
case .media:
let duration = segment.durationSeconds ?? 0
fputs(String(format: "[record-sim] segment %@ duration=%.3fs bytes=%d\n", segment.relativePath, duration, segment.byteCount), stderr)
}
}
try recorder.start()
try recorder.waitUntilFirstFrame()
fputs("[record-sim] recording -> \(options.output!)\n", stderr)
while !isStopRequested() {
Thread.sleep(forTimeInterval: 0.1)
}
if requestedStopSignal != 0 {
let signalName = switch requestedStopSignal {
case SIGINT: "SIGINT"
case SIGTERM: "SIGTERM"
default: "signal \(requestedStopSignal)"
}
fputs("[record-sim] received \(signalName); finishing recording\n", stderr)
}
let manifest = try recorder.stop()
if let recording = manifest.recording {
fputs("[record-sim] done recording=\(recording)\n", stderr)
} else {
fputs("[record-sim] done segments=\(manifest.segments.count)\n", stderr)
}
fputs("[record-sim] manifest \(URL(fileURLWithPath: options.output!).appendingPathComponent("session.json").path)\n", stderr)
} catch {
fputs("error: \(error.localizedDescription)\n", stderr)
exit(1)
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -eox pipefail
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
yarn gql
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
@@ -318,6 +318,49 @@ describe('Datadog singleton', () => {
);
});
it('POSTs distribution metrics to the device run session worker API with the session token', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.distribution('eas.workflow.hook', 1, { hook: 'on_start' });
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.workflow.hook',
type: 'distribution',
value: 1,
tags: { hook: 'on_start', target: 'job_run' },
},
],
},
headers: { 'expo-device-run-session-token': 'session-token' },
retries: 2,
}
);
});
it('does not report logs for device run session targets', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
deviceRunSessionToken: 'session-token',
target: { kind: 'deviceRunSession' },
});
Datadog.log('Custom build saw user-provided function');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
@@ -0,0 +1,5 @@
import { Android } from '@expo/eas-build-job';
export function resolveArtifactPath(job: Android.Job): string {
return job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}';
}
@@ -60,7 +60,8 @@ jest.mock('../../utils/expoUpdates', () => ({
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
uploadEmbeddedBundleAsync: jest.fn(async () => ({ status: 'uploaded' })),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
@@ -317,36 +318,108 @@ describe(androidBuilder, () => {
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
},
uploadArtifact: jest.fn(),
describe('embedded bundle upload phase', () => {
function createContext({
env = {},
sdkVersion,
}: {
env?: Record<string, string>;
sdkVersion?: string;
}): BuildContext<Android.Job> {
return new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
...env,
},
uploadArtifact: jest.fn(),
metadata: sdkVersion ? { sdkVersion } : undefined,
});
}
it('runs by default on SDK 58 and later', async () => {
const ctx = createContext({ sdkVersion: '58.0.0' });
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job, env: ctx.env, projectDir: '/workingdir/build' })
);
});
await androidBuilder(ctx);
it.each([
['uploaded', BuildPhaseResult.SUCCESS],
['skipped', BuildPhaseResult.SKIPPED],
['failed', BuildPhaseResult.WARNING],
] as const)('marks the phase from the %s result', async (uploadResult, phaseResult) => {
jest.mocked(uploadEmbeddedBundleAsync).mockResolvedValueOnce({ status: uploadResult });
const ctx = createContext({ sdkVersion: '58.0.0' });
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
});
await androidBuilder(ctx);
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
build_phase: BuildPhase.UPLOAD_EMBEDDED_BUNDLE.toLowerCase(),
result: phaseResult,
})
);
});
await androidBuilder(ctx);
it('skips by default on SDK 57 and earlier', async () => {
const ctx = createContext({ sdkVersion: '57.0.0' });
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('skips by default when the SDK version is unknown', async () => {
const ctx = createContext({});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
const ctx = createContext({
sdkVersion: '58.0.0',
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
const ctx = createContext({
sdkVersion: '57.0.0',
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(
expect.objectContaining({ job: ctx.job })
);
});
});
});
+19 -4
View File
@@ -11,6 +11,7 @@ import {
runGradleCommand,
} from '../android/gradle';
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
import { resolveArtifactPath } from '../android/resolve';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
@@ -32,7 +33,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -217,15 +221,26 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
+18 -4
View File
@@ -29,7 +29,10 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import {
shouldUploadEmbeddedBundle,
uploadEmbeddedBundleAsync,
} from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -234,15 +237,26 @@ async function buildInnerAsync(
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
patternOrPath: resolveArtifactPath(ctx.job),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
if (shouldUploadEmbeddedBundle(ctx)) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
const { status } = await uploadEmbeddedBundleAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
projectDir: ctx.getReactNativeProjectDirectory(),
appConfig: await ctx.appConfig,
});
if (status === 'skipped') {
ctx.markBuildPhaseSkipped();
} else if (status === 'failed') {
ctx.markBuildPhaseHasWarnings();
}
});
}
@@ -16,11 +16,13 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info: jest.fn(),
info,
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -36,7 +38,7 @@ function createCtx(
workingdir,
}
);
return { ctx, warn };
return { ctx, info, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -79,22 +81,31 @@ describe(parseJobHooksAsync, () => {
});
});
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -192,10 +203,9 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -12,6 +12,7 @@ import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { Readable } from 'stream';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -390,7 +391,13 @@ describe('projectSources', () => {
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
let uploadedBody = '';
fetchMock.mockImplementation(async (_url, request) => {
for await (const chunk of request!.body as Readable) {
uploadedBody += chunk;
}
return { ok: true } as Response;
});
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
@@ -414,10 +421,14 @@ describe('projectSources', () => {
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
headers: {
'x-custom-header': 'value',
'content-length': '63',
'content-type': 'text/plain;charset=UTF-8',
},
})
);
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
@@ -44,9 +44,8 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
} catch {
// The phase error wins.
}
}
throw phaseError;
+5 -16
View File
@@ -53,19 +53,17 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut(ctx.logger);
globalContext.markAsCheckedOut();
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
// a malformed `before_submit` fails a steps build, so it fails here too). An
// unknown key is inert — a key newer than this worker must be ignored, never
// fail the job.
for (const [key, steps] of Object.entries(hooks)) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
if (parseHookKey(key) === null) {
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
@@ -81,15 +79,6 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,10 +208,9 @@ async function uploadProjectMetadataAsync(
headers: Record<string, string>;
};
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
await uploadContentWithSignedUrl({
signedUrl: uploadSession,
content: JSON.stringify({ archiveContent: sortedFiles }),
});
const updateMetadataResult = await ctx.graphqlClient
+5 -4
View File
@@ -4,13 +4,11 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -42,7 +40,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: GenericArtifactType;
type: null;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -69,6 +67,7 @@ export interface BuildContextOptions {
metadata?: Metadata;
expoApiV2BaseUrl?: string;
mcpServerUrl?: string;
addBuildLogMask?: (secret: string) => void;
}
export class SkipNativeBuildError extends Error {}
@@ -86,6 +85,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public readonly addBuildLogMask?: (secret: string) => void;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -115,6 +115,7 @@ export class BuildContext<TJob extends Job = Job> {
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.mcpServerUrl = options.mcpServerUrl;
this.addBuildLogMask = options.addBuildLogMask;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -238,7 +239,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && !isGenericArtifact(artifact)) {
if (result.filename && artifact.type !== null) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
@@ -32,6 +32,7 @@ export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
addBuildLogMask?: (secret: string) => void;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
@@ -63,12 +64,12 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public readonly expoApiV2BaseUrl?: string;
public readonly mcpServerUrl?: string;
public job: TJob;
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
@@ -84,6 +85,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
addBuildLogMask: buildCtx.addBuildLogMask,
};
this.mcpServerUrl = buildCtx.mcpServerUrl;
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
+42 -15
View File
@@ -1,15 +1,24 @@
import { Sentry } from './sentry';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from './steps/utils/deviceRunSessionWorkerApi';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
| { kind: 'jobRun'; turtleJobRunId: string }
// A device run session job with a session token. The token names the session and its job run.
| { kind: 'deviceRunSession' };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
type DatadogSetupOptions =
| {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: Exclude<MetricsTarget, { kind: 'deviceRunSession' }>;
}
| {
expoApiV2BaseUrl: string;
deviceRunSessionToken: string;
target: Extract<MetricsTarget, { kind: 'deviceRunSession' }>;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
@@ -18,9 +27,30 @@ function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
// A device run session job is a job run, so its metrics keep the job run tag.
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
function getMetricsRequest(options: DatadogSetupOptions): {
path: string;
headers: Record<string, string>;
} {
if ('deviceRunSessionToken' in options) {
return {
path: 'device-run-session-worker/metrics',
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: options.deviceRunSessionToken },
};
}
const { target, robotAccessToken } = options;
return {
path:
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`,
headers: { Authorization: `Bearer ${robotAccessToken}` },
};
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -30,7 +60,7 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const { expoApiV2BaseUrl, target } = setupOptions;
const metrics = [
{
name,
@@ -40,16 +70,11 @@ export const Datadog = {
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const { path: metricsPath, headers } = getMetricsRequest(setupOptions);
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
headers,
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
@@ -61,7 +86,9 @@ export const Datadog = {
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
// The device run session worker API has no logs route, and the session token is not a bearer
// credential, so a device run session job does not report these logs.
if (!setupOptions || !('robotAccessToken' in setupOptions)) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
+11 -2
View File
@@ -3,8 +3,16 @@ import HttpLogStream from './logging/HttpLogStream';
import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, HttpLogStream, RemoteLoggerStream };
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from './storage/uploadWithSignedUrl';
export type {
SignedUrl,
UploadWithSignedUrlOptions,
UploadWithSignedUrlParams,
} from './storage/uploadWithSignedUrl';
export { downloadCacheAsync } from './steps/functions/restoreCache';
export { uploadCacheAsync } from './steps/functions/saveCache';
@@ -33,5 +41,6 @@ export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { getDeviceRunSessionTokenFromEnvironmentSecrets } from './steps/utils/deviceRunSessionWorkerApi';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -1,11 +1,9 @@
import { Ios } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { BuildContext } from '../../../context';
import { distributionCertificate } from '../__tests__/fixtures';
import Keychain from '../keychain';
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
// We need the JS code to modify the file system, so we need to mock it.
jest.unmock('fs');
let ctx: BuildContext<Ios.Job>;
describe('Keychain class', () => {
describe('ensureCertificateImported method', () => {
let keychain: Keychain<Ios.Job>;
let keychain: Keychain;
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
beforeAll(async () => {
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
});
beforeEach(async () => {
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: mockLogger,
env: {},
uploadArtifact: jest.fn(),
});
keychain = new Keychain(ctx);
await keychain.create();
keychain = new Keychain();
await keychain.create({ logger: mockLogger });
});
afterEach(async () => {
await keychain.destroy();
await keychain.destroy({ logger: mockLogger });
});
it("should throw an error if the certificate hasn't been imported", async () => {
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
keychain.ensureCertificateImported({
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).rejects.toThrowError(/hasn't been imported successfully/);
});
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
await keychain.importCertificate(certificatePath, distributionCertificate.password);
await keychain.importCertificate({
logger: mockLogger,
certPath: certificatePath,
certPassword: distributionCertificate.password,
});
await expect(
keychain.ensureCertificateImported(
distributionCertificate.teamId,
distributionCertificate.fingerprint
)
keychain.ensureCertificateImported({
teamId: distributionCertificate.teamId,
fingerprint: distributionCertificate.fingerprint,
})
).resolves.not.toThrow();
});
});
@@ -0,0 +1,133 @@
import { errors } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { SpawnResult } from '@expo/turtle-spawn';
import { runFastlane } from '../../fastlane';
import Keychain from '../keychain';
jest.mock('../../fastlane');
const runFastlaneMock = jest.mocked(runFastlane);
const logger = createLogger({ name: 'test' });
const logError = jest.spyOn(logger, 'error').mockImplementation();
let keychain: Keychain;
const options = {
logger,
certPath: '/test.p12',
certPassword: 'certificate-password',
};
const result = (stdout: string, stderr = ''): SpawnResult => ({
stdout,
stderr,
output: [stdout, stderr],
pid: 1,
status: 0,
signal: null,
});
beforeEach(async () => {
keychain = new Keychain();
runFastlaneMock.mockResolvedValueOnce(result(''));
await keychain.create({ logger });
runFastlaneMock.mockClear();
});
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
await keychain.importCertificate(options);
expect(runFastlaneMock).toHaveBeenCalledWith([
'run',
'import_certificate',
'certificate_path:/test.p12',
'certificate_password:certificate-password',
`keychain_path:${keychain.data.path}`,
`keychain_password:${keychain.data.password}`,
]);
expect(logError).not.toHaveBeenCalled();
});
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
it.each([
{
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
},
{
output: 'SecKeychainItemImport: Unknown format in import',
codes: ['PKCS12_UNKNOWN_FORMAT'],
},
{
output: 'SecKeychainItemImport: Some other error',
codes: ['CERTIFICATE_IMPORT_FAILED'],
},
{
output:
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
},
{
output:
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
},
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
if (outcome === 'success') {
runFastlaneMock.mockResolvedValueOnce(result(output));
await keychain.importCertificate(options);
} else {
runFastlaneMock.mockRejectedValueOnce(
Object.assign(new Error('import failed'), {
stderr: output,
})
);
const failure = await keychain.importCertificate(options).catch(error => error);
expect(failure.metadata.diagnosticCodes).toEqual(codes);
}
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
});
});
it('never forwards output, command arguments, private key data, or the original error', async () => {
const secret = 'very-secret-password';
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
stdout: `private key data ${secret}`,
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
});
runFastlaneMock.mockRejectedValueOnce(error);
const failure = await keychain.importCertificate(options).catch(error => error);
expect(failure).toBeInstanceOf(errors.UserError);
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
expect(failure.toExternalExpoError().message).toContain(
'Fastlane could not complete certificate import'
);
expect(failure.cause).toBeUndefined();
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
expect(logError).toHaveBeenCalledWith(
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
expect.stringContaining('private key')
);
});
it.each(['ENOENT', 'EACCES'])(
'classifies a %s process launch failure as a system error',
async code => {
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
const error = await keychain.importCertificate(options).catch(error => error);
expect(error).toBeInstanceOf(errors.SystemError);
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
expect(error.cause).toBeUndefined();
}
);
it.each([
null,
new Error('failure'),
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
])('handles rejected values without usable process output: %p', async value => {
runFastlaneMock.mockRejectedValueOnce(value);
await expect(keychain.importCertificate(options)).rejects.toThrow(
'Fastlane could not complete certificate import'
);
expect(logError).not.toHaveBeenCalled();
});
@@ -18,7 +18,7 @@ jest.unmock('fs');
describe('ProvisioningProfile class', () => {
describe('verifyCertificate method', () => {
let ctx: BuildContext<Ios.Job>;
let keychain: Keychain<Ios.Job>;
let keychain: Keychain;
beforeAll(async () => {
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
},
uploadArtifact: jest.fn(),
});
keychain = new Keychain(ctx);
await keychain.create();
keychain = new Keychain();
await keychain.create({ logger: mockLogger });
});
afterAll(async () => {
await keychain.destroy();
await keychain.destroy({ logger: mockLogger });
});
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
@@ -1,19 +1,53 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import os from 'os';
import path from 'path';
import { v4 as uuid } from 'uuid';
import { BuildContext } from '../../context';
import { runFastlane } from '../fastlane';
export default class Keychain<TJob extends Ios.Job> {
// Do not log raw fastlane output or spawn errors: they can contain passwords,
// command arguments, and private key attributes from set-key-partition-list.
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
{
code: 'PKCS12_MAC_VERIFICATION_FAILED',
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
message:
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
},
{
code: 'PKCS12_UNKNOWN_FORMAT',
pattern: /SecKeychainItemImport: Unknown format in import/i,
message: 'macOS did not recognize the certificate import format.',
},
{
code: 'CERTIFICATE_IMPORT_FAILED',
pattern:
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
},
{
code: 'PRIVATE_KEY_ACCESS_FAILED',
pattern: /SecKeychainItemSetAccessWithPassword:/,
message:
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
},
{
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
pattern: /SecItemCopyMatching:/,
message:
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
},
];
export default class Keychain {
private readonly keychainPath: string;
private readonly keychainPassword: string;
private created = false;
private destroyed = false;
constructor(private readonly ctx: BuildContext<TJob>) {
constructor() {
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
this.keychainPassword = uuid();
}
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
};
}
public async create(): Promise<void> {
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
public async create({ logger }: { logger: bunyan }): Promise<void> {
logger.debug(`Creating keychain - ${this.keychainPath}`);
await runFastlane([
'run',
'create_keychain',
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
this.created = true;
}
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
public async importCertificate({
logger,
certPath,
certPassword,
}: {
logger: bunyan;
certPath: string;
certPassword: string;
}): Promise<void> {
if (!this.created) {
throw new Error('You must create a keychain first.');
}
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
try {
const result = await runFastlane([
'run',
'import_certificate',
`certificate_path:${certPath}`,
`certificate_password:${certPassword}`,
`keychain_path:${this.keychainPath}`,
`keychain_password:${this.keychainPassword}`,
]);
const output = [result.stdout, result.stderr].join('\n');
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
if (diagnostic.pattern.test(output)) {
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
}
}
} catch (error) {
const processError =
error instanceof Error
? (error as Error &
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
: undefined;
const output = [processError?.stdout, processError?.stderr]
.filter(value => typeof value === 'string')
.join('\n');
const diagnosticCodes: string[] = [];
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
if (diagnostic.pattern.test(output)) {
diagnosticCodes.push(diagnostic.code);
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
}
}
// Never attach the original error: its message includes passwords.
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
});
}
throw new errors.UserError(
errors.ErrorCode.UNKNOWN_ERROR,
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
{
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
metadata: { diagnosticCodes },
}
);
}
}
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
public async ensureCertificateImported({
teamId,
fingerprint,
}: {
teamId: string;
fingerprint: string;
}): Promise<void> {
const identities = await this.findIdentitiesByTeamId(teamId);
if (!identities.includes(fingerprint)) {
throw new Error(
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
}
}
public async destroy(keychainPath?: string): Promise<void> {
public async destroy({
logger,
keychainPath,
}: {
logger: bunyan;
keychainPath?: string;
}): Promise<void> {
if (!keychainPath && !this.created) {
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
return;
}
if (this.destroyed) {
this.ctx.logger.warn('The keychain has been already destroyed');
logger.warn('The keychain has been already destroyed');
return;
}
const keychainToDeletePath = keychainPath ?? this.keychainPath;
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
try {
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
this.destroyed = true;
} catch (err) {
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
logger.error({ err }, 'Failed to delete the keychain\n');
throw err;
}
}
public async cleanUpKeychains(): Promise<void> {
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
i.slice(1, i.length - 1)
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
/eas-build-[\w-]+\.keychain$/.exec(keychain)
);
for (const turtleKeychainPath of turtleKeychainList) {
await this.destroy(turtleKeychainPath);
await this.destroy({ logger, keychainPath: turtleKeychainPath });
}
}
@@ -26,7 +26,7 @@ export interface Credentials {
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
export default class IosCredentialsManager<TJob extends Ios.Job> {
private keychain?: Keychain<TJob>;
private keychain?: Keychain;
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
private cleanedUp = false;
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
this.ctx.logger.info('Preparing credentials');
this.ctx.logger.info('Creating keychain');
this.keychain = new Keychain(this.ctx);
await this.keychain.create();
this.keychain = new Keychain();
await this.keychain.create({ logger: this.ctx.logger });
const targets = Object.keys(buildCredentials);
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
}
if (this.keychain) {
await this.keychain.destroy();
await this.keychain.destroy({ logger: this.ctx.logger });
}
if (this.provisioningProfiles) {
for (const provisioningProfile of this.provisioningProfiles) {
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
);
this.ctx.logger.info('Importing distribution certificate into the keychain');
await this.keychain.importCertificate(
distCertPath,
targetCredentials.distributionCertificate.password
);
await this.keychain.importCertificate({
logger: this.ctx.logger,
certPath: distCertPath,
certPassword: targetCredentials.distributionCertificate.password,
});
this.ctx.logger.info('Initializing provisioning profile');
const provisioningProfile = new ProvisioningProfile(
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
this.ctx.logger.info(
'Validating whether the distribution certificate has been imported successfully'
);
await this.keychain.ensureCertificateImported(
provisioningProfile.data.teamId,
certificateFingerprint
);
await this.keychain.ensureCertificateImported({
teamId: provisioningProfile.data.teamId,
fingerprint: certificateFingerprint,
});
this.ctx.logger.info(
'Verifying whether the distribution certificate and provisioning profile match'
+4 -4
View File
@@ -15,10 +15,10 @@ export function resolveScheme(ctx: BuildContext<Ios.Job>): string {
return schemes[0];
}
export function resolveArtifactPath(ctx: BuildContext<Ios.Job>): string {
if (ctx.job.applicationArchivePath) {
return ctx.job.applicationArchivePath;
} else if (ctx.job.simulator) {
export function resolveArtifactPath(job: Ios.Job): string {
if (job.applicationArchivePath) {
return job.applicationArchivePath;
} else if (job.simulator) {
return 'ios/build/Build/Products/*simulator/*.app';
} else {
return 'ios/build/*.ipa';
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
return null;
}
public async init(): Promise<string> {
public async init(): Promise<void> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
return (await this.flush(true)) as string;
await this.flush(true);
}
public async cleanUp(): Promise<void> {
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
}
}
private async flush(force = false): Promise<string | void> {
private async flush(force = false): Promise<void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
return await this.flush(force);
await this.flush(force);
return;
}
return await this.flushInternal();
await this.flushInternal();
}
}
private async flushInternal(): Promise<string | void> {
private async flushInternal(): Promise<void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
return await this.upload()
.then(result => {
return result;
})
await this.upload()
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(result => {
.then(() => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<string | void> {
private async upload(): Promise<void> {
if (!this.uploadMethod) {
return;
}
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
);
};
return await uploadWithSignedUrl({
await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -11,7 +11,7 @@ import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
it('declares the expected hook anchors', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
@@ -24,6 +24,7 @@ describe('hook anchor self-declarations', () => {
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/submit_to_google_play': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
@@ -87,6 +88,17 @@ describe('hook anchor self-declarations', () => {
bundle_short_version: '1.0.0',
},
],
[
'eas/submit_to_google_play',
'before_submit',
'after_submit',
{
artifact_path: 'app.aab',
artifact_type: 'aab',
package_name: 'com.example.app',
service_account_key_path: 'key.json',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
+22 -2
View File
@@ -10,6 +10,7 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createDownloadSubmissionArchiveFunction } from './functions/downloadSubmissionArchive';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
@@ -19,6 +20,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
import { createInstallBuildFunction } from './functions/installBuild';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
@@ -26,6 +28,8 @@ import { createLaunchApplicationFunction } from './functions/launchApplication';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadAppConfigBuildFunction } from './functions/readAppConfig';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createReadAndroidAppInfoBuildFunction } from './functions/readAndroidAppInfo';
import { createSubmitToGooglePlayBuildFunction } from './functions/submitToGooglePlay';
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
@@ -42,9 +46,10 @@ import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createRunAgentBuildFunction } from './functions/runAgent';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
@@ -54,11 +59,16 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
import { createStartSandboxBuildFunction } from './functions/startSandbox';
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -77,7 +87,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createReadAndroidAppInfoBuildFunction(),
createDownloadBuildFunction(ctx),
createDownloadSubmissionArchiveFunction(ctx),
createInstallBuildFunction(),
createLaunchApplicationFunction(),
createEasExportBuildFunction(),
@@ -99,7 +111,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartAgentDeviceSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
@@ -111,9 +123,13 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartWebPreviewRemoteSessionBuildFunction(ctx),
createStartServeSimMetricsBuildFunction(),
createStartServeSimLogsBuildFunction(),
createCollectServeSimLogsBuildFunction(ctx),
createStartSandboxBuildFunction(ctx),
createRunAgentBuildFunction(ctx),
createCollectServeSimMetricsBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallMitmproxyBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
@@ -127,7 +143,10 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -137,6 +156,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
functions.push(
...[
createFindAndUploadBuildArtifactsBuildFunction(ctx),
createUploadEmbeddedBundleBuildFunction(ctx),
createResolveBuildConfigBuildFunction(ctx),
createGetCredentialsForBuildTriggeredByGithubIntegration(ctx),
]
@@ -219,4 +219,71 @@ describe(createEasBuildBuildFunctionGroup, () => {
'Build function group is not supported in generic jobs.'
);
});
describe('embedded bundle upload', () => {
function getStepNames(
options: Parameters<typeof createMockBuildToolsContext>[0],
{ sdkVersion, env = {} }: { sdkVersion?: string; env?: Record<string, string> }
): string[] {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: sdkVersion ? { sdkVersion } : null },
});
globalCtx.updateEnv(env);
return functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.map(step => step.displayName);
}
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('uploads the embedded bundle after the build artifacts on SDK 58 (%s)', (_, options) => {
const stepNames = getStepNames(options, { sdkVersion: '58.0.0' });
expect(stepNames.indexOf('Upload embedded bundle')).toBe(
stepNames.indexOf('Find and upload build artifacts') + 1
);
});
it.each([
['Android', { platform: Platform.ANDROID }],
['Android with credentials', { platform: Platform.ANDROID, buildCredentials: { test: {} } }],
['iOS with credentials', { platform: Platform.IOS, buildCredentials: { test: {} } }],
])('passes ignore_error to the embedded bundle step (%s)', (_, options) => {
const functionGroup = createEasBuildBuildFunctionGroup(createMockBuildToolsContext(options));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { metadata: { sdkVersion: '58.0.0' } },
});
const uploadStep = functionGroup
.createBuildStepsFromFunctionGroupCall(globalCtx)
.find(step => step.displayName === 'Upload embedded bundle');
expect(uploadStep).toBeDefined();
expect(uploadStep!.inputs?.find(input => input.id === 'ignore_error')?.rawValue).toBe(true);
});
it('does not upload the embedded bundle on SDK 57 without the opt-in', () => {
expect(getStepNames({}, { sdkVersion: '57.0.0' })).not.toContain('Upload embedded bundle');
});
it('uploads the embedded bundle on SDK 57 with the opt-in', () => {
expect(
getStepNames(
{},
{ sdkVersion: '57.0.0', env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' } }
)
).toContain('Upload embedded bundle');
});
it('does not upload the embedded bundle for iOS simulator builds', () => {
expect(
getStepNames({ platform: Platform.IOS, simulator: true }, { sdkVersion: '58.0.0' })
).not.toContain('Upload embedded bundle');
});
});
});
@@ -10,6 +10,7 @@ import path from 'path';
import { shouldUseEagerBundle } from '../../common/eagerBundle';
import { CustomBuildContext } from '../../customBuildContext';
import { shouldUploadEmbeddedBundle } from '../../utils/expoUpdatesEmbedded';
import { calculateEASUpdateRuntimeVersionFunction } from '../functions/calculateEASUpdateRuntimeVersion';
import { createCheckoutBuildFunction } from '../functions/checkout';
import { configureAndroidVersionFunction } from '../functions/configureAndroidVersion';
@@ -32,6 +33,7 @@ import {
import { runFastlaneFunction } from '../functions/runFastlane';
import { runGradleFunction } from '../functions/runGradle';
import { createSaveBuildCacheFunction } from '../functions/saveBuildCache';
import { createUploadEmbeddedBundleBuildFunction } from '../functions/uploadEmbeddedBundle';
import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
interface HelperFunctionsInput {
@@ -303,6 +305,19 @@ function createStepsForIosBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -390,6 +405,19 @@ function createStepsForAndroidBuildWithoutCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -483,6 +511,19 @@ function createStepsForAndroidBuildWithCredentials({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
...(shouldUploadEmbeddedBundle({
env: globalCtx.env,
metadata: globalCtx.staticContext.metadata,
})
? [
createUploadEmbeddedBundleBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: { ignore_error: true },
}),
]
: []),
saveCache,
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
@@ -0,0 +1,47 @@
import { UserError } from '@expo/eas-build-job';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { readAndroidArtifactInfoAsync } from '../../utils/android/appArtifact';
import { createReadAndroidAppInfoBuildFunction } from '../readAndroidAppInfo';
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
jest.unmock('node:fs/promises');
jest.unmock('@expo/spawn-async');
const fixturesDirectory = path.join(__dirname, 'fixtures/android-app-info');
it.each(['apk', 'aab'])('reads a real %s and publishes build step outputs', async type => {
const step = createReadAndroidAppInfoBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
projectTargetDirectory: fixturesDirectory,
staticContextContent: { job: {} },
}),
{ callInputs: { artifact_path: `app.${type}` } }
);
await step.executeAsync();
expect(step.outputById.artifact_type.value).toBe(type);
expect(step.outputById.package_name.value).toBe('com.example.app');
});
it('reports an invalid ZIP as a user error', async () => {
await expect(readAndroidArtifactInfoAsync(__filename)).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_ARTIFACT',
cause: expect.any(Error),
});
});
it.each(['apk', 'aab'])('retains the native tool error for an invalid %s manifest', async type => {
await expect(
readAndroidArtifactInfoAsync(path.join(fixturesDirectory, `invalid-manifest-${type}.zip`))
).rejects.toMatchObject({
constructor: UserError,
errorCode: 'EAS_READ_ANDROID_APP_INFO_INVALID_MANIFEST',
cause: expect.any(Error),
});
});
@@ -48,7 +48,10 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -56,6 +59,9 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -96,14 +102,11 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut(logger);
globalCtx.markAsCheckedOut();
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -0,0 +1,401 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import type { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
spawnDetached,
startNgrokTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
jest.mock('@expo/turtle-spawn');
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceEvents');
jest.mock('../../utils/appiumEvents');
jest.mock('../../utils/agentDeviceArtifacts');
// The agent-device session step boots the emulator itself; this test covers the session host.
jest.mock('../startAndroidEmulator', () => ({
startAndroidEmulatorAsync: jest.fn().mockResolvedValue({
serialId: 'emulator-5554',
emulatorPromise: Promise.resolve(),
shouldAdjustAnimationScale: true,
}),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
spawnDetached: jest.fn(),
startNgrokTunnelAsync: jest.fn(),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
waitForFileAsync: jest.fn(),
}));
const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
const stopTunnel = jest.fn();
const stopEvents = jest.fn();
const openPreview = jest.fn();
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
beforeEach(() => {
jest.clearAllMocks();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises);
jest
.spyOn(fs.promises, 'mkdtemp')
.mockImplementation(async prefix =>
String(prefix).includes('eas-android-emulator-logcat-')
? '/tmp/eas-android-emulator-logcat-test'
: mkdtemp(prefix)
);
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
stop.mockResolvedValue(undefined);
}
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.example.test',
closeAsync: closePreview,
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: finishHost,
});
jest.mocked(spawnDetached).mockReturnValue({
pid: undefined,
getOutput: () => '',
getExitError: () => undefined,
stopAsync: stopTool,
});
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
url: 'https://tool.example.test',
subdomainId: 'tool-id',
stopAsync: stopTunnel,
});
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy(
{
public_preview: { value: publicPreview },
frame_ancestors: { value: frameAncestors },
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
}
),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
it('holds the host until session completion', async () => {
await runAsync();
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test', authtoken: 'token' });
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
previewApiUrl: 'https://preview.example.test',
}),
})
);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
});
it('opens tunnels with the ngrok token but keeps it out of spawned processes', async () => {
await runAsync();
expect(startNgrokTunnelAsync).toHaveBeenCalledWith(
expect.objectContaining({ authtoken: 'token' })
);
const spawnedEnvs = [
...jest.mocked(spawn).mock.calls.map(([, , options]) => options?.env),
...jest.mocked(spawnDetached).mock.calls.map(([options]) => options.env),
...jest.mocked(startDeviceSessionHostAsync).mock.calls.map(([, options]) => options.env),
];
expect(spawnedEnvs.length).toBeGreaterThan(0);
for (const env of spawnedEnvs) {
expect(env).toEqual(expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'session-id' }));
expect(env).not.toHaveProperty('NGROK_AUTHTOKEN');
}
});
it.each([undefined, true, false])('maps public_preview=%s to the host', async publicPreview => {
await runAsync(publicPreview);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ disableShare: publicPreview === false })
);
});
it('maps frame_ancestors to the host and defaults to none', async () => {
await runAsync(undefined, ['https://embed.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: [] })
);
});
it('maps cors_origins to the host and defaults to none', async () => {
await runAsync(undefined, undefined, ['https://app.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: [] })
);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
const pendingFinish = new Promise<void>(resolve => {
release = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopped = resolve;
});
finishHost.mockReturnValueOnce(pendingFinish);
stopTool.mockImplementationOnce(async () => {
stopped();
});
let completed = false;
const running = runAsync().then(() => {
completed = true;
});
try {
await toolStopped;
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(completed).toBe(false);
} finally {
release();
await running;
}
});
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
let release!: () => void;
let finished!: () => void;
const pendingClose = new Promise<void>(resolve => {
release = resolve;
});
const hostFinished = new Promise<void>(resolve => {
finished = resolve;
});
const toolStopped = new Promise<void>(resolve => {
stopTool.mockImplementationOnce(async () => resolve());
});
stopTunnel.mockReturnValueOnce(pendingClose);
finishHost.mockImplementationOnce(async () => {
finished();
});
const running = runAsync();
try {
// Both settle while the tunnel close is still held open.
await Promise.all([hostFinished, toolStopped]);
expect(stopTunnel).toHaveBeenCalledTimes(1);
} finally {
release();
await running;
}
});
it('still stops the automation process when event collection stop rejects', async () => {
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
const outcome = await runAsync().then(
() => 'resolved',
(err: Error) => err.message
);
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
});
it('still stops automation when recording finalization rejects', async () => {
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopTunnel).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
const error = new Error(`${phase} failed`);
// A teardown failure must not replace the error that ended the session.
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
if (phase === 'preview') {
openPreview.mockRejectedValueOnce(error);
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
// An agent-device preview failure aborts the daemon task, which then may not start its
// daemon or tunnel. Everything that started must be stopped.
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
expect(stopTool).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
);
expect(stopTunnel).toHaveBeenCalledTimes(
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
'Could not stop the session host during remote session teardown.'
);
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not stop the session host after the remote session failed',
expect.objectContaining({ message: 'recording cleanup failed' }),
{ level: 'warning' }
);
});
});
describe('Appium ffmpeg setup', () => {
async function runAppiumAsync(): Promise<void> {
const fn = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await fn.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env: {
DEVICE_RUN_SESSION_ID: 'session-id',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
NGROK_AUTHTOKEN: 'token',
},
} as never
);
}
function holdFfmpegInstall({ appiumError }: { appiumError?: Error } = {}) {
let finish!: (outcome: 'installed' | 'failed') => void;
const install = new Promise((resolve, reject) => {
finish = outcome =>
outcome === 'installed' ? resolve({}) : reject(new Error('apt-get failed'));
});
jest.mocked(spawn).mockImplementation(((command: string, args: string[]) => {
if (command === 'ffmpeg') {
return Promise.reject(new Error('ffmpeg missing'));
}
if (args.includes('install') && args.includes('ffmpeg')) {
return install;
}
return command === 'npm' && appiumError
? Promise.reject(appiumError)
: Promise.resolve({ stdout: '{}' });
}) as never);
return finish;
}
it.each(['installed', 'failed'] as const)(
'starts Appium after the ffmpeg install settles (%s)',
async outcome => {
const finishFfmpegInstall = holdFfmpegInstall();
const run = runAppiumAsync();
await new Promise(resolve => setImmediate(resolve));
expect(spawnDetached).not.toHaveBeenCalled();
finishFfmpegInstall(outcome);
await run;
expect(spawnDetached).toHaveBeenCalledTimes(1);
}
);
it('waits for the ffmpeg install before failing on an Appium install error', async () => {
const appiumError = new Error('npm failed');
const finishFfmpegInstall = holdFfmpegInstall({ appiumError });
let settled = false;
const run = runAppiumAsync().finally(() => {
settled = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(settled).toBe(false);
finishFfmpegInstall('installed');
await expect(run).rejects.toBe(appiumError);
});
});
@@ -11,6 +11,8 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
@@ -29,11 +31,17 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -61,7 +69,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer;
body: Buffer | Readable;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -71,7 +79,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: Readable.from(body),
body: body instanceof Readable ? body : Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -79,6 +87,47 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -107,6 +156,11 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -255,6 +309,144 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('passes the abort signal to the build lookup', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const buildId = randomUUID();
await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: new AbortController().signal,
});
expect(graphqlClient.query).toHaveBeenCalledWith(
expect.anything(),
{ buildId },
{ fetch: expect.any(Function) }
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ signal: expect.any(Object) })
);
});
it('stops a pending build lookup when aborted', async () => {
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
// hangs until its signal fires, like a stalled API call.
const realFetch = globalThis.fetch;
globalThis.fetch = jest.fn(
(_input: unknown, init?: RequestInit) =>
new Promise<never>((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
})
) as typeof globalThis.fetch;
const graphqlClient = {
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
toPromise: () =>
context.fetch('https://api.expo.dev/graphql', {}).then(
() => ({ data: undefined }),
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
),
})),
} as unknown as Client;
const controller = new AbortController();
const failure = new Error('daemon failed');
try {
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
});
await new Promise(resolve => setImmediate(resolve));
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
controller.abort(failure);
await expect(download).rejects.toBe(failure);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
} finally {
globalThis.fetch = realFetch;
}
});
it('lets a started extraction finish before it reports an abort', async () => {
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
);
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
const controller = new AbortController();
const failure = new Error('daemon failed');
let extractionDirectory: string | undefined;
let extractionFinished = false;
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
extractionDirectory = args.destinationDirectory;
// The startup fails while the archive is being extracted.
controller.abort(failure);
await decompressTarAsync(args);
extractionFinished = true;
});
try {
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
signal: controller.signal,
})
).rejects.toBe(failure);
// The download settled only after the extraction wrote its last file.
expect(extractionFinished).toBe(true);
expect(
await fs.promises.readFile(
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
'utf8'
)
).toBe('i am executable\n');
} finally {
decompressSpy.mockRestore();
}
});
it('does not look up the build when already aborted', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const failure = new Error('boot failed');
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
signal: AbortSignal.abort(failure),
})
).rejects.toBe(failure);
expect(graphqlClient.query).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('rejects missing or ambiguous build sources', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -326,13 +518,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
error: createUndiciNetworkError('ECONNRESET'),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -341,19 +533,81 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
response: { status: 500 },
}),
});
@@ -365,12 +619,14 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await expect(promise).rejects.toBeInstanceOf(SystemError);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -395,10 +651,193 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -424,6 +863,159 @@ describe('downloadBuild', () => {
});
describe('createDownloadBuildFunction', () => {
it('aborts a pending archive request when the step times out', async () => {
let requestAborted = false;
jest.mocked(fetch).mockImplementation(
async (_url, options) =>
await new Promise<Response>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => {
requestAborted = true;
reject(new Error('Archive request aborted'));
});
})
);
const graphqlClient = createMockGraphqlClient({});
const step = createDownloadBuildFunction({
graphqlClient,
} as any).createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
timeoutMs: 100,
}
);
await expect(step.executeAsync()).rejects.toThrow('timed out');
expect(fetch).toHaveBeenCalledTimes(1);
expect(requestAborted).toBe(true);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
describe('with a device run session token in the job env', () => {
const { Response: ActualResponse } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
const workerApiUrl = 'https://api.expo.test/v2/device-run-session-worker/application';
function createSessionCtx(graphqlClient: Client): any {
return {
graphqlClient,
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: { secrets: { robotAccessToken: 'session-token' } } },
}),
{ callInputs }
);
}
it('looks up build_id through the worker API and downloads without an Authorization header', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
}
return {
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response;
});
await expect(
createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
workerApiUrl,
expect.objectContaining({
method: 'GET',
headers: { 'expo-device-run-session-token': 'session-token' },
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
ok: false,
text: () => Promise.resolve('Internal Server Error'),
status: 500,
} as unknown as Response);
await expect(
createStep(createSessionCtx(graphqlClient), {
application_archive_url: APPLICATION_ARCHIVE_URL,
extensions: ['app'],
}).executeAsync()
).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(graphqlClient.query).not.toHaveBeenCalled();
});
});
it('should download a build', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -0,0 +1,192 @@
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createDownloadSubmissionArchiveFunction } from '../downloadSubmissionArchive';
const submissionId = 'aabbccdd-1234-4321-abcd-123456789012';
const url = 'https://storage.example/app.ipa?signature=secret';
it('reports invalid submission ID and extensions together', async () => {
const mutation = jest.fn();
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: 'invalid', extensions: [123] },
});
await expect(step.executeAsync()).rejects.toMatchObject({
issues: expect.arrayContaining([
expect.objectContaining({ path: ['submissionId'] }),
expect.objectContaining({ path: ['extensions', 0] }),
]),
});
expect(mutation).not.toHaveBeenCalled();
});
it('requests a fresh URL on each execution, downloads without auth and does not log the URL', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: url },
},
}),
}));
const logger = createMockLogger();
const fn = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext);
for (let i = 0; i < 2; i++) {
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock({ logger }), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
}
expect(mutation).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(url, { headers: undefined, signal: undefined });
expect(JSON.stringify(jest.mocked(logger.info).mock.calls)).not.toContain(url);
});
it('preserves the API error as the cause without downloading', async () => {
const error = new CombinedError({ networkError: new Error('Connection reset') });
const mutation = jest.fn(() => ({
toPromise: async () => ({
error,
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({
message: 'Could not request the submission archive. Try again later.',
cause: error,
});
expect(fetch).not.toHaveBeenCalled();
expect(mutation).toHaveBeenCalledTimes(4);
});
it('retries a temporary URL request failure and then downloads the archive', async () => {
const mutation = jest
.fn()
.mockReturnValueOnce({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
})
.mockReturnValue({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
url,
body: Readable.from(Buffer.from('IPA bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await step.executeAsync();
expect(mutation).toHaveBeenCalledTimes(2);
expect(await fs.promises.readFile(step.getOutputValueByName('artifact_path')!, 'utf8')).toBe(
'IPA bytes'
);
});
it('does not retry an access error', async () => {
const error = new CombinedError({ graphQLErrors: [{ message: 'Not authorized' }] });
const mutation = jest.fn(() => ({ toPromise: async () => ({ error }) }));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toMatchObject({ cause: error });
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('does not send another URL request after the step times out during retry backoff', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
error: new CombinedError({ networkError: new Error('Connection reset') }),
}),
}));
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
timeoutMs: 100,
});
await expect(step.executeAsync()).rejects.toThrow('timed out');
await new Promise(resolve => setTimeout(resolve, 1100));
expect(mutation).toHaveBeenCalledTimes(1);
expect(fetch).not.toHaveBeenCalled();
});
it('uses the requested extension for an archive without a filename extension', async () => {
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: {
submission: { generateSubmissionArchiveDownloadUrl: 'https://storage.example/archive' },
},
}),
}));
jest.mocked(fetch).mockResolvedValue({
ok: true,
url: 'https://storage.example/archive',
body: Readable.from(Buffer.from('Android archive bytes')),
headers: { get: () => null },
} as unknown as Response);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId, extensions: ['aab'] },
});
await step.executeAsync();
const artifactPath = step.getOutputValueByName('artifact_path')!;
expect(artifactPath).toMatch(/\.aab$/);
expect(await fs.promises.readFile(artifactPath, 'utf8')).toBe('Android archive bytes');
});
it('propagates the original download error', async () => {
const error = new Error('Download failed');
const mutation = jest.fn(() => ({
toPromise: async () => ({
data: { submission: { generateSubmissionArchiveDownloadUrl: url } },
}),
}));
jest.mocked(fetch).mockRejectedValue(error);
const step = createDownloadSubmissionArchiveFunction({
graphqlClient: { mutation } as unknown as Client,
} as CustomBuildContext).createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: { submission_id: submissionId },
});
await expect(step.executeAsync()).rejects.toBe(error);
});
@@ -463,114 +463,127 @@ describe('createInstallMaestroBuildFunction', () => {
}
});
it('installs the prebuilt WDA cache for each available iOS runtime', async () => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: 'maestro-runner 1.2.3\n' };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
it.each(['1.1.27', '1.1.28', '1.2.3'])(
'prepares the WDA cache only for legacy Runner versions (%s)',
async version => {
const homeDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'install-maestro-test-')
);
for (const runtimeVersion of ['18.3', '26.0']) {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'maestro-runner') {
return { stdout: `maestro-runner ${version}\n` };
}
if (command === 'xcrun') {
return {
stdout: JSON.stringify({
runtimes: [
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3',
isAvailable: true,
version: '18.3.1',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-0',
isAvailable: true,
version: '26.0',
},
{
identifier: 'com.apple.CoreSimulator.SimRuntime.iOS-17-5',
isAvailable: false,
version: '17.5',
},
],
}),
};
}
if (command === 'xcodebuild') {
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
}
if (command === 'tar') {
const maestroRunnerHome = args[args.indexOf('-C') + 1];
const productsDirectory = path.join(
maestroRunnerHome,
'cache',
'wda-builds',
'generic',
'DerivedData',
'Build',
'Products'
);
await fs.promises.mkdir(productsDirectory, { recursive: true });
await fs.promises.writeFile(
path.join(productsDirectory, 'WebDriverAgentRunner.xctestrun'),
'cached'
);
}
return { stdout: '' };
}) as any);
try {
await writeInstalledWdaVersion(homeDirectory, '11.1.3');
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com',
HOME: homeDirectory,
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe(version);
if (version !== '1.1.27') {
expect(mockedDownloadFile).not.toHaveBeenCalled();
expect(mockedSpawn.mock.calls.map(([command]) => command)).not.toContain('xcrun');
await expect(
fs.promises.access(path.join(homeDirectory, '.maestro-runner', 'cache'))
).rejects.toThrow();
return;
}
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.0-wda-11.1.3.tar.gz',
expect.stringMatching(/install_maestro_runner_wda_cache.*\/wda-cache\.tar\.gz$/),
{ retry: 3, timeout: 20_000 }
);
for (const runtimeVersion of ['18.3', '26.0']) {
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
)
).resolves.toBe('cached');
}
await expect(
fs.promises.readFile(
path.join(
homeDirectory,
'.maestro-runner',
'cache',
'wda-builds',
`sim-ios${runtimeVersion}-iphone`,
'DerivedData',
'Build',
'Products',
'WebDriverAgentRunner.xctestrun'
),
'utf8'
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).resolves.toBe('cached');
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
await expect(
fs.promises.access(
path.join(homeDirectory, '.maestro-runner', 'cache', 'wda-builds', 'sim-ios17.5-iphone')
)
).rejects.toThrow();
} finally {
await fs.promises.rm(homeDirectory, { force: true, recursive: true });
}
});
);
it('does not download the WDA cache when the installed WDA version is unknown', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.2.3\n',
command === 'xcodebuild' ? 'Xcode 26.5\nBuild version 17F90\n' : 'maestro-runner 1.1.27\n',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock({
@@ -593,7 +606,7 @@ describe('createInstallMaestroBuildFunction', () => {
mockedSpawn.mockImplementation((async (command: string) => {
switch (command) {
case 'maestro-runner':
return { stdout: 'maestro-runner 1.2.3\n' };
return { stdout: 'maestro-runner 1.1.27\n' };
case 'xcodebuild':
return { stdout: 'Xcode 26.5\nBuild version 17F90\n' };
case 'xcrun':
@@ -633,7 +646,7 @@ describe('createInstallMaestroBuildFunction', () => {
});
await expect(step.executeAsync()).resolves.toBeUndefined();
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.27');
expect(mockedDownloadFile.mock.calls.map(([url]) => url)).toEqual([
'https://cache.example.com/storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
'https://storage.googleapis.com/turtle-v2/maestro-runner-wda-cache/xcode-26.5-wda-11.1.3.tar.gz',
@@ -0,0 +1,151 @@
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { fs, vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
// An x86 Homebrew prefix, to check that the app is found from PATH and not from process.arch.
const APP = '/usr/local/Caskroom/mitmproxy/12.2.3/mitmproxy.app';
const LINK = '/usr/local/bin/mitmdump';
function installCask(): void {
vol.fromJSON({ [`${APP}/Contents/MacOS/mitmdump`]: '', '/usr/local/bin': null });
fs.symlinkSync(`${APP}/Contents/MacOS/mitmdump`, LINK);
}
/**
* `which` finds mitmdump once the cask is installed, `brew` installs it, `xattr -p` reports
* quarantine while `stuck` is set, and each `mitmdump --version` takes the next result.
*/
function mockCommands({
installed = false,
stuck = false,
runs = [],
}: {
installed?: boolean;
stuck?: boolean;
runs?: boolean[];
}): void {
if (installed) {
installCask();
}
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
if (command === 'which' && !installed) {
throw new Error('not found');
}
if (command === 'which') {
return { stdout: `${LINK}\n` };
}
if (command === 'brew' && !installed) {
installed = true;
installCask();
}
if (command === 'xattr' && args[0] === '-p' && !stuck) {
throw new Error('No such xattr: com.apple.quarantine');
}
if (command === 'mitmdump' && !runs.shift()) {
throw new Error('not found');
}
return { stdout: '' };
}) as never);
}
function spawnedCommands(): string[] {
return mockedSpawn.mock.calls.map(([command, args]) =>
command === 'xattr' ? `xattr ${args?.[0]}` : command
);
}
describe('createInstallMitmproxyBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createStep(env: Record<string, string>): BuildStep {
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
globalCtx.updateEnv(env);
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
}
it('takes the app behind mitmdump on PATH out of quarantine before running it', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'xattr',
['-dr', 'com.apple.quarantine', APP],
expect.anything()
);
expect(spawnedCommands()).toEqual(['which', 'xattr -dr', 'xattr -p', 'mitmdump']);
});
it('kills a mitmdump --version that does not return', async () => {
mockCommands({ installed: true, runs: [true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'mitmdump',
['--version'],
expect.objectContaining({ stdio: 'ignore', timeout: 60_000, killSignal: 'SIGKILL' })
);
});
it('does not run a mitmdump that stays quarantined', async () => {
mockCommands({ installed: true, stuck: true });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
expect(spawnedCommands()).not.toContain('mitmdump');
});
it('does not change quarantine outside EAS Build VMs', async () => {
mockCommands({ installed: true, stuck: true, runs: [true] });
await createStep({}).executeAsync();
expect(spawnedCommands()).toEqual(['mitmdump']);
});
it('installs the cask with Homebrew and takes it out of quarantine', async () => {
mockCommands({ runs: [false, true] });
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'brew',
['install', '--cask', 'mitmproxy'],
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
);
expect(spawnedCommands()).toEqual([
'which',
'mitmdump',
'brew',
'which',
'xattr -dr',
'xattr -p',
'mitmdump',
]);
});
it('does not fail the job when mitmdump is still not runnable', async () => {
mockCommands({ runs: [false, false] });
await expect(
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
).resolves.toBeUndefined();
});
});
@@ -1,5 +1,6 @@
import { BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { spawnSync } from 'node:child_process';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
@@ -179,17 +180,7 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'-n',
'host.exp.exponent/host.exp.exponent.MainActivity',
],
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
@@ -211,6 +202,263 @@ describe(launchApplicationAsync, () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('quotes Android launch arguments and URLs for the device shell', async () => {
const logger = createMockLogger();
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity',
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
openUrl: 'exp://example.test/path?a=1&b=2',
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger,
});
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'shell',
'am',
'start',
'-d',
'euxy://tempo',
'--es',
'probe',
"'hello world'",
'--es',
'quote',
"'it'\\''s'",
'-n',
'dev.example.app/dev.example.app.MainActivity',
],
{ env: {}, logger }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
"'exp://example.test/path?a=1&b=2'",
],
{ env: {}, logger }
);
expect(logger.info).toHaveBeenNthCalledWith(
1,
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
);
});
it('passes iOS launch arguments with spaces unquoted', async () => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
launchArgs: ['-name', 'hello world'],
runtimePlatform: BuildRuntimePlatform.DARWIN,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'xcrun',
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
expect.any(Object)
);
});
it('round-trips quoted Android arguments through a POSIX shell', async () => {
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
const launchArgs = [
'--es',
'text',
'hello world',
'--es',
'json',
'{"a":1,"b":2}',
'--es',
'shell',
'$(printf injected) `printf injected` $HOME ; & | < >',
'--es',
'glob',
'*',
'--es',
'home',
'~',
'--esa',
'list',
'a\\,b,c',
'--es',
'whitespace',
'one\ntwo\tthree',
'--es',
'unicode',
'héllo 🌍',
'--es',
'empty',
'',
];
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
await launchApplicationAsync({
applicationIdentifier: 'dev.example.app',
activityName: 'dev.example.app.MainActivity$Inner',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
// adb joins the words after `shell` with spaces and the device shell parses the result.
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
const { stdout } = spawnSync(
'/bin/sh',
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
{ encoding: 'utf8' }
);
return stdout.split('\0').slice(0, -1);
});
expect(parsedCommands).toEqual([
['start', ...launchArgs, '-n', component],
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
]);
});
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
'passes empty Android option values as empty arguments (%j)',
async launchArgs => {
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
'-n',
'com.example.app/com.example.app.MainActivity',
],
expect.any(Object)
);
}
);
it('explains an Android launch failure when a launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true', ''],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
message: expect.stringContaining('empty argument at position 4'),
cause: launchError,
});
});
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
const launchError = new Error('adb shell am start exited with non-zero code: 1');
mockedSpawn.mockRejectedValueOnce(launchError);
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--ez', 'isTest', 'true'],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toBe(launchError);
});
it.each([
{ launchArgs: ['--es', 'key', 'a\0b'] },
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs,
openUrl,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
expect.arrayContaining([expect.stringContaining('\0')]),
expect.any(Object)
);
});
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
await launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
});
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android URL command of 65,001 bytes', async () => {
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('rejects an Android launch command that is too long after quoting', async () => {
await expect(
launchApplicationAsync({
applicationIdentifier: 'com.example.app',
activityName: 'com.example.app.MainActivity',
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
runtimePlatform: BuildRuntimePlatform.LINUX,
env: {},
logger: createMockLogger(),
})
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('launches using application metadata passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
@@ -240,16 +488,36 @@ describe(launchApplicationAsync, () => {
],
expect.any(Object)
);
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'exp://example.test'],
expect.any(Object)
);
});
it('quotes Android launch arguments passed to the build step', async () => {
const launchApplication = createLaunchApplicationFunction();
const buildStep = launchApplication.createBuildStepFromFunctionCall(
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
{
callInputs: {
application_identifier: 'com.example.app',
activity_name: 'com.example.app.MainActivity',
launch_args: ['--es', 'probe', 'hello world'],
},
}
);
await buildStep.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
'adb',
[
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
'exp://example.test',
'--es',
'probe',
"'hello world'",
'-n',
'com.example.app/com.example.app.MainActivity',
],
@@ -1,4 +1,4 @@
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('rejects sharding with maestro-runner', async () => {
it('passes shards to maestro-runner as parallel devices', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
@@ -264,26 +266,128 @@ describe('createMaestroTestsBuildFunction', () => {
shards: 2,
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner does not support EAS Maestro test sharding'
);
expect(mockedSpawn).not.toHaveBeenCalled();
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
});
it('rejects a non-junit output_format with maestro-runner', async () => {
it.each(['html', 'allure'])(
'accepts %s output_format with maestro-runner and keeps JUnit',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: format,
});
await step.executeAsync();
expect(mockedSpawn.mock.calls[0][1]).toEqual(
expect.arrayContaining([
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
])
);
expect(copyFileSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
);
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
expect(step.getOutputValueByName('final_report_path')).toBe(
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
);
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
format === 'html'
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
],
}),
})
);
}
);
it('does not expose a selected runner report before an attempt starts', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
flow_path: [],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/home/expo/.maestro/tests/junit-reports'
);
});
it('rejects an unknown output_format with maestro-runner', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'csv',
});
await expect(step.executeAsync()).rejects.toThrow(
'maestro-runner only supports the "junit" output_format'
'maestro-runner supports "junit", "html", and "allure" output_format values'
);
expect(mockedSpawn).not.toHaveBeenCalled();
});
it('uploads the last runner HTML report even when the test fails', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
jest.spyOn(fs, 'copyFile').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'html',
retries: 1,
retry_failed_only: false,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro Runner HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
}),
})
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
);
});
it('does not fail a passing test when the runner report upload fails', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
const step = createStep({
flow_path: ['flows/a.yaml'],
platform: 'android',
backend: 'maestro-runner',
output_format: 'allure',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('logs that maestro-runner does not support direct DADB', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
@@ -965,7 +1069,7 @@ describe('createMaestroTestsBuildFunction', () => {
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
@@ -976,11 +1080,118 @@ describe('createMaestroTestsBuildFunction', () => {
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/home/expo/.maestro/tests/android-maestro-html.html'
);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
})
);
});
it('warns that Maestro CLI does not support Allure reports', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'allure',
platform: 'android',
},
{ logger }
);
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(logger.warn).toHaveBeenCalledWith(
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
mockedSpawn.mockRejectedValue(rejectExit1());
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
retries: 1,
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(
removeSpy.mock.calls.filter(
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
)
).toHaveLength(2);
expect(removeSpy).toHaveBeenNthCalledWith(
1,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy).toHaveBeenNthCalledWith(
2,
'/home/expo/.maestro/tests/android-maestro-html.html',
{
force: true,
}
);
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[0]
);
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
mockedSpawn.mock.invocationCallOrder[1]
);
});
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
jest.spyOn(fs, 'rm').mockImplementation(async target => {
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
throw new Error('permission denied');
}
});
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(SystemError);
expect(mockedSpawn).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
@@ -1207,7 +1418,6 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -1215,28 +1425,31 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('discovers maestro-runner flow results from the attempt report directory', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
it.each(['junit', undefined])(
'uploads runner screenshots with output_format=%s',
async format => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
jest.spyOn(fs, 'copyFile').mockResolvedValue();
mockedRunnerHarvest.mockResolvedValue([shot]);
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
});
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
output_format: 'junit',
backend: 'maestro-runner',
});
await step.executeAsync();
const step = createStep({
flow_path: ['a.yaml'],
platform: 'ios',
...(format === undefined ? {} : { output_format: format }),
backend: 'maestro-runner',
});
await step.executeAsync();
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
}
);
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
@@ -1300,14 +1513,15 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', async () => {
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
await step.executeAsync();
expect(mockedHarvest).not.toHaveBeenCalled();
expect(mockUploadArtifact).not.toHaveBeenCalled();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { isProcessDescendantOfAsync } from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
monitorLocalEgressAsync,
readLocalEgressHandoffAsync,
@@ -14,11 +15,16 @@ import {
stopLocalEgressResourcesAsync,
} from '../../utils/localEgress';
import {
startDeviceWebPreviewWithTunnelAsync,
reportLocalEgressGuardCoverageAsync,
resolveLocalEgressServeSimBootEnvironmentAsync,
startLocalEgressGuardRelayAsync,
verifyLocalEgressGuardAsync,
} from '../../utils/localEgressGuard';
import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
@@ -27,7 +33,14 @@ jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/processes');
jest.mock('../../../utils/turtleFetch');
jest.mock('../../utils/deviceSessionHost');
jest.mock('../../utils/agentDeviceArtifacts');
jest.mock('../startIosSimulator', () => ({
...jest.requireActual('../startIosSimulator'),
bootIosSimulatorAsync: jest
.fn()
.mockResolvedValue({ deviceIdentifier: 'sim', udid: 'sim', displayName: 'iPhone' }),
}));
jest.mock('../../utils/argentArtifacts');
jest.mock('../../utils/agentDeviceEvents', () => ({
startAgentDeviceEventCollectionAsync: async () => ({ stopAsync: jest.fn() }),
@@ -44,7 +57,15 @@ jest.mock('../../utils/localEgress', () => ({
readLocalEgressHandoffAsync: jest.fn(),
monitorLocalEgressAsync: jest.fn(),
}));
jest.mock('../../utils/localEgressGuard', () => ({
...jest.requireActual('../../utils/localEgressGuard'),
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn(),
startLocalEgressGuardRelayAsync: jest.fn(),
reportLocalEgressGuardCoverageAsync: jest.fn(),
verifyLocalEgressGuardAsync: jest.fn(),
}));
jest.mock('../../utils/remoteDeviceRunSession', () => ({
...jest.requireActual('../../utils/remoteDeviceRunSession'),
getDeviceRunSessionIdOrThrow: () => 'session-id',
getNgrokTunnelDomainOrThrow: () => 'example.test',
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
@@ -52,13 +73,20 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
ensureFfmpegInstalledOnceAsync: jest.fn(),
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
startNgrokTunnelAsync: async () => ({
url: 'https://controller.test',
subdomainId: 'controller-id',
stopAsync: jest.fn(),
}),
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
const logger = {
info: jest.fn(),
warn: jest.fn(),
child: jest.fn().mockReturnThis(),
} as unknown as bunyan;
const ctx = {} as CustomBuildContext;
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
@@ -69,7 +97,7 @@ const fields = {
egressPort: handoff.port,
};
const controllers = [
['agent-device', createStartAgentDeviceRemoteSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['agent-device', createStartAgentDeviceSessionBuildFunction, 'agentDeviceRemoteSessionUrl'],
['Appium', createStartAppiumRemoteSessionBuildFunction, 'appiumUrl'],
['Argent', createStartArgentRemoteSessionBuildFunction, 'toolsUrl'],
['web preview', createStartWebPreviewRemoteSessionBuildFunction, 'previewUrl'],
@@ -77,16 +105,17 @@ const controllers = [
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
const stopPreview = jest.fn();
const openPreview = jest.fn();
const stopEgress = jest.fn();
let lifetime: AbortSignal;
async function run(signal?: AbortSignal): Promise<void> {
await createFunction(ctx).fn!(
{ logger, global: { runtimePlatform: BuildRuntimePlatform.DARWIN } } as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy({} as Record<string, { value: unknown }>, {
get: (target, id: string) => target[id] ?? { value: undefined },
}),
outputs: {},
env,
signal,
@@ -110,14 +139,31 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.mocked(isProcessDescendantOfAsync).mockResolvedValue(true);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'sim' }] as never);
.mockResolvedValue([{ udid: 'sim', name: 'iPhone 17' }] as never);
jest.mocked(IosSimulatorUtils.resolveUdidAsync).mockResolvedValue('sim' as never);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
jest
.mocked(resolveLocalEgressServeSimBootEnvironmentAsync)
.mockImplementation(async () =>
(await readLocalEgressHandoffAsync())
? { SERVE_SIM_ADDITIONAL_DYLIBS: '/test/egress-guard.dylib' }
: null
);
jest.mocked(startLocalEgressGuardRelayAsync).mockResolvedValue(undefined);
jest.mocked(reportLocalEgressGuardCoverageAsync).mockResolvedValue(null);
jest.mocked(verifyLocalEgressGuardAsync).mockResolvedValue(undefined);
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
previewUrl: 'https://preview.test',
openPreview.mockResolvedValue({
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
apiUrl: 'https://preview.test',
previewToken: 'preview-secret',
stopAsync: stopPreview,
closeAsync: jest.fn(),
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: openPreview,
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
@@ -163,12 +209,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopEgress).not.toHaveBeenCalled();
});
it.each(['startup', 'report', 'wait', 'teardown'])(
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
'releases egress after %s fails',
async phase => {
const error = new Error(`${phase} failed`);
if (phase === 'startup') {
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
} else if (phase === 'preview') {
openPreview.mockRejectedValue(error);
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
@@ -180,7 +228,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
phase === 'startup' || phase === 'report' ? 0 : 1
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
);
}
);
Loaded 100 of 664 files, more files were not shown because too many files have changed in this diff. Show more