Compare commits

...
193 Commits
Author SHA1 Message Date
Expo CI a62289b988 v21.0.1 2026-07-15 06:42:00 +00:00
Brent Vatne 78c0584331 [eas-cli] Add -p alias for --platform in simulator:start (#3902) 2026-07-14 15:46:37 -07:00
Brent Vatne c4a7b7c9f1 [eas-cli] Add eas project:delete command (#4012) 2026-07-14 09:52:30 -07:00
Ash Wu f448bc75b5 [build-tools] unify workflows hook (#3957)
* [eas-build-job] Add hook anchors registry and step stamp fields

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

* [steps] Add hook insertion, after-hook gate, and source function retention

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

* [build-tools] Pass job hooks into the steps parser

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-07-14 15:31:11 +08:00
Brent VatneandDouglas Lowder 0dda7ba880 [eas-cli] Add --dev-domain flag to eas deploy (#4004)
Co-authored-by: Douglas Lowder <[email protected]>
2026-07-13 20:26:02 -07:00
Jon SampandDouglas Lowder 9679af9670 [eas-cli] Add eas browse command (#4003)
* [eas-cli] Add `eas browse` command

Add `eas browse [PAGE]` to open the project page on expo.dev, with an
optional whitelisted page argument (e.g. `build`, `hosting`, `cicd`) for
project subpages and a `--no-browser` flag to print the URL instead.
`eas open` becomes a thin alias that opens the project dashboard.

* [eas-cli] Update changelog PR link

* [eas-cli] Add `observe` page to `eas browse`

* [eas-cli] Support --json and --non-interactive in eas browse, hide eas open

* Fix CHANGELOG

---------

Co-authored-by: Douglas Lowder <[email protected]>
2026-07-13 17:44:19 -05:00
Szymon Dziedzic 3e0e823e56 Bump tar (#3971)
* Bump tar

* Add changelog for tar bump
2026-07-13 21:28:20 +02:00
Expo CI fbb8af6fca update CHANGELOG.md 2026-07-13 17:25:08 +00:00
Expo CI c72cee372f v21.0.0 2026-07-13 17:21:41 +00:00
Szymon Dziedzic 73b2a716da [eas-cli] Bump node-forge (#3970)
* [eas-cli] Bump node-forge

* [eas-cli] Add changelog for node-forge bump
2026-07-13 18:54:28 +02:00
Gabe Debes 55191f9697 [build-tools] Add eas/posthog_wait_for_query workflow function (#3949)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric

* [build-tools] Add eas/posthog_upload_sourcemaps workflow function

* [build-tools] use PosthogUtils namespace in sourcemap upload

* [build-tools] name missing credentials in sourcemap upload

* [build-tools] use credentials-error factory in sourcemap upload

* [build-tools] use PosthogUtils.missingCredentialsError in sourcemap upload

* [build-tools] pass abort signal to the sourcemap upload subprocess

* [build-tools] Add eas/posthog_annotation workflow function

* [build-tools] use PosthogUtils namespace in annotation

* [build-tools] name missing credentials in annotation

* [build-tools] use credentials-error factory in annotation

* [build-tools] use PosthogUtils.missingCredentialsError in annotation

* [build-tools] thread abort signal through annotation request

* [build-tools] Add eas/posthog_wait_for_query workflow function

* [build-tools] respect abort signal while waiting for query

* [build-tools] name missing credentials when waiting for query

* [build-tools] share poll helpers and thread signal (query)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for query
2026-07-13 09:32:03 -07:00
Szymon Dziedzic a08beb6392 [codex] fix form-data dependabot alert (#3962)
* fix form-data dependabot alert

* add changelog for form-data security fix
2026-07-13 18:29:07 +02:00
Szymon Dziedzic 643cc9e387 [codex] fix shell-quote dependabot alert (#3959)
* fix shell-quote dependabot alert

* add changelog for shell-quote security fix
2026-07-13 18:06:12 +02:00
Gabe Debes 7c44d5515a [build-tools] Add eas/posthog_annotation workflow function (#3948)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric

* [build-tools] Add eas/posthog_upload_sourcemaps workflow function

* [build-tools] use PosthogUtils namespace in sourcemap upload

* [build-tools] name missing credentials in sourcemap upload

* [build-tools] use credentials-error factory in sourcemap upload

* [build-tools] use PosthogUtils.missingCredentialsError in sourcemap upload

* [build-tools] pass abort signal to the sourcemap upload subprocess

* [build-tools] Add eas/posthog_annotation workflow function

* [build-tools] use PosthogUtils namespace in annotation

* [build-tools] name missing credentials in annotation

* [build-tools] use credentials-error factory in annotation

* [build-tools] use PosthogUtils.missingCredentialsError in annotation

* [build-tools] thread abort signal through annotation request
2026-07-13 09:00:07 -07:00
Gabe Debes 6fd172ee30 [build-tools] Add eas/posthog_upload_sourcemaps workflow function (#3947)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric

* [build-tools] Add eas/posthog_upload_sourcemaps workflow function

* [build-tools] use PosthogUtils namespace in sourcemap upload

* [build-tools] name missing credentials in sourcemap upload

* [build-tools] use credentials-error factory in sourcemap upload

* [build-tools] use PosthogUtils.missingCredentialsError in sourcemap upload

* [build-tools] pass abort signal to the sourcemap upload subprocess
2026-07-13 08:53:19 -07:00
Gabe Debes 4edf0dc547 [build-tools] Add eas/posthog_wait_for_metric workflow function (#3945)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests

* [build-tools] Add eas/posthog_wait_for_metric workflow function

* [build-tools] respect abort signal while waiting for metric

* [build-tools] name missing credentials when waiting for metric

* [build-tools] share poll helpers, harden operator guard, thread signal (metric)

* [build-tools] use PosthogUtils.missingCredentialsError when waiting for metric
2026-07-13 08:46:03 -07:00
Gabe Debes f82bdb57e6 [build-tools] Add eas/posthog_flag_rollout workflow function (#3944)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests

* [build-tools] Add eas/posthog_flag_rollout workflow function

* [build-tools] use PosthogUtils namespace in flag rollout

* [build-tools] name missing credentials in flag rollout

* [build-tools] use credentials-error factory in flag rollout

* [build-tools] use PosthogUtils.missingCredentialsError in flag rollout

* [build-tools] thread abort signal through flag rollout requests
2026-07-13 08:39:43 -07:00
Szymon Dziedzic 9641735781 [codex] fix fast-xml-parser dependabot alert (#3960)
* fix fast-xml-parser dependabot alert

* add changelog for fast-xml-parser security fix
2026-07-13 17:33:25 +02:00
Gabe Debes 1ce05e3b28 [build-tools] Add eas/posthog_capture_event workflow function (#3934)
* [build-tools] Add eas/posthog_capture_event workflow function

* [build-tools] address PostHog client review nits

* [build-tools] report which PostHog credentials are missing

* [build-tools] add poll helpers, credentials-error factory, query abort signal

* [build-tools] move credential catalog and error into PosthogUtils

* [build-tools] accept an abort signal on all PostHog client requests
2026-07-13 08:33:06 -07:00
Szymon Dziedzic b2f34e5c03 [codex] fix fast-xml-builder dependabot alert (#3966)
* fix fast-xml-builder dependabot alert

* add changelog for fast-xml-builder security fix
2026-07-13 17:04:09 +02:00
Stanisław Chmiela 8204d18951 [build-tools] Add simulator details to screen recording metadata (#4000)
## Summary

- include the simulator device name and human-readable runtime name in Device Run Session screen recording metadata
- carry those fields through the finish-step output and construct the artifact display name only during upload
- normalize CoreSimulator runtime identifiers such as `iOS-18-6` to `iOS 18.6`
- add focused coverage for runtime normalization and the uploaded metadata payload

## Why

The website needs device and runtime details to identify and present EAS-generated simulator screen recordings without parsing artifact names or CoreSimulator identifiers.

## Validation

- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/build-tools jest-unit --runInBand src/utils/__tests__/IosSimulatorUtils.test.ts src/steps/functions/__tests__/uploadDeviceRunSessionScreenRecordings.test.ts`
2026-07-13 15:22:40 +02:00
Szymon Dziedzic 69024620f9 Bump cross-spawn in steps fixture (#3977)
* Bump cross-spawn in steps fixture

* Add changelog entry for cross-spawn bump
2026-07-13 14:51:04 +02:00
Szymon Dziedzic c9442988c7 Bump Koa dependency (#3974)
* Bump Koa dependency

* Add changelog entry for Koa bump
2026-07-13 13:31:49 +02:00
Szymon Dziedzic 2a258e80a9 Bump jws in root lockfile (#3978)
* Bump jws in root lockfile

* Add changelog entry for jws bump
2026-07-13 13:11:51 +02:00
Stanisław Chmiela 4b1f785abe [build-tools] Handle detached Android emulator exits (#3998)
## Summary

Observe the detached Android emulator process promise immediately after startup so expected exits during retry cleanup do not surface as unhandled promise rejections.

The emulator is managed through adb after launch. When a startup attempt times out and cleanup kills the emulator, the underlying SpawnPromise can reject while the retry loop is handling the readiness failure. Observing the promise keeps Sentry from capturing that cleanup path as an unhandled SIGKILL while preserving the returned promise for callers that still await it later.

## Test plan

- yarn run -T oxfmt packages/build-tools/src/utils/AndroidEmulatorUtils.ts
- yarn workspace @expo/build-tools typecheck
2026-07-10 17:39:39 +02:00
Stanisław Chmiela 2e2e8ffacc [build-tools] Fix Argent tool server state discovery (#3997)
# Why

Argent now writes per-install tool-server state files such as `~/.argent/tool-server-<hash>.json`. The remote session step only watched the legacy `tool-server.json`, so it could time out even when the newly launched tool server was running.

# How

- expose the PID of detached processes
- add a process ancestry helper that works on macOS and Linux
- run Argent in the foreground beneath the detached `bunx` process and pass `--force`
- scan legacy and hashed Argent state files, selecting only the state whose PID descends from the launched `bunx` process
- validate the state schema includes the tool-server PID
- cover legacy, hashed, matching, and unrelated state files

# Test Plan

- `yarn workspace @expo/build-tools jest-unit packages/build-tools/src/steps/functions/__tests__/startArgentRemoteSession.test.ts --runInBand`
- `yarn workspace @expo/build-tools build`
- `yarn workspace @expo/worker build`
- `yarn workspace eas-cli build`
- `yarn workspace @expo/build-tools typecheck`
- `yarn lint`
- `yarn fmt:check`
2026-07-10 15:57:48 +02:00
Stanisław Chmiela 708bddd2ad [build-tools] Add screen recording metadata marker (#3996)
Didn't realize haven't pushed this commit before merging.

https://github.com/expo/eas-cli/pull/3994
2026-07-10 15:29:55 +02:00
Stanisław Chmiela 6d02728a25 [build-tools] Wire record-sim into EAS functions (#3994)
## Summary

- add separate `eas/start_ios_simulator_recordings` and `eas/finish_ios_simulator_recordings` build functions
- poll booted iOS Simulators and record each session with `record-sim`
- retry failed recorder processes up to three times per simulator boot
- upload completed MP4 files as screen-recording device run session artifacts with recording metadata
- extend device run session artifact uploads to accept metadata

## Why

Device run sessions need simulator screen recordings that can start independently from remote sessions and be finalized and uploaded when the build workflow finishes.

## Impact

Workflows can explicitly start simulator recording polling and later finish and upload recordings. Missing `record-sim` binaries or device run session IDs disable recording or upload without failing the workflow.

## Validation

- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/build-tools jest-unit steps/utils/__tests__/deviceRunSessionArtifacts.test.ts --runInBand`
- `yarn oxfmt --check` on all changed files
- `git diff --check origin/main...HEAD`
2026-07-10 15:22:59 +02:00
Douglas Lowder 9a94201ce5 [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding (#3989)
# Why

`syncCapabilitiesForEntitlementsAsync` in bundleIdCapabilities.ts wrapped `bundleId.updateBundleIdCapabilityAsync` in a try/catch whose block only rethrew for the specific error "bundle '…' cannot be deleted. Delete all the Apps". Every other Apple API error was silently swallowed while the function still returned the intended enabled/disabled lists — leading to undebuggable signing failures downstream. (Reported in issue #3986 against 18.12.3 and 20.5.1.)

# How

Rethrow every non-matching error with the same actionable context we already gave the "cannot be deleted" case — the Apple developer console URL and the EXPO_NO_CAPABILITY_SYNC=1 escape hatch — and preserve the underlying Apple error via `.cause` so EXPO_DEBUG=1 still surfaces the raw response. Log the failing request payload for all failures, not just the matched one, so future variants are debuggable from CLI output.
    
Also fix the "developer-mdn.apple.com" typo in the existing error message (the correct host is developer.apple.com).

Note that capability sync will not work correctly until the fix in https://github.com/expo/third-party/pull/161 is merged and released.

# Test Plan

- Expand the existing "throw useful error message" test to cover generic Apple errors
- Correct expected URL in test assertion
2026-07-10 05:57:41 -07:00
Szymon Dziedzic 6189ae9ed3 [eas-cli] Expose simulator session artifacts in get (#3991) 2026-07-10 13:27:52 +02:00
Stanisław Chmiela 9d373d9778 [build-tools] Fix iOS Simulator apsd disable for iOS 26.5 (#3988)
## Summary

Fixes the iOS Simulator `apsd` shutdown helper to use the launchd domain requested by iOS 26.5:

- disables and boots out `user/foreground/com.apple.apsd` first
- falls back to `system/com.apple.apsd` for compatibility
- verifies whether the service is still `state = running`
- treats `launchctl print` returning service-not-found as a successful stopped state

## Root cause

The previous implementation used `system/com.apple.apsd`. On iOS 26.5, `launchctl` warns to switch to `user/foreground/com.apple.apsd`, and workflow logs showed `bootout system/com.apple.apsd` exiting non-zero. That made a successful or partially successful disable look like a failure and could leave `apsd` running.

## Validation

- `yarn test packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts`
- `yarn typecheck` in the original checkout
- `git diff --check`

I also verified locally on a disposable iOS 26.5 simulator clone that `disable` + `bootout` with `user/foreground/com.apple.apsd` stops the service, and subsequent `launchctl print` returns service-not-found.
2026-07-09 16:41:34 +00:00
Byron Karlen 43662ca9f5 [eas-cli] Remove unused eas onboarding command (#3993) 2026-07-09 10:41:04 -06:00
Jon Samp cc42fdc28d Improves workflow:create command (#3943)
* improves create command

* save

* fable review

* removes question

* dont prompt for application bundle id when not needed

* [eas-cli] Exit non-zero when workflow:create fails

* [eas-cli] Simplify project configuration in workflow:create

* [eas-cli] Document why workflow:create skips owner/slug consistency check
2026-07-08 14:51:20 -05:00
Douglas Lowder 0727ae467a [eas-cli] Accept navigation metric aliases as positional args to observe:metrics (#3973)
# Why

When running `observe:metrics` in non-interactive mode, navigation metric names could not be passed into the command; only app startup metrics were accepted.

# How

- Included the navigation metrics in the validation check
- Renamed the short names (keys) for the navigation metrics to avoid confusion with app startup metric short names

# Test Plan

- Tested locally
- Unit tests added and updated
2026-07-08 05:17:35 -07:00
Gabe Debes fc0ebff305 [eas-cli] Open the PostHog dashboard via a signed-in deep link (#3975)
# Why

`eas integrations:posthog:dashboard` should open the linked PostHog project already signed in, instead of a static URL that hits a login wall.

# How

Mints a one-time, signed-in link via the new `createPostHogDeepLink` mutation and opens it. The link carries a single-use token, so it isn't printed by default; pass `--show-link` to print it. `--json` / `--non-interactive` return the stable static URL, which minting failures also fall back to.

# Test Plan

Unit tests + verified end-to-end with `easd`

<img width="1548" height="484" alt="carbon (5)" src="https://github.com/user-attachments/assets/c00961e5-da49-456c-af1e-c4a2700eece4" />
2026-07-07 15:39:51 -07:00
David Mokos f89e9ddef0 [cli] add agent detection to telemetry (#3983)
Ports the Expo CLI agent telemetry integration from https://github.com/expo/expo/pull/47248 to EAS CLI.

Adds `agent-cli-detector` to EAS CLI telemetry so events recorded from known coding agents include lightweight agent context. The CLI uses `[email protected]`, which exposes a CommonJS entry point, so detection stays synchronous and does not participate in telemetry initialization.

When a known agent is detected, telemetry event context includes:

```ts
context.agent = {
  id: string,
  sessionId: string | undefined,
}
```

When no known agent is detected, `context.agent` is omitted to keep RudderStack and BigQuery payloads sparse. CLI version is already available in telemetry, so analysis can distinguish newer CLI versions with no detected agent from older CLI versions without this integration.

Tests cover detected and undetected telemetry payloads. Locally verified with `yarn workspace eas-cli test src/analytics/__tests__/AnalyticsManager-test.ts --runInBand`, `yarn workspace eas-cli typecheck`, `yarn lint-changelog`, and targeted `oxlint`.
2026-07-07 16:53:26 +02:00
Szymon Dziedzic b9d59e2628 Bump glob in root lockfile (#3979)
* Bump glob in root lockfile

* Add changelog entry for glob bump
2026-07-07 15:20:18 +02:00
Szymon Dziedzic 502f713467 Remove stale changelog PR template instruction (#3984) 2026-07-07 15:09:12 +02:00
Stanisław Chmiela 48bdd42bc3 [build-tools] Wait for remote sessions to stop cleanly (#3946)
## Summary

- replace the remote device session infinite waits with polling for the device run session to become `STOPPED`
- let agent-device, Argent, and serve-sim session steps return normally after the stop signal so the worker can report success and the launcher can close the VM
- make Argent artifact polling stoppable, drain one final artifact list on shutdown, and wait briefly for pending uploads

## Why

Stopping a device run session currently cancels the backing turtle job run, which tears down the VM immediately. That skips graceful shutdown work such as finishing recordings and uploading session artifacts. This change makes the worker observe the session stop state and finish cleanup before exiting.

## Validation

- `yarn oxfmt <modified files>`
- `yarn --cwd packages/build-tools test -- remoteDeviceRunSession`
- `yarn --cwd packages/build-tools test -- argentArtifacts`
- `yarn --cwd packages/build-tools typecheck`
- `git diff --check`

## Note

This relies on the backend/API behavior changing so `ensureDeviceRunSessionStopped` marks the device run session `STOPPED` without canceling the turtle job run, and allows artifact uploads while the stopped session is still cleaning up.
2026-07-07 14:52:58 +02:00
Stanisław Chmiela 81d09514fc [build-tools] Wire agent-device artifact kinds (#3980)
## Summary

- Preserve `artifactType` from agent-device artifact inventory responses.
- Send that value to the device run session artifact upload-session mutation as `kind`.
- Make the local device run session artifact upload helper require an explicit `kind: string | undefined` so untyped uploads are intentional.
- Regenerate EAS CLI GraphQL schema/types with the repository codegen script and update the one fixture affected by regenerated schema drift.

## Why

agent-device now emits semantic artifact types, and universe now stores them on device run session artifacts. This wires the build-tools upload path between those two changes so users can see producer-owned artifact kinds instead of relying on filenames.

## Validation

CI should pass.
2026-07-07 14:17:19 +02:00
Stanisław Chmiela 081e1a9cf7 [build-tools] Add iOS Simulator recorder (#3950)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Add a small Swift helper for recording iOS Simulator screen output from EAS build workers. The helper produces either segmented fMP4 output with manifest metadata for playlist construction or one continuous MP4 when segmentation is disabled.

# How

- Added a `record-sim` Swift package under `@expo/build-tools` resources.
- Added `yarn workspace @expo/build-tools build:record-sim` for local builds into `packages/build-tools/bin/record-sim`.
- Updated iOS worker packaging to build `record-sim` from source and copy only the executable into the worker archive.
- Ignored the generated local `bin/record-sim` binary.

# Test Plan

- `yarn workspace @expo/build-tools build:record-sim`
- `packages/build-tools/bin/record-sim --help`
- `bash -n packages/worker/package.sh`
- `yarn workspace @expo/build-tools typecheck`
- `./node_modules/.bin/oxfmt --check packages/build-tools/.gitignore packages/build-tools/package.json packages/worker/package.sh packages/build-tools/src/index.ts`
2026-07-07 12:29:14 +02:00
Gabe Debes bc6369204d [build-tools] Skip embedded bundle upload for dev client builds (#3940)
## Why

Dev client builds don't embed a bundle. We only skipped the upload for iOS simulators, so other dev builds warned about a missing bundle. This skips all dev client builds.

## Test plan

Added a unit test. CI passes.
2026-07-06 16:36:12 -07:00
Szymon Dziedzic 4855745d67 [eas-cli] Upload agent-device session artifacts (#3939)
* [eas-cli] Retain agent-device session artifacts

* [eas-cli] Upload agent-device session artifacts
2026-07-06 18:22:27 +02:00
Gabe Debes a848411278 [eas-cli] Re-upload assets stuck in polling loop when GCF finalize retry window is exhausted (#3918)
* [eas-cli] Retry uploading update assets that do not finish processing

* [eas-cli] Prefix changelog entry with [eas-cli]

* [eas-cli] Restore poll-window comment and parallelize asset limit fetch

* [eas-cli] Move changelog entry back under main
2026-07-02 15:18:53 -07:00
Gabe Debes 58968ddcd9 [eas-cli] Regenerate GraphQL schema and fix affected types (#3938) 2026-07-02 12:44:05 -07:00
Mustafa Abban 72ca5c2527 [build-tools] Apply npm cache registry only to the dependency install (#3925)
* [build-tools] Apply npm cache registry only to the dependency install

* cache install test

* inline env, remove deprecated env

* use cache url as signal
2026-07-02 10:17:30 -04:00
Stanisław Chmiela 8aa4cae097 [build-tools] Reset Gradle oom score before launch (#3898)
## Summary

Replace the delayed Gradle descendant `oom_score_adj` scan with an immediate shell-level reset before launching Gradle.

The worker service is protected with `OOMScoreAdjust=-999`, so Gradle inherits that value unless we reset it. This changes the Gradle launch wrapper to write `0` to `/proc/$$/oom_score_adj` before `exec ./gradlew ...`, which makes Gradle and its child processes inherit the default OOM score instead of the worker's protected score.

## Validation

- `yarn --cwd packages/build-tools typecheck`
- `yarn oxfmt --check packages/build-tools/src/android/gradle.ts packages/build-tools/src/steps/utils/android/gradle.ts`
- `yarn oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json packages/build-tools/src/android/gradle.ts packages/build-tools/src/steps/utils/android/gradle.ts`
2026-07-02 12:25:28 +02:00
Stanisław Chmiela 8a66d3808e [build-tools] Let updateEnv override existing values (#3901)
## Summary
- Change `BuildContext.updateEnv` so incoming values override existing env values.
- Add a regression test covering overrides and clearing via undefined.

## Why
This makes `updateEnv` behave like an update operation and lets callers intentionally refresh or clear existing environment entries.

No idea how it existed so long like this. I guess the values we update with don't overlap with the values we already have that often.

## Test Plan

CI should pass.
2026-07-02 11:06:20 +02:00
Expo CI 6697add2c0 update CHANGELOG.md 2026-07-01 16:37:38 +00:00
Expo CI cd47185f29 v20.5.1 2026-07-01 16:34:31 +00:00
88c51c3ccc [eas-cli] Fix Free plan users being falsely warned they hit their build limit (#3927)
The "over" usage tier fired whenever the summed value of any overageMetrics
row was greater than zero, independent of the plan limit or whether a charge
was actually incurred. For Free plans that message collapsed into the
"you've reached your limit, upgrade to keep building" warning, so free users
well below their limit (e.g. 6 builds) were told they were blocked.

Free plans can never be in an "over" state (they are blocked at the limit
and never charged pay-as-you-go), and even paid plans now require a real
billable overage cost (totalCost > 0) before showing the overage warning.

Co-authored-by: Sarah Lane <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-01 09:38:27 -06:00
Stanisław Chmiela 9247f3cf78 [eas-cli] Upgrade apple-utils to 2.1.22 (#3913)
## Summary

- [eas-cli] Upgrade `@expo/apple-utils` from `2.1.19` to `2.1.22`.
- Refresh the Yarn lockfile entry for the new package tarball.
- Add a changelog entry under the unreleased `main` section.

## Why

This picks up the latest published Apple API helpers, including recent App Store Connect pagination, SIWA, and review-related fixes from `@expo/apple-utils`.

## Validation

CI should pass.
2026-06-30 18:33:59 +02:00
Stanisław Chmiela bde331da63 [eas-cli] Fix managed tvOS env typecheck (#3920)
## Summary
- fix main typecheck after `Env` was widened to allow undefined values
- align `getManagedTvBuildSettings` with the shared `Env` type instead of requiring `Record<string, string>`

## Test plan

CI should pass.
2026-06-30 12:13:25 +00:00
Stanisław Chmiela fd055f08b3 [eas-build-job] Allow undefined env values (#3900)
## Summary

Widens the shared build `Env` type to allow `undefined` values, matching Node/process env semantics where undefined entries can represent absent values.

## Details

- Changes `Env` to `Record<string, string | undefined>` in `@expo/eas-build-job`.
- Allows `ctx.reportError` extras to contain undefined values for diagnostic metadata.
- Updates strict string boundaries to explicitly require values where needed, such as GraphQL IDs and static context API URLs.
- Filters undefined values out of `displayRuntimeInfo` string-only display maps.

## Validation

CI should pass.
2026-06-30 14:03:53 +02:00
Stanisław Chmiela 785d690e65 [build-tools] Enable simulator accessibility prefs before boot (#3914)
## Summary

- Add an opt-in `enable_accessibility_settings` boolean input to `eas/start_ios_simulator`.
- When enabled, call `IosSimulatorUtils.enableAccessibilitySettingsAsync` inline immediately before each `IosSimulatorUtils.startAsync` call, including cloned simulators.
- Reuse the existing `xcrun simctl list devices --json --no-escape-slashes available` wrapper so the `dataPath` from simctl JSON is used directly, then do an exact UDID/name equality check in JS. This avoids `simctl` search-term substring matches like `iPhone 17` matching `iPhone 17 Pro`.
- Treat invalid requested simulator state as `UserError`; wrap command/filesystem setup failures as `SystemError`.

## Why

These accessibility preferences help Argent and other AX-tree based tools inspect Simulator state more reliably. They can influence app/runtime accessibility behavior, so the setting is explicit and default-off rather than applied to every simulator startup.

## Maestro compatibility

`enable_accessibility_settings` defaults to `false`, so existing Maestro test runs and other `eas/start_ios_simulator` users keep the previous startup behavior unless they opt in. When enabled, the setup only writes preferences for an exact, available, shutdown simulator before `simctl bootstatus -b`. If the requested simulator is not available or not shutdown, the step fails with `UserError`; if the setup command/filesystem work fails, it fails with `SystemError`.

## Notes

- `plutil` is a standard macOS utility (`/usr/bin/plutil` on the local validation machine), so this does not add an external tool installation requirement.

## Validation

CI should pass.
2026-06-30 11:02:21 +02:00
Kudo Chien 47335f02f0 [build-tools] Fix repack AAB output path (#3892) 2026-06-30 14:38:06 +08:00
Douglas Lowder 75d9be813f [eas-cli] Resolve ApplePlatform.TV_OS for managed tvOS targets via EXPO_TV (#3907)
# Why

Non interactive builds were attempting to incorrectly verify tvOS provisioning profiles with settings for the iOS platform.

# How

Recognize when EXPO_TV env variable is set, and in that case, set the target platform to tvOS.

Fixes #3905

# Test Plan

- Tested with a real SDK 56 TV project
- New unit tests added
2026-06-29 18:32:47 -07:00
Expo CI cdaa011ce3 update CHANGELOG.md 2026-06-29 21:45:52 +00:00
Expo CI ee6e197587 v20.5.0 2026-06-29 21:43:22 +00:00
Gabe Debes 63baff4daf [eas-cli] Connect existing PostHog accounts via browser handoff (#3895) 2026-06-29 14:33:53 -07:00
Stanisław Chmiela 85a4054b17 [eas-cli] Use simulator session dashboard URLs (#3904)
## Why

The Expo dashboard route for device run sessions moved to the simulator sessions surface, so EAS CLI should send users to that page and use the simulator-session terminology in user-facing output.

## How

- Add a dashboard URL helper that points device run sessions at `/simulator-sessions/{id}`.
- Update `eas simulator:{start,get,list}` output and JSON to expose `deviceRunSessionUrl` using the simulator sessions route.
- Update user-facing simulator command copy from "device run session" to "simulator session".

## Test Plan

CI should pass.
2026-06-29 16:03:04 +02:00
Stanisław Chmiela 15cd0c5ae2 [worker] Disable Homebrew auto-update in EAS (#3912)
# Why

I don't think auto-updating Homebrew in CI makes sense. It creates a harder-to-reproduce / inconsistent environment.

# How

Added `HOMEBREW_NO_AUTO_UPDATE=1` to worker env.

# Test Plan

Next time https://expo.dev/accounts/gwdp/projects/expo-go-maestro/workflows/019f12db-23cd-78af-866b-3af410ccc10f runs I'll confirm `xcodes` does not update other formulas.
2026-06-29 13:39:00 +02:00
Stanisław Chmiela efc78f43d0 [ci] Update codemention action pin (#3903)
## Summary

Updates the codemention GitHub Action pin from `v1.4.0` to a reviewed post-`v1.5.2` upstream commit:

- `tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b`
- keeps the action pinned to an immutable commit SHA because this workflow runs on `pull_request_target` with `pull-requests: write`
- avoids pinning to moving `main` or a mutable tag

## Security review

I reviewed upstream `tobyhs/codemention` changes from `v1.4.0` through `v1.5.2` and found that `v1.5.2` introduced `[email protected]`, which currently has critical/high production dependency advisories. I therefore did not pin to `v1.5.2`.

The selected post-release commit includes follow-up dependency fixes on upstream `main`, including `[email protected]`, `[email protected]`, and `[email protected].0`. `npm audit --omit=dev` on that upstream commit reported 0 vulnerabilities at review time.

## Validation

- `yarn fmt:check .github/workflows/codemention.yml`
- YAML parse check
- `git diff --check`
2026-06-26 14:21:30 +02:00
a905a50f65 [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) (#3882)
* [eas-cli] Add tiered build-credit usage warnings (approaching/at/over)

Lowers the warning threshold from 85% to 80% to match email
notifications. Adds two new tiers beyond the existing "approaching":
one when the user has reached their included credits, and one when
they've exceeded them on a paid plan. Free plans collapse the "over"
case into the "at" message since Free is blocked at the limit.

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

* [eas-cli] Add CHANGELOG entry for tiered usage warnings (#3882)

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

---------

Co-authored-by: Sarah Lane <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-25 15:29:20 -06:00
Expo CI b36b5b36d7 update CHANGELOG.md 2026-06-25 21:14:47 +00:00
Expo CI f867b7b9f2 v20.4.0 2026-06-25 21:12:10 +00:00
David Mokos 99fba8c814 [eas-cli] share agent templates from llm-configs (#3865)
Syncs the generated app `AGENTS.md` and `CLAUDE.md` templates from `expo/llm-configs` during EAS CLI `prepack`, before the package is rebuilt for publishing. Keeps the synced templates bundled in EAS CLI so `eas project:new` still works offline and does not add a runtime `@expo/llm-configs` dependency.

Verified with `yarn workspace eas-cli sync-agent-templates`, byte-for-byte comparison against the upstream raw files, `yarn workspace eas-cli test src/commandUtils/new/__tests__/projectFiles.test.ts --runInBand`, `yarn workspace eas-cli typecheck-for-build`, and `yarn workspace eas-cli build`. Previously also ran a local `eas project:new` smoke test and confirmed generated agent files matched the shared templates.
2026-06-25 13:34:53 +02:00
Gabe Debes 49101c06c1 [eas-cli] Auto-create branch when channel has no branches mapped (#3891)
`eas update --channel <name>` was failing with `Channel has no branches associated with it` when the channel existed but had no branch mapped.

This trips up fresh projects: `eas build` auto-creates the channel without a branch, so by the time you run `eas update` the channel already exists. The CLI only ever auto-created a branch when the channel was *missing* (`ChannelNotFoundError`), so the existing-but-empty channel fell through to the error instead.

Now the empty-channel case does the same thing as the missing-channel case: create a branch with the channel's name and map it.

## Test plan

- Added a unit test for the empty-channel case
- Ran `eas update --channel <empty channel>` against a real project: the old CLI fails with the error above, this build creates the branch and publishes the update
2026-06-24 09:18:11 -07:00
Ash Wu 4071f4834c [build-tools] add metadata to eas/upload_artifact (#3878) 2026-06-22 17:59:44 +08:00
Ash Wu 55fe28cf63 [build-tools] upload maestro screenshots (#3872)
Signed-off-by: Ash Wu <[email protected]>
2026-06-22 17:13:29 +08:00
Stanisław Chmiela 88a404e45f [build-tools] Skip Xcode selection in development (#3884)
This was needed for running in development where `sudo` is NOT passwordless and waits for password input.

## Summary
- skip `sudo xcode-select` for remote session startup when `ENVIRONMENT=development`
- centralize Xcode developer directory selection for agent-device, Argent, and serve-sim remote sessions
- run `sudo xcode-select` with stdin ignored and stdout/stderr piped to the step logger outside development

## Test plan
- yarn workspace @expo/build-tools test src/steps/utils/__tests__/remoteDeviceRunSession.test.ts
- yarn workspace @expo/build-tools typecheck
2026-06-19 14:15:18 +02:00
Stanisław Chmiela cb2daddbfc [build-tools] Fix Argent artifact upload size (#3883)
Since https://github.com/software-mansion/argent/pull/347 Argent exposes a `GET /artifacts/` endpoint which returns a `size` for every artifact. This `size` is then being used to create a `www` upload session (which validates and includes the `size` as `Content-Length` of the signed upload URL).

The `size` is being calculated in Argent as `fs.stat()` which works great for single files, but for directories it returns 96 bytes. The actual archiving of a given directory (producing an artifact) is only happening when downloading the artifact with `GET /artifacts/:id`.

This pull request changes the artifacts behavior around Argent from:
- list artifacts
- trust `size`
- pipe `GET /artifacts/:id` directly to signed upload URL

to:
- list artifacts
- download from `GET /artifacts/:id` to temporary directory
- check the `size` of the actually downloaded file
- pipe it to signed upload URL.

## Test

You can see which traces have been created _with_ the fix and which _without_ it.

<img width="445" height="354" alt="Zrzut ekranu 2026-06-19 o 13 03 01" src="https://github.com/user-attachments/assets/fddecddf-419c-46a1-8876-599bc00ea8ce" />
2026-06-19 12:02:16 +00:00
Stanisław Chmiela b2388fdfa0 [build-tools] Add Argent remote session artifact uploads (#3880)
## Summary
- bump the minimum supported Argent remote-session version to the latest published `@swmansion/argent` version (`0.12.0`)
- enable Argent's artifact inventory endpoint before starting the tool-server
- poll the local Argent tool-server every 5 seconds for new artifacts and stream each new artifact to the EAS device-run-session artifact upload backend
- add focused tests for version validation, artifact inventory listing, and signed upload streaming

## Testing

CI should pass.
2026-06-19 09:40:58 +02:00
Douglas Lowder 0986682159 [eas-cli] Add observe:session for inspecting events by session ID (#3868)
# Why

New command for retrieving and analyzing events in a session.

```
USAGE
  $ eas observe:session SESSIONID [--project-id <value>] [--json] [--non-interactive]

ARGUMENTS
  SESSIONID  Session ID to inspect

FLAGS
  --json                Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
  --non-interactive     Run the command in non-interactive mode.
  --project-id=<value>  EAS project ID (defaults to the project ID of the current directory)

DESCRIPTION
  display the timeline of metric and log events for a specific session

```

# How

Query both metrics and logs, filtered by the session ID passed in. 

Example results:

```
# Results for an app startup session (main session)
easd observe:session 2bb44b16-7650-4efd-8ec8-a1bd75ee81dc

App version: 1.1.0 (31)
Device:      Pixel 7 · Android 13
First seen:  May 11, 2026, 04:12:07.662 AM
Last seen:   May 11, 2026, 04:13:04.886 AM

Offset  Type    Name          Value  Properties         Severity
------  ------  ------------  -----  -----------------  --------
0.00s   metric  Bundle Load   0.77s  -                  -       
0.05s   log     app.open      -      cold_start=false   INFO    
0.15s   metric  Startup TTR   1.38s  -                  -       
0.45s   metric  Startup TTI   2.16s  -                  -       
0.55s   metric  Warm Launch   1.29s  -                  -       
0.95s   log     screen.view   -      screen=profile     INFO    
17.16s  log     habit.logged  -      habit_id=meditate  INFO    
                                     streak_days=12             
56.59s  log     screen.view   -      screen=streak      INFO    
57.22s  log     screen.view   -      screen=paywall     INFO

# Results for a navigation session with routes
$ eas observe:session 7e57f6cd-f8e9-4fe0-b6a5-934856d61663

App version: 1.1.0 (31)
Device:      iPhone16,1 · iOS 17.5
First seen:  May 6, 2026, 06:54:35.820 AM
Last seen:   May 6, 2026, 06:56:11.753 AM

Offset  Type    Name                           Value / Severity
------  ------  -----------------------------  ----------------
0.00s   metric  Nav Cold TTR · /task/[taskId]  0.51s           
0.08s   metric  Nav TTI · /task/[taskId]       0.53s           
4.78s   metric  Nav Warm TTR · /stats          0.55s           
4.84s   metric  Nav TTI · /stats               0.57s           
33.66s  metric  Nav Warm TTR · /stats          0.53s           
33.72s  metric  Nav TTI · /stats               0.47s           
53.96s  metric  Nav Warm TTR · /stats          0.24s           
54.02s  metric  Nav TTI · /stats               0.82s           
78.61s  metric  Nav Warm TTR · /new            0.21s           
78.67s  metric  Nav TTI · /new                 0.27s           
95.87s  metric  Nav Warm TTR · /new            0.08s           
95.93s  metric  Nav TTI · /new                 0.20s

```

# Test Plan

- Tested against a project in staging.
- New unit tests added.
2026-06-18 13:19:55 -07:00
Expo CI 24354688b6 update CHANGELOG.md 2026-06-18 17:47:10 +00:00
Expo CI 490457976c v20.3.0 2026-06-18 17:44:26 +00:00
Gabe Debes 787fe4d3a7 [eas-cli] Make integrations:posthog:connect resilient to dynamic app configs (#3877) 2026-06-18 09:54:59 -07:00
Gabe Debes 2dae2cdd0e [eas-cli] Add integrations:posthog:disconnect (#3838) 2026-06-18 09:44:07 -07:00
Gabe Debes 967dff73df [eas-cli] Add integrations:posthog:dashboard (#3837) 2026-06-18 09:29:04 -07:00
Stanisław Chmiela 3a3995817e [build-tools] Fix Argent remote session startup (#3881)
Fixes Argent remote simulator sessions getting stuck immediately after starting the Argent tool-server.

The latest Argent support change switched the startup path to `bunx @swmansion/argent server start ...` and awaited that process. When the server process stays in the foreground, the build step never advances to read the tool-server state file, start the ngrok tunnel, or report remote config back to the API.

This restores detached process launching for the Argent tool-server while keeping the new server/auth flow. If the state file is not created in time, the error now includes captured Argent process output to aid diagnosis.

Failing job run referenced while debugging: https://expo.dev/accounts/sjchmiela/projects/test-production/job-runs/019eda48-6467-789d-8c5e-a7ed5f4ec89d
2026-06-18 18:26:47 +02:00
Gabe Debes 8a392544f2 [eas-cli] Add integrations:posthog:connect (#3836)
## Why

Connect an Expo app to PostHog in one command. It provisions the PostHog project, asks which features you want, installs the SDK, and writes the env vars — so analytics / session replay / error tracking work after a build.

## How

Fast-forward order — provision first, then ask what to wire up:

1. **Provision** — reuses your account's PostHog org + this app's project, or creates them. Only a brand-new connection asks a question: **region** (US / EU), which sets data residency and can't be changed later.
2. **Pick features** — a multiselect, all preselected: Analytics, Session replay, Error tracking (source maps, _requires a personal API key_).
3. **Error tracking, if selected** — prompts you to paste a PostHog personal API key (we can't mint it — you create it in PostHog and paste it in).

It then:

- **Installs** `posthog-react-native` (+ `expo-file-system`, `expo-application`, `expo-device`, `expo-localization`; plus `posthog-react-native-session-replay` when replay is on) via `npx expo install`
- **Adds** the `posthog-react-native/expo` config plugin (prints manual steps if your app config is dynamic)
- **Writes env vars** to `.env.local` (local dev) and EAS (Production / Preview / Development):
  - Analytics → `EXPO_PUBLIC_POSTHOG_API_KEY`, `EXPO_PUBLIC_POSTHOG_HOST` (public)
  - Error tracking → `POSTHOG_CLI_API_KEY` (sensitive), `POSTHOG_CLI_PROJECT_ID`, `POSTHOG_CLI_HOST` (public)
- Source-map uploads on builds are covered by our guide; the command just sets the `POSTHOG_CLI_*` vars.

This PR also includes the PostHog GraphQL layer the command builds on — `PostHogQuery` / `PostHogMutation`, the connection/project types, and `commandUtils/posthog` (dashboard URL).

**Flags:** `--region`, `--session-replay` / `--no-session-replay`, `--error-tracking` / `--no-error-tracking`, `--posthog-cli-api-key`, `--overwrite`, plus `--json` / `--non-interactive`. Agent-ready: error tracking auto-skips without a key (or pass `--posthog-cli-api-key`); `--error-tracking` without a key fails before provisioning; `--region` is required non-interactively.

## Test Plan

- CI — 34 unit tests, full coverage of `connect.ts`
- Ran end-to-end against production: provisioned a PostHog project, selected all features, and confirmed the env vars land in `.env.local` + EAS and the SDK packages / config plugin are added

<img width="2048" height="2718" alt="carbon (1)" src="https://github.com/user-attachments/assets/073a5c41-7f07-48d6-b5b7-b1de3cff89fa" />
2026-06-18 09:20:28 -07:00
Stanisław Chmiela 84015ebe32 [build-tools] Upgrade Argent support (#3879)
- v0.11 of Argent added official support for "remote mode", i.e. we don't need to start the MCP server for _it_ to start the tools-server, we can just start the tools-server.
- just to be sure I've added a min Argent version check.
- new Argent versions also guard the tools server by authorization, so added `authToken` support
- new Argent versions also check `Host` header so made ngrok rewrite it to `localhost:...` to fix error below

<img width="868" height="538" alt="Zrzut ekranu 2026-06-18 o 10 59 54" src="https://github.com/user-attachments/assets/6b3689a4-9359-4f8f-bba7-4e98e97ff0cd" />
2026-06-18 12:13:26 +02:00
Jeff C 7a38191692 [eas-cli] Fix update:embedded topic description (#3866)
* [eas-cli] Fix update:embedded topic description

Without an explicit topic entry, oclif derived the "update:embedded"
group description from a subcommand, so the topic listing showed
"delete an embedded update registered with EAS Update" — confusingly
describing only the delete subcommand. Add an explicit topic description.

* [eas-cli] Add changelog entry for update:embedded topic fix
2026-06-17 11:42:34 -07:00
Keith KurakandClaude Opus 4.8 3e393650dc [eas-cli] Add account:audit command to view account audit logs (#3863)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-16 14:58:44 -04:00
Keith KurakandClaude Opus 4.8 5e2ecb5997 [eas-cli] Support platform-specific update IDs in update:view (#3864)
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-16 11:41:39 -04:00
Will Schurman 🦦 51aceb1db0 [eas-cli] Simplify 2FA now that SMS is no longer supported (#3859) 2026-06-15 16:22:07 -07:00
Expo CI 90f78ab2ae update CHANGELOG.md 2026-06-15 20:16:07 +00:00
Expo CI 203e9997ee v20.2.0 2026-06-15 20:13:05 +00:00
Quinlan Jung 🦥andClaude Opus 4.8 792af28a5d [eas-cli] add support for non-interactive update:rollback (#3825)
* [eas-cli] add support for non-interactive update:rollback

* [eas-cli] Add CHANGELOG entry for non-interactive update:rollback

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 13:02:37 -07:00
Mustafa Abban e62bb1a840 [worker] Resolve runtime cache settings from job env and fallback on npm cache failures (#3855)
* [worker] Resolve runtime cache settings from job env and fallback on npm cache failures

* [worker] Resolve runtime cache settings from job env and fallback on npm cache failures

* no longer url fallback

* use env over runtime stngs

* report log to sentry only

* proper subclass errs

* proper subclass errs

* proper subclass errs

* runtime setting override, rem set registry cmd

* continue support DISABLE flags, in line err tracker
2026-06-15 13:46:25 -04:00
Stanisław Chmiela f8c2b2daf4 [worker] Remove legacy upload code, add retries, auto-waive builds (#3848)
## What changed

- Removed `gcsSignedUploadUrlForApplicationArchive` and `gcsSignedUploadUrlForBuildArtifacts` from worker runtime config and runtime config typing.
- Removed launcher signed-upload fallback behavior for managed application archive and build artifacts uploads.
- Classified managed artifact upload failures as `SystemError`s with internal tracking codes while keeping the public error code as `SERVER_ERROR`.
- Added retry options to worker upload API `turtleFetch` calls for upload-session creation and artifact metadata saving.

## Why

These signed upload URL fields are no longer expected, so keeping fallback paths made the worker handle an obsolete runtime contract. Upload failures in these managed paths are infrastructure/API/storage failures rather than user-actionable project issues.

## Validation

- `yarn oxfmt packages/worker/src/config.ts packages/worker/src/upload.ts packages/worker/src/external/turtle.ts packages/worker/src/__unit__/upload.test.ts`
- `yarn workspace @expo/worker test:unit src/__unit__/upload.test.ts --runInBand`
- `yarn workspace @expo/worker typecheck`
2026-06-15 17:38:41 +02:00
Mustafa Abban 22fc4da732 [build-tools] Enable Gradle cache via machine-level gradle.properties to avoid fingerprint mutation (#3852) 2026-06-12 09:34:58 -04:00
Stanisław Chmiela 1c7c79d445 [build-tools] Drop legacy cache URL fallbacks (#3856)
## Summary

Drops the temporary legacy cache URL fallback path now that the worker env-var PR is deployed.

This removes the fallback URL state from runtime settings, removes the old cache URL fields from worker runtime config parsing/types, and removes the now-obsolete config fallback test.

## Impact

Cache URL sourcing now relies on the runtime-settings cache gates plus the new worker cache URL environment variables only. Public build env names remain unchanged.

## Validation

CI should pass. Once deployed to staging I'll confirm the env vars and cache config is there.
2026-06-12 10:06:16 +02:00
Szymon Świerk b30dd9054d [eas-cli] Validate/regenerate/create provisioning profile in non-interactive iOS builds using submission ASC API key when present (#3805)
# Why

Many projects already configure an App Store Connect API key in EAS credentials for submissions, but non-interactive iOS App Store and Enterprise builds (`eas build --non-interactive`) could not use that key when validating or repairing provisioning profiles on Apple servers. Instead, CI had to provide `EXPO_ASC_*` environment variables separately, even when a usable key was already stored on EAS.

This makes non-interactive credential setup more convenient by reusing the EAS-stored submission ASC API key for App Store and Enterprise profile validation and repair.

# How

- Added `tryAuthenticateAppStoreWithEasAscApiKeyAsync` in `AscApiKeyUtils.ts` — a best-effort, non-throwing helper that authenticates `ctx.appStore` in non-interactive mode using:
  1. `EXPO_ASC_API_KEY_PATH` / `EXPO_ASC_KEY_ID` / `EXPO_ASC_ISSUER_ID` environment variables, or
  2. The App Store Connect API key configured for submissions on the app in EAS credentials.
- In `SetUpProvisioningProfile.runAsync`, call this helper before Apple-side validation when running non-interactively without an existing auth context.
- Use actual authentication state (`ctx.appStore.authCtx`) instead of startup `defaultAuthenticationMode` when deciding whether Apple authentication is available in `SetUpProvisioningProfile`, `CreateProvisioningProfile`, and `ConfigureProvisioningProfile`.
- In non-interactive mode, treat unexpected Apple validation errors as best-effort: log a warning and continue rather than failing the build.
- Extracted shared `resolveAscApiKeyForAppCredentialsAsync` (previously duplicated in `SetUpAdhocProvisioningProfile`) for reuse across credential flows.
- Derive the `AppleTeamType` for profile operations from the distribution type (`IN_HOUSE` for enterprise, `COMPANY_OR_ORGANIZATION` otherwise), with `EXPO_APPLE_TEAM_TYPE` override.
- Updated error messages to mention both `EXPO_ASC_*` environment variables and EAS-stored ASC API keys.

Out of scope: ad-hoc provisioning profile refresh (opt-in, handled separately via `--refresh-ad-hoc-provisioning-profile`).

# Test Plan

- Added unit tests for auto-authentication, team type resolution, best-effort validation skip, and missing-auth paths in `SetUpProvisioningProfile-test.ts`.
- Tested manually:
  - Run an interactive iOS production build and create a provisioning profile
  - Confirm non-interactive build works with that profile
  - Revoke the provisioning profile in the Apple Developer portal
  - Confirm non-interactive build succeeds and the profile is repaired when the submission ASC API key is configured in app credentials (`easd build --platform ios --profile production --non-interactive`)
2026-06-11 19:12:58 +02:00
Szymon DziedzicandClaude Opus 4.7 7af74c605e [eas-cli] Add --max-duration-minutes flag to eas simulator:start (#3806)
* [eas-cli] Regenerate GraphQL schema

* [eas-cli] Add --max-duration-minutes flag to eas simulator:start

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

* [eas-cli] Only reject negative --max-duration-minutes in simulator:start

Leave the upper-bound check to the server instead of capping it client-side.

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

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-11 16:58:14 +02:00
Szymon DziedzicandClaude Opus 4.8 a5fccedf4e build-tools: fetch Cloudflare TURN ICE servers from www and pass to serve-sim (#3828)
When launching serve-sim, the worker requests short-lived TURN ICE servers from www's turtle-job-runs route (same robot-token pattern as the project clone URL) and forwards them as --stun-url/--turn-url/--turn-username/--turn-credential. Covers all three serve-sim launchers (serve_sim, agent_device, argent).

Best-effort: on any failure serve-sim falls back to its built-in P2P/STUN behavior, so a TURN outage never blocks a session.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-11 16:43:24 +02:00
Ash Wu f1aa26d9f3 [build-tools] read JUnit new file attribute for Maestro 2.6+ (#3839)
Signed-off-by: Ash Wu <[email protected]>
2026-06-11 20:59:27 +08:00
Szymon Świerk 68a0847058 [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually (#3842)
* [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually

* more precise error handling (make use of UserError/SystemError)
2026-06-11 14:15:13 +02:00
Stanisław Chmiela 1431a20b86 Log user-provided custom functions during parsing (#3847)
I want to learn how much are custom functions being used.
2026-06-11 10:40:49 +02:00
Szymon Dziedzic d2ed1feb67 Speed up agent-device session startup (#3841) 2026-06-10 14:29:55 +02:00
Stanisław Chmiela cc49343b39 [worker] Use runtime settings from storage (#3801)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://github.com/expo/workflow-orchestration/pull/851

# How

Added `RuntimeSettings` namespace in `build-tools`. It is expected to be called in the beginning with `loadAsync` to fetch data from the bucket.

# Test Plan

Once approved deploy to staging, run a few builds with different settings.
2026-06-10 12:21:53 +02:00
Ash Wu 10f9edc910 [build-tools] fix getMaestroVersion parsing error (#3834)
Signed-off-by: Ash Wu <[email protected]>
2026-06-10 16:21:51 +08:00
Stanisław Chmiela 9d1a6b7796 [scripts] Add runtime settings editor (#3812)
I want us to move off of killswitches in Redis coming from `workflow-orchestration` towards fetching killswitches from a simple JSON file.

I picked GCS over R2 because it lets us make objects fully public (no `Authorization` required) and I (Stanley) found no way to achieve the same thing with R2.

This pull request adds a script `scripts/edit-runtime-settings staging | production` that is going to let us edit the file.
2026-06-09 14:14:50 +02:00
Gabe Debes d8cca2c8e8 [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4 (#3824)
* [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4

* update CHANGELOG.md

* Add manual worker build workflow

* Add manual worker build workflow

* tmp: trigger worker build on branch push

* Trigger worker build on PRs touching worker or cocoapods-proxy

* Print job statuses on EAS workflow success

* Remove manual worker build workflow
2026-06-08 08:59:27 -07:00
Kudo Chien fb818fce54 [repack] add ios signing entitlements params (#3803) 2026-06-08 22:52:07 +08:00
Ash Wu 11263c23a0 [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output (#3818)
* [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-06-08 21:04:27 +08:00
Ash Wu 80baa13ffa [build-tools] track installed maestro cli version (#3822)
Signed-off-by: Ash Wu <[email protected]>
2026-06-08 15:05:54 +08:00
Expo CI 58982ee212 update CHANGELOG.md 2026-06-05 21:39:01 +00:00
Expo CI 33225d2b73 v20.1.0 2026-06-05 21:36:09 +00:00
Gabe Debes 7a97f297ee [eas-cli] Add eas update:embedded:delete command (#3809) 2026-06-05 14:27:30 -07:00
Gabe Debes 5f952fd33b [eas-cli] Add eas update:embedded:list command (#3811)
* [eas-cli] Add eas update:embedded:list command

* [eas-cli] Put "All channels" first in the channel-filter prompt

* [eas-cli] Refresh graphql schema snapshot
2026-06-05 14:09:31 -07:00
Gabe Debes 45354d12da [eas-cli] Add eas update:embedded:view command (#3810)
* [eas-cli] Add eas update:embedded:view command

* [eas-cli] Unit tests for EmbeddedUpdateQuery wrapper
2026-06-05 10:04:14 -07:00
Ash Wu 598f0050be [build-tools] Route Datadog metrics and logs by build vs job-run target (#3819)
Signed-off-by: Ash Wu <[email protected]>
2026-06-05 14:01:20 +08:00
Gabe Debes 543261b68a [build-tools] disable apsd in iOS simulator (#3795)
* [build-tools] Disable apsd in iOS Simulator after boot

* [build-tools] Add unit test for disableApsdAsync

* [build-tools] Use { err } logging convention for apsd warning

* [build-tools] Test apsd disable in start_ios_simulator
2026-06-02 09:08:43 -07:00
Krystof Woldrich e1148f2b00 feat(simulator): add exec command and config persistence via dotenv (#3777) 2026-06-02 14:31:27 +00:00
Gabe Debes 2c1837f8ba [build-tools] Auto-upload embedded bundle after build when opt-in flag is set (#3767)
* [build-tools] Auto-upload embedded bundle after build when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set

* [build-tools] Add changelog entry for #3767

* Move isEASUpdateConfigured check inside uploadEmbeddedBundleAsync

* Skip simulator builds; use endsWith for Android archive entry matching

* Skip simulator builds; add tests for uploadEmbeddedBundleAsync

* Simplify archive pattern branch for readability

* Add tests for upload error paths and builder env gate

* Address review: explicit platform branch, channel check first, safe zip.close

* Move #3767 entry back under ## main after v20 release rebase
2026-05-29 10:44:06 -07:00
Szymon Dziedzic 7bdc009c5e [eas-cli] use webrtc for simulator service streaming (#3807)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Use webrtc for better perf

# How

Use webrtc with serve sim for better perf

# Test Plan

Looks good locally
2026-05-29 18:28:56 +02:00
Expo CI b5592e16ad update CHANGELOG.md 2026-05-29 00:04:43 +00:00
Expo CI 890387b3b6 v20.0.0 2026-05-29 00:01:30 +00:00
Douglas Lowder b93c550d12 [eas-cli] Unhide observe commands (#3793)
# Why

Unhide the commands when ready.

# How

Remove `hidden = true` lines, clean up outdated warnings.

# Test Plan

CI should pass.
2026-05-28 16:55:18 -07:00
Mustafa Abban e648961eca [worker] Fail silently on cache archive errors in CacheManager (#3802) 2026-05-28 16:55:36 -04:00
Gabe Debes 15bbb3150d [eas-cli] Add eas update:embedded:upload command (#3720)
* [eas-cli] Add eas update:embedded:upload command

* Add tests for json output flag and mutation wrappers

* Apply prettier formatting to mutation test

* Invoke upload progress callback in test to cover no-op

* Use fs-extra so manifest read and bundle existence check can be awaited

* Drop misleading already-registered warn from upload

* Recognize EMBEDDED_UPDATE_ALREADY_EXISTS on upload and warn

* Drop already-exists CLI warn, server message is clear on its own

* Surface EMBEDDED_UPDATE_ALREADY_EXISTS as a single clean error on upload

* Flatten retry catch with early continue
2026-05-28 09:35:23 -07:00
Szymon Dziedzic 45c4cf3c32 [build-tools] Use h264 codec for serve-sim stream (#3800)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Try if h264 codec will improve stream perf for serve-sim

# How

Add `--codec h264` to the single `serve-sim` invocation in `startServeSimWithTunnelAsync` ([packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts](https://github.com/expo/eas-cli/blob/main/packages/build-tools/src/steps/utils/remoteDeviceRunSession.ts)). All three callers — `start_agent_device_remote_session`, `start_argent_remote_session`, and `start_serve_sim_remote_session` — route through this helper, so the flag applies everywhere `serve-sim` is launched.

# Test Plan

- `yarn typecheck` on `build-tools` — only the two pre-existing `refreshAdHocProvisioningProfile` errors on `main`, unrelated to this change.
- Manual verification will happen once the step runs end-to-end on a turtle worker; the change is a single CLI flag added to an existing argument list.
2026-05-28 15:13:35 +02:00
Stanisław Chmiela 0ab2a04b67 [build-tools] Support absolute artifact glob paths (#3788)
See https://app.datadoghq.com/logs?query=findArtifacts&agg_m=count&agg_m_source=base&agg_q=status%2Cservice&agg_q_source=base%2Cbase&agg_t=count&clustering_pattern_field_path=message&cols=host%2Cservice&messageDisplay=inline&refresh_mode=sliding&sort_m=%2C&sort_m_source=%2C&sort_t=%2C&storage=hot&stream_sort=desc&top_n=10%2C10&top_o=top%2Ctop&viz=pattern&x_missing=true%2Ctrue&from_ts=1779570516711&to_ts=1779743316711&live=true

---

## Summary
- applies the validated absolute-glob artifact lookup behavior directly
- keeps existing exact absolute path handling intact
- adds unit coverage for absolute glob artifact patterns

## Why
The dry-run Sentry instrumentation validated that absolute artifact glob support is the intended behavior. This PR removes the dry-run path and uses the supported lookup logic directly.

## Validation
- yarn oxfmt packages/build-tools/src/utils/artifacts.ts packages/build-tools/src/utils/__tests__/artifacts.test.ts
- yarn --cwd packages/build-tools test --runTestsByPath src/utils/__tests__/artifacts.test.ts
2026-05-28 14:32:31 +02:00
Stanisław Chmiela 544739a613 [build-tools] Respect EXPO precompiled opt-out for iOS pods (#3799)
### Motivation
- Ensure `EXPO_USE_PRECOMPILED_MODULES=0` in the build environment always wins and is not overwritten by `EAS_USE_PRECOMPILED_MODULES=1` to avoid surprising behavior for customers disabling precompiled modules. 
- Make the precedence explicit and observable via logs so maintainers can reason about the opt-out path.

### Description
- Added an early guard in `resolvePrecompiledModulesPodInstallEnvAsync` to return early when `ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0'` and log the opt-out message. (`packages/build-tools/src/ios/pod.ts`).
- Logged the presence of `EXPO_USE_PRECOMPILED_MODULES=0` with a clear message to explain why precompiled env vars are not injected. (`packages/build-tools/src/ios/pod.ts`).
- Extended the test helper to allow passing `EXPO_USE_PRECOMPILED_MODULES` via the build context environment and added a regression test that verifies `EXPO_USE_PRECOMPILED_MODULES=0` is preserved and that no base URL is set when both flags are present. (`packages/build-tools/src/ios/__tests__/pod.test.ts`).

### Testing
- Ran formatter: `npx oxfmt packages/build-tools/src/ios/pod.ts packages/build-tools/src/ios/__tests__/pod.test.ts` which completed successfully. 
- Ran the unit test file: `cd packages/build-tools && yarn test src/ios/__tests__/pod.test.ts` which failed in this environment due to missing workspace module resolution for `@expo/eas-build-job` and `@expo/turtle-spawn` (test runner error, not a behavior regression).

------
[Codex Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a180a1026848328b76bf4240a5872b1)
2026-05-28 11:50:29 +02:00
Szymon DziedzicandClaude Opus 4.7 86a891b377 [build-tools] Use ngrok tunnels for remote device run sessions (#3796)
* [build-tools] Use ngrok tunnels for remote device run sessions

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

* [build-tools] Use in-process @ngrok/ngrok SDK for our tunnels

Replace the spawned ngrok CLI (binary install + stdout URL parsing +
web_addr workaround) with the in-process @ngrok/ngrok SDK for the
agent-device and argent tunnels. The ngrok CLI is still installed for
serve-sim, which shells out to it for --tunnel-provider ngrok.

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

* [build-tools] Drop ngrok CLI install; serve-sim uses the SDK

serve-sim now embeds the @ngrok/ngrok SDK for --tunnel-provider ngrok, so
the ngrok agent no longer needs to be on PATH. No tunnel binary is
installed anywhere now: our own tunnels and serve-sim both run the agent
in-process. Removes ensureNgrokCliInstalledAsync and its callers.

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

* [build-tools] Address review feedback

Simplify the "env var not set" SystemError messages (drop "created by the
API server" / the domain example), reword the fail-fast comments, and make
matchLabeledUrl take an options object.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-27 15:18:36 +02:00
Gabe Debes d51ca46f9c [build-tools] Fix iOS Simulator readiness hang on Xcode 26.4 (#3794)
* [build-tools] Fix iOS Simulator readiness hang on Xcode 26.4

* [build-tools] Add changelog entry

* [build-tools] Use os.tmpdir() and node:fs for readiness screenshot

* [build-tools] Add unit test for Simulator readiness screenshot
2026-05-27 00:23:47 -07:00
Gabe DebesandStanisław Chmiela cfc2c76ae4 [eas-cli] eas go: prompt for SDK version interactively (#3768)
* [eas-cli] eas go: use SDK version from current project when available

* [eas-cli] eas go: prompt for SDK version interactively

* Regenerate GQL types for ExpoGoBuild supportedSdkVersions

* Update packages/eas-cli/src/commands/go.ts

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

* Address review feedback on selectSdkVersionAsync

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-05-26 15:20:27 -07:00
Kadi Kraman 489f38a6e5 [eas-cli] improve naming for observe commands (#3778)
* Rename /metrics -> /metrics-summary

* Rename events -> metrics

* Rename logs -> events

* Add changelog

* Add update download to metric names

* Fix warnings

* Add changelog PR number

* Format

* Rename metric-summary -> metrics-summary
2026-05-26 11:23:20 +01:00
Ash Wu 5f26518feb [build-tools] handle custom Gymfile err in xcactivitylog (#3790)
Signed-off-by: Ash Wu <[email protected]>
2026-05-26 14:09:32 +08:00
Stanisław Chmiela 2fc5f203b6 [build-tools] Log artifact dry-run results to Datadog (#3791)
## Summary
- adds `Datadog.log` for posting build-scoped log events to Datadog
- moves the Gradle cache restore log emission onto the shared Datadog helper
- replaces artifact dry-run Sentry reporting with Datadog logs for match, mismatch, and error outcomes

## Why
The absolute artifact glob validation should show both positive and negative dry-run outcomes. Sentry only surfaced mismatches/errors, while Datadog logs let us count successful matches as well.

## Validation

CI should pass. Going to review Datadog logs post-merge.
2026-05-25 18:01:39 +02:00
Expo CI 4a18363061 update CHANGELOG.md 2026-05-25 13:46:03 +00:00
Expo CI 27a2e2abc7 v19.1.0 2026-05-25 13:43:21 +00:00
Szymon Świerk 149f5d86a4 [build-tools] Pass refresh ad-hoc provisioning profile through build:internal (#3770)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Git-based integration builds call `eas build:internal` via `runEasBuildInternalAsync`. The refresh flag must reach the CLI and remain on the job object returned to the rest of the build.

# How

Forward `--refresh-ad-hoc-provisioning-profile` when set on the iOS job; retain `refresh_ad_hoc_provisioning_profile` in `validateEasBuildInternalResult`.

# Test Plan

`cd packages/build-tools && yarn test src/common/__tests__/easBuildInternal.test.ts`
2026-05-25 12:18:13 +02:00
Szymon Świerk 18c2e82385 [eas-cli] Wire refresh ad-hoc profile through build:internal and prepareJob (#3769)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Workflow builds run `eas build:internal` on EAS workers to resolve credentials and emit the job JSON. The base branch adds `--refresh-ad-hoc-provisioning-profile` to eas build, but that flag was not available on `build:internal`, and the iOS job did not persist `refresh_ad_hoc_provisioning_profile` when the flag was set.

Without this wiring, git-based/workflow builds could not trigger ad-hoc profile refresh or pass the intent through to the job payload.

# How

- Add `--refresh-ad-hoc-provisioning-profile` to `eas build:internal` and pass it into `runBuildAndSubmitAsync` (same behavior as eas build).
- In `prepareJob.ts`, set `refresh_ad_hoc_provisioning_profile: true` on the iOS job when the credentials context has the flag enabled.

# Test Plan

`yarn typecheck`
2026-05-25 11:49:51 +02:00
Szymon Świerk 43ffe46428 [eas-build-job] Add refreshAdHocProvisioningProfile to iOS job (#3717)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

ENG-21094 needs a job field to carry ad-hoc provisioning profile refresh intent for git-based/workflow builds; #3716 added CLI behavior on eas build; this PR is schema-only.

# How

Updated `packages/eas-build-job/src/ios.ts` interface + Joi schema.

# Test Plan

`cd packages/eas-build-job && yarn test src/__tests__/ios.test.ts`
`yarn typecheck`
2026-05-25 11:43:14 +02:00
Ash Wu fb9b49cd5b [build-tools] keep exit code & stderr when xclogparser failed (#3772)
* [build-tools] keep exit code & stderr when xclogparser failed

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-25 15:15:21 +08:00
Stanisław Chmiela 17eb5f0687 [worker] Remove build phase stats websocket reporting (#3787)
We now send build phases through `Datadog`!

---

## Summary
- remove the stale BuildPhaseStats type and BuildContext reporting callback
- drop the worker BUILD_PHASE_STATS websocket message and service forwarding path
- update tests to assert the remaining Datadog phase duration metrics path

## Why
Build phase metrics now flow from worker to www to Datadog, so the old worker to launcher to Datadog reporting path was dead code.

## Validation
- yarn run -T oxfmt --check packages/eas-build-job/src/common.ts packages/eas-build-job/src/index.ts packages/build-tools/src/context.ts packages/build-tools/src/builders/__tests__/android.test.ts packages/worker/src/context.ts packages/worker/src/service.ts packages/worker/src/external/turtle.ts packages/worker/src/__integration__/utils.ts packages/worker/src/__unit__/service.test.ts
- yarn jest --config jest/unit-config.ts src/builders/__tests__/android.test.ts src/__tests__/context.test.ts --runInBand --no-cache (from packages/build-tools)
- yarn jest --config jest.config.unit.ts src/__unit__/service.test.ts --runInBand (from packages/worker)
- yarn typecheck
2026-05-25 08:36:40 +02:00
Stanisław Chmiela 6297ac19d9 [build-tools] Dry-run add support for absolute artifact glob paths (#3738)
## Summary

Updates artifact path resolution so absolute paths containing glob metacharacters are treated as glob patterns instead of literal paths.

This lets `eas/upload_artifact` handle paths like `/var/folders/.../T/maestro_xctestrunner_xcodebuild_output*`, while preserving the existing literal-path behavior for non-dynamic absolute paths.

## Why

`upload_artifact.path` already accepts paths or patterns, but absolute patterns were previously short-circuited through `fs.pathExists()` and never reached `fast-glob`.

## Test Plan

- `yarn workspace @expo/build-tools jest-unit packages/build-tools/src/utils/__tests__/artifacts.test.ts`

Note: the focused test was run in the existing local checkout with the same two-file patch. The clean PR worktree did not have its own install state.
2026-05-22 18:13:54 +02:00
Stanisław Chmiela 6d86f91dab [worker] Derive worker runtime info from os (#3783)
## Summary
- Remove worker-side `resourceClass` parsing and types from runtime config.
- Remove `ResourceClassToPlatform`, since worker env setup now uses the job platform directly.
- Replace resource-class VM spec logging with OS-derived CPU model, vCPU count, and total RAM.

## Notes
- CPU model is normalized to `Apple M4 Pro` (from "Apple M4 Pro (Virtual)") for Apple chips and `Intel`/`AMD` for cloud CPU models.

## Validation
- `yarn oxfmt --check packages/worker/src/config.ts packages/worker/src/displayRuntimeInfo.ts packages/worker/src/external/turtle.ts packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
- verified `os.cpus().length` is 4 for 4 CPU tart VM and `os.totalmem() / 1024 / 1024 / 1024` is `8` for 8 GB RAM tart VM
2026-05-22 16:35:44 +02:00
Stanisław Chmiela cedfabe18a [worker] Size Gradle heap from runner memory (#3782)
## Summary
- Replace resource-class-based Gradle heap sizing with a live calculation from runner total memory.
- Preserve existing Gradle settings for metaspace, parallel execution, daemon disablement, and old-Java MaxPermSize handling.
- Add worker unit coverage for the effective heap sizes.

## Effective heap sizing
- 15-16 GB runners resolve to `-Xmx4g`.
- 31-32 GB runners resolve to `-Xmx8g`.
- Lower-memory runners are clamped to at least `-Xmx1g`.

## Validation
- `yarn oxfmt --check packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
2026-05-22 16:18:36 +02:00
Stanisław Chmiela 20a21908d0 [worker] Stop logging dependency metrics failures to build output (#3784)
## Summary

- Stop reporting project dependency metrics failures to the build logger.
- Keep the existing Sentry warning capture for this failure path.

## Context

This addresses user-visible logging seen in build https://expo.dev/accounts/ammar_tripiq/projects/tripiq-client/builds/8f01c353-187b-4368-b569-94484d04220b where missing package.json caused:

```
Failed to report project dependencies metrics
ENOENT: no such file or directory, open '/Users/expo/workingdir/build/package.json'
```

## Validation

- yarn oxfmt packages/worker/src/external/analytics.ts
2026-05-22 16:09:55 +02:00
Stanisław Chmiela 18b1dd8d9d [build-tools] Preserve app entitlements when resigning iOS builds (#3785)
Not sure if this is the right fix, but sounds right?

## Summary

Updates the iOS build resign Fastfile to pass `use_app_entitlements: true` to Fastlane's `resign` action.

This makes EAS preserve and merge the app's existing code-signing entitlements when re-signing an IPA with a new provisioning profile, instead of signing directly with the broader provisioning-profile entitlements. This matters for capabilities like associated domains, where the profile may contain `*` but the app signature needs concrete values such as `applinks:...` for universal links to keep working.

Context: https://exponent-internal.slack.com/archives/C09H6RFAX27/p1779446992844929

## Validation

- `yarn install`
- `yarn workspace @expo/template-file build`
- `yarn workspace @expo/build-tools jest-unit fastfile.test.ts`
- `yarn oxfmt packages/build-tools/src/templates/FastfileResign.ts packages/build-tools/src/ios/__tests__/__snapshots__/fastfile.test.ts.snap`
2026-05-22 14:23:58 +02:00
Szymon Świerk fce2a77909 [eas-cli] Add --refresh-ad-hoc-provisioning-profile for non-interactive builds (#3716)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

When a new device is added to an account, and a non-interactive development build is run (e.g. invoked by CI or by a workflow job), even without the `freeze-credentials` flag, the provisioning profile is not refreshed, and the internal build is not available for the newly registered device. See the Linear issue: https://linear.app/expo/issue/ENG-7041/allow-including-all-eas-registered-devices-automatically-in-non

# How

Added an opt-in ad-hoc provisioning profile refresh to non-interactive builds with flag `--refresh-ad-hoc-provisioning-profile`.

When the flag is present, we hit `www` GQL API to get the ASC API key (the submission key), and we invoke the ad-hoc profile sync with all devices selected. `packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts` is where this logic happens.

The flag mustn't be present when `--freeze-credentials` is present, these are conflicting flags.

I'm leaning towards an opt-in flag instead of extending the default behavior because of the extra credentials configuration requirements and side effects of the provisioning profile refresh logic.

# Test Plan

Added/amended tests.

Manual verification:
1. Did `easd build --platform ios --profile development --non-interactive --refresh-ad-hoc-credential`, verified the provisioning profile matches the set of Apple devices on my account.
2. Changed the set of Apple devices on my account.
3. `easd build --platform ios --profile development --non-interactive --refresh-ad-hoc-credential` again, verified the re-generated provisioning profile matches the Apple devices on my account.
2026-05-22 10:41:42 +02:00
Expo CI c562f08da9 update CHANGELOG.md 2026-05-21 23:19:58 +00:00
Expo CI 5dee409567 v19.0.8 2026-05-21 23:17:53 +00:00
Gabe Debes ea2cd4f318 [eas-cli] eas go: use SDK version from current project when available (#3776)
* [eas-cli] eas go: use SDK version from current project when available

* fix fmt
2026-05-21 15:36:59 -07:00
Expo CI 639a7cce61 update CHANGELOG.md 2026-05-21 21:02:47 +00:00
Expo CI b0db6d1dfa v19.0.7 2026-05-21 21:00:27 +00:00
Szymon Dziedzic 783033acb9 [eas-cli] Add simulator:list command (#3775) 2026-05-21 22:58:13 +02:00
Expo CI 4366f517c1 update CHANGELOG.md 2026-05-21 17:08:37 +00:00
Expo CI 1e9a0981a5 v19.0.6 2026-05-21 17:05:52 +00:00
Szymon Dziedzic 43953d6f16 [eas-cli] Add --json flag to simulator:stop command (#3773)
# Why

`eas simulator:stop` only emitted human-readable spinner output, so
wrapper scripts and automation can't consume the resulting session id
and status. Sibling commands (e.g. `simulator:start`, `branch:rename`)
already expose `--json` for machine-readable output.

# How

- Swap `EASNonInteractiveFlag` for `EasNonInteractiveAndJsonFlags`
  (adds `--json`, preserves `--non-interactive`).
- Use `resolveNonInteractiveAndJsonFlags` so `--json` implies
  `--non-interactive`, matching `simulator:start`.
- Call `enableJsonOutput()` before any logging so spinner output is
  redirected to stderr when `--json` is set.
- On successful stop, emit `{ id, status }` via `printJsonOnlyOutput`.

# Test Plan

- `yarn typecheck`, `yarn lint`, and `yarn fmt:check` pass for the
  modified file.
- Manual: `eas simulator:stop --id <id> --json` writes only the JSON
  payload to stdout; spinner/log messages are redirected to stderr.
2026-05-21 18:03:46 +02:00
Krystof Woldrich 7bb8c0e749 feat(simulator): add get --json output (#3765) 2026-05-21 17:51:45 +02:00
Christian Falch 150176debc fix(precompile) changed min sdk-version for precompile to 5.0.26 (#3771)
* fix(precompile) changed min sdk-version for precompile to 5.0.26

After publishing new fixes also on SDK-55 for the precompile, we'd like to lock in on a higher version.

This commit fixes this by updating the tests and implementation to require 55.0.26 as the minimum sdk version.

* fixed changelog
2026-05-21 15:38:22 +02:00
Szymon Dziedzic fa6c877a81 [build-tools] Add start_argent_remote_session build function (#3757) 2026-05-20 20:16:26 +02:00
Expo CI 9ee8758aac update CHANGELOG.md 2026-05-20 18:14:13 +00:00
Expo CI d084e9ab9d v19.0.5 2026-05-20 18:11:50 +00:00
Stanisław Chmiela ec0ac26fc3 [eas-cli] Improve iOS config fallback Sentry diagnostics (#3763)
## Summary
- capture an explicit `iOS entitlements config fallback` Sentry message so this fallback is easier to find than the generic spawn error
- tag the event with app/build/config-resolution metadata
- include spawn failure details in a JSON extra, preserving empty stdout/stderr as `""`
- add a changelog entry

## Test plan
- `yarn workspace eas-cli typecheck`
- `git diff --check`
2026-05-20 19:37:12 +02:00
Expo CI 51fb25c689 update CHANGELOG.md 2026-05-20 15:31:46 +00:00
Expo CI 81cfeede9a v19.0.4 2026-05-20 15:28:33 +00:00
Stanisław Chmiela 4bff24b71c [eas-cli] Update eas-cli observe GraphQL types (#3762)
## Summary
- update eas-cli GraphQL schema/types for the new observe `uniqueActiveUserCount` field
- add the new `lastSeenUserCount` generated field from the PR diff

## Validation
- `yarn run -T oxfmt packages/eas-cli/src/graphql/types/Observe.ts packages/eas-cli/src/commands/observe/__tests__/versions.test.ts packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts packages/eas-cli/src/graphql/generated.ts packages/eas-cli/graphql.schema.json`
- `yarn workspace eas-cli typecheck`
2026-05-20 17:24:20 +02:00
Expo CI 503b62b73d v19.0.3 2026-05-20 15:02:48 +00:00
Mustafa Abban aaab3c98a1 [build-tools] place gradle profile step after run gradle (#3761)
* [build-tools] place gradle profile step after run gradle

* [build-tools] place gradle profile step after run gradle
2026-05-20 11:00:29 -04:00
Expo CI b64f25b36d v19.0.2 2026-05-20 14:39:34 +00:00
Ash Wu 1e209b00a8 [build-tools] Reduce xcactivitylog parser output size (#3760)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 22:29:59 +08:00
Stanisław Chmiela 6ec2ea0530 [eas-cli] Capture iOS config introspection fallback in Sentry (#3759)
## Summary
- capture local Expo CLI iOS entitlements introspection failures in Sentry before falling back to bundled config resolution
- tag the event with `app_id` from `EAS_BUILD_PROJECT_ID` and the config resolution path
- capture a short fallback message so this is easier to distinguish from generic spawn failures
- include the failed command stdout, stderr, and exit status as Sentry extras

## Why
If `expo config --json --type introspect` fails but the bundled config fallback succeeds, users no longer hit a hard failure. Capturing the original command failure gives us visibility into the remaining fallback cases without breaking builds, and the explicit message makes these events easier to identify.

## Validation

CI should pass.
2026-05-20 15:51:37 +02:00
Szymon Dziedzic d5a6b13dae [eas-cli] Add --json mode to simulator:start (#3751)
# Why

`eas simulator:start` currently only emits human-readable connect instructions, which is awkward to consume from scripts/automation. `--json` (which implies `--non-interactive`) makes the session id, job run URL, and remote config directly parseable.

# How

- Swapped `EASNonInteractiveFlag` for `EasNonInteractiveAndJsonFlags` and resolved both via `resolveNonInteractiveAndJsonFlags`, matching the pattern used by `eas upload` and other JSON-capable commands.
- Calls `enableJsonOutput()` early so spinners/log lines redirect to stderr when JSON is enabled.
- After the remote config is ready, emits a single JSON payload (`deviceRunSessionId`, `type`, `jobRunUrl`, `remoteConfig`) and returns without entering the interactive wait loop.
- Existing `--non-interactive` (without `--json`) behavior is preserved.

# Test Plan

- `yarn typecheck` reports no new errors (the two pre-existing `src/build/metadata.ts` errors are unrelated to this PR).
- `yarn run -T oxlint packages/eas-cli/src/commands/simulator/start.ts` → 0 errors.
- `yarn run -T oxfmt --check packages/eas-cli/src/commands/simulator/start.ts` → clean.
- Manual (TODO before merge): run `eas simulator:start --platform ios --json`, confirm a single JSON object lands on stdout while spinners/log output go to stderr, and that the payload contains the expected fields.
2026-05-20 12:47:00 +02:00
Ash Wu 41eb9f1499 [build-tools] Enable build time statistics for xcodebuild (#3750)
* [build-tools] Enable build time statistics for xcodebuild

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-20 15:45:59 +08:00
Ash Wu 5c9b93fc34 [build-tools] Pass ctx.env to parse xcactivitylog (#3758)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 15:08:55 +08:00
Ash Wu fbc57831cc [build-tools] Use pre-install xclogparser (#3747)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 09:40:19 +08:00
Mustafa Abban 34ca7b0ab9 [build-tools] ignore no Gradle profile found error (#3755)
* [build-tools] ignore no Gradle profile found error

* [build-tools] ignore no Gradle profile found error

* [build-tools] ignore no Gradle profile found error
2026-05-19 15:42:33 -04:00
Expo CI 7d6e4db4e8 update CHANGELOG.md 2026-05-19 18:28:20 +00:00
Expo CI 32355274ec v19.0.1 2026-05-19 18:26:02 +00:00
Stanisław Chmiela 50ca4cbec1 [eas-cli] Restore iOS entitlements introspection fallback (#3753)
## Summary
- wrap managed iOS `expo config --json --type introspect` entitlement resolution in a local Expo CLI try/catch
- fall back to bundled `getPrebuildConfigAsync` + `compileModsAsync`, matching the previous behavior
- include stderr/stdout in the thrown error when both the local CLI and fallback fail
- check `isExpoInstalled` before spawning the local Expo CLI, consistent with the regular config reader

## Why
After `18.13.1`, managed iOS builds fail hard if the local Expo CLI introspection command exits non-zero. Previously this path could still resolve entitlements through the bundled config fallback. Restoring that fallback limits the regression while preserving the local CLI path when it is available.

## Validation

CI should pass.
2026-05-19 20:24:00 +02:00
Stanisław Chmiela 2dbb4372d8 [eas-cli] Add EXPO_NO_DOTENV when resolving iOS entitlements back (#3752)
## Summary
- restore `EXPO_NO_DOTENV=1` when resolving managed iOS entitlements through `expo config --type introspect`
- keep the introspection environment consistent with the regular Expo config path

## Why
`18.13.1` changed this path to call the local Expo CLI directly, but the entitlements-specific call no longer disabled dotenv loading. This can make the iOS-only entitlements introspection evaluate app config under a different environment than the rest of EAS CLI config resolution.

## Validation

CI should pass. Repacks should work.
2026-05-19 20:14:45 +02:00
Expo CI 4bf38452a9 update CHANGELOG.md 2026-05-19 16:42:12 +00:00
Expo CI fc7c2950e7 v19.0.0 2026-05-19 16:38:56 +00:00
Byron Karlen 30248528ad [auth] Make browser-based login the default (#3746) 2026-05-19 09:19:39 -07:00
Mustafa Abban ecf58ab8a8 [build-tools] - Gradle build profile report with task execution breakdown (#3629)
* [build] gradle parse profiler

* gradleProfileTasks

* individual tasks nested underneath with tree characters

* filter out whats less than 1s

* parseGradleProfile

* profile flag

* enable by default

* gradleProfile utils

* parse profile HTML with fast-xml-parser

* parse profile HTML with fast-xml-parser

* error handling, send to sentry

* sentry capture
2026-05-19 12:07:36 -04:00
Douglas Lowder 10c7ac64ba [eas-cli] filter observe:routes results by route names (#3744)
# Why

The `eas observe:routes` command lacked the ability to filter navigation route results by specific route names. Users had no way to narrow down the routes data to only the routes they cared about.

# How

A new `--route-name` flag (repeatable) was added to the command. When one or more values are provided, they are collected into a `routeNames` array and forwarded to the `fetchObserveNavigationRoutesAsync` function, which passes them along in the GraphQL filter. An empty array is treated the same as omitting the flag — no `routeNames` key is sent in the filter.

Example:

```
$ easd observe:routes 
EAS Observe is in preview and subject to breaking changes.

Med values (navigation count) for the last 60 days

Android
Route           Nav Cold TTR  Nav Warm TTR  Nav TTI    
--------------  ------------  ------------  -----------
/               0.18s (63)    0.08s (217)   0.22s (280)
/task/[taskId]  0.55s (39)    0.17s (139)   0.35s (178)
/new            0.37s (20)    0.13s (83)    0.26s (103)
/stats          0.88s (19)    0.36s (94)    0.64s (113)
/settings       0.15s (13)    0.06s (41)    0.12s (54) 

iOS
Route           Nav Cold TTR  Nav Warm TTR  Nav TTI    
--------------  ------------  ------------  -----------
/               0.18s (106)   0.08s (504)   0.22s (610)
/task/[taskId]  0.51s (88)    0.18s (325)   0.34s (413)
/stats          1.06s (68)    0.34s (243)   0.62s (311)
/new            0.32s (67)    0.13s (219)   0.27s (286)
/settings       0.16s (17)    0.06s (87)    0.13s (104)

$ easd observe:routes --route-name /new --route-name /settings
EAS Observe is in preview and subject to breaking changes.

Med values (navigation count) for the last 60 days

Android
Route      Nav Cold TTR  Nav Warm TTR  Nav TTI    
---------  ------------  ------------  -----------
/new       0.37s (20)    0.13s (83)    0.26s (103)
/settings  0.15s (13)    0.06s (41)    0.12s (54) 

iOS
Route      Nav Cold TTR  Nav Warm TTR  Nav TTI    
---------  ------------  ------------  -----------
/new       0.32s (67)    0.13s (219)   0.27s (286)
/settings  0.16s (17)    0.06s (87)    0.13s (104)
```

# Test Plan

- New unit tests cover both the command flag parsing and the fetch function behavior
- Manual testing with the kadiexpo/cadence app in staging
2026-05-19 03:50:12 -07:00
Phil Pluckthun 7ee81e856b fix(eas-cli): Check if expo is resolvable before assuming it's installed (#3748)
* Add resolution check to `isExpoCommand` shared with `spawnExpoCommand`

* Add CHANGELOG entry
2026-05-19 11:02:14 +01:00
Gabe Debes 8328172827 [worker] report build phase duration to www metrics endpoint (#3732)
# Why

Turtle reports build-phase durations to Datadog. Orchestrator-routed builds don't, so we lose those stats post-migration.

Ref ENG-21208

# How

Adds a `Datadog` helper in `@expo/build-tools`, following the existing Sentry singleton pattern. Worker configures it once per task with the www API base URL, turtle build id, and robot token when a token is available. `BuildContext` emits `eas.build.phase_duration` distribution samples when build phases end, while the existing build phase stats callback remains only for launcher websocket reporting.

The payload matches what shipped in https://github.com/expo/universe/pull/27475: `POST /v2/turtle-builds/:buildId/metrics` with `metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value, tags }]`. Upload failures are captured through Sentry and never fail the build. Worker flushes pending metric sends before sending terminal build results.

# Test Plan

- CI
- `yarn exec oxfmt --check CHANGELOG.md packages/build-tools/src/datadog.ts packages/build-tools/src/index.ts packages/build-tools/src/context.ts packages/build-tools/src/__tests__/datadog.test.ts packages/build-tools/src/__tests__/context.test.ts packages/worker/src/context.ts packages/worker/src/service.ts packages/worker/src/__unit__/service.test.ts`
- `yarn workspace @expo/build-tools build`
- `yarn workspace @expo/build-tools jest-unit src/__tests__/datadog.test.ts src/__tests__/context.test.ts --runInBand`
- `yarn workspace @expo/worker test:unit packages/worker/src/__unit__/service.test.ts --runInBand`
2026-05-19 11:13:53 +02:00
Expo CI 29909b223d update CHANGELOG.md 2026-05-18 21:34:02 +00:00
Expo CI dbf597957d v18.13.1 2026-05-18 21:15:12 +00:00
Mustafa Abban 4d2b406dc5 [build-job] add GRADLE_PROFILE build phase enum (#3743) 2026-05-18 16:22:22 -04:00
Douglas Lowder 774195ff74 [eas-cli] Add direct dependency on debug (#3741)
# Why

The `Log.debug()` helper in `src/log.ts` has been calling `require('debug')` under an `eslint-disable` comment, relying on the package being resolved transitively through `@babel/*` and other dependencies.

# How

Declare `debug` and `@types/debug` directly so the require is stable under stricter resolution modes and so we can drop the `eslint-disable` and the runtime require in favor of a typed import.

Fixes #3630 .

# Test Plan

- Unit tests pass
- Tested various commands and tested with `DEBUG=eas:*`
2026-05-18 12:37:09 -07:00
Phil Pluckthun 255590cd45 refactor(eas-cli): Remove npx expo invocations and config/entitlements fallbacks (#3282)
* Expose raw `spawnExpoCommand` utility

* Remove use of `npx expo` invocation for `spawnExpoCommand`

* Replace manual `npx expo` invocation in onboarding with `expoCommandAsync`

* Remove npx invocation in "new" command

* Remove catch-case from `getExpoConfigInternalAsync`

* Remove catch-case from `getManagedApplicationTargetEntitlementsAsync`

* Force fallback to `@expo/config` in tests

This isn't great and shouldn't be done, but otherwise we're mocking the
expo command utility here which also isn't great

* Update commands.test.ts

* Add CHANGELOG entry

* Apply lints
2026-05-18 11:59:30 +01:00
Szymon Dziedzic d15641948c Improve tunnel perf by using constrained stream quality and dimensions (#3737)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

Use parameters to improve tunnel perf.

# How

Use quic cloudflared tunnel protocol + constrain stream max dimension + quality to improve speed.

# Test Plan

Tested locally.
2026-05-18 11:54:14 +02:00
Stanisław Chmiela 6cbcc8537c [eas-cli] Add expoPackageVersion to build metadata (#3725)
# Why

Persist the installed `expo` package version separately from `sdkVersion` so server-side log uploads can reproduce the legacy `expo_package_version` Datadog tag without conflating it with SDK version.

# How

- Added `expoPackageVersion` to build metadata schema/types.
- Moved the existing worker `getExpoPackageVersionAsync(...)` behavior into shared `@expo/eas-build-job#getInstalledExpoPackageVersionAsync(...)`: `node --print require.resolve('expo/package.json')`, `fs.readJson`, semver validation, and the same `EAS_BUILD_EXPO_PACKAGE_VERSION_*` `UserError`s.
- `collectMetadataAsync(...)` now resolves `expoPackageVersion` inline with `asyncResult(getInstalledExpoPackageVersionAsync(...)).value`, so build creation and the existing internal `updateBuildMetadata` path send it alongside the rest of the build metadata.
- Worker error-log tags now read `expo_package_version` from build metadata; the worker no longer resolves or mutates this field.

# Test Plan

CI should pass.
2026-05-15 16:32:13 +02:00
Expo CI 5ec4eaf052 update CHANGELOG.md 2026-05-14 20:15:30 +00:00
Expo CI 08a9b5ff7f v18.13.0 2026-05-14 20:13:13 +00:00
Douglas Lowder 501af5213e [eas-cli] Extract shared observe flag groups into observe/flags.ts (#3731)
# Why

It was found that observe commands were using common flag definitions with duplicate code.

# How

Refactored the observe commands to extract flag definitions.

# Test Plan

Commands tested locally.

Unit tests pass.
2026-05-14 13:10:13 -07:00
372 changed files with 43872 additions and 7314 deletions

No files matched your search

-1
View File
@@ -1,4 +1,3 @@
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->
# Why
+5 -5
View File
@@ -12,9 +12,9 @@ jobs:
contents: read
pull-requests: write
steps:
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag from
# a compromised upstream account would give the attacker repo write access.
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
# Pinned to a commit SHA (not a tag or branch) because this runs under
# pull_request_target with a write-scoped token — a re-pointed tag or
# moving branch would allow unreviewed upstream code to run here.
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
githubToken: ${{ secrets.GITHUB_TOKEN }}
+2 -1
View File
@@ -5,5 +5,6 @@
"**/*.code-search": true,
"**/src/**/build/**": false,
"**/src/build/**": false,
}
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
+202 -1
View File
@@ -8,12 +8,213 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add `eas project:delete` command to delete a project — the current directory's project by default, or any project passed as an argument (`@account/slug` or ID). Confirmation requires the project's full name, typed at the interactive prompt or passed via `--dangerously-confirm-deletion` (required in non-interactive mode). Deletion requires sudo mode: the command prompts for your password (and OTP) to upgrade the session when the server requires it. Deletion was previously only possible from the website. ([#4012](https://github.com/expo/eas-cli/pull/4012) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `eas browse` to open the project page on expo.dev in a web browser, with an optional page argument (e.g. `eas browse build`, `eas browse hosting`) for project subpages and a `--no-browser` flag to print the URL. `eas open` is now an alias of `eas browse`. ([#4003](https://github.com/expo/eas-cli/pull/4003) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] Add `--dev-domain` flag to `eas deploy` to choose the project's preview URL (`<name>.expo.app`) without prompting, allowing first-time deploys with `--non-interactive`. ([#4004](https://github.com/expo/eas-cli/pull/4004) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Add `-p` as an alias for `--platform` in `eas simulator:start`. ([#3902](https://github.com/expo/eas-cli/pull/3902) by [@brentvatne](https://github.com/brentvatne))
### 🐛 Bug fixes
### 🧹 Chores
- [eas-cli][build-tools][local-build-plugin][worker] Bump `tar` to `7.5.19`. ([#3971](https://github.com/expo/eas-cli/pull/3971) by [@szdziedzic](https://github.com/szdziedzic))
## [21.0.0](https://github.com/expo/eas-cli/releases/tag/v21.0.0) - 2026-07-13
### 🛠 Breaking changes
- [eas-cli] Remove `eas onboarding` command (aliases `init:onboarding`, `project:onboarding`). ([#3993](https://github.com/expo/eas-cli/pull/3993) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_flag_rollout` workflow function to update PostHog feature flags from workflow runs. ([#3944](https://github.com/expo/eas-cli/pull/3944) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_wait_for_metric` workflow function to gate workflow runs on a PostHog metric. ([#3945](https://github.com/expo/eas-cli/pull/3945) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_upload_sourcemaps` workflow function to upload source maps to PostHog. ([#3947](https://github.com/expo/eas-cli/pull/3947) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_annotation` workflow function to create PostHog annotations from workflow runs. ([#3948](https://github.com/expo/eas-cli/pull/3948) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/posthog_wait_for_query` workflow function to gate workflow runs on a PostHog query. ([#3949](https://github.com/expo/eas-cli/pull/3949) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] Rethrow Apple errors during capability sync instead of silently succeeding. ([#3989](https://github.com/expo/eas-cli/pull/3989) by [@douglowder](https://github.com/douglowder))
- [build-tools] Skip embedded bundle upload for development client builds instead of warning that the bundle is missing. ([#3940](https://github.com/expo/eas-cli/pull/3940) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Accept a navigation metric name in observe:metrics when passed in on the command line. ([#3973](https://github.com/expo/eas-cli/pull/3973) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Retry uploading assets that don't finish processing during `eas update`, instead of failing the update. ([#3918](https://github.com/expo/eas-cli/pull/3918) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] add agent detection to analytics events. ([#3983](https://github.com/expo/eas-cli/pull/3983) by [@davidmokos](https://github.com/davidmokos))
- Bump `glob` 10.x to a patched version in the root lockfile. ([#3979](https://github.com/expo/eas-cli/pull/3979) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Bump `jws` to a patched version in the root lockfile. ([#3978](https://github.com/expo/eas-cli/pull/3978) by [@szdziedzic](https://github.com/szdziedzic))
- [worker] Bump `koa` to `3.1.2`. ([#3974](https://github.com/expo/eas-cli/pull/3974) by [@szdziedzic](https://github.com/szdziedzic))
- [steps] Bump `cross-spawn` to a patched version in the TypeScript custom function fixture. ([#3977](https://github.com/expo/eas-cli/pull/3977) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `node-forge` to `1.4.0`. ([#3970](https://github.com/expo/eas-cli/pull/3970) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `fast-xml-builder` to `1.2.1` to resolve [Dependabot alert 400](https://github.com/expo/eas-cli/security/dependabot/400). ([#3966](https://github.com/expo/eas-cli/pull/3966) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `form-data` to patched releases to resolve [Dependabot alert 465](https://github.com/expo/eas-cli/security/dependabot/465). ([#3962](https://github.com/expo/eas-cli/pull/3962) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `fast-xml-parser` to `4.5.7` to resolve [Dependabot alert 286](https://github.com/expo/eas-cli/security/dependabot/286). ([#3960](https://github.com/expo/eas-cli/pull/3960) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Bump `shell-quote` to `1.9.0` to resolve [Dependabot alert 440](https://github.com/expo/eas-cli/security/dependabot/440). ([#3959](https://github.com/expo/eas-cli/pull/3959) by [@szdziedzic](https://github.com/szdziedzic))
## [20.5.1](https://github.com/expo/eas-cli/releases/tag/v20.5.1) - 2026-07-01
### 🐛 Bug fixes
- [eas-cli] Fix Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
### 🎉 New features
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
### 🎉 New features
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
### 🐛 Bug fixes
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
### 🎉 New features
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
### 🎉 New features
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
### 🎉 New features
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
### 🎉 New features
- [eas-cli] `eas go` now prompts to select an Expo SDK version interactively when `--sdk-version` is not provided. ([#3768](https://github.com/expo/eas-cli/pull/3768) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:upload` command. ([#3720](https://github.com/expo/eas-cli/pull/3720) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
### 🎉 New features
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
### 🐛 Bug fixes
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
### 🛠 Breaking changes
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
### 🎉 New features
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
### 🎉 New features
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
### 🎉 New features
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.12.3",
"version": "21.0.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2
View File
@@ -3,4 +3,6 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
bin/record-sim
.secrets*
!.secrets.example
+11 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.12.3",
"version": "21.0.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -23,6 +23,7 @@
"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",
"typecheck": "tsc",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
@@ -37,19 +38,20 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.12.0",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.0.1",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/logger": "21.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.12.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/steps": "21.0.1",
"@expo/template-file": "21.0.0",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
@@ -70,13 +72,13 @@
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.7",
"tar": "7.5.19",
"uuid": "9.0.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.4.1",
"@expo/repack-app": "~0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -0,0 +1,4 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
@@ -0,0 +1,28 @@
// 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"]),
]
)
@@ -0,0 +1,79 @@
# 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`.
@@ -0,0 +1,192 @@
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()
}
}
@@ -0,0 +1,96 @@
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
}
}
@@ -0,0 +1,70 @@
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 struct RecordingManifest: Codable, Sendable {
public let firstFrameWallClock: FirstFrameWallClock
public let hlsVersion: Int?
public let hlsTargetDurationSeconds: Int?
public let hlsMediaSequence: Int?
public let recording: String?
public let initSegment: String?
public let segments: [MediaSegmentRecord]
}
@@ -0,0 +1,136 @@
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
) 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)
),
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
}
}
}
@@ -0,0 +1,632 @@
import AVFoundation
import CoreVideo
import Foundation
import IOSurface
import UniformTypeIdentifiers
public final class SimulatorRecorder {
public var onSegment: ((SegmentOutput) -> Void)?
public var onSimulatorStopped: ((String) -> 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 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
}
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
}
guard let firstAcceptedWallClock else {
throw RecorderError.make(25, "Missing first frame wall-clock timestamp")
}
return try outputWriter.writeManifest(
configuration: configuration,
firstFrameWallClock: firstAcceptedWallClock
)
}
}
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
}
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
}
}
}
@@ -0,0 +1,60 @@
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
}
}
@@ -0,0 +1,143 @@
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 stopRequested: CInt = 0
private var simulatorStopped: CInt = 0
private func handleStopSignal(_: CInt) {
stopRequested = 1
}
private func isStopRequested() -> Bool {
stopRequested != 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.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)
}
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)
}
@@ -1,14 +1,38 @@
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import {
BuildPhase,
BuildPhaseResult,
BuildTrigger,
Job,
Metadata,
Platform,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { Datadog } from '../datadog';
jest.mock('fs');
jest.mock('fs-extra');
jest.mock('../datadog', () => ({
Datadog: {
distribution: jest.fn(),
log: jest.fn(),
},
}));
const datadogDistributionMock = jest.mocked(Datadog.distribution);
const datadogLogMock = jest.mocked(Datadog.log);
describe('BuildContext', () => {
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
});
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
@@ -116,4 +140,139 @@ describe('BuildContext', () => {
foo: 'bar',
});
});
it('overwrites existing environment variables when updating env', async () => {
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
EXISTING_ENV: 'old-value',
REMOVED_ENV: 'old-value',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateEnv({
EXISTING_ENV: 'new-value',
REMOVED_ENV: undefined,
NEW_ENV: 'new-env-value',
});
expect(ctx.env.EXISTING_ENV).toBe('new-value');
expect(ctx.env.REMOVED_ENV).toBeUndefined();
expect(ctx.env.NEW_ENV).toBe('new-env-value');
const logMessage = datadogLogMock.mock.calls[0][0];
expect(logMessage).toContain('BuildContext.updateEnv merge order produced different env');
expect(logMessage).toContain('different_env_names=');
expect(logMessage).toContain('EXISTING_ENV');
expect(logMessage).toContain('REMOVED_ENV');
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('old-value');
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-value');
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('new-env-value');
});
it('logs when updateEnv merge order produces the same env without values', async () => {
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
EXISTING_ENV: 'same-value',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateEnv({
EXISTING_ENV: 'same-value',
UNSET_ENV: undefined,
});
expect(datadogLogMock).toHaveBeenCalledWith(
'BuildContext.updateEnv merge order produced same env'
);
expect(JSON.stringify(datadogLogMock.mock.calls)).not.toContain('same-value');
});
it('emits build phase duration metrics for successful build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.IOS });
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'run_fastlane',
platform: Platform.IOS,
result: BuildPhaseResult.SUCCESS,
}
);
});
it('emits build phase duration metrics for failed build phases', async () => {
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
await expect(
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
throw new Error('boom');
})
).rejects.toThrow();
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
{
build_phase: 'install_dependencies',
platform: Platform.ANDROID,
result: BuildPhaseResult.FAIL,
}
);
});
it('does not emit build phase duration metrics for non-platform jobs', async () => {
const ctx = createTestBuildContext({});
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
expect(datadogDistributionMock).not.toHaveBeenCalled();
});
});
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
vol.mkdirSync('/workingdir/env', { recursive: true });
return new BuildContext(
{
...(platform ? { platform } : {}),
builderEnvironment: { env: {} },
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
}
@@ -0,0 +1,407 @@
import { Response } from 'node-fetch';
import { Datadog } from '../datadog';
import { Sentry } from '../sentry';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
const turtleFetchMock = jest.mocked(turtleFetch);
const sentryCaptureMock = jest.mocked(Sentry.capture);
describe('Datadog singleton', () => {
beforeEach(() => {
jest.clearAllMocks();
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1234, {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1234,
tags: {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
target: 'build',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-builds/logs',
'POST',
{
json: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
target: 'build',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
target: 'job_run',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('is a no-op when setup is null', () => {
Datadog.setup(null);
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows metric upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1,
tags: { target: 'build' },
},
],
},
})
);
});
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
await flushPromises();
expect(turtleFetchMock).toHaveBeenCalled();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle build log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: { target: 'build' },
},
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: { target: 'job_run' },
},
},
})
);
});
it('uses the latest setup options', () => {
turtleFetchMock.mockResolvedValue({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
Datadog.log('artifact dry-run matched');
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
})
);
});
it('flushes pending metric uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.distribution('eas.build.phase_duration', 1);
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('flushes pending log uploads', async () => {
let resolveUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
Datadog.log('artifact dry-run matched');
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
await Promise.resolve();
expect(flushed).toBe(false);
resolveUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
await new Promise(resolve => setImmediate(resolve));
}
@@ -111,6 +111,37 @@ describe(runGenericJobAsync, () => {
expect(rejected).toBe(true);
});
it('passes the job hooks into the steps parser', async () => {
// @expo/steps is fully mocked here — assert the plumbing only (real
// insertion behavior is covered by the parser's own tests).
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockCtx.job.hooks = { before_install_node_modules: [{ run: 'echo hi' }] };
await runGenericJobAsync(mockCtx);
expect(StepsConfigParser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hooks: mockCtx.job.hooks })
);
});
it('passes hooks: undefined into the steps parser for a hook-less job (dormancy)', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await runGenericJobAsync(mockCtx);
expect(StepsConfigParser).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ hooks: undefined })
);
});
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
mockCtx.expoApiV2BaseUrl = undefined;
@@ -0,0 +1,329 @@
import fetch from 'node-fetch';
import { RuntimeSettings } from '../runtimeSettings';
import { Sentry } from '../sentry';
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe('runtimeSettings', () => {
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
};
const originalPlatform = process.platform;
afterEach(() => {
jest.mocked(fetch).mockReset();
jest.restoreAllMocks();
jest.mocked(Sentry.capture).mockReset();
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
});
beforeEach(async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
jest.mocked(fetch).mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'production' });
await RuntimeSettings.loadAsync({ environment: 'test' });
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not use caches by default when remote settings are unavailable', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({}, 404));
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
extras: {
status: 404,
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
});
});
it('does not override existing settings when remote settings are invalid', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest
.mocked(fetch)
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
await RuntimeSettings.loadAsync({ environment: 'staging' });
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to load worker runtime settings',
expect.any(Error),
{
extras: {
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
}
);
});
it('accepts partial runtime settings and ignores unknown fields', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false, nodejs: true, maven: true },
darwin: { npm: true, maven: true, cocoapods: false },
},
iosPrecompiledModules: true,
unknownFutureSetting: true,
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
});
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { maven: true },
darwin: { maven: true, cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({ environment: 'staging' });
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('returns null when job env enables cache but worker env var is empty', async () => {
process.env.EAS_NPM_CACHE_URL = '';
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use cache URLs from the job environment', async () => {
await RuntimeSettings.loadAsync({
environment: 'test',
env: {
EAS_USE_NPM_CACHE: '1',
EAS_NPM_CACHE_URL: 'https://job-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_NPM_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
});
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
},
});
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
});
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
env: {
EAS_USE_NPM_CACHE: '0',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.isolateModules(() => {
const { RuntimeSettings } = require('../runtimeSettings');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
});
});
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
mockProcessPlatform('darwin');
await RuntimeSettings.loadAsync({ environment: 'staging' });
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Runtime settings are missing cache settings for platform "darwin"',
}),
{
extras: {
configuredPlatforms: ['linux'],
platform: 'darwin',
},
level: 'error',
}
);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
@@ -10,7 +10,10 @@ import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { createTestIosJob } from './job';
import { createMockLogger } from './logger';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
export class MockContextProvider implements ExternalBuildContextProvider {
private _env: BuildStepEnv = {};
@@ -72,6 +75,19 @@ export function createStepContextMock({
});
}
export function createCustomBuildContextMock(): CustomBuildContext {
const ctx = new BuildContext(createTestIosJob({}), {
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '',
});
return new CustomBuildContext(ctx);
}
export function createGlobalContextMock({
logger,
skipCleanup,
+43 -49
View File
@@ -1,12 +1,10 @@
import { Android, Env, Job, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
export async function ensureLFLineEndingsInGradlewScript<TJob extends Job>(
ctx: BuildContext<TJob>
@@ -31,55 +29,51 @@ export async function runGradleCommand(
logger.info(`Running 'gradlew ${gradleCommand}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const shouldResetOOMScore =
ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux';
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
});
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
await spawnPromise;
await spawn(
'bash',
[
'-c',
getGradleShellCommand({
gradleCommand,
oomScoreAdj: shouldResetOOMScore ? 0 : undefined,
verboseFlag,
}),
],
{
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: {
...ctx.env,
...extraEnv,
...resolveVersionOverridesEnvs(ctx),
LC_ALL: 'C.UTF-8',
},
}
);
}
/**
* OOM Killer sometimes kills worker server while build is exceeding memory limits.
* `oom_score_adj` is a value between -1000 and 1000 and it defaults to 0.
* It defines which process is more likely to get killed (higher value more likely).
*
* This function sets oom_score_adj for Gradle process and all its child processes.
*/
function adjustOOMScore(spawnPromise: SpawnPromise<SpawnResult>, logger: bunyan): void {
setTimeout(
async () => {
try {
assert(spawnPromise.child.pid);
const pids = await getParentAndDescendantProcessPidsAsync(spawnPromise.child.pid);
await Promise.all(
pids.map(async (pid: number) => {
// Value 800 is just a guess here. It's probably higher than most other
// process. I didn't want to set it any higher, because I'm not sure if OOM Killer
// can start killing processes when there is still enough memory left.
const oomScoreOverride = 800;
await fs.writeFile(`/proc/${pid}/oom_score_adj`, `${oomScoreOverride}\n`);
})
);
} catch (err: any) {
logger.debug({ err, stderr: err?.stderr }, 'Failed to override oom_score_adj');
}
},
// Wait 20 seconds to make sure all child processes are started
20000
);
export function getGradleShellCommand({
gradleCommand,
oomScoreAdj,
verboseFlag,
}: {
gradleCommand: string;
oomScoreAdj?: number;
verboseFlag: string;
}): string {
const oomScoreAdjPrefix =
oomScoreAdj === undefined ? '' : `echo ${oomScoreAdj} > /proc/$$/oom_score_adj || true; `;
return `${oomScoreAdjPrefix}exec ./gradlew ${gradleCommand} --profile ${verboseFlag}`;
}
// Version envs should be set at the beginning of the build, but when building
@@ -0,0 +1,231 @@
import { XMLParser } from 'fast-xml-parser';
import fs from 'fs-extra';
import path from 'path';
export interface GradleProfileTask {
path: string;
durationMs: number;
result: string;
}
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
// Gradle profile may not exist for all builds (e.g. custom, repack
// jobs). We should short circuit and not unnecessarily log.
if (!(await fs.pathExists(profileDir))) {
return [];
}
const files = await fs.readdir(profileDir);
const htmlFile = files
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
.sort()
.pop();
if (!htmlFile) {
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
}
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
// Locate the <h2>Task Execution</h2> heading and extract its <table>
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
if (!headingMatch || headingMatch.index === undefined) {
throw new Error('Could not find Task Execution section in Gradle profile');
}
const sectionStart = headingMatch.index;
const tableStart = html.indexOf('<table', sectionStart);
const tableEnd = html.indexOf('</table>', tableStart);
if (tableStart === -1 || tableEnd === -1) {
throw new Error('Could not find task execution table in Gradle profile');
}
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
const parser = new XMLParser({
ignoreAttributes: true,
isArray: name => name === 'tr' || name === 'td',
trimValues: true,
});
const parsed = parser.parse(tableHtml);
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
const tasks: GradleProfileTask[] = [];
for (const row of rows) {
const cells: unknown[] = row?.td;
if (!cells || cells.length < 2) {
continue;
}
const taskPath = String(cells[0] ?? '').trim();
const durationStr = String(cells[1] ?? '').trim();
const result = String(cells[2] ?? '').trim() || 'executed';
if (!taskPath || !durationStr) {
continue;
}
const durationMs = parseDurationToMs(durationStr);
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
}
return tasks;
}
function parseDurationToMs(duration: string): number {
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
const secondsMatch = duration.match(/^([\d.]+)s$/);
if (secondsMatch) {
return Math.round(parseFloat(secondsMatch[1]) * 1000);
}
return 0;
}
function formatSeconds(ms: number): string {
const s = ms / 1000;
if (s < 0.1) {
return `${ms}ms`;
}
return `${s.toFixed(1)}s`;
}
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
// Filter out tasks under 1 second
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
// Separate module totals from individual tasks
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
// Group individual tasks by their module prefix
const moduleChildren = new Map<string, GradleProfileTask[]>();
const orphanTasks: GradleProfileTask[] = [];
for (const task of individualTasks) {
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
if (parent) {
const children = moduleChildren.get(parent.path) ?? [];
children.push(task);
moduleChildren.set(parent.path, children);
} else {
orphanTasks.push(task);
}
}
// Sort module totals by duration, sort children within each module
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
for (const children of moduleChildren.values()) {
children.sort((a, b) => b.durationMs - a.durationMs);
}
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
// Build display rows: [displayName, task]
const rows: { displayName: string; task: GradleProfileTask }[] = [];
for (const mod of sortedModules) {
rows.push({ displayName: mod.path, task: mod });
const children = moduleChildren.get(mod.path) ?? [];
for (let i = 0; i < children.length; i++) {
const isLast = i === children.length - 1;
const prefix = isLast ? ' └─ ' : ' ├─ ';
const shortName = children[i].path.slice(mod.path.length + 1);
rows.push({ displayName: prefix + shortName, task: children[i] });
}
}
for (const task of orphanTasks) {
rows.push({ displayName: task.path, task });
}
// Compute totals from individual tasks only (avoid double-counting)
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
const maxMs = totalMs || 1;
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
const barMaxWidth = 20;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const taskCount = individualTasks.length;
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
const lines: string[] = [];
lines.push('Gradle Build — Task Execution Profile');
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
lines.push('% Time = share of total task execution time');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Task'.padEnd(nameWidth) +
' │ ' +
'Duration'.padStart(10) +
' │ ' +
'% Time'.padStart(8) +
' │ ' +
'Result'.padEnd(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const row of rows) {
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
const result = row.task.result === '(total)' ? 'total' : row.task.result;
lines.push(
'│ ' +
row.displayName.padEnd(nameWidth) +
' │ ' +
formatSeconds(row.task.durationMs).padStart(10) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(8) +
' │ ' +
result.padEnd(10) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalMs).padStart(10) +
' │ ' +
'100.0%'.padStart(8) +
' │ ' +
' '.repeat(10) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -4,7 +4,9 @@ import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
@@ -56,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
uploadEmbeddedBundleAsync: jest.fn(),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
@@ -67,8 +72,11 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
@@ -138,7 +146,6 @@ describe(androidBuilder, () => {
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
@@ -157,23 +164,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
@@ -182,23 +189,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
@@ -214,7 +221,6 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
@@ -234,15 +240,19 @@ describe(androidBuilder, () => {
versionName: '1.2.3',
}
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
@@ -263,4 +273,37 @@ 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(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(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(),
});
await androidBuilder(ctx);
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
});
});
@@ -7,6 +7,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { Datadog } from '../../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
const datadogLogSpy = jest.spyOn(Datadog, 'log');
beforeEach(() => {
const job = createTestIosJob();
@@ -27,10 +29,24 @@ describe(runCustomBuildAsync, () => {
vol.fromJSON(
{
'test.yaml': `
functions:
test_function:
name: Test function
path: ./custom-function
shell: /bin/zsh
supported_platforms:
- darwin
- linux
inputs:
- name
- profile
outputs:
- artifact_path
build:
steps:
- eas/checkout
`,
'custom-function/package.json': '{}',
},
'/workingdir/temporary-custom-build'
);
@@ -77,6 +93,21 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('reports user-provided custom functions when parsing the custom build config', async () => {
await runCustomBuildAsync(ctx);
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: 'test_function',
custom_function_input_count: '2',
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
custom_function_name: 'Test function',
custom_function_output_count: '1',
custom_function_shell: '/bin/zsh',
custom_function_supported_runtime_platforms: 'darwin,linux',
});
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
@@ -10,6 +10,8 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
@@ -30,6 +32,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
@@ -180,6 +183,20 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
try {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -192,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
+33 -1
View File
@@ -1,5 +1,11 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
import {
BuildConfigParser,
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
errors,
} from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
@@ -9,6 +15,7 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
@@ -56,6 +63,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
externalFunctions: easFunctions,
externalFunctionGroups: easFunctionGroups,
steps: ctx.job.steps,
hooks: ctx.job.hooks,
})
: new BuildConfigParser(globalContext, {
externalFunctions: easFunctions,
@@ -81,6 +89,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
throw parseError;
}
});
logUserProvidedCustomFunctions(workflow);
try {
try {
await workflow.executeAsync();
@@ -103,3 +112,26 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
return ctx.artifacts;
}
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
for (const buildFunction of Object.values(workflow.buildFunctions)) {
if (!buildFunction.customFunctionModulePath) {
continue;
}
Datadog.log('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: buildFunction.getFullId(),
custom_function_module_path: buildFunction.customFunctionModulePath,
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
...(buildFunction.supportedRuntimePlatforms
? {
custom_function_supported_runtime_platforms:
buildFunction.supportedRuntimePlatforms.join(','),
}
: {}),
});
}
}
+25 -6
View File
@@ -16,6 +16,7 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
@@ -24,6 +25,7 @@ import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
@@ -173,17 +175,28 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds skip build performance analysis.');
return;
}
try {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
const { skipped } = await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
env: ctx.env,
});
});
}
if (skipped) {
ctx.markBuildPhaseSkipped();
}
} catch (err: any) {
Sentry.capture('Failed to parse xcactivitylog', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
@@ -197,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
});
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
await uploadEmbeddedBundleAsync(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
@@ -1,4 +1,4 @@
import { BuildJob } from '@expo/eas-build-job';
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
@@ -69,4 +69,131 @@ describe('easBuildInternal', () => {
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'build:internal',
'--platform',
Platform.IOS,
'--profile',
'preview',
'--refresh-ad-hoc-provisioning-profile',
]),
expect.any(Object)
);
});
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
const { newJob } = await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
});
@@ -0,0 +1,256 @@
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { PackageManager } from '../../utils/packageManager';
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(2);
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
env: {
EAS_VERBOSE: '1',
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Failed to install dependencies using npm cache registry',
name: 'NpmCacheRegistryInstallError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
status: 1,
signal: null,
},
}
);
});
it('reports non-fatal npm cache registry errors from successful installs', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
options.lineTransformer(auditErrorLine);
options.lineTransformer(
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
);
return createSpawnPromise(Promise.resolve(createSpawnResult()));
});
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Non-fatal npm cache registry error during dependency install',
name: 'NpmCacheRegistryNonFatalError',
}),
{
level: 'warning',
tags: {
packageManager: 'npm',
},
extras: {
cwd: '/tmp/build',
npmCacheUrl,
useFrozenLockfile: false,
firstErrorLine: auditErrorLine,
errorLineCount: 1,
},
}
);
});
it('does not retry when install failure does not reference npm cache registry', async () => {
const logger = createMockLogger();
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: 'npm error command sh -c node postinstall.js',
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: 'http://npm.staging.caches.eas-build.internal',
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('does not retry when the npm cache is not enabled', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
const error = Object.assign(
new Error('npm install --include=dev exited with non-zero code: 1'),
{
status: 1,
signal: null,
stdout: '',
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
}
);
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
await expect(
installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
})
).rejects.toThrow(error);
expect(spawn).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('installs through the npm cache registry without retrying when the cache install succeeds', async () => {
const logger = createMockLogger();
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
jest
.mocked(spawn)
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: PackageManager.NPM,
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
},
logger,
cwd: '/tmp/build',
useFrozenLockfile: false,
});
expect(spawn).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledWith('npm', ['install', '--include=dev'], {
cwd: '/tmp/build',
logger,
infoCallbackFn: undefined,
lineTransformer: expect.any(Function),
env: {
EAS_USE_NPM_CACHE: '1',
EAS_BUILD_NPM_CACHE_URL: npmCacheUrl,
NPM_CONFIG_REGISTRY: npmCacheUrl,
},
});
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
return Object.assign(result, {
child: {
pid: 123,
},
}) as SpawnPromise<SpawnResult>;
}
function createSpawnResult(): SpawnResult {
return {
pid: 123,
output: ['', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -1,4 +1,12 @@
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import {
BuildJob,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -42,6 +50,11 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
try {
const result = await spawn(
cmd,
@@ -53,6 +66,7 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
cwd,
@@ -138,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
@@ -1,8 +1,9 @@
import { Job } from '@expo/eas-build-job';
import { Job, SystemError } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -11,6 +12,7 @@ export async function installDependenciesAsync({
env,
logger,
infoCallbackFn,
lineTransformer,
cwd,
useFrozenLockfile,
}: {
@@ -19,6 +21,7 @@ export async function installDependenciesAsync({
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
lineTransformer?: SpawnOptions['lineTransformer'];
useFrozenLockfile: boolean;
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
let args: string[];
@@ -69,11 +72,108 @@ export async function installDependenciesAsync({
cwd,
logger,
infoCallbackFn,
lineTransformer,
env,
}),
};
}
export async function installDependenciesWithNpmCacheFallbackAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
}: {
packageManager: PackageManager;
env: Record<string, string | undefined>;
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
useFrozenLockfile: boolean;
}): Promise<void> {
const npmCacheUrl = env.EAS_BUILD_NPM_CACHE_URL;
if (!npmCacheUrl) {
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
return;
}
logger.info(`Installing dependencies using the npm cache registry (${npmCacheUrl}).`);
let firstErrorLine: string | undefined;
let errorLineCount = 0;
try {
await (
await installDependenciesAsync({
packageManager,
env: { ...env, NPM_CONFIG_REGISTRY: npmCacheUrl },
logger,
infoCallbackFn,
lineTransformer: (line: string) => {
if (isNpmCacheRegistryErrorLine(line, npmCacheUrl)) {
firstErrorLine ??= line;
errorLineCount += 1;
}
return line;
},
cwd,
useFrozenLockfile,
})
).spawnPromise;
if (firstErrorLine) {
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
level: 'warning',
tags: { packageManager },
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
});
}
} catch (err: unknown) {
if (!isNpmCacheInstallFailure(err, npmCacheUrl)) {
throw err;
}
logger.warn(
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(err), {
level: 'warning',
tags: { packageManager },
extras: {
cwd,
npmCacheUrl,
useFrozenLockfile,
originalErrorMessage: err instanceof Error ? err.message : String(err),
status: err instanceof Error ? (err as any).status : undefined,
signal: err instanceof Error ? (err as any).signal : undefined,
},
});
await (
await installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
})
).spawnPromise;
}
}
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
@@ -87,3 +187,36 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
}
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+12 -12
View File
@@ -2,7 +2,10 @@ import { BuildJob } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { SpawnOptions } from '@expo/turtle-spawn';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import {
installDependenciesWithNpmCacheFallbackAsync,
resolvePackagerDir,
} from './installDependencies';
import { BuildContext } from '../context';
import { runExpoCliCommand } from '../utils/project';
@@ -30,17 +33,14 @@ export async function prebuildAsync<TJob extends BuildJob>(
options: spawnOptions,
packageManager: ctx.packageManager,
});
const installDependenciesSpawnPromise = (
await installDependenciesAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
})
).spawnPromise;
await installDependenciesSpawnPromise;
await installDependenciesWithNpmCacheFallbackAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
});
}
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
}
`),
{
buildId: ctx.env.EAS_BUILD_ID,
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
projectMetadataFile: {
type: 'GCS',
bucketKey: uploadSession.bucketKey,
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
+60 -8
View File
@@ -2,7 +2,6 @@ import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
@@ -20,6 +19,7 @@ import fs from 'fs-extra';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { Datadog } from './datadog';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
@@ -45,6 +45,7 @@ export type ArtifactToUpload =
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
};
export interface BuildContextOptions {
@@ -62,9 +63,8 @@ export interface BuildContextOptions {
reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
@@ -72,6 +72,41 @@ export interface BuildContextOptions {
export class SkipNativeBuildError extends Error {}
function logEnvComparison({
oldMergeOrderEnv,
newMergeOrderEnv,
}: {
oldMergeOrderEnv: Env;
newMergeOrderEnv: Env;
}): void {
const envNames = new Set([...Object.keys(oldMergeOrderEnv), ...Object.keys(newMergeOrderEnv)]);
const sameEnvNames: string[] = [];
const differentEnvNames: string[] = [];
for (const key of envNames) {
const hasOldValue = key in oldMergeOrderEnv;
const hasNewValue = key in newMergeOrderEnv;
if (hasOldValue === hasNewValue && oldMergeOrderEnv[key] === newMergeOrderEnv[key]) {
sameEnvNames.push(key);
} else {
differentEnvNames.push(key);
}
}
if (differentEnvNames.length === 0) {
Datadog.log('BuildContext.updateEnv merge order produced same env');
return;
}
Datadog.log(
`BuildContext.updateEnv merge order produced different env: compared_env_count=${
envNames.size
} different_env_count=${differentEnvNames.length} different_env_names=${differentEnvNames.join(
','
)} same_env_count=${sameEnvNames.length} same_env_names=${sameEnvNames.join(',')}`
);
}
export class BuildContext<TJob extends Job = Job> {
public readonly workingdir: string;
public logger: bunyan;
@@ -80,7 +115,7 @@ export class BuildContext<TJob extends Job = Job> {
public readonly reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
@@ -97,7 +132,6 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -113,7 +147,6 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -249,11 +282,24 @@ export class BuildContext<TJob extends Job = Job> {
'Updating environment variables is only allowed when build was triggered by a git-based integration.'
);
}
this._env = {
const oldMergeOrderEnv: Env = {
...env,
...this._env,
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
};
const newMergeOrderEnv: Env = {
...this._env,
...env,
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
};
logEnvComparison({
oldMergeOrderEnv,
newMergeOrderEnv,
});
this._env = newMergeOrderEnv;
this._env.PATH = this._env.PATH
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
: this.buildExecutablesDirectory;
@@ -354,7 +400,13 @@ export class BuildContext<TJob extends Job = Job> {
}
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
platform: this.job.platform,
result,
});
}
if (!doNotMarkEnd) {
this.logger.info(
+11 -1
View File
@@ -9,12 +9,18 @@ import {
StaticJobInterpolationContext,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
import {
BuildRuntimePlatform,
ExternalBuildContextProvider,
StepMetric,
WorkflowHookMetric,
} from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
import { ArtifactToUpload, BuildContext } from './context';
import { reportWorkflowHookMetricToDatadog } from './utils/hookMetrics';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
@@ -165,6 +171,10 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.pendingMetricUploads.push(p);
}
public reportWorkflowHookMetric(metric: WorkflowHookMetric): void {
reportWorkflowHookMetricToDatadog(metric);
}
public async drainPendingMetricUploads(): Promise<void> {
await Promise.allSettled(this.pendingMetricUploads);
}
+97
View File
@@ -0,0 +1,97 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
robotAccessToken: string;
target: MetricsTarget;
};
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
},
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags: withTargetTag(tags, target),
},
];
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
extras: { metrics },
});
});
pendingUploads.push(uploadPromise);
},
log(message: string, tags: Record<string, string> = {}): void {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
pendingUploads.push(uploadPromise);
},
async flushAsync(): Promise<void> {
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
},
};
+1
View File
@@ -47,6 +47,7 @@ export async function runGenericJobAsync(
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
steps: ctx.job.steps,
hooks: ctx.job.hooks,
});
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
+5
View File
@@ -20,6 +20,11 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
export * from './runtimeSettings';
@@ -9,6 +9,7 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
@@ -22,6 +23,7 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -39,6 +41,7 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -53,6 +56,7 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
@@ -67,6 +71,7 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -81,6 +86,7 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -1,5 +1,6 @@
import { Ios } from '@expo/eas-build-job';
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
@@ -8,6 +9,10 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/eas-build-job', () => ({
...jest.requireActual('@expo/eas-build-job'),
getInstalledExpoPackageVersionAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
@@ -21,10 +26,12 @@ function makeIosBuildContext({
expoVersion,
cocoapodsCacheUrl,
usePrecompiledModules = true,
expoUsePrecompiledModules,
}: {
expoVersion?: string;
cocoapodsCacheUrl?: string;
usePrecompiledModules?: boolean;
expoUsePrecompiledModules?: string;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
@@ -52,6 +59,9 @@ function makeIosBuildContext({
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: {
__API_SERVER_URL: 'http://api.expo.test',
...(expoUsePrecompiledModules
? { EXPO_USE_PRECOMPILED_MODULES: expoUsePrecompiledModules }
: {}),
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
},
uploadArtifact: jest.fn(),
@@ -61,14 +71,10 @@ function makeIosBuildContext({
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
});
(spawn as jest.Mock).mockResolvedValue(undefined);
});
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
@@ -76,17 +82,12 @@ describe(installPods, () => {
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
projectDir: PROJECT_DIR,
});
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
@@ -130,14 +131,14 @@ describe(installPods, () => {
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
)
);
});
@@ -147,7 +148,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
@@ -158,7 +159,7 @@ describe(installPods, () => {
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
@@ -180,4 +181,23 @@ describe(installPods, () => {
).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
it('does not override EXPO_USE_PRECOMPILED_MODULES=0 even when builder flag is enabled', async () => {
const ctx = makeIosBuildContext({
expoVersion: '999.0.0',
usePrecompiledModules: true,
expoUsePrecompiledModules: '0',
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBe('0');
expect(
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.'
);
expect(getInstalledExpoPackageVersionAsync).not.toHaveBeenCalled();
});
});
+2 -2
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
@@ -93,7 +93,7 @@ export async function runFastlane(
cwd,
}: {
logger?: bunyan;
env?: Record<string, string>;
env?: Env;
cwd?: string;
} = {}
): Promise<SpawnResult> {
+12 -17
View File
@@ -1,12 +1,11 @@
import { Env, Ios } from '@expo/eas-build-job';
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
@@ -47,13 +46,21 @@ export async function installPods<TJob extends Ios.Job>(
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
if (ctx.env.EXPO_USE_PRECOMPILED_MODULES === '0') {
ctx.logger.info('EXPO_USE_PRECOMPILED_MODULES=0 is set; not enabling precompiled modules use.');
return {};
}
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
env: ctx.env,
projectDir: ctx.getReactNativeProjectDirectory(),
});
} catch (err) {
ctx.logger.info(
{ err },
@@ -93,18 +100,6 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
return PRECOMPILED_MODULES_BASE_URL;
+138
View File
@@ -0,0 +1,138 @@
import { Env } from '@expo/eas-build-job';
import fetch from 'node-fetch';
import { z } from 'zod';
import { Sentry } from './sentry';
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
};
const RuntimeSettingsSchema = z
.object({
caches: z.record(
z.string(),
z
.object({
npm: z.boolean(),
nodejs: z.boolean(),
maven: z.boolean(),
cocoapods: z.boolean(),
})
.partial()
),
iosPrecompiledModules: z.boolean(),
})
.partial();
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let runtimeEnvironment: Env = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
env: nextRuntimeEnvironment = {},
}: {
environment: string;
env?: Env;
}): Promise<void> {
runtimeEnvironment = nextRuntimeEnvironment;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000),
});
if (!response.ok) {
Sentry.capture('Failed to fetch worker runtime settings', {
extras: { url, status: response.status },
level: 'warning',
});
return;
}
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
Sentry.capture(
new Error(
`Runtime settings are missing cache settings for platform "${process.platform}"`
),
{
extras: {
configuredPlatforms: Object.keys(runtimeSettings.caches),
platform: process.platform,
},
level: 'error',
}
);
}
} catch (err) {
Sentry.capture(
'Failed to load worker runtime settings',
err instanceof Error ? err : new Error(String(err)),
{
extras: { url },
level: 'warning',
}
);
}
}
export function isUsingIosPrecompiledModulesEnabled(): boolean {
return runtimeSettings.iosPrecompiledModules ?? false;
}
export function getNpmCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
}
export function getNodeJsCacheUrl(): string | null {
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
}
export function getMavenCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
}
export function getCocoapodsCacheUrl(): string | null {
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
return null;
}
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
if (runtimeEnabled === false) {
return null;
}
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
}
}
@@ -0,0 +1,117 @@
import { HookAnchorId } from '@expo/eas-build-job';
import { StepsConfigParser } from '@expo/steps';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../__tests__/utils/context';
import { getEasFunctionGroups } from '../easFunctionGroups';
import { getEasFunctions } from '../easFunctions';
jest.mock('fs');
describe('hook anchor self-declarations', () => {
it('exactly the four v0 functions declare anchors, with these bindings', () => {
const declarations: Record<string, HookAnchorId> = {};
for (const buildFunction of getEasFunctions(createCustomBuildContextMock())) {
if (buildFunction.__hookId !== undefined) {
declarations[buildFunction.getFullId()] = buildFunction.__hookId;
}
}
// An empty declaration set must fail: without these, every user-authored
// unstamped `uses:` step and every `eas/build` expansion loses hooks.
expect(declarations).toEqual({
'eas/checkout': 'checkout',
'eas/install_node_modules': 'install_node_modules',
'eas/upload_to_asc': 'submit',
'eas/maestro_tests': 'maestro_tests',
});
});
it('no function group id collides with a declaring function id', () => {
// Group lookup shadows function lookup in StepsConfigParser, so a
// collision would silently unbind the anchor.
const declaringFunctionIds = getEasFunctions(createCustomBuildContextMock())
.filter(buildFunction => buildFunction.__hookId !== undefined)
.map(buildFunction => buildFunction.getFullId());
const functionGroupIds = getEasFunctionGroups(createCustomBuildContextMock()).map(
functionGroup => functionGroup.getFullId()
);
for (const functionId of declaringFunctionIds) {
expect(functionGroupIds).not.toContain(functionId);
}
});
it('attaches hooks inside a uses: eas/build expansion (declaration-driven anchor discovery)', async () => {
// This is the guard against a semantically stale declaration — the
// declaration existence test alone cannot catch a binding that no longer
// corresponds to what the group actually expands to.
const customBuildCtx = createCustomBuildContextMock();
const globalContext = createGlobalContextMock({});
const parser = new StepsConfigParser(globalContext, {
steps: [{ uses: 'eas/build' }],
hooks: {
before_install_node_modules: [{ run: 'echo before', id: 'before-hook' }],
after_install_node_modules: [{ run: 'echo after', id: 'after-hook' }],
},
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
});
const workflow = await parser.parseAsync();
const installStep = workflow.buildSteps.find(step => step.__hookId === 'install_node_modules');
expect(installStep).toBeDefined();
const anchorHooks = workflow.hooksByAnchorStep.get(installStep!);
expect(anchorHooks?.anchor).toBe('install_node_modules');
expect(anchorHooks?.before[0].steps[0].id).toBe('before-hook');
expect(anchorHooks?.after[0].steps[0].id).toBe('after-hook');
});
it.each([
['eas/checkout', 'before_checkout', 'after_checkout', undefined],
[
'eas/install_node_modules',
'before_install_node_modules',
'after_install_node_modules',
undefined,
],
[
'eas/upload_to_asc',
'before_submit',
'after_submit',
{
ipa_path: 'app.ipa',
asc_api_key_path: 'key.p8',
apple_app_identifier: '1234567890',
bundle_version: '1',
bundle_short_version: '1.0.0',
},
],
[
'eas/maestro_tests',
'before_maestro_tests',
'after_maestro_tests',
{ flow_path: ['flow.yaml'] },
],
] as const)(
'an unstamped user-authored %s step fires its hook keys end-to-end',
async (functionId, beforeKey, afterKey, callInputs) => {
const customBuildCtx = createCustomBuildContextMock();
const globalContext = createGlobalContextMock({});
const parser = new StepsConfigParser(globalContext, {
steps: [{ uses: functionId, ...(callInputs !== undefined ? { with: callInputs } : null) }],
hooks: {
[beforeKey]: [{ run: 'echo before', id: 'before-hook' }],
[afterKey]: [{ run: 'echo after', id: 'after-hook' }],
},
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
});
const workflow = await parser.parseAsync();
expect(workflow.hooksByAnchorStep.size).toBe(1);
const anchorHooks = [...workflow.hooksByAnchorStep.values()][0];
expect(anchorHooks.before[0].steps[0].id).toBe('before-hook');
expect(anchorHooks.after[0].steps[0].id).toBe('after-hook');
}
);
});
+22 -2
View File
@@ -1,6 +1,7 @@
import { BuildFunction } from '@expo/steps';
import { calculateEASUpdateRuntimeVersionFunction } from './functions/calculateEASUpdateRuntimeVersion';
import { createCapturePosthogEventFunction } from './functions/capturePosthogEvent';
import { createCheckoutBuildFunction } from './functions/checkout';
import { configureAndroidVersionFunction } from './functions/configureAndroidVersion';
import { configureEASUpdateIfInstalledFunction } from './functions/configureEASUpdateIfInstalled';
@@ -13,6 +14,7 @@ import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { createFinishIosSimulatorRecordingsBuildFunction } from './functions/finishIosSimulatorRecordings';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
@@ -37,16 +39,24 @@ import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
import { createPosthogAnnotationFunction } from './functions/createPosthogAnnotation';
import { createWaitForPosthogQueryFunction } from './functions/waitForPosthogQuery';
import { CustomBuildContext } from '../customBuildContext';
export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
@@ -60,7 +70,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createDownloadBuildFunction(ctx),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -81,14 +91,24 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
createStartIosSimulatorRecordingsBuildFunction(),
createFinishIosSimulatorRecordingsBuildFunction(),
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
createStartServeSimRemoteSessionBuildFunction(ctx),
createInstallMaestroBuildFunction(),
createInstallPodsBuildFunction(),
createSendSlackMessageFunction(),
createCapturePosthogEventFunction(),
createRolloutPosthogFlagFunction(),
createWaitForPosthogMetricFunction(),
createUploadPosthogSourcemapsFunction(),
createPosthogAnnotationFunction(),
createWaitForPosthogQueryFunction(),
calculateEASUpdateRuntimeVersionFunction(),
@@ -96,7 +116,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
createMaestroTestsBuildFunction(ctx),
];
if (ctx.hasBuildJob()) {
@@ -0,0 +1,176 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createCapturePosthogEventFunction } from '../capturePosthogEvent';
jest.mock('@expo/logger');
jest.mock('node-fetch');
describe(createCapturePosthogEventFunction, () => {
const fetchMock = jest.mocked(fetch);
const captureEvent = createCapturePosthogEventFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string>
): ReturnType<typeof captureEvent.createBuildStepFromFunctionCall> {
return captureEvent.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: captureEvent.id,
});
}
it('sends the event to the ingestion endpoint using the env key', async () => {
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
const step = createStep(
{ event: 'workflow_finished', distinct_id: 'account-1' },
{
EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test',
EXPO_PUBLIC_POSTHOG_HOST: 'https://us.posthog.com',
}
);
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/i/v0/e/');
const body = JSON.parse(init?.body as string);
expect(body).toMatchObject({
api_key: 'phc_test',
event: 'workflow_finished',
distinct_id: 'account-1',
});
expect(body.properties?.$process_person_profile).toBeUndefined();
});
it('sends an anonymous system event when no distinct_id is given', async () => {
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await step.executeAsync();
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.distinct_id).toBe('eas-workflow');
expect(body.properties.$process_person_profile).toBe(false);
});
it('skips when no api key is available and ignore_error is set', async () => {
const step = createStep(
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
{}
);
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error\.$/)
);
});
it('logs and does not throw on a non-2xx response when ignore_error is set', async () => {
fetchMock.mockResolvedValue({
status: 400,
ok: false,
text: async () => '',
} as unknown as Response);
const step = createStep(
{ event: 'workflow_finished', distinct_id: 'account-1', ignore_error: true },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Sending PostHog event "workflow_finished" failed with status 400. Ignoring error.'
);
});
it('does not let properties override the anonymous marker', async () => {
fetchMock.mockResolvedValue({ status: 200, ok: true } as Response);
const step = createStep(
{ event: 'workflow_finished', properties: { $process_person_profile: true, foo: 'bar' } },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await step.executeAsync();
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.properties.$process_person_profile).toBe(false);
expect(body.properties.foo).toBe('bar');
});
it('throws on a network error by default', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await expect(step.executeAsync()).rejects.toThrow(
'Sending PostHog event "workflow_finished" failed.'
);
});
it('logs and does not throw on a network error when ignore_error is set', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep(
{ event: 'workflow_finished', ignore_error: true },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Sending PostHog event "workflow_finished" failed. Ignoring error.'
);
});
it('throws with the PostHog error detail on failure by default', async () => {
fetchMock.mockResolvedValue({
status: 400,
ok: false,
text: async () => JSON.stringify({ detail: 'Invalid event name' }),
} as unknown as Response);
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: Invalid event name/);
});
it('falls back to the raw error body when PostHog omits a detail', async () => {
fetchMock.mockResolvedValue({
status: 400,
ok: false,
text: async () => JSON.stringify({ error: 'nope' }),
} as unknown as Response);
const step = createStep(
{ event: 'workflow_finished' },
{ EXPO_PUBLIC_POSTHOG_API_KEY: 'phc_test' }
);
await expect(step.executeAsync()).rejects.toThrow(/failed with status 400: {"error":"nope"}/);
});
it('throws on missing key by default', async () => {
const step = createStep({ event: 'workflow_finished' }, {});
await expect(step.executeAsync()).rejects.toThrow(/PostHog API key not provided/);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,138 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createPosthogAnnotationFunction } from '../createPosthogAnnotation';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
} as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createPosthogAnnotationFunction, () => {
const fetchMock = jest.mocked(fetch);
const annotation = createPosthogAnnotationFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof annotation.createBuildStepFromFunctionCall> {
return annotation.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: annotation.id,
});
}
it('posts the annotation with an explicit date_marker', async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
const step = createStep({ content: 'Released abc123', date_marker: '2026-07-03T00:00:00Z' });
await step.executeAsync();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/api/projects/123/annotations/');
expect(JSON.parse(init?.body as string)).toEqual({
content: 'Released abc123',
date_marker: '2026-07-03T00:00:00Z',
});
});
it('defaults date_marker to now when not given', async () => {
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }, { status: 201 }));
const step = createStep({ content: 'Deploy' });
await step.executeAsync();
const body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string);
expect(body.content).toBe('Deploy');
expect(typeof body.date_marker).toBe('string');
});
it('throws when credentials are missing by default', async () => {
const step = createStep({ content: 'Deploy' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('warns and skips when credentials are missing and ignore_error is set', async () => {
const step = createStep({ content: 'Deploy', ignore_error: true }, {});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
it('throws on a network error by default', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep({ content: 'Deploy' });
await expect(step.executeAsync()).rejects.toThrow(/Creating the PostHog annotation failed/);
});
it('warns and continues on a network error when ignore_error is set', async () => {
fetchMock.mockRejectedValue(new Error('ECONNRESET'));
const step = createStep({ content: 'Deploy', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
it('throws on a 403 without swallowing it', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ content: 'Deploy' });
await expect(step.executeAsync()).rejects.toThrow(/annotation:write/);
});
it('throws with the PostHog error detail on a non-2xx response', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ detail: 'Invalid date_marker' }, { ok: false, status: 400 })
);
const step = createStep({ content: 'Deploy' });
await expect(step.executeAsync()).rejects.toThrow(
/failed with status 400: Invalid date_marker/
);
});
it('warns and continues on a non-2xx response when ignore_error is set', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ content: 'Deploy', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
});
@@ -1,4 +1,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
@@ -17,22 +19,63 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
applicationArchiveUrl,
error,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
data: {
builds: {
byId: {
id: randomUUID(),
platform: 'IOS',
artifacts: {
applicationArchiveUrl: applicationArchiveUrl ?? null,
},
},
},
},
}
);
return {
query: jest.fn().mockReturnValue({ toPromise }),
} as unknown as Client;
}
describe('downloadBuild', () => {
it('should handle an archive', async () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(artifactPath).toBeDefined();
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
@@ -40,16 +83,21 @@ describe('downloadBuild', () => {
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: applicationArchiveUrl,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
@@ -58,18 +106,114 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
message: 'Build does not have an application archive url',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws UserError when GraphQL returns a client error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Build not found' }],
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
})
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
describe('createDownloadBuildFunction', () => {
it('should download a build', async () => {
const downloadBuild = createDownloadBuildFunction();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
const logger = createMockLogger();
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
}),
{
callInputs: {
build_id: randomUUID(),
build_id: buildId,
extensions: ['app'],
},
}
@@ -105,5 +253,9 @@ describe('createDownloadBuildFunction', () => {
} as unknown as Response);
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
});
});
@@ -0,0 +1,90 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? '1.41.0\n' : '',
})) as any);
});
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
});
});
it('does not report to Datadog outside EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {},
});
await step.executeAsync();
expect(Datadog.distribution).not.toHaveBeenCalled();
});
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro'
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
});
});
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
});
@@ -4,10 +4,14 @@ import { vol } from 'memfs';
import {
copyLatestAttemptXml,
isFileAttrRun,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from '../maestroResultParser';
describe(parseMaestroResults, () => {
@@ -298,7 +302,169 @@ describe(parseMaestroResults, () => {
});
});
describe(parseMaestroResultsFromFileAttrs, () => {
it('uses file= as the path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results[0]).toEqual(
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
);
});
it('keeps two same-named flows separate by their file= path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('returns per-attempt results keyed by file= across attempt files', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
'/junit/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
]);
});
it('returns empty array when the directory does not exist', async () => {
const results = await parseMaestroResultsFromFileAttrs('/nope');
expect(results).toEqual([]);
});
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
});
});
describe('junitFileHasFileAttrs', () => {
it('returns true when every testcase has a non-empty file= attribute', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
});
it('returns false when any testcase lacks file= (mixed report)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when file= is an empty string', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false for a legacy report with no file= attributes', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when the file cannot be read', async () => {
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
});
});
describe('isFileAttrRun', () => {
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
expect(isFileAttrRun([])).toBe(false);
});
});
describe(parseJUnitTestCases, () => {
it('extracts the file= attribute when present', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].file).toBe('.maestro/login.yaml');
});
it('treats a missing or empty file= attribute as undefined', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
});
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -582,6 +748,100 @@ describe(parseJUnitTestCases, () => {
});
});
describe(parseFailedFlowsFromFileAttrs, () => {
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/proj/b/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['b/login.yaml']);
});
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/flows/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['flows/login.yaml']);
});
it('resolves and existence-checks an absolute file= path', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/outside/flow.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['/outside/flow.yaml']);
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
@@ -765,6 +1025,28 @@ describe('mergeJUnitReports', () => {
expect(b.failure).toBeUndefined();
});
it('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
const testcases = parsed.testsuites.testsuite.testcase;
const files = testcases.map((t: any) => t['@_file']).sort();
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('throws when source directory contains no *.xml files', async () => {
// No per-attempt JUnit files were ever written (maestro crashed before
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
@@ -0,0 +1,287 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
parseFailureScreenshotFilename,
selectFailureScreenshots,
} from '../maestroScreenshots';
describe(parseFailureScreenshotFilename, () => {
it('parses a plain failure screenshot', () => {
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('parses a sharded failure screenshot', () => {
expect(
parseFailureScreenshotFilename('screenshot-shard-2-❌-1781186692250-(Login Flow).png')
).toEqual({
flowName: 'Login Flow',
capturedAtMs: 1781186692250,
});
});
it('handles parentheses inside the flow name (right-to-left parse)', () => {
expect(
parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login (staging) Flow).png')
).toEqual({
flowName: 'Login (staging) Flow',
capturedAtMs: 1781186692250,
});
});
it('rejects non-failure screenshots (✅, ⚠️) and unrelated files', () => {
expect(
parseFailureScreenshotFilename('screenshot-✅-1781186692250-(Login Flow).png')
).toBeNull();
expect(parseFailureScreenshotFilename('commands-(Login Flow).json')).toBeNull();
expect(parseFailureScreenshotFilename('android-maestro-junit-attempt-0.xml')).toBeNull();
});
});
describe(harvestFailureScreenshotsAsync, () => {
const logger = createMockLogger();
let testsDirectory: string;
beforeEach(async () => {
testsDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-harvest-test-'));
});
afterEach(async () => {
await fs.rm(testsDirectory, { recursive: true, force: true });
});
async function makeDebugDir(name: string, files: string[], mtimeMs: number): Promise<void> {
const dir = path.join(testsDirectory, name);
await fs.mkdir(dir);
for (const file of files) {
await fs.writeFile(path.join(dir, file), '');
}
const when = new Date(mtimeMs);
await fs.utimes(dir, when, when);
}
it('returns shots from dirs modified at/after sinceMtimeMs, attributed to the attempt', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'attempt-dir',
['screenshot-❌-1781186692250-(Login Flow).png', 'commands.json'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([
{
fileAbsPath: path.join(
testsDirectory,
'attempt-dir',
'screenshot-❌-1781186692250-(Login Flow).png'
),
displayName: 'Failure Screenshot: Login Flow (attempt 2)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login Flow',
attemptIndex: 1,
capturedAtMs: 1781186692250,
},
},
]);
});
it('ignores dirs older than sinceMtimeMs (previous attempts)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'old-dir',
['screenshot-❌-1781186692250-(Old Flow).png'],
sinceMtimeMs - 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
it('scans multiple new dirs (maestro <2.5.0 split-dir bug)', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'dir-a',
[`screenshot-❌-${sinceMtimeMs + 100}-(Flow A).png`],
sinceMtimeMs + 1_000
);
await makeDebugDir(
'dir-b',
[`screenshot-❌-${sinceMtimeMs + 200}-(Flow B).png`],
sinceMtimeMs + 2_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual(['Flow A', 'Flow B']);
});
it('keeps the disk flow name (slashes already substituted) and strips the shard prefix', async () => {
const sinceMtimeMs = 1_000_000;
await makeDebugDir(
'sharded-dir',
['screenshot-shard-3-❌-1781186692250-(Login_Sub Flow).png'],
sinceMtimeMs + 1_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 0,
logger,
});
expect(shots).toHaveLength(1);
expect(shots[0].metadata.flowName).toBe('Login_Sub Flow');
});
it('ignores a stale screenshot whose capturedAtMs predates sinceMtimeMs (dir touched later)', async () => {
const sinceMtimeMs = 1_000_000;
// The dir mtime is AFTER this attempt started (so it passes the dir gate), but the
// screenshot inside was captured BEFORE it — a prior attempt's shot in a dir touched later.
await makeDebugDir(
'touched-dir',
['screenshot-❌-999000-(Stale Flow).png'],
sinceMtimeMs + 5_000
);
const shots = await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: sinceMtimeMs,
attemptIndex: 1,
logger,
});
expect(shots).toEqual([]);
});
it('logs and returns [] when the tests dir cannot be read', async () => {
const shots = await harvestFailureScreenshotsAsync({
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
capturedSinceMs: 0,
attemptIndex: 0,
logger,
});
expect(shots).toEqual([]);
});
});
describe(computePureFailureFlowNames, () => {
it('classifies a flow that failed every attempt as pure', () => {
const pure = computePureFailureFlowNames([
{ name: 'Login Flow', status: 'failed' },
{ name: 'Login Flow', status: 'failed' },
]);
expect([...pure]).toEqual(['Login Flow']);
});
it('excludes a flaky flow (failed then passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'Flaky Flow', status: 'failed' },
{ name: 'Flaky Flow', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a fail-pass-fail flow (it passed at some point)', () => {
const pure = computePureFailureFlowNames([
{ name: 'FPF', status: 'failed' },
{ name: 'FPF', status: 'passed' },
{ name: 'FPF', status: 'failed' },
]);
expect(pure.size).toBe(0);
});
it('excludes a flow that only passed', () => {
expect(computePureFailureFlowNames([{ name: 'Happy', status: 'passed' }]).size).toBe(0);
});
it('normalizes the JUnit name to the screenshot form (/ -> _)', () => {
const pure = computePureFailureFlowNames([{ name: 'sub/Login', status: 'failed' }]);
expect([...pure]).toEqual(['sub_Login']);
});
it('excludes flows that collide only after / -> _ normalization (one failed, one passed)', () => {
const pure = computePureFailureFlowNames([
{ name: 'sub/Login', status: 'failed' },
{ name: 'sub_Login', status: 'passed' },
]);
expect(pure.size).toBe(0);
});
it('returns an empty set for no testcases', () => {
expect(computePureFailureFlowNames([]).size).toBe(0);
});
});
describe(selectFailureScreenshots, () => {
function makeShot(flowName: string, attemptIndex: number): HarvestedScreenshot {
return {
fileAbsPath: `/tmp/${flowName}-${attemptIndex}.png`,
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1_000 + attemptIndex,
},
};
}
it('keeps only the final attempt for a pure-failure flow', () => {
const shots = [makeShot('Pure', 0), makeShot('Pure', 1), makeShot('Pure', 2)];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([2]);
});
it('keeps every attempt for a flow not classified pure (flaky)', () => {
const shots = [makeShot('Flaky', 0), makeShot('Flaky', 1)];
const selected = selectFailureScreenshots(shots, new Set());
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([0, 1]);
});
it('reduces pure flows while preserving flaky flows and input order', () => {
const shots = [
makeShot('Pure', 0),
makeShot('Flaky', 0),
makeShot('Pure', 1),
makeShot('Flaky', 1),
];
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
expect(selected.map(shot => `${shot.metadata.flowName}#${shot.metadata.attemptIndex}`)).toEqual(
['Flaky#0', 'Pure#1', 'Flaky#1']
);
});
it('keeps all when the pure set is empty', () => {
const shots = [makeShot('A', 0), makeShot('A', 1)];
expect(selectFailureScreenshots(shots, new Set())).toHaveLength(2);
});
});
@@ -1,10 +1,15 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { CustomBuildContext } from '../../../customBuildContext';
import * as discovery from '../maestroFlowDiscovery';
import * as parser from '../maestroResultParser';
import * as maestroScreenshots from '../maestroScreenshots';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
@@ -17,7 +22,29 @@ jest.mock('../../../utils/retry', () => ({
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
// Partial mock: stub the harvest (filesystem) but keep the real reduction helpers
// (computePureFailureFlowNames / selectFailureScreenshots) the step now calls post-loop.
jest.mock('../maestroScreenshots', () => ({
...jest.requireActual('../maestroScreenshots'),
harvestFailureScreenshotsAsync: jest.fn(),
}));
const mockedSpawn = jest.mocked(spawn);
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
const mockUploadArtifact = jest.fn();
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
return {
fileAbsPath: path.join(os.tmpdir(), `src-screenshot-${index}.png`),
displayName: 'Failure Screenshot: Login (attempt 1)',
metadata: {
kind: 'maestro-test-screenshot',
flowName: 'Login',
attemptIndex: 0,
capturedAtMs: 1781186692250,
},
};
}
const SPAWN_SUCCESS = {
status: 0,
@@ -41,7 +68,10 @@ function createStep(
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext;
const fn = createMaestroTestsBuildFunction(ctx);
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
@@ -54,10 +84,18 @@ describe('createMaestroTestsBuildFunction', () => {
// hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
mockedHarvest.mockReset();
mockedHarvest.mockResolvedValue([]);
mockUploadArtifact.mockReset();
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
expect(
createMaestroTestsBuildFunction({
runtimeApi: { uploadArtifact: mockUploadArtifact },
} as unknown as CustomBuildContext)
).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
@@ -273,6 +311,33 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const newParseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
.mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(newParseSpy).toHaveBeenCalledTimes(1);
// The new branch needs no name→path map: no scan, no legacy parser.
expect(discoverySpy).not.toHaveBeenCalled();
expect(legacyParseSpy).not.toHaveBeenCalled();
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
@@ -293,6 +358,44 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('never scans flow files when all attempts succeed', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await step.executeAsync();
expect(discoverySpy).not.toHaveBeenCalled();
});
it('memoizes the flow scan across retries (legacy reports)', async () => {
// Routing note: the step lands in the legacy arm because the real
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
// the mock fs. Creating that file with file= attrs would flip the branch.
mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const discoverySpy = jest
.spyOn(discovery, 'buildFlowNameToPathMap')
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/a.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Two failed attempts hit the legacy branch twice; the scan runs once.
expect(parseSpy).toHaveBeenCalledTimes(2);
expect(discoverySpy).toHaveBeenCalledTimes(1);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
@@ -639,4 +742,149 @@ describe('createMaestroTestsBuildFunction', () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
it('uploads harvested screenshots after the retry loop with metadata', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
})
);
});
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('swallows upload errors without affecting the test verdict', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shot = makeShot(0);
await fs.writeFile(shot.fileAbsPath, '');
mockedHarvest.mockResolvedValue([shot]);
mockUploadArtifact.mockRejectedValue(new Error('upload boom'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
});
it('skips screenshot upload but preserves the verdict when the JUnit re-parse throws', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockRejectedValue(new Error('malformed junit'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// The classify throw is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('skips screenshot upload but preserves the verdict when the staging dir cannot be created', async () => {
mockedSpawn.mockRejectedValue(rejectExit1());
mockedHarvest.mockResolvedValue([makeShot(0)]);
jest.spyOn(parser, 'parseJUnitTestCases').mockResolvedValue([]);
jest.spyOn(fs, 'mkdtemp').mockRejectedValue(new Error('no tmp space'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
// mkdtemp failure is swallowed; the maestro failure verdict still surfaces.
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockUploadArtifact).not.toHaveBeenCalled();
});
it('caps uploads at 30 screenshots (excess dropped)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const shots = Array.from({ length: 35 }, (_, index) => makeShot(index));
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
});
it('does not harvest or upload screenshots when output_format is not junit', 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();
});
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const tc = (name: string, status: 'passed' | 'failed'): parser.JUnitTestCaseResult => ({
name,
file: undefined,
status,
duration: 0,
errorMessage: null,
tags: [],
properties: {},
});
jest
.spyOn(parser, 'parseJUnitTestCases')
.mockResolvedValue([
tc('PureFlow', 'failed'),
tc('FlakyFlow', 'failed'),
tc('FlakyFlow', 'passed'),
]);
const makeShotFor = (
flowName: string,
attemptIndex: number
): maestroScreenshots.HarvestedScreenshot => ({
fileAbsPath: path.join(os.tmpdir(), `wiring-${flowName}-${attemptIndex}.png`),
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName,
attemptIndex,
capturedAtMs: 1781186692250 + attemptIndex,
},
});
const shots = [
makeShotFor('PureFlow', 0),
makeShotFor('PureFlow', 1),
makeShotFor('FlakyFlow', 0),
makeShotFor('FlakyFlow', 1),
];
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
mockedHarvest.mockResolvedValue(shots);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
await step.executeAsync();
// PureFlow keeps only its final attempt (1); FlakyFlow keeps every failed attempt.
// Uploads run concurrently, so compare the set, not the order.
const uploaded = mockUploadArtifact.mock.calls
.map(([arg]) => `${arg.artifact.metadata.flowName}#${arg.artifact.metadata.attemptIndex}`)
.sort();
expect(uploaded).toEqual(['FlakyFlow#0', 'FlakyFlow#1', 'PureFlow#1']);
});
});
@@ -27,7 +27,7 @@ describe(createRepackBuildFunction, () => {
vol.reset();
});
it('should set the output path for successful repack', async () => {
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -38,14 +38,33 @@ describe(createRepackBuildFunction, () => {
{
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app',
output_path: '/path/to/output_app',
source_app_path: '/path/to/source_app.ipa',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app');
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/);
});
it('should rename generated aab output path to apk', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
createGlobalContextMock({
staticContextContent: {
job: createTestAndroidJob(),
},
}),
{
callInputs: {
platform: 'android',
source_app_path: '/path/to/source_app.aab',
},
}
);
await repackStep.executeAsync();
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/);
});
it('should throw for unsupported platforms', async () => {
@@ -3,6 +3,7 @@ import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
@@ -43,15 +44,24 @@ describe(createReportMaestroTestResultsFunction, () => {
} as unknown as Client;
});
// Returns a mock logger whose child() is itself, so step-level log calls can
// be asserted on the returned object.
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
const logger = createMockLogger();
(logger.child as jest.Mock).mockReturnValue(logger);
return logger;
}
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
logger?: ReturnType<typeof createMockLogger>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
logger: overrides?.logger ?? createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
@@ -105,6 +115,54 @@ describe(createReportMaestroTestResultsFunction, () => {
]);
});
it('reports both flows when two share a name but have distinct file= paths', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
'a/login.yaml',
'b/login.yaml',
]);
});
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
},
});
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(discoverySpy).not.toHaveBeenCalled();
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -221,7 +279,7 @@ describe(createReportMaestroTestResultsFunction, () => {
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -238,8 +296,27 @@ describe(createReportMaestroTestResultsFunction, () => {
}),
});
await createStep().executeAsync();
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
});
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
});
it('sends per-attempt results with same name but different retryCount', async () => {
@@ -0,0 +1,443 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createRolloutPosthogFlagFunction } from '../rolloutPosthogFlag';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
} as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createRolloutPosthogFlagFunction, () => {
const fetchMock = jest.mocked(fetch);
const rolloutFlag = createRolloutPosthogFlagFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof rolloutFlag.createBuildStepFromFunctionCall> {
return rolloutFlag.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: rolloutFlag.id,
});
}
it('enables a flag by key', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await step.executeAsync();
const [patchUrl, patchInit] = fetchMock.mock.calls[1];
expect(patchUrl).toBe('https://us.posthog.com/api/projects/123/feature_flags/42/');
expect(patchInit?.method).toBe('PATCH');
expect(JSON.parse(patchInit?.body as string)).toEqual({ active: true });
});
it('sets rollout_percentage only on the catch-all group, preserving others', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
results: [
{
id: 7,
key: 'gradual',
filters: {
groups: [
{ properties: [{ key: 'is_internal' }], rollout_percentage: 100 },
{ properties: [], rollout_percentage: 5 },
],
},
},
],
})
)
.mockResolvedValueOnce(jsonResponse({ id: 7 }));
const step = createStep({ flag: 'gradual', rollout_percentage: '50' });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups).toEqual([
{ properties: [{ key: 'is_internal' }], rollout_percentage: 100 },
{ properties: [], rollout_percentage: 50 },
]);
});
it('creates a catch-all group when the flag has none', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 8, key: 'bare', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 8 }));
const step = createStep({ flag: 'bare', rollout_percentage: '25' });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups).toEqual([{ properties: [], rollout_percentage: 25 }]);
});
it('falls back to the first group with a warning when no catch-all exists', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
results: [
{
id: 9,
key: 'scoped',
filters: { groups: [{ properties: [{ key: 'is_internal' }] }] },
},
],
})
)
.mockResolvedValueOnce(jsonResponse({ id: 9 }));
const step = createStep({ flag: 'scoped', rollout_percentage: '10' });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups).toEqual([
{ properties: [{ key: 'is_internal' }], rollout_percentage: 10 },
]);
expect(warnMock).toHaveBeenCalledWith(expect.stringMatching(/no catch-all/));
});
it('throws when the flag key does not exist', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [{ id: 1, key: 'other-flag' }] }));
const step = createStep({ flag: 'missing-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(
/No PostHog feature flag with key "missing-flag"/
);
});
it('throws on a 403 even when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
});
it('throws when none of active, rollout_percentage, or payload is provided', async () => {
const step = createStep({ flag: 'my-flag' });
await expect(step.executeAsync()).rejects.toThrow(
/"active", "rollout_percentage", or "payload"/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('sets a boolean-flag payload under the "true" key as a JSON string', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', payload: { color: 'blue', count: 3 } });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.payloads).toEqual({ true: '{"color":"blue","count":3}' });
});
it('keys the payload under a given variant', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', payload: { v: 1 }, variant: 'control' });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.payloads).toEqual({ control: '{"v":1}' });
});
it('sets rollout_percentage and payload together in one filters object', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({
results: [{ id: 7, key: 'my-flag', filters: { groups: [{ properties: [] }] } }],
})
)
.mockResolvedValueOnce(jsonResponse({ id: 7 }));
const step = createStep({ flag: 'my-flag', rollout_percentage: 25, payload: { a: 1 } });
await step.executeAsync();
const patchBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string);
expect(patchBody.filters.groups[0].rollout_percentage).toBe(25);
expect(patchBody.filters.payloads).toEqual({ true: '{"a":1}' });
});
it('surfaces the PostHog error detail on a failed update', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(
jsonResponse(
{ type: 'validation_error', detail: 'Variant control does not exist' },
{ ok: false, status: 400 }
)
);
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(
/failed with status 400: Variant control does not exist/
);
});
it('falls back to the raw body when the error body is not JSON', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce({
ok: false,
status: 502,
text: async () => 'Bad Gateway',
} as unknown as Response);
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(/failed with status 502: Bad Gateway/);
});
it('warns and skips when the flag is not found and ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [] }));
const step = createStep({ flag: 'missing-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/No PostHog feature flag with key "missing-flag"/)
);
});
it('throws on an out-of-range rollout_percentage', async () => {
const step = createStep({ flag: 'my-flag', rollout_percentage: '150' });
await expect(step.executeAsync()).rejects.toThrow(/between 0 and 100/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws when credentials are missing by default', async () => {
const step = createStep({ flag: 'my-flag', active: 'true' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('warns and skips on missing credentials when ignore_error is set', async () => {
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true }, {});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error\.$/)
);
});
it('throws on a failed update by default', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(/failed with status 500/);
});
it('warns and skips on a failed update when ignore_error is set', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Updating PostHog flag "my-flag" failed with status 500: {}. Ignoring error.'
);
});
it('warns and skips on a lookup network error when ignore_error is set', async () => {
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Looking up PostHog flag "my-flag" failed. Ignoring error.'
);
});
it('warns and skips on a non-object lookup response when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse('not-json-object'));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.anything() },
expect.stringMatching(/unexpected response/)
);
});
it('warns and skips on an unexpected lookup response when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ nope: true }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.anything() },
expect.stringMatching(/unexpected response/)
);
});
it('ignores malformed entries in the lookup results', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse({ results: ['junk', null, { id: 42, key: 'my-flag', filters: {} }] })
)
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws on a failed lookup by default', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(
/Looking up PostHog flag "my-flag" failed with status 500/
);
});
it('warns and skips on a failed lookup when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Looking up PostHog flag "my-flag" failed with status 500: {}. Ignoring error.'
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('warns and skips on an update network error when ignore_error is set', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockRejectedValueOnce(new Error('ECONNRESET'));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Updating PostHog flag "my-flag" failed. Ignoring error.'
);
});
it('throws on a 403 update even when ignore_error is set', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
await expect(step.executeAsync()).rejects.toThrow(/feature_flag:write/);
});
it('debugs the underlying error before throwing on a network failure by default', async () => {
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'));
const step = createStep({ flag: 'my-flag', active: 'true' });
const debugMock = jest.spyOn(step.ctx.logger, 'debug');
await expect(step.executeAsync()).rejects.toThrow(/Looking up PostHog flag "my-flag" failed/);
expect(debugMock).toHaveBeenCalled();
});
it('throws on an invalid JSON lookup response by default', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('unexpected token');
},
} as unknown as Response);
const step = createStep({ flag: 'my-flag', active: 'true' });
await expect(step.executeAsync()).rejects.toThrow(/returned an unexpected response/);
});
it('warns and skips on an invalid JSON lookup response when ignore_error is set', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('unexpected token');
},
} as unknown as Response);
const step = createStep({ flag: 'my-flag', active: 'true', ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'PostHog flag lookup for "my-flag" returned an unexpected response. Ignoring error.'
);
});
it('throws on a non-integer rollout_percentage', async () => {
const step = createStep({ flag: 'my-flag', rollout_percentage: 25.5 });
await expect(step.executeAsync()).rejects.toThrow(/must be an integer between 0 and 100/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('falls back to the default host when POSTHOG_CLI_HOST is unset', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [{ id: 42, key: 'my-flag', filters: {} }] }))
.mockResolvedValueOnce(jsonResponse({ id: 42 }));
const step = createStep(
{ flag: 'my-flag', active: 'true' },
{ POSTHOG_CLI_API_KEY: 'phx_test', POSTHOG_CLI_PROJECT_ID: '123' }
);
await step.executeAsync();
const [searchUrl] = fetchMock.mock.calls[0];
expect(searchUrl).toBe(
'https://us.posthog.com/api/projects/123/feature_flags/?limit=200&search=my-flag'
);
});
});
@@ -0,0 +1,145 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
MIN_ARGENT_REMOTE_SESSION_VERSION,
waitForArgentToolServerStateAsync,
warnIfArgentPackageVersionCannotBeVerified,
} from '../startArgentRemoteSession';
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
const warn = jest.fn();
const logger = { warn } as unknown as bunyan;
const supportedVersion = '999.0.0';
const oldVersion = '0.0.0';
beforeEach(() => {
warn.mockClear();
});
it('allows the default latest package version', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('allows exact supported semver versions', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
logger,
})
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `v${supportedVersion}`,
logger,
})
).not.toThrow();
expect(warn).not.toHaveBeenCalled();
});
it('rejects exact semver versions that are too old', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(SystemError);
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
});
it('warns for package tags or ranges that cannot be verified', () => {
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
).not.toThrow();
expect(() =>
warnIfArgentPackageVersionCannotBeVerified({
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
logger,
})
).not.toThrow();
expect(warn).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Continuing and letting bunx resolve it.')
);
});
});
describe(waitForArgentToolServerStateAsync, () => {
let stateDir: string;
beforeEach(async () => {
stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'argent-state-test-'));
});
afterEach(async () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
});
function stateJson({ port, pid, token }: { port: number; pid: number; token?: string }): string {
return JSON.stringify({ port, pid, token });
}
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
'reads a matching process from %s',
async fileName => {
await fs.promises.writeFile(
path.join(stateDir, fileName),
stateJson({ port: 4321, pid: process.pid, token: 'secret' })
);
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
})
).resolves.toEqual({ port: 4321, pid: process.pid, token: 'secret' });
}
);
it('selects the state whose pid belongs to the launched process tree', async () => {
await fs.promises.writeFile(
path.join(stateDir, 'tool-server-111111111111.json'),
stateJson({ port: 1111, pid: Number.MAX_SAFE_INTEGER })
);
await fs.promises.writeFile(
path.join(stateDir, 'tool-server-222222222222.json'),
stateJson({ port: 2222, pid: process.pid })
);
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.ppid,
timeoutMs: 100,
pollIntervalMs: 1,
})
).resolves.toEqual({ port: 2222, pid: process.pid });
});
it('does not return a state file outside the launched process tree', async () => {
const filePath = path.join(stateDir, 'tool-server-012345abcdef.json');
await fs.promises.writeFile(filePath, stateJson({ port: 1111, pid: Number.MAX_SAFE_INTEGER }));
await expect(
waitForArgentToolServerStateAsync({
stateDir,
ancestorPid: process.pid,
timeoutMs: 10,
pollIntervalMs: 1,
})
).rejects.toThrow(`state file belonging to process ${process.pid}`);
});
});
@@ -0,0 +1,121 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils', () => ({
IosSimulatorUtils: {
getAvailableDevicesAsync: jest.fn(),
getDeviceAsync: jest.fn(),
cloneAsync: jest.fn(),
enableAccessibilitySettingsAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableApsdAsync: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
const mockedUtils = jest.mocked(IosSimulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
const logger = createMockLogger();
const fn = createStartIosSimulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
return Object.assign(step, { logger });
}
describe(createStartIosSimulatorBuildFunction, () => {
beforeEach(() => {
jest.clearAllMocks();
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedUtils.getDeviceAsync.mockResolvedValue(null);
mockedUtils.cloneAsync.mockResolvedValue(undefined);
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
});
it('does not enable accessibility settings by default', async () => {
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
});
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({
device_identifier: 'iPhone 15',
count: 2,
enable_accessibility_settings: true,
}).executeAsync();
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'iPhone 15',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-1',
env: expect.any(Object),
});
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
deviceIdentifier: 'eas-simulator-2',
env: expect.any(Object),
});
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
const enableCallOrder =
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
expect(enableCallOrder).toBeLessThan(startCallOrder);
}
});
it('disables apsd on the main device and every clone', async () => {
mockedUtils.startAsync
.mockResolvedValueOnce({ udid: 'base' as any })
.mockResolvedValueOnce({ udid: 'clone-1' as any })
.mockResolvedValueOnce({ udid: 'clone-2' as any });
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'base',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-1',
env: expect.any(Object),
});
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
udid: 'clone-2',
env: expect.any(Object),
});
});
it('continues when disabling apsd fails', async () => {
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
// Startup is not aborted: readiness is still awaited for each device.
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
});
});
@@ -120,4 +120,44 @@ describe(createUploadArtifactBuildFunction, () => {
const typeInput = buildStep.inputs?.find(input => input.id === 'type')!;
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
type: 'build-artifact',
path: '/',
metadata: { kind: 'screen-recording' },
},
});
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.not.objectContaining({ metadata: expect.anything() }),
})
);
});
});
@@ -0,0 +1,67 @@
import { BuildRuntimePlatform } from '@expo/steps';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from '../uploadDeviceRunSessionScreenRecordings';
jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
uploadDeviceRunSessionArtifactAsync: jest.fn(),
}));
describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
it('uploads simulator device metadata', async () => {
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
await fs.writeFile(recordingPath, 'recording');
await fs.writeFile(
path.join(recordingDirectory, 'session.json'),
JSON.stringify({
recording: path.basename(recordingPath),
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
})
);
try {
const globalContext = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
{} as CustomBuildContext
).createBuildStepFromFunctionCall(globalContext, {
env: { DEVICE_RUN_SESSION_ID: 'device-run-session-id' },
callInputs: {
recordings_json: [
{
udid: 'simulator-udid',
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
directory: recordingDirectory,
},
],
},
});
await buildStep.executeAsync();
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'iPhone 16 screen recording',
metadata: {
__eas_screen_recording: '1',
udid: 'simulator-udid',
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
firstFrameAt: '2026-07-10T10:00:00.000Z',
},
})
);
} finally {
await fs.rm(recordingDirectory, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,144 @@
import { PipeMode } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createUploadPosthogSourcemapsFunction } from '../uploadPosthogSourcemaps';
jest.mock('@expo/logger');
jest.mock('@expo/turtle-spawn');
const spawnMock = jest.mocked(spawn);
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createUploadPosthogSourcemapsFunction, () => {
const uploadSourcemaps = createUploadPosthogSourcemapsFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown> = {},
env: Record<string, string> = ENV
): ReturnType<typeof uploadSourcemaps.createBuildStepFromFunctionCall> {
return uploadSourcemaps.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: uploadSourcemaps.id,
});
}
it('runs @posthog/cli hermes upload with the resolved credentials and host', async () => {
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
ReturnType<typeof spawn>
>);
const step = createStep();
await step.executeAsync();
expect(spawnMock).toHaveBeenCalledTimes(1);
const [command, args, options] = spawnMock.mock.calls[0];
expect(command).toBe('npx');
expect(args).toEqual([
'-y',
'@posthog/cli',
'--host',
'https://us.posthog.com',
'hermes',
'upload',
'--directory',
'dist',
]);
expect(options?.env).toMatchObject({
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
});
expect(options?.mode).toBe(PipeMode.COMBINED_AS_STDOUT);
});
it('passes a custom host through to --host', async () => {
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
ReturnType<typeof spawn>
>);
const step = createStep({}, { ...ENV, POSTHOG_CLI_HOST: 'https://eu.i.posthog.com' });
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual([
'-y',
'@posthog/cli',
'--host',
'https://eu.i.posthog.com',
'hermes',
'upload',
'--directory',
'dist',
]);
});
it('uploads the given directory', async () => {
spawnMock.mockResolvedValue({ stdout: '', stderr: '' } as unknown as Awaited<
ReturnType<typeof spawn>
>);
const step = createStep({ directory: 'build-output' });
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual([
'-y',
'@posthog/cli',
'--host',
'https://us.posthog.com',
'hermes',
'upload',
'--directory',
'build-output',
]);
});
it('throws when credentials are missing by default', async () => {
const step = createStep({}, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(spawnMock).not.toHaveBeenCalled();
});
it('warns and skips when credentials are missing and ignore_error is set', async () => {
const step = createStep({ ignore_error: true }, {});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(spawnMock).not.toHaveBeenCalled();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
it('throws when the upload fails by default', async () => {
spawnMock.mockRejectedValue(new Error('upload failed'));
const step = createStep();
await expect(step.executeAsync()).rejects.toThrow(/Uploading source maps to PostHog failed/);
});
it('warns and continues when the upload fails and ignore_error is set', async () => {
spawnMock.mockRejectedValue(new Error('upload failed'));
const step = createStep({ ignore_error: true });
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await expect(step.executeAsync()).resolves.not.toThrow();
expect(warnMock).toHaveBeenCalledWith(
{ err: expect.any(Error) },
expect.stringMatching(/Ignoring error/)
);
});
});
@@ -0,0 +1,263 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createWaitForPosthogMetricFunction } from '../waitForPosthogMetric';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return {
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
} as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createWaitForPosthogMetricFunction, () => {
const fetchMock = jest.mocked(fetch);
const waitForMetric = createWaitForPosthogMetricFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof waitForMetric.createBuildStepFromFunctionCall> {
return waitForMetric.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: waitForMetric.id,
});
}
it('clears when the metric already meets the condition (blocking query)', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[3]] }));
const step = createStep({
query: 'select count() from events',
operator: 'lt',
threshold: '5',
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://us.posthog.com/api/projects/123/query/');
expect(JSON.parse(init?.body as string)).toMatchObject({
query: { kind: 'HogQLQuery' },
refresh: 'blocking',
});
expect(step.outputById.value.value).toBe('3');
});
it('throws when credentials are missing', async () => {
const step = createStep({ query: 'q', operator: 'lt', threshold: '5' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on an invalid operator', async () => {
const step = createStep({ query: 'q', operator: 'between', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(/Invalid "operator"/);
});
it('rejects an Object.prototype key as an operator', async () => {
const step = createStep({ query: 'q', operator: 'toString', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(/Invalid "operator"/);
});
it('throws with the PostHog error detail when the query fails to run', async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ detail: 'Malformed HogQL query' }, { ok: false, status: 400 })
);
const step = createStep({ query: 'bad', operator: 'lt', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(
/failed with status 400: Malformed HogQL query/
);
});
it('retries after a transient 500 and clears when the metric recovers', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 }))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps polling while the query returns no numeric value', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [['not-a-number']] }))
.mockResolvedValueOnce(jsonResponse({ results: [[2]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('reports a query-shape problem when no numeric value ever arrives', async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [['not-a-number']] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 0.15,
});
await expect(step.executeAsync()).rejects.toThrow(/never returned a numeric value/);
});
it('includes the last observed value in the timeout error', async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [[10]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 0.15,
});
await expect(step.executeAsync()).rejects.toThrow(/last value: 10/);
});
it('throws on a non-positive interval', async () => {
const step = createStep({ query: 'q', operator: 'lt', threshold: '5', interval_seconds: 0 });
await expect(step.executeAsync()).rejects.toThrow(/greater than 0/);
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
['lte', 3, 3],
['gt', 10, 5],
['gte', 5, 5],
['eq', 3, 3],
] as const)('clears with the %s operator', async (operator, value, threshold) => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[value]] }));
const step = createStep({ query: 'q', operator, threshold });
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('treats a non-numeric non-string cell as no value and keeps polling', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('retries after a network error and clears when the query recovers', async () => {
fetchMock
.mockRejectedValueOnce(new Error('ECONNRESET'))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
const warnMock = jest.spyOn(step.ctx.logger, 'warn');
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(warnMock).toHaveBeenCalledWith(
'Running the PostHog query failed with a network error; will retry on the next poll.'
);
});
it('retries after an invalid JSON response and clears when the query recovers', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('unexpected token');
},
} as unknown as Response)
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws on a 403 without retrying', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ query: 'q', operator: 'lt', threshold: '5' });
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('keeps polling through malformed response shapes until a numeric value arrives', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [[Infinity]] }))
.mockResolvedValueOnce(jsonResponse('not an object'))
.mockResolvedValueOnce(jsonResponse({ results: [5] }))
.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({
query: 'q',
operator: 'lt',
threshold: '5',
interval_seconds: 0.05,
timeout_seconds: 5,
});
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,109 @@
import fetch, { Response } from 'node-fetch';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createWaitForPosthogQueryFunction } from '../waitForPosthogQuery';
jest.mock('@expo/logger');
jest.mock('node-fetch');
function jsonResponse(data: unknown, { ok = true, status = 200 } = {}): Response {
return { ok, status, json: async () => data } as unknown as Response;
}
const ENV = {
POSTHOG_CLI_API_KEY: 'phx_test',
POSTHOG_CLI_PROJECT_ID: '123',
POSTHOG_CLI_HOST: 'https://us.posthog.com',
};
describe(createWaitForPosthogQueryFunction, () => {
const fetchMock = jest.mocked(fetch);
const waitForQuery = createWaitForPosthogQueryFunction();
afterEach(() => {
jest.resetAllMocks();
});
function createStep(
callInputs: Record<string, unknown>,
env: Record<string, string> = ENV
): ReturnType<typeof waitForQuery.createBuildStepFromFunctionCall> {
return waitForQuery.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs,
env,
id: waitForQuery.id,
});
}
it('resolves when the query returns true', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
const step = createStep({ query: 'SELECT count() > 0 FROM events' });
await expect(step.executeAsync()).resolves.not.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(init?.body as string).query).toEqual({
kind: 'HogQLQuery',
query: 'SELECT count() > 0 FROM events',
});
});
it('resolves when the query returns a nonzero number', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ results: [[1]] }));
const step = createStep({ query: 'q' });
await expect(step.executeAsync()).resolves.not.toThrow();
});
it('keeps polling until the query turns true', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ results: [[0]] }))
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 });
await step.executeAsync();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps polling after a transient failure and clears when the query recovers', async () => {
fetchMock
.mockRejectedValueOnce(new Error('ECONNRESET'))
.mockResolvedValueOnce(jsonResponse({ results: [[true]] }));
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 5 });
await expect(step.executeAsync()).resolves.not.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('throws when the query never returns true within the timeout', async () => {
fetchMock.mockResolvedValue(jsonResponse({ results: [[false]] }));
const step = createStep({ query: 'q', interval_seconds: 0.05, timeout_seconds: 0.1 });
await expect(step.executeAsync()).rejects.toThrow(/did not return true within/);
});
it('throws when credentials are missing', async () => {
const step = createStep({ query: 'q' }, {});
await expect(step.executeAsync()).rejects.toThrow(
/Missing PostHog credentials: personal API key, project id/
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on a non-positive interval', async () => {
const step = createStep({ query: 'q', interval_seconds: 0 });
await expect(step.executeAsync()).rejects.toThrow(/greater than 0/);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on a 403 without retrying', async () => {
fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 403 }));
const step = createStep({ query: 'q' });
await expect(step.executeAsync()).rejects.toThrow(/forbidden \(403\)/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,82 @@
import { UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createCapturePosthogEventFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_capture_event',
name: 'Capture PostHog event',
__metricsId: 'eas/posthog_capture_event',
inputProviders: [
BuildStepInput.createProvider({
id: 'event',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'distinct_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'properties',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'host',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const event = inputs.event.value as string;
const client = PosthogClient.forIngestion({
apiKeyOverride: inputs.api_key.value as string | undefined,
hostOverride: inputs.host.value as string | undefined,
env,
});
if (!client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_MISSING_API_KEY',
'PostHog API key not provided. Set the "api_key" input or the EXPO_PUBLIC_POSTHOG_API_KEY environment variable.'
),
});
return;
}
logger.info(`Sending PostHog event "${event}"`);
try {
await client.captureEventAsync({
event,
distinctId: inputs.distinct_id.value as string | undefined,
properties: inputs.properties.value as Record<string, unknown> | undefined,
signal,
});
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
logger.info(`Sent PostHog event "${event}"`);
},
});
}
@@ -7,6 +7,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
id: 'checkout',
name: 'Checkout',
__metricsId: 'eas/checkout',
__hookId: 'checkout',
fn: async stepsCtx => {
if (stepsCtx.global.wasCheckedOut()) {
stepsCtx.logger.info('Project directory is already checked out');
@@ -0,0 +1,78 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createPosthogAnnotationFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_annotation',
name: 'Create a PostHog annotation',
__metricsId: 'eas/posthog_annotation',
inputProviders: [
BuildStepInput.createProvider({
id: 'content',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'date_marker',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: PosthogUtils.missingCredentialsError(result.missing),
});
return;
}
const client = result.client;
const content = inputs.content.value as string;
const dateMarker =
(inputs.date_marker.value as string | undefined) ?? new Date().toISOString();
logger.info(`Creating PostHog annotation "${content}"`);
try {
await client.requestAsync('POST', '/annotations/', {
action: 'Creating the PostHog annotation',
forbiddenScope: 'annotation:write',
body: { content, date_marker: dateMarker },
signal,
});
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
logger.info('Created PostHog annotation');
},
});
}
@@ -1,6 +1,7 @@
import { asyncResult } from '@expo/results';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { Sentry } from '../../sentry';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
export function createSubmissionEntityFunction(): BuildFunction {
@@ -54,18 +55,42 @@ export function createSubmissionEntityFunction(): BuildFunction {
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
stepsCtx.logger.error('Failed to create submission entity: no robot access token found');
Sentry.capture('Failed to create submission entity: missing robot access token');
return;
}
const buildId = inputs.build_id.value;
if (!buildId) {
stepsCtx.logger.error('Failed to create submission entity: no build ID provided');
Sentry.capture('Failed to create submission entity: missing build ID', {
extras: {
buildId,
},
});
return;
}
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
if (!workflowJobId) {
stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found');
Sentry.capture('Failed to create submission entity: missing workflow job ID', {
extras: {
buildId,
workflowJobId,
},
});
return;
}
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
if (!expoApiServerURL) {
stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found');
Sentry.capture('Failed to create submission entity: missing Expo API server URL', {
extras: {
buildId,
workflowJobId,
},
});
return;
}
@@ -83,7 +108,7 @@ export function createSubmissionEntityFunction(): BuildFunction {
try {
const response = await retryOnDNSFailure(fetch)(
new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL),
new URL('/v2/app-store-submissions/', expoApiServerURL),
{
method: 'POST',
headers: {
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -56,18 +56,23 @@ export function createDownloadArtifactFunction(): BuildFunction {
const interpolationContext = stepsCtx.global.getInterpolationContext();
if (!('workflow' in interpolationContext)) {
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
'No workflow found in the interpolation context.'
);
throw new SystemError('No workflow found in the interpolation context.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
});
}
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
'No robot access token found in the job secrets.'
);
throw new SystemError('No robot access token found in the job secrets.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
});
}
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
if (!expoApiServerURL) {
throw new SystemError('Missing Expo API server URL.', {
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL',
});
}
const workflowRunId = interpolationContext.workflow.id;
@@ -82,7 +87,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const { artifactPath } = await downloadArtifactAsync({
logger,
workflowRunId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
params,
});
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,9 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
@@ -16,6 +18,7 @@ import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
@@ -23,7 +26,21 @@ import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
export function createDownloadBuildFunction(): BuildFunction {
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
artifacts {
applicationArchiveUrl
}
}
}
}
`);
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'download_build',
@@ -59,7 +76,7 @@ export function createDownloadBuildFunction(): BuildFunction {
const { artifactPath } = await downloadBuildAsync({
logger,
buildId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
});
@@ -69,16 +86,45 @@ export function createDownloadBuildFunction(): BuildFunction {
});
}
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
}: {
buildId: string;
graphqlClient: Client;
}): Promise<string> {
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return applicationArchiveUrl;
}
export async function downloadBuildAsync({
logger,
buildId,
expoApiServerURL,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
expoApiServerURL: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
@@ -86,12 +132,11 @@ export async function downloadBuildAsync({
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const response = await retryOnDNSFailure(fetch)(
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
{
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
}
);
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
if (!response.ok) {
const textResult = await asyncResult(response.text());
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
try {
await uploadApplicationArchivesAsync({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload application archives.`, err);
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
firstError ||= err;
}
try {
await uploadBuildArtifacts({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
firstError ||= err;
}
@@ -0,0 +1,23 @@
import { BuildFunction, BuildRuntimePlatform, BuildStepOutput } from '@expo/steps';
import { IosSimulatorRecordingUtils } from '../utils/IosSimulatorRecordingUtils';
export function createFinishIosSimulatorRecordingsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'finish_ios_simulator_recordings',
name: 'Finish iOS Simulator recordings',
__metricsId: 'eas/finish_ios_simulator_recordings',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
outputProviders: [
BuildStepOutput.createProvider({
id: 'recordings_json',
required: true,
}),
],
fn: async ({ logger }, { outputs }) => {
const recordings = await IosSimulatorRecordingUtils.finishAsync({ logger });
outputs.recordings_json.set(JSON.stringify(recordings));
},
});
}
@@ -15,6 +15,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -123,13 +125,24 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
return maestroVersion.stdout.trim();
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
}
async function installMaestro({
@@ -16,6 +16,7 @@ export function createInstallNodeModulesBuildFunction(): BuildFunction {
id: 'install_node_modules',
name: 'Install node modules',
__metricsId: 'eas/install_node_modules',
__hookId: 'install_node_modules',
fn: async (stepCtx, { env }) => {
await installNodeModules(stepCtx, env);
},
@@ -1,3 +1,4 @@
import { asyncResult } from '@expo/results';
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
@@ -18,6 +19,7 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
export interface JUnitTestCaseResult {
name: string;
file: string | undefined;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
@@ -32,6 +34,12 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// A `file=` attribute counts as present only when it is a non-empty string.
function fileAttrOf(tc: any): string | undefined {
const f = tc?.['@_file'];
return typeof f === 'string' && f.length > 0 ? f : undefined;
}
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
const results: JUnitTestCaseResult[] = [];
try {
@@ -54,6 +62,8 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
continue;
}
const file = fileAttrOf(tc);
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
@@ -94,7 +104,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
results.push({ name, file, status, duration, errorMessage, tags, properties });
}
}
} catch {
@@ -131,6 +141,142 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return perFile.flat();
}
// ---------------------------------------------------------------------------
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
// its flow's own path (all-or-none per run). Callers check the report first
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
// scan is then never consulted.
// ---------------------------------------------------------------------------
export function isFileAttrRun(
testcases: JUnitTestCaseResult[]
): testcases is (JUnitTestCaseResult & { file: string })[] {
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
}
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
// legacy path, whose own guards degrade safely.
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
return isFileAttrRun(await parseJUnitFile(junitFile));
}
async function fileExists(absPath: string): Promise<boolean> {
return (await asyncResult(fs.stat(absPath))).ok;
}
// Group by `file=` so two same-named flows in different files stay separate.
export async function parseMaestroResultsFromFileAttrs(
junitDirectory: string
): Promise<MaestroFlowResult[]> {
interface JUnitEntry {
result: JUnitTestCaseResult;
sourceFile: string;
}
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
const entries: JUnitEntry[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
entries.push({ result, sourceFile: xmlFile });
}
}
const byPath = new Map<string, JUnitEntry[]>();
for (const entry of entries) {
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
// skip (rather than emit an undefined path) if that contract is violated.
const flowPath = entry.result.file;
if (flowPath === undefined) {
continue;
}
const group = byPath.get(flowPath) ?? [];
group.push(entry);
byPath.set(flowPath, group);
}
const results: MaestroFlowResult[] = [];
for (const [flowPath, group] of byPath) {
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = group
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: result.name,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
return results;
}
/**
* Returns the `file=` paths of the failing testcases in the given attempt's
* JUnit file, or `null` when the result cannot be trusted (caller then falls
* back to dumb retry — re-run everything; never the legacy scan).
*
* Callers verify the report carries `file=` attributes first
* (junitFileHasFileAttrs).
*/
export async function parseFailedFlowsFromFileAttrs(args: {
junitFile: string;
workingDirectory: string;
}): Promise<string[] | null> {
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
// truncated XML could drop testcases, and trusting that partial failing set
// would skip retries for cut-off flows.
let content: string;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
const testcases = parseJUnitContent(content);
if (!isFileAttrRun(testcases)) {
return null;
}
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Each failing testcase is self-identifying via `file=`, so duplicate flow
// names retry correctly. path.resolve (not join): `file=` may be absolute
// when the flow lives outside the working directory.
const paths = [...new Set(failing.map(tc => tc.file))];
for (const p of paths) {
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
return null;
}
}
return paths;
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
@@ -313,6 +459,13 @@ export async function mergeJUnitReports(args: {
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
// different files merge separately. Legacy reports keep name keying.
const allCases = testsuites.flatMap((suite: any) =>
Array.isArray(suite?.testcase) ? suite.testcase : []
);
const useFileKeys =
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
@@ -324,9 +477,10 @@ export async function mergeJUnitReports(args: {
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
const key = useFileKeys ? fileAttrOf(tc)! : name;
const group = testcasesByName.get(key) ?? [];
group.push(tc);
testcasesByName.set(name, group);
testcasesByName.set(key, group);
}
}
if (testcasesByName.size === 0) {
@@ -0,0 +1,159 @@
import { bunyan } from '@expo/logger';
import { type Dirent } from 'fs';
import fs from 'fs/promises';
import path from 'path';
export interface ParsedFailureScreenshot {
flowName: string;
capturedAtMs: number;
}
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
// `).png` suffix rather than scanning for parens.
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
if (!match) {
return null;
}
const capturedAtMs = Number(match[1]);
if (!Number.isFinite(capturedAtMs)) {
return null;
}
return {
flowName: match[2],
capturedAtMs,
};
}
export interface HarvestedScreenshot {
fileAbsPath: string;
displayName: string; // `Failure Screenshot: <flowName> (attempt <n>)`
metadata: {
kind: 'maestro-test-screenshot';
// The Maestro flow name straight from the filename (already '/'->'_' substituted by
// Maestro). The website matches this against `attempt.name` (normalized the same way).
flowName: string;
attemptIndex: number;
capturedAtMs: number;
};
}
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
// and skipped so a screenshot harvest can't affect the maestro step outcome.
export async function harvestFailureScreenshotsAsync(args: {
testsDirectory: string;
capturedSinceMs: number;
attemptIndex: number;
logger: bunyan;
}): Promise<HarvestedScreenshot[]> {
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
// prevents mis-attributing an earlier attempt's screenshot.
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
let entries: Dirent[];
try {
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
} catch (err: any) {
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
return [];
}
const results: HarvestedScreenshot[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const dirPath = path.join(args.testsDirectory, entry.name);
let filenames: string[];
try {
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
continue;
}
filenames = await fs.readdir(dirPath);
} catch (err: any) {
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
continue;
}
for (const filename of filenames) {
const parsed = parseFailureScreenshotFilename(filename);
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
// earlier attempt's screenshot to this one.
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
continue;
}
results.push({
fileAbsPath: path.join(dirPath, filename),
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
metadata: {
kind: 'maestro-test-screenshot',
flowName: parsed.flowName,
attemptIndex: args.attemptIndex,
capturedAtMs: parsed.capturedAtMs,
},
});
}
}
return results;
}
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
// is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
// name the same way before comparing — matching the website's own normalization.
function normalizeFlowName(name: string): string {
return name.replace(/\//g, '_');
}
// A flow is a "pure failure" when it failed and never passed across all attempts. The website
// surfaces only the final screenshot for such a flow, so keeping its earlier attempts would
// spend the per-job artifact budget on screenshots that are never shown — we keep only the final
// one (selectFailureScreenshots). A flow that passed at least once (flaky / fail-pass-fail) is
// NOT pure: each of its failed attempts is a distinct failure and is kept.
export function computePureFailureFlowNames(
testCases: readonly { name: string; status: 'passed' | 'failed' }[]
): Set<string> {
const passed = new Set<string>();
const failed = new Set<string>();
for (const testCase of testCases) {
const flowName = normalizeFlowName(testCase.name);
if (testCase.status === 'passed') {
passed.add(flowName);
} else {
failed.add(flowName);
}
}
const pure = new Set<string>();
for (const flowName of failed) {
if (!passed.has(flowName)) {
pure.add(flowName);
}
}
return pure;
}
// Keep every harvested screenshot for flaky/mixed flows; for pure-failure flows keep only
// the final (highest attemptIndex) screenshot.
export function selectFailureScreenshots(
harvested: readonly HarvestedScreenshot[],
pureFailureFlowNames: ReadonlySet<string>
): HarvestedScreenshot[] {
const finalAttemptByPureFlow = new Map<string, number>();
for (const shot of harvested) {
if (!pureFailureFlowNames.has(shot.metadata.flowName)) {
continue;
}
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
if (finalAttempt === undefined || shot.metadata.attemptIndex > finalAttempt) {
finalAttemptByPureFlow.set(shot.metadata.flowName, shot.metadata.attemptIndex);
}
}
return harvested.filter(shot => {
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
return finalAttempt === undefined || shot.metadata.attemptIndex === finalAttempt;
});
}
@@ -1,4 +1,5 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -8,15 +9,26 @@ import {
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
} from './maestroResultParser';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
selectFailureScreenshots,
} from './maestroScreenshots';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -79,12 +91,13 @@ function buildMaestroArgs(args: {
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
__hookId: 'maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
@@ -200,13 +213,10 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; retry-failed-only disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
});
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// flow scan is built lazily in the retry branch below and memoized so
// retries share one scan. Never runs when the report has `file=`.
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
@@ -214,10 +224,11 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// flows. The failed-flow parsers return null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const harvested: HarvestedScreenshot[] = [];
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -240,6 +251,8 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
const attemptStartedAtMs = Date.now();
try {
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
@@ -259,15 +272,44 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
}
}
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
// runs, so harvesting other formats would just create orphan artifacts the website hides.
if (outputFormat === 'junit') {
harvested.push(
...(await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
logger,
}))
);
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
let failed: string[] | null;
if (await junitFileHasFileAttrs(outputPath)) {
failed = await parseFailedFlowsFromFileAttrs({
junitFile: outputPath,
workingDirectory: stepCtx.workingDirectory,
});
} else {
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
// paths via the flow-file scan. DELETE this arm once the fleet is
// on >= 2.6.0.
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
}));
failed = nameToPath
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
: null;
}
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
@@ -316,6 +358,12 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
}
}
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
if (outputFormat === 'junit') {
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
}
// The retry loop exits via success (0), numeric status (retryable),
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
@@ -328,3 +376,82 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
},
});
}
// Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
// artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
// the maestro test result. Caller guards on junit (harvest only runs for junit).
async function uploadFailureScreenshotsAsync({
harvested,
junitReportDirectory,
ctx,
logger,
}: {
harvested: HarvestedScreenshot[];
junitReportDirectory: string;
ctx: CustomBuildContext;
logger: bunyan;
}): Promise<void> {
// Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
// attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
// Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
// test verdict (the whole step is verdict-neutral for screenshots).
let selected: HarvestedScreenshot[];
try {
const pureFailureFlowNames = computePureFailureFlowNames(
await parseJUnitTestCases(junitReportDirectory)
);
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
} catch (err: any) {
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
return;
}
if (selected.length === 0) {
return;
}
// Cap well under www's 50-artifact-per-job limit.
const MAX_SCREENSHOT_UPLOADS = 30;
const toUpload = selected.slice(0, MAX_SCREENSHOT_UPLOADS);
if (selected.length > toUpload.length) {
logger.warn(
`Found ${selected.length} failure screenshots; uploading only the first ${toUpload.length}.`
);
}
// Copy each shot to an ASCII-safe name outside testsDirectory (the originals contain a
// non-ASCII marker and testsDirectory is uploaded wholesale as the tarball).
let safeScreenshotDir: string;
try {
safeScreenshotDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-maestro-screenshots-'));
} catch (err: any) {
logger.warn(
{ err },
'Failed to create the failure-screenshot staging dir; skipping screenshot upload.'
);
return;
}
await Promise.all(
toUpload.map(async (shot, index) => {
try {
// `index` disambiguates two flows that fail within the same millisecond of an attempt.
const safePath = path.join(
safeScreenshotDir,
`failure-attempt-${shot.metadata.attemptIndex}-${index}-${shot.metadata.capturedAtMs}.png`
);
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: GenericArtifactType.OTHER,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
},
logger,
});
} catch (err: any) {
logger.warn({ err }, 'Failed to upload failure screenshot.');
}
})
);
}
@@ -45,6 +45,7 @@ export function parseXcactivitylogFunction(): BuildFunction {
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
env,
});
},
});
@@ -42,11 +42,6 @@ export function createRepackBuildFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'embed_bundle_assets',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
@@ -57,6 +52,16 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_use_source_app_entitlements',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_app_entitlements_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -99,9 +104,10 @@ export function createRepackBuildFunction(): BuildFunction {
stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`);
const sourceAppPath = inputs.source_app_path.value as string;
const outputPath =
(inputs.output_path.value as string) ??
path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
const outputPath = createOutputPath({
sourceAppPath,
tmpDir,
});
const exportEmbedOptions = inputs.embed_bundle_assets.value
? {
sourcemapOutput: undefined,
@@ -138,6 +144,12 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -186,6 +198,21 @@ export function createRepackBuildFunction(): BuildFunction {
});
}
function createOutputPath({
sourceAppPath,
tmpDir,
}: {
sourceAppPath: string;
tmpDir: string;
}): string {
const outputPath = path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
const extension = path.extname(outputPath);
if (extension.toLowerCase() !== '.aab') {
return outputPath;
}
return `${outputPath.slice(0, -extension.length)}.apk`;
}
/**
* Install `@expo/repack-app` in a sandbox directory and import it.
*/
@@ -275,9 +302,13 @@ export async function resolveAndroidSigningOptionsAsync({
export async function resolveIosSigningOptionsAsync({
job,
logger,
useAppEntitlements,
entitlementsPath,
}: {
job: Job;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
@@ -295,5 +326,7 @@ export async function resolveIosSigningOptionsAsync({
provisioningProfile,
keychainPath: credentials.keychainPath,
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
useAppEntitlements,
entitlementsPath,
};
}
@@ -3,7 +3,13 @@ import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import {
MaestroFlowResult,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -61,47 +67,64 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
return;
}
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
try {
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
// `file=` attribute; older reports need the legacy flow-file scan to
// map testcase names back to paths.
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
let flowResults: MaestroFlowResult[];
if (usedFileAttrs) {
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
} else {
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
}
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
// share the same name (Maestro config override), which we can't disambiguate.
// Same name with different retryCount is expected (per-attempt results from retries).
// Detect truly conflicting results: the same (path, retryCount) twice means two
// flow files resolved to the same path, which we can't disambiguate and the API
// rejects as a duplicate. Same path with different retryCount is expected
// (per-attempt results from retries).
const seen = new Set<string>();
const conflicting = new Set<string>();
for (const r of flowResults) {
const key = `${r.name}:${r.retryCount}`;
const key = `${r.path}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
conflicting.add(r.path);
}
seen.add(key);
}
if (conflicting.size > 0) {
const conflictList = [...conflicting].join(', ');
logger.error(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
usedFileAttrs
? `The same flow file was reported more than once in a single attempt: ` +
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
`or leftover XML files in the JUnit report directory.`
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
`then identified by file path, so duplicate names are fine).`
);
return;
}
@@ -19,6 +19,7 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -205,16 +206,16 @@ export async function restoreGradleCacheAsync({
}
try {
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
await fs.promises.writeFile(
gradlePropertiesPath,
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
);
// Enable build cache via user-level gradle.properties.
// This avoids mutating android/gradle.properties which affects fingerprinting.
const gradleUserHome = path.join(os.homedir(), '.gradle');
await fs.promises.mkdir(gradleUserHome, { recursive: true });
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
await fs.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
// org.gradle.cache.cleanup property was removed in Gradle 9)
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
// Configure cache cleanup via init script.
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
const initScriptDir = path.join(gradleUserHome, 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
@@ -282,22 +283,10 @@ export async function restoreGradleCacheAsync({
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
try {
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
json: {
buildId: jobId,
message: `Gradle cache restored (${hitType})`,
tags: {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
});
} catch {}
Datadog.log(`Gradle cache restored (${hitType})`, {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
});
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -69,6 +69,10 @@ export function createRestoreCacheFunction(): BuildFunction {
.filter(key => key !== '');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(
stepsCtx.global.staticContext.expoApiServerURL,
'expoApiServerURL is not set'
);
const robotAccessToken = nullthrows(
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
'robotAccessToken is not set'
@@ -77,7 +81,7 @@ export function createRestoreCacheFunction(): BuildFunction {
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
paths,
key,
@@ -0,0 +1,279 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { z } from 'zod';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
const SEARCH_LIMIT = 200;
const FeatureFlagGroupSchema = z.object({ properties: z.unknown().optional() }).passthrough();
const FeatureFlagSchema = z
.object({
id: z.number(),
key: z.string(),
filters: z
.object({
groups: z.array(FeatureFlagGroupSchema).optional(),
payloads: z.record(z.string(), z.unknown()).optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const FlagSearchResponseSchema = z.object({ results: z.array(z.unknown()) });
type FeatureFlagGroup = z.infer<typeof FeatureFlagGroupSchema>;
type FeatureFlagFilters = NonNullable<z.infer<typeof FeatureFlagSchema>['filters']>;
function isCatchAllGroup(group: FeatureFlagGroup): boolean {
const { properties } = group;
return (
properties === undefined ||
properties === null ||
(Array.isArray(properties) && properties.length === 0)
);
}
export function createRolloutPosthogFlagFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_flag_rollout',
name: 'Roll out a PostHog feature flag',
__metricsId: 'eas/posthog_flag_rollout',
inputProviders: [
BuildStepInput.createProvider({
id: 'flag',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'active',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'rollout_percentage',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
}),
BuildStepInput.createProvider({
id: 'payload',
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
required: false,
}),
BuildStepInput.createProvider({
id: 'variant',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const flagKey = inputs.flag.value as string;
const active = inputs.active.value as boolean | undefined;
const rolloutPercentage = inputs.rollout_percentage.value as number | undefined;
const payload = inputs.payload.value as Record<string, unknown> | undefined;
const variant = inputs.variant.value as string | undefined;
if (active === undefined && rolloutPercentage === undefined && payload === undefined) {
throw new UserError(
'EAS_POSTHOG_FLAG_NO_CHANGE',
'You need to provide "active", "rollout_percentage", or "payload" to change the feature flag.'
);
}
if (
rolloutPercentage !== undefined &&
(!Number.isInteger(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100)
) {
throw new UserError(
'EAS_POSTHOG_FLAG_INVALID_ROLLOUT',
`"rollout_percentage" must be an integer between 0 and 100, got ${rolloutPercentage}.`
);
}
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: PosthogUtils.missingCredentialsError(result.missing),
});
return;
}
const client = result.client;
await rolloutPosthogFlagAsync({
logger,
client,
flagKey,
active,
rolloutPercentage,
payload,
variant,
ignoreError,
signal,
});
},
});
}
async function rolloutPosthogFlagAsync({
logger,
client,
flagKey,
active,
rolloutPercentage,
payload,
variant,
ignoreError,
signal,
}: {
logger: bunyan;
client: PosthogClient;
flagKey: string;
active: boolean | undefined;
rolloutPercentage: number | undefined;
payload: Record<string, unknown> | undefined;
variant: string | undefined;
ignoreError: boolean;
signal: AbortSignal | undefined;
}): Promise<void> {
let searchResponse;
try {
searchResponse = await client.requestAsync(
'GET',
`/feature_flags/?limit=${SEARCH_LIMIT}&search=${encodeURIComponent(flagKey)}`,
{
action: `Looking up PostHog flag "${flagKey}"`,
forbiddenScope: 'feature_flag:read',
signal,
}
);
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
let results: unknown[];
try {
results = FlagSearchResponseSchema.parse(await searchResponse.json()).results;
} catch (error) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_FLAG_UNEXPECTED_RESPONSE',
`PostHog flag lookup for "${flagKey}" returned an unexpected response.`,
{ cause: error }
),
});
return;
}
const flag = results
.flatMap(result => {
const parsed = FeatureFlagSchema.safeParse(result);
return parsed.success ? [parsed.data] : [];
})
.find(candidate => candidate.key === flagKey);
if (!flag) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_FLAG_NOT_FOUND',
`No PostHog feature flag with key "${flagKey}" was found. Check the "flag" input.`
),
});
return;
}
const patch: Record<string, unknown> = {};
if (active !== undefined) {
patch.active = active;
}
let filters = flag.filters;
if (rolloutPercentage !== undefined) {
filters = buildRolloutFilters({ logger, flagKey, filters, rolloutPercentage });
}
if (payload !== undefined) {
filters = {
...filters,
payloads: { ...filters?.payloads, [variant ?? 'true']: JSON.stringify(payload) },
};
}
if (rolloutPercentage !== undefined || payload !== undefined) {
patch.filters = filters;
}
try {
await client.requestAsync('PATCH', `/feature_flags/${flag.id}/`, {
action: `Updating PostHog flag "${flagKey}"`,
forbiddenScope: 'feature_flag:write',
body: patch,
signal,
});
} catch (error) {
PosthogUtils.failOrLogError({ logger, ignoreError, error });
return;
}
logger.info(`Updated PostHog feature flag "${flagKey}".`);
}
function buildRolloutFilters({
logger,
flagKey,
filters,
rolloutPercentage,
}: {
logger: bunyan;
flagKey: string;
filters: FeatureFlagFilters | undefined;
rolloutPercentage: number;
}): FeatureFlagFilters {
const groups = filters?.groups ?? [];
if (groups.length === 0) {
return { ...filters, groups: [{ properties: [], rollout_percentage: rolloutPercentage }] };
}
const catchAllIndex = groups.findIndex(isCatchAllGroup);
const hasCatchAll = catchAllIndex >= 0;
const targetIndex = hasCatchAll ? catchAllIndex : 0;
if (!hasCatchAll) {
logger.warn(
`Flag "${flagKey}" has no catch-all release-condition group; setting rollout_percentage on the first group, which is condition-scoped.`
);
} else if (groups.length > 1) {
logger.warn(
`Flag "${flagKey}" has ${groups.length} release-condition groups; setting rollout_percentage only on the catch-all group to preserve the others.`
);
}
const groupsWithRollout = groups.map((group, index) =>
index === targetIndex ? { ...group, rollout_percentage: rolloutPercentage } : group
);
return { ...filters, groups: groupsWithRollout };
}
@@ -43,6 +43,10 @@ export function createSaveCacheFunction(): BuildFunction {
.filter(path => path.length > 0);
const key = z.string().parse(inputs.key.value);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(
stepsCtx.global.staticContext.expoApiServerURL,
'expoApiServerURL is not set'
);
const robotAccessToken = nullthrows(
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
'robotAccessToken is not set'
@@ -61,7 +65,7 @@ export function createSaveCacheFunction(): BuildFunction {
await uploadPublicCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
@@ -73,7 +77,7 @@ export function createSaveCacheFunction(): BuildFunction {
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
@@ -1,9 +1,9 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
@@ -12,23 +12,32 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
import {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstack/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const STARTUP_TIMEOUT_MS = 60_000;
const AGENT_DEVICE_DAEMON_ENV = {
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
};
export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx: CustomBuildContext
@@ -45,11 +54,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the orchestrator-injected
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
// report the remote config back to the API server.
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const { runtimePlatform } = global;
@@ -58,61 +70,24 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
logger.info('Ensuring cloudflared is installed.');
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
});
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${daemonPort}`],
env,
});
logger.info('Waiting for a public tunnel URL.');
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
const agentDeviceRemoteSessionUrl = await startNgrokTunnelAsync({
port: daemonPort,
subdomainPrefix: 'agent-device',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
logger,
});
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
@@ -120,7 +95,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -139,69 +115,108 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
},
logger,
});
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
daemonUrl: `http://127.0.0.1:${daemonPort}`,
daemonToken,
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the daemon and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
},
});
}
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
return 'cloudflared';
}
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
// Return the absolute install path so the tunnel command works even when
// /usr/local/bin is not on the step's PATH.
return CLOUDFLARED_LINUX_INSTALL_PATH;
}
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'x64') {
return 'amd64';
}
if (arch === 'arm64') {
return 'arm64';
}
throw new SystemError(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
logger.info('Launching daemon from cloned agent-device source.');
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
});
}
async function cloneAgentDeviceAsync({
@@ -220,54 +235,64 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
async function waitForDaemonInfoAsync({
daemonProcess,
}: {
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
daemonProcess: DetachedProcessHandle;
}): Promise<{ port: number; token: string }> {
try {
return await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
});
} catch (err) {
const output = daemonProcess.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for agent-device daemon credentials.`
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
);
}
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
}
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
try {
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
return result.stdout.trim() || 'unknown';
} catch {
return 'unknown';
}
}
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
'src',
'internal',
'daemon.js'
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
@@ -276,7 +301,7 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
@@ -0,0 +1,290 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import semver from 'semver';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { isProcessDescendantOfAsync } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import {
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
startNgrokTunnelAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
const ARGENT_STATE_DIR = path.join(os.homedir(), '.argent');
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({
port: z.number(),
pid: z.number(),
token: z.string().optional(),
});
type ArgentToolServerState = z.infer<typeof ArgentToolServerStateSchema>;
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
logger.info('Enabling the Argent artifacts list endpoint flag.');
await spawn(
'bunx',
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
{ env, logger }
);
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
// Keep Argent itself in foreground mode under the detached bunx process. This preserves
// the bunx -> Argent CLI -> tool-server ancestry used to identify the matching state file.
const argentServer = spawnDetached({
command: 'bunx',
args: [
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
'server',
'start',
'--port',
'0',
'--idle-timeout',
'0',
'--force',
],
env,
});
if (argentServer.pid === undefined) {
throw new SystemError(
'Failed to start Argent: could not determine the PID of the launched process.'
);
}
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
let toolServerPort: number;
let toolServerToken: string | undefined;
try {
const toolServerState = await waitForArgentToolServerStateAsync({
stateDir: ARGENT_STATE_DIR,
ancestorPid: argentServer.pid,
timeoutMs: STARTUP_TIMEOUT_MS,
});
toolServerPort = toolServerState.port;
toolServerToken = toolServerState.token;
} catch (err) {
const output = argentServer.getOutput();
throw new SystemError(
`${
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
);
}
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
const artifactPollAbortController = new AbortController();
const artifactPollSignal = signal
? AbortSignal.any([signal, artifactPollAbortController.signal])
: artifactPollAbortController.signal;
const artifactPollingPromise = pollArgentArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
signal: artifactPollSignal,
});
try {
const publicToolsUrl = await startNgrokTunnelAsync({
port: toolServerPort,
subdomainPrefix: 'argent',
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
rewriteHostHeader: true,
logger,
});
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl: publicToolsUrl,
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
} finally {
artifactPollAbortController.abort();
try {
await artifactPollingPromise;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not finish Argent remote session artifact polling', error);
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
}
}
},
});
}
export function warnIfArgentPackageVersionCannotBeVerified({
packageVersion,
logger,
}: {
packageVersion: string | undefined;
logger: bunyan;
}): void {
if (!packageVersion || packageVersion === 'latest') {
return;
}
const validVersion = semver.valid(packageVersion);
if (!validVersion) {
logger.warn(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
`Continuing and letting bunx resolve it.`
);
return;
}
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
throw new SystemError(
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
);
}
}
function parseArgentToolServerState(raw: string): ArgentToolServerState {
let json: unknown;
try {
json = JSON.parse(raw);
} catch (err) {
throw new SystemError('Expected tool-server state to contain valid JSON.', { cause: err });
}
const result = ArgentToolServerStateSchema.safeParse(json);
if (!result.success) {
throw new SystemError(
`Expected tool-server state to contain { "port": <number>, "pid": <number>, ... }: ${result.error.message}`
);
}
return result.data;
}
export async function waitForArgentToolServerStateAsync({
stateDir,
ancestorPid,
timeoutMs,
pollIntervalMs = 1_000,
}: {
stateDir: string;
ancestorPid: number;
timeoutMs: number;
pollIntervalMs?: number;
}): Promise<ArgentToolServerState> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
name => name.startsWith('tool-server') && name.endsWith('.json')
);
for (const stateFileName of stateFileNames) {
try {
const state = parseArgentToolServerState(
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
);
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
return state;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
throw err;
}
}
}
} catch (err) {
lastError = err;
}
await sleepAsync(pollIntervalMs);
}
throw new SystemError(
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}.`
);
}
@@ -31,6 +31,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
defaultValue: 1,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'enable_accessibility_settings',
required: false,
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
],
fn: async ({ logger }, { inputs, env }) => {
try {
@@ -55,16 +61,29 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
| undefined;
const originalDeviceIdentifier =
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
if (!originalDeviceIdentifier) {
throw new Error('Could not find an iPhone among available simulator devices.');
}
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
});
}
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: originalDeviceIdentifier,
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
logger.info('');
@@ -91,11 +110,23 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
env,
});
if (enableAccessibilitySettings) {
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
deviceIdentifier: cloneDeviceName,
env,
});
}
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: cloneDeviceName,
env,
});
try {
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
} catch (err) {
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
}
await IosSimulatorUtils.waitForReadyAsync({
udid: cloneUdid,
env,
@@ -0,0 +1,19 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { IosSimulatorRecordingUtils } from '../utils/IosSimulatorRecordingUtils';
export function createStartIosSimulatorRecordingsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_ios_simulator_recordings',
name: 'Start iOS Simulator recordings',
__metricsId: 'eas/start_ios_simulator_recordings',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
await IosSimulatorRecordingUtils.startAsync({
env,
logger,
});
},
});
}
@@ -1,15 +1,15 @@
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { CustomBuildContext } from '../../customBuildContext';
import {
getDeviceRunSessionIdOrThrow,
ensureBrewPackageInstalledAsync,
getNgrokTunnelDomainOrThrow,
selectXcodeDeveloperDirectoryAsync,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartServeSimRemoteSessionBuildFunction(
@@ -21,18 +21,16 @@ export function createStartServeSimRemoteSessionBuildFunction(
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
fn: async ({ logger }, { env }) => {
fn: async ({ logger }, { env, signal }) => {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
logger.info('Starting serve-sim remote session.');
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
await selectXcodeDeveloperDirectoryAsync({ env, logger });
logger.info('Ensuring cloudflared is installed.');
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
@@ -47,10 +45,12 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the serve-sim tunnel stays reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
signal,
});
},
});
}
@@ -1,4 +1,9 @@
import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -65,6 +70,11 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'metadata',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -108,7 +118,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
throw result.reason;
});
const artifact = {
const artifactSpec = {
type: parseArtifactTypeInput({
platform: ctx.job.platform,
inputValue: `${inputs.type.value ?? ''}`,
@@ -116,19 +126,22 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
artifact,
logger,
});
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
if (artifactId) {
outputs.artifact_id.set(artifactId);
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -0,0 +1,111 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import limitFactory from 'promise-limit';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { formatBytes } from '../../utils/artifacts';
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
const RecordingManifestSchema = z.object({
firstFrameWallClock: z.object({
iso8601: z.string(),
}),
recording: z.string(),
});
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'upload_device_run_session_screen_recordings',
name: 'Upload device run session screen recordings',
__metricsId: 'eas/upload_device_run_session_screen_recordings',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'recordings_json',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async ({ logger }, { env, inputs }) => {
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
if (!result.success) {
throw new SystemError('Invalid iOS Simulator recordings input.', {
cause: result.error,
});
}
const recordings = result.data;
if (recordings.length === 0) {
logger.info('No iOS Simulator recordings found; skipping uploads.');
return;
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const limit = limitFactory(5);
await Promise.all(
recordings.map(recording =>
limit(async () => {
const displayName = `${recording.deviceName} screen recording`;
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
);
const recordingPath = path.join(recording.directory, metadata.recording);
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
},
size,
stream: createReadStream(recordingPath),
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload iOS Simulator screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.deviceName}.`
);
}
})
)
);
},
});
}
@@ -0,0 +1,89 @@
import { UserError } from '@expo/eas-build-job';
import { PipeMode } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { PosthogClient } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createUploadPosthogSourcemapsFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_upload_sourcemaps',
name: 'Upload source maps to PostHog',
__metricsId: 'eas/posthog_upload_sourcemaps',
inputProviders: [
BuildStepInput.createProvider({
id: 'directory',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'ignore_error',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const ignoreError = Boolean(inputs.ignore_error.value);
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: PosthogUtils.missingCredentialsError(result.missing),
});
return;
}
const client = result.client;
const directory = inputs.directory.value as string;
const { host, env: cliEnv } = client.cliConfig();
logger.info(`Uploading source maps from "${directory}" to PostHog`);
try {
await spawn(
'npx',
['-y', '@posthog/cli', '--host', host, 'hermes', 'upload', '--directory', directory],
{
logger,
// @posthog/cli writes all its logs, progress and success included, to stderr; tag them as stdout so they don't surface as build errors.
mode: PipeMode.COMBINED_AS_STDOUT,
cwd: stepCtx.workingDirectory,
env: { ...env, ...cliEnv },
signal,
}
);
} catch (error) {
PosthogUtils.failOrLogError({
logger,
ignoreError,
error: new UserError(
'EAS_POSTHOG_SOURCEMAPS_FAILED',
'Uploading source maps to PostHog failed.',
{ cause: error }
),
});
return;
}
logger.info('Uploaded source maps to PostHog');
},
});
}
@@ -23,6 +23,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
id: 'upload_to_asc',
name: 'Upload to App Store Connect',
__metricsId: 'eas/upload_to_asc',
__hookId: 'submit',
inputProviders: [
BuildStepInput.createProvider({
id: 'ipa_path',
@@ -0,0 +1,185 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
const OPERATORS = {
lt: { symbol: '<', test: (value: number, threshold: number) => value < threshold },
lte: { symbol: '<=', test: (value: number, threshold: number) => value <= threshold },
gt: { symbol: '>', test: (value: number, threshold: number) => value > threshold },
gte: { symbol: '>=', test: (value: number, threshold: number) => value >= threshold },
eq: { symbol: '=', test: (value: number, threshold: number) => value === threshold },
};
type Operator = keyof typeof OPERATORS;
export function createWaitForPosthogMetricFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_wait_for_metric',
name: 'Wait for a PostHog metric',
__metricsId: 'eas/posthog_wait_for_metric',
inputProviders: [
BuildStepInput.createProvider({
id: 'query',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'operator',
allowedValues: Object.keys(OPERATORS),
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'threshold',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: true,
}),
BuildStepInput.createProvider({
id: 'timeout_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS,
}),
BuildStepInput.createProvider({
id: 'interval_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
],
outputProviders: [BuildStepOutput.createProvider({ id: 'value', required: false })],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger } = stepCtx;
const operator = inputs.operator.value as string;
if (!Object.keys(OPERATORS).includes(operator)) {
throw new UserError(
'EAS_POSTHOG_METRIC_INVALID_OPERATOR',
`Invalid "operator" "${operator}". Must be one of: ${Object.keys(OPERATORS).join(', ')}.`
);
}
const timeoutSeconds = inputs.timeout_seconds.value as number;
const intervalSeconds = inputs.interval_seconds.value as number;
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds,
intervalSeconds,
errorCode: 'EAS_POSTHOG_METRIC_INVALID_INTERVAL',
});
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
throw PosthogUtils.missingCredentialsError(result.missing);
}
const client = result.client;
const value = await waitForPosthogMetricAsync({
logger,
client,
query: inputs.query.value as string,
operator: operator as Operator,
threshold: inputs.threshold.value as number,
timeoutSeconds,
intervalSeconds,
signal,
});
outputs.value.set(String(value));
},
});
}
async function waitForPosthogMetricAsync({
logger,
client,
query,
operator,
threshold,
timeoutSeconds,
intervalSeconds,
signal,
}: {
logger: bunyan;
client: PosthogClient;
query: string;
operator: Operator;
threshold: number;
timeoutSeconds: number;
intervalSeconds: number;
signal: AbortSignal | undefined;
}): Promise<number> {
const deadline = Date.now() + timeoutSeconds * 1000;
const { symbol, test } = OPERATORS[operator];
const target = `${symbol} ${threshold}`;
let lastValue: number | undefined;
logger.info(
`Waiting for the PostHog metric to reach ${target}. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.`
);
for (;;) {
let value: number | undefined = undefined;
try {
value = await queryMetricAsync({ logger, client, query, signal });
} catch (error) {
if (!(error instanceof PosthogRetryableError)) {
throw error;
}
}
if (value !== undefined) {
lastValue = value;
if (test(value, threshold)) {
logger.info(`Metric is ${value}. Target ${target} met.`);
return value;
}
logger.info(`Metric is ${value}. Still waiting for ${target}.`);
} else {
logger.info('The query has not returned a numeric value yet. Still waiting.');
}
if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) {
throw new UserError(
'EAS_POSTHOG_METRIC_TIMEOUT',
lastValue !== undefined
? `The PostHog metric did not reach ${target} within ${timeoutSeconds}s (last value: ${lastValue}).`
: `The PostHog query never returned a numeric value within ${timeoutSeconds}s. Check that the query returns a single number and that PostHog is reachable.`
);
}
}
}
async function queryMetricAsync({
logger,
client,
query,
signal,
}: {
logger: bunyan;
client: PosthogClient;
query: string;
signal: AbortSignal | undefined;
}): Promise<number | undefined> {
const cell = await client.runQueryAsync(query, logger, signal);
return typeof cell === 'number' && Number.isFinite(cell) ? cell : undefined;
}
@@ -0,0 +1,120 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { PosthogClient, PosthogRetryableError } from '../utils/PosthogClient';
import { PosthogUtils } from '../utils/PosthogUtils';
export function createWaitForPosthogQueryFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'posthog_wait_for_query',
name: 'Wait for a PostHog query',
__metricsId: 'eas/posthog_wait_for_query',
inputProviders: [
BuildStepInput.createProvider({
id: 'query',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: true,
}),
BuildStepInput.createProvider({
id: 'timeout_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_TIMEOUT_SECONDS,
}),
BuildStepInput.createProvider({
id: 'interval_seconds',
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
required: false,
defaultValue: PosthogUtils.DEFAULT_POLL_INTERVAL_SECONDS,
}),
BuildStepInput.createProvider({
id: 'api_key',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'project_id',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
],
fn: async (stepCtx, { inputs, env, signal }) => {
const { logger } = stepCtx;
const timeoutSeconds = inputs.timeout_seconds.value as number;
const intervalSeconds = inputs.interval_seconds.value as number;
PosthogUtils.assertPollBoundsPositive({
timeoutSeconds,
intervalSeconds,
errorCode: 'EAS_POSTHOG_QUERY_INVALID_INTERVAL',
});
const result = PosthogClient.fromEnv({
apiKeyOverride: inputs.api_key.value as string | undefined,
projectIdOverride: inputs.project_id.value as string | undefined,
env,
});
if (!result.client) {
throw PosthogUtils.missingCredentialsError(result.missing);
}
const client = result.client;
await waitForPosthogQueryAsync({
logger,
client,
query: inputs.query.value as string,
timeoutSeconds,
intervalSeconds,
signal,
});
},
});
}
async function waitForPosthogQueryAsync({
logger,
client,
query,
timeoutSeconds,
intervalSeconds,
signal,
}: {
logger: bunyan;
client: PosthogClient;
query: string;
timeoutSeconds: number;
intervalSeconds: number;
signal: AbortSignal | undefined;
}): Promise<void> {
const deadline = Date.now() + timeoutSeconds * 1000;
logger.info(
`Waiting for the PostHog query to return true. Checking every ${intervalSeconds}s for up to ${timeoutSeconds}s.`
);
for (;;) {
let cell: unknown;
try {
cell = await client.runQueryAsync(query, logger, signal);
} catch (error) {
if (!(error instanceof PosthogRetryableError)) {
throw error;
}
cell = undefined;
}
if (cell === true || (typeof cell === 'number' && cell !== 0)) {
logger.info('Query returned true.');
return;
}
logger.info('Query is not true yet. Still waiting.');
if (!(await PosthogUtils.waitForNextPollAsync({ intervalSeconds, deadline, signal }))) {
throw new UserError(
'EAS_POSTHOG_QUERY_TIMEOUT',
`The PostHog query did not return true within ${timeoutSeconds}s. It must select a single boolean, e.g. "SELECT count() > 100 FROM events".`
);
}
}
}
@@ -0,0 +1,303 @@
import { type Env } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { access, mkdir, mkdtemp } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { Sentry } from '../../sentry';
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
const IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS = 2_000;
const RECORD_SIM_FINISH_TIMEOUT_MS = 30_000;
const RECORD_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
const RECORD_SIM_MAX_ATTEMPTS_PER_BOOT = 3;
const RECORD_SIM_COMMAND = 'record-sim';
type IosSimulatorRecording = {
id: string;
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
outputDirectory: string;
startedAt: Date;
getOutput: () => string;
};
type ActiveIosSimulatorRecording = IosSimulatorRecording & {
recordingProcess: ChildProcess;
completionPromise: Promise<void>;
};
type IosSimulatorRecordingSession = {
env: Env;
logger: bunyan;
recordSimCommand: string;
recordingsRootDirectory: string;
activeRecordings: Map<IosSimulatorUuid, ActiveIosSimulatorRecording>;
completedRecordings: IosSimulatorRecording[];
recordingFailureCounts: Map<IosSimulatorUuid, number>;
pollingPromise: Promise<void>;
abortController: AbortController;
};
let activeIosSimulatorRecordingSession: IosSimulatorRecordingSession | null = null;
export namespace IosSimulatorRecordingUtils {
export async function startAsync({ env, logger }: { env: Env; logger: bunyan }): Promise<void> {
if (activeIosSimulatorRecordingSession) {
logger.info('iOS Simulator screen recording polling is already running.');
return;
}
const recordSimCommand = await resolveRecordSimCommandAsync({ env });
if (!recordSimCommand) {
logger.warn(
'record-sim binary is not available; iOS Simulator screen recordings are disabled.'
);
return;
}
const recordingsRootDirectory = await mkdtemp(
path.join(os.tmpdir(), 'ios-simulator-recordings-')
);
const session: IosSimulatorRecordingSession = {
env,
logger,
recordSimCommand,
recordingsRootDirectory,
activeRecordings: new Map(),
completedRecordings: [],
recordingFailureCounts: new Map(),
pollingPromise: Promise.resolve(),
abortController: new AbortController(),
};
activeIosSimulatorRecordingSession = session;
logger.info('Started polling iOS Simulators for screen recordings.');
session.pollingPromise = pollIosSimulatorRecordingsAsync(session).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording poller failed', error);
logger.warn({ err: error }, 'iOS Simulator screen recording poller failed.');
});
}
export async function finishAsync({ logger }: { logger: bunyan }): Promise<
{
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
directory: string;
}[]
> {
const session = activeIosSimulatorRecordingSession;
if (!session) {
logger.info('No iOS Simulator screen recordings are running.');
return [];
}
activeIosSimulatorRecordingSession = null;
session.abortController.abort();
await session.pollingPromise;
await Promise.all(
[...session.activeRecordings.values()].map(async recording => {
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
recording.recordingProcess.kill('SIGINT');
const finished = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FINISH_TIMEOUT_MS, false, { ref: false }),
]);
if (finished) {
return;
}
logger.warn(`Forcing iOS Simulator recording process for ${recording.deviceName} to stop.`);
recording.recordingProcess.kill('SIGKILL');
const killed = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
]);
if (!killed) {
logger.warn(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
Sentry.capture(
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
{
extras: {
output: recording.getOutput(),
},
}
);
}
})
);
const completedRecordings = [...session.completedRecordings].sort(
(a, b) => a.startedAt.getTime() - b.startedAt.getTime()
);
return completedRecordings.map(recording => ({
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
directory: recording.outputDirectory,
}));
}
}
async function pollIosSimulatorRecordingsAsync(
session: IosSimulatorRecordingSession
): Promise<void> {
let listDevicesErrorCount = 0;
const signal = session.abortController.signal;
while (!signal.aborted) {
try {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env: session.env,
filter: 'booted',
});
if (signal.aborted) {
break;
}
listDevicesErrorCount = 0;
const bootedUdids = new Set(bootedDevices.map(device => device.udid));
for (const udid of session.recordingFailureCounts.keys()) {
if (!bootedUdids.has(udid)) {
session.recordingFailureCounts.delete(udid);
}
}
for (const device of bootedDevices) {
if (
session.activeRecordings.has(device.udid) ||
(session.recordingFailureCounts.get(device.udid) ?? 0) >= RECORD_SIM_MAX_ATTEMPTS_PER_BOOT
) {
continue;
}
await startIosSimulatorRecordingAsync(session, {
udid: device.udid,
deviceName: device.name,
runtimeDisplayName: device.runtimeDisplayName,
});
}
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
listDevicesErrorCount += 1;
if (listDevicesErrorCount === 1 || listDevicesErrorCount % 5 === 0) {
Sentry.capture('Could not poll iOS Simulators for screen recordings', error);
session.logger.warn(
{ err: error, failedSimulatorListCount: listDevicesErrorCount },
'Could not poll iOS Simulators for screen recordings.'
);
}
}
if (!signal.aborted) {
try {
await setTimeout(IOS_SIMULATOR_RECORDING_POLL_INTERVAL_MS, undefined, {
signal,
});
} catch (err) {
if (!signal.aborted) {
throw err;
}
}
}
}
}
async function startIosSimulatorRecordingAsync(
session: IosSimulatorRecordingSession,
{
udid,
deviceName,
runtimeDisplayName,
}: {
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
}
): Promise<void> {
const startedAt = new Date();
const recordingId = randomUUID();
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
await mkdir(outputDirectory, { recursive: true });
session.logger.info(`Starting screen recording for ${deviceName}.`);
const recordingSpawn = spawn(
session.recordSimCommand,
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
{
env: session.env,
stdio: ['ignore', 'pipe', 'pipe'],
}
);
const getOutput = captureProcessOutput(recordingSpawn.child);
const completionPromise = recordingSpawn
.then(() => undefined)
.catch((err: unknown) => {
session.recordingFailureCounts.set(udid, (session.recordingFailureCounts.get(udid) ?? 0) + 1);
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('iOS Simulator screen recording process failed', error);
session.logger.warn(
{ err: error, recordSimOutput: getOutput() },
`Screen recording process failed for ${deviceName}.`
);
})
.finally(() => {
session.activeRecordings.delete(udid);
session.completedRecordings.push({
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
});
});
session.activeRecordings.set(udid, {
id: recordingId,
udid,
deviceName,
runtimeDisplayName,
outputDirectory,
recordingProcess: recordingSpawn.child,
completionPromise,
startedAt,
getOutput,
});
}
async function resolveRecordSimCommandAsync({ env }: { env: Env }): Promise<string | null> {
try {
await spawn('which', [RECORD_SIM_COMMAND], { env });
return RECORD_SIM_COMMAND;
} catch {}
const packagedRecordSimPath = path.join(__dirname, '..', '..', '..', 'bin', RECORD_SIM_COMMAND);
try {
await access(packagedRecordSimPath);
return packagedRecordSimPath;
} catch {
return null;
}
}
function captureProcessOutput(recordingProcess: ChildProcess): () => string {
let output = '';
const appendChunk = (chunk: Buffer | string): void => {
// Keep enough recorder stderr/stdout for diagnostics without retaining unbounded output.
output = `${output}${chunk.toString()}`.slice(-16_384);
};
recordingProcess.stdout?.on('data', appendChunk);
recordingProcess.stderr?.on('data', appendChunk);
return () => output;
}
@@ -0,0 +1,226 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { type BuildStepEnv } from '@expo/steps';
import fetch, { Response } from 'node-fetch';
import { z } from 'zod';
import { PosthogUtils } from './PosthogUtils';
const POSTHOG_DEFAULT_HOST = 'https://us.posthog.com';
const SYSTEM_DISTINCT_ID = 'eas-workflow';
const QueryResponseSchema = z.object({ results: z.array(z.array(z.unknown())) });
export class PosthogRetryableError extends Error {}
export class PosthogClient {
private constructor(
private readonly host: string,
private readonly apiKey: string,
private readonly projectId: string
) {}
static fromEnv({
apiKeyOverride,
projectIdOverride,
env,
}: {
apiKeyOverride: string | undefined;
projectIdOverride: string | undefined;
env: BuildStepEnv;
}): { client: PosthogClient } | { client: undefined; missing: PosthogUtils.Credential[] } {
const apiKey = apiKeyOverride || env.POSTHOG_CLI_API_KEY;
const projectId = projectIdOverride || env.POSTHOG_CLI_PROJECT_ID;
if (!apiKey || !projectId) {
return {
client: undefined,
missing: [
...(apiKey ? [] : [PosthogUtils.API_CREDENTIALS.apiKey]),
...(projectId ? [] : [PosthogUtils.API_CREDENTIALS.projectId]),
],
};
}
return {
client: new PosthogClient(
(env.POSTHOG_CLI_HOST || POSTHOG_DEFAULT_HOST).replace(/\/+$/, ''),
apiKey,
projectId
),
};
}
static forIngestion({
apiKeyOverride,
hostOverride,
env,
}: {
apiKeyOverride: string | undefined;
hostOverride: string | undefined;
env: BuildStepEnv;
}): PosthogClient | undefined {
const apiKey = apiKeyOverride || env.EXPO_PUBLIC_POSTHOG_API_KEY || env.POSTHOG_API_KEY;
if (!apiKey) {
return undefined;
}
return new PosthogClient(
(hostOverride || env.EXPO_PUBLIC_POSTHOG_HOST || POSTHOG_DEFAULT_HOST).replace(/\/+$/, ''),
apiKey,
''
);
}
async captureEventAsync({
event,
distinctId,
properties,
signal,
}: {
event: string;
distinctId: string | undefined;
properties: Record<string, unknown> | undefined;
signal: AbortSignal | undefined;
}): Promise<void> {
let response: Response;
try {
response = await fetch(`${this.host}/i/v0/e/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: this.apiKey,
event,
distinct_id: distinctId ?? SYSTEM_DISTINCT_ID,
properties:
distinctId !== undefined
? properties
: { ...properties, $process_person_profile: false },
timestamp: new Date().toISOString(),
}),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
throw new UserError(
'EAS_POSTHOG_CAPTURE_FAILED',
`Sending PostHog event "${event}" failed.`,
{
cause: error,
}
);
}
if (!response.ok) {
throw new UserError(
'EAS_POSTHOG_CAPTURE_FAILED',
`Sending PostHog event "${event}" failed with ${await PosthogUtils.readErrorAsync(response)}.`
);
}
}
async requestAsync(
method: 'GET' | 'POST' | 'PATCH',
path: string,
{
action,
forbiddenScope,
body,
signal,
}: { action: string; forbiddenScope: string; body?: unknown; signal: AbortSignal | undefined }
): Promise<Response> {
let response: Response;
try {
response = await fetch(this.apiUrl(path), {
method,
headers: this.authHeaders(),
body: body !== undefined ? JSON.stringify(body) : undefined,
signal,
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
throw new UserError('EAS_POSTHOG_REQUEST_FAILED', `${action} failed.`, { cause: error });
}
if (response.status === 403) {
throw new UserError(
'EAS_POSTHOG_FORBIDDEN',
`${action} was forbidden (403). The personal API key likely lacks the "${forbiddenScope}" scope.`
);
}
if (!response.ok) {
throw new UserError(
'EAS_POSTHOG_REQUEST_FAILED',
`${action} failed with ${await PosthogUtils.readErrorAsync(response)}.`
);
}
return response;
}
async runQueryAsync(
query: string,
logger: bunyan,
signal: AbortSignal | undefined
): Promise<unknown> {
let response: Response;
try {
response = await fetch(this.apiUrl('/query/'), {
method: 'POST',
headers: this.authHeaders(),
body: JSON.stringify({ query: { kind: 'HogQLQuery', query }, refresh: 'blocking' }),
signal,
});
} catch (error) {
if (signal?.aborted) {
throw error;
}
logger.debug(error);
logger.warn(
'Running the PostHog query failed with a network error; will retry on the next poll.'
);
throw new PosthogRetryableError();
}
if (response.status === 403) {
throw new UserError(
'EAS_POSTHOG_FORBIDDEN',
'Running the PostHog query was forbidden (403). The personal API key likely lacks the "query:read" scope.'
);
}
if (response.status >= 500 || response.status === 429) {
logger.warn(
`Running the PostHog query failed with status ${response.status}; will retry on the next poll.`
);
throw new PosthogRetryableError();
}
if (!response.ok) {
throw new UserError(
'EAS_POSTHOG_REQUEST_FAILED',
`Running the PostHog query failed with ${await PosthogUtils.readErrorAsync(response)}.`
);
}
let body: unknown;
try {
body = await response.json();
} catch (error) {
logger.debug(error);
logger.warn('The PostHog query response was not valid JSON; will retry on the next poll.');
throw new PosthogRetryableError();
}
const parsed = QueryResponseSchema.safeParse(body);
return parsed.success ? parsed.data.results[0]?.[0] : undefined;
}
cliConfig(): { host: string; env: Record<string, string> } {
return {
host: this.host,
env: { POSTHOG_CLI_API_KEY: this.apiKey, POSTHOG_CLI_PROJECT_ID: this.projectId },
};
}
private apiUrl(path: string): string {
return `${this.host}/api/projects/${encodeURIComponent(this.projectId)}${path}`;
}
private authHeaders(): Record<string, string> {
return { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
}
}
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more