Compare commits

...
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 06dbc19a5a docs(metadata/pricing): add TODOs for schedule push via expo/third-party#147
Document the new API surface from expo/third-party#147 that will enable
schedule push once @expo/apple-utils is bumped:
- AppPriceSchedule.createAsync (replaces entire schedule, no merge)
- AppPricePoint.getForAppAsync (resolve tiers to price point IDs)
- App.getPriceScheduleAsync (read current schedule)

Update the schedule-not-pushed warning to reference the tracking PR.
Add comments clarifying territory availability uses the legacy path
which is separate from pricing in the modern ASC API.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 13:36:35 -07:00
evanbaconandClaude Opus 4.6 84fadc4eac feat(metadata): support pricing tier and territory availability
EAS metadata previously had no way to set the App Store price tier or
territory availability, so every new app sat at free / worldwide
forever. Add `pricing` and `availability` blocks to the metadata schema
plus a new `PricingTask` that pulls and pushes both via the legacy
`App.updateAsync({ appPriceTier, territories })` endpoint exposed by
@expo/apple-utils.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:39:35 -07:00
Evan BaconandClaude Opus 4.6 8c170af829 feat(metadata): add app clips support (#3590)
* Add App Clip metadata support

Introduce App Clip support across metadata pipeline: add JSON schema definitions for AppleAppClip and related types; extend Apple types with AppClip interfaces and action enum; add reader accessors (getAppClip*, getAppClipLocalizedInfo) and writer mutators (setAppClipDefaultExperience, setAppClipReviewDetail, setAppClipLocalizedInfo). Implement AppClipTask to prepare/download/upload App Clip default experience, localizations, header images and review invocation URLs, including helper functions for syncing and downloading header images. Wire the task into the tasks index, include AppClipData in AppleData, and add unit tests for reader, writer, and task behavior.

* Preserve placeholder assets and app clip fallback

Fix race/regression issues when App Store Connect records are not yet rendered: - ScreenshotsTask and PreviewsTask now preserve placeholder local paths when imageAsset/videoUrl is null (AWAITING_UPLOAD) so pulled config doesn't drop entries and users can recover by adding files or removing entries. - PreviewsTask/ScreenshotsTask use the downloaded path when available, otherwise build a deterministic store path (with filename or index-based fallback). - AppClipTask.downloadAsync now falls back to the template experience when the current version has no default experience.

Also adds/updates unit tests for app clip behaviors (fallback to template, preserving header image path, skipping no-op PATCHes and review detail updates, and aborting when no editable version), screenshots (placeholder paths and index-based filename fallback), and previews (preserving placeholder entries when videoUrl is null). These changes address intermittent failures and improve round-trip push/pull resilience.

* [eas-cli] Bump @expo/apple-utils to 2.1.19

Brings in the fix for image and video uploads via metadata:push. The
asset client was inheriting Bearer token injection from the App Store
Connect API client, which caused S3 presigned URL uploads to be silently
mishandled by Apple's CDN — leaving screenshots, previews, and App Clip
header images stuck in AWAITING_UPLOAD state.

Also adds new App Clip models (AppClipDefaultExperience,
AppClipDefaultExperienceLocalization, AppClipHeaderImage,
AppClipAppStoreReviewDetail) used by the new App Clip metadata support.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 12:31:22 -07:00
Byron Karlen ad4c5299e3 [auth] logout changes (#3555) 2026-04-09 08:44:14 -07:00
Evan BaconandClaude Opus 4.6 dee07ae0a2 [eas-cli] Bump @expo/apple-utils to 2.1.18 (#3588)
* Sync Apple metadata schema, docs, and flags

Add new Apple advisory fields to metadata-0.json (advertising, ageAssurance, ageRatingOverrideV2, developerAgeRatingInfoUrl, gunsOrOtherWeapons, healthOrWellnessTopics, messagingAndChat, parentalControls, userGeneratedContent) and an AppleAgeRatingOverrideV2 enum. Introduce CLAUDE.md documenting how to keep schema and reader/writer in sync. Replace manual 'non-interactive' flags in metadata push/pull with the shared EASNonInteractiveFlag. Add a unit test that validates AppleConfigWriter output against the JSON schema (imports validateConfig) as a drift guard to surface mismatches between writer output and the schema early. These changes prevent confusing validation errors when pulling/pushing metadata by keeping schema, docs, CLI flags, and tests aligned.

* [eas-cli] Bump @expo/apple-utils to 2.1.18

Fixes `eas metadata:push` failing in the age-rating phase with
"'gamblingAndContests' is not an attribute on the resource
'ageRatingDeclarations'". The published 2.1.17 bundle still emitted
that field at runtime even though its `.d.ts` typed it as `never`,
because @expo/apple-utils' prepare script regenerated types from source
but bundled @expo/app-store from a stale `build/` directory.
2.1.18 rebuilds @expo/app-store as part of the prepare flow.

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

* Fix changelog PR link

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 08:34:49 -07:00
Evan BaconandClaude Opus 4.6 4ad12340f2 Add new Apple metadata attributes (#3584)
* Add new Apple metadata attributes

Extend AppleConfigReader and AppleConfigWriter to include additional app rating/content fields (advertising, ageAssurance, ageRatingOverrideV2, developerAgeRatingInfoUrl, gunsOrOtherWeapons, healthOrWellnessTopics, messagingAndChat, parentalControls, userGeneratedContent) with sensible defaults (false/null or Rating.NONE). This ensures these attributes are preserved when reading and writing Apple metadata.

* update package

* Add missing fields to test fixtures and CHANGELOG entry

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 07:45:54 -07:00
c6d4faefe7 [build-tools][eas-cli] Support iOS Development provisioning profiles (#3496)
* [build-tools] Detect iOS Development provisioning profiles and set correct code signing identity

Add DEVELOPMENT distribution type to distinguish Development profiles
from Ad Hoc. Development profiles have get-task-allow=true in their
entitlements; Ad Hoc profiles don't.

When a Development profile is detected, override CODE_SIGN_IDENTITY to
"Apple Development" instead of the "iPhone Distribution" that
@expo/config-plugins hardcodes. This fixes builds that use
credentialsSource: "local" with Development profiles (e.g., for
Family Controls entitlement).

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

* [eas-cli] Accept Development provisioning profiles for internal distribution

Fix isAdHocProfile() to distinguish Development from Ad Hoc profiles by
checking the get-task-allow entitlement. Add isDevelopmentProfile() for
explicit detection.

Update assertProvisioningProfileType() to accept Development profiles
for internal distribution, and reject them for store distribution.

This enables credentialsSource: "local" with Development profiles needed
for entitlements like Family Controls (com.apple.developer.family-controls)
that only work with Development provisioning profiles.

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

* [build-tools] Use certificateCommonName for development code signing identity

Address review feedback: use the certificate common name from the
provisioning profile instead of hardcoding "Apple Development", which
doesn't cover all certificate types (e.g. "iPhone Developer", "iOS
Development"). Also fix grammar in error message ("an development" →
"a development").

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

* [build-tools] Add CHANGELOG entry for development provisioning profile fix

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

* [build-tools] Use upstream codeSignIdentity parameter instead of workaround

Now that @expo/[email protected] supports the optional
codeSignIdentity parameter in setProvisioningProfileForPbxproj,
pass profile.certificateCommonName directly for development profiles
and remove the overrideCodeSignIdentityForDevelopment workaround
that was reading/writing the pbxproj a second time.

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

* fix changelog

Temporary Commit at 4/2/2026, 1:09:37 PM

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Quinlan Jung <[email protected]>
2026-04-02 13:25:13 -07:00
Phil Pluckthun 2f8712a0f3 [build-tools] Differentiate between Yarn Classic/Modern based on lockfile contents (#3572)
* Detect Yarn legacy/classic based on lockfile contents

* Add CHANGELOG entry

* Fix typo

* Add tests
2026-04-02 21:11:11 +01:00
Expo CI a439721054 update CHANGELOG.md 2026-04-02 10:44:22 +00:00
Expo CI 2e2dddb59a v18.5.0 2026-04-02 10:41:22 +00:00
Szymon ŚwierkandStanisław Chmiela b7df3ff4f8 [eas-build-job] Add app_store_connect.build_upload workflow interpolation context (#3566)
* Add app_store_connect.build_upload workflow interpolation context

* limit the possible states

* make the id type uuid

* Update packages/eas-build-job/src/common.ts

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

* Update packages/eas-build-job/src/common.ts

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

* remove no longer valid test

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-04-02 12:06:44 +02:00
Stanisław Chmiela 8a74758d56 [build-tools] Remove reuse_devices: false dead code (#3560)
## Summary
- remove the internal `eas/__maestro_test` function registration and implementation
- delete the Maestro flow/config discovery helper used by that internal path
- remove the related tests and fixture files for Maestro config parsing and flow resolution

## Testing
- `yarn --cwd packages/build-tools typecheck`
- `yarn --cwd packages/build-tools jest-unit --runInBand maestroResultParser reportMaestroTestResults easFunctions`
2026-04-02 10:38:59 +02:00
Szymon Świerk 26fdf8fb75 [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext (#3530)
* [eas-build-job] Add app_store_connect event context to WorkflowInterpolationContext

* app_store_connect.app, not app_store_connect.event.app
2026-04-01 11:10:48 +02:00
Stanisław Chmiela 9328063547 [build-tools] Remove precompiled modules preparation flow (#3565)
## Summary
- remove the worker and build-tools plumbing for precompiled dependency downloads and extraction
- stop waiting on precompiled dependency preparation before `pod install`
- delete the dedicated precompiled dependency helpers, fixtures, and tests
- keep the `EAS_USE_PRECOMPILED_MODULES=1` passthrough so iOS jobs still set `EXPO_USE_PRECOMPILED_MODULES=1`

## Testing

CI should pass.
2026-04-01 09:44:39 +02:00
Stanisław Chmiela fc66b8251e [eas-cli] Fix hasIgnoredIosProjectAsync (#3562)
## Why

`hasIgnoredIosProjectAsync()` currently always returns `false` because its `finally` block unconditionally returns `false`, overriding the value from the `try` block.

That means callers never see a positive ignored result even when the Xcode project is actually ignored.

## How

- replace the unconditional `finally` return with a `catch` fallback
- add a unit test that covers the ignored iOS project case

## Test Plan

CI should pass. Verified the test would fail if not for this change.
2026-04-01 09:25:07 +02:00
Stanisław Chmiela 394576368a [worker] Lower project dependencies telemetry errors to warning (#3535)
## Summary
- mark `FAILED_TO_REPORT_PROJECT_DEPENDENCIES_EVENT` as a Sentry warning instead of an error
- reduce noise from non-critical analytics failures in worker project dependency reporting
2026-03-31 10:30:17 +02:00
Evan BaconandClaude Opus 4.6 f80e34cffe feat(metadata): add screenshots and preview support (#3301)
* Add support for Apple screenshots and video previews

Introduces new tasks for managing App Store screenshots and video previews, including downloading and uploading assets per locale and display type. Updates AppleData and PartialAppleData types to include screenshots and previews, and extends config reader/writer to handle these assets. Also bumps @expo/apple-utils to 2.1.13 and updates related tests and types.

* Update apple-utils and improve asset file handling

Bump @expo/apple-utils to 2.1.14 and add a version check for preview support. Normalize preview and screenshot asset directory structure and filenames for consistency and future compatibility.

* Use original type names for preview and screenshot dirs

Updated directory structure for storing Apple previews and screenshots to use the original previewType and displayType values instead of lowercased, hyphenated versions. This change ensures consistency with the original type naming.

* Improve Apple metadata tasks error handling and schema

Replaces assert-based error handling with conditional checks and log messages in Apple metadata tasks (age-rating, app-review-detail, previews, screenshots) to prevent crashes when context is missing. Updates the metadata schema to add support for 'screenshots' and 'previews' fields, including detailed structure for video previews.

* Use original filenames for Apple previews and screenshots

Updated the download logic to use the original filenames from Apple metadata for previews and screenshots instead of normalized index-based names. This change improves file matching during sync operations.

* Update tests to skip on missing context instead of abort

Changed age rating and app review detail task tests to expect skipping (not throwing) when required context is missing, reflecting updated behavior. Also updated CHANGELOG to note new screenshots and previews support for metadata commands.

* wip -- unit tests for metadata

* Include .query(true) in apple metadata tests

Allow nock mocks to match requests with query parameters in two tests. Added `.query(true)` to the GET interceptors in packages/eas-cli/src/metadata/apple/tasks/__tests__/previews.test.ts and packages/eas-cli/src/metadata/apple/tasks/__tests__/screenshots.test.ts so the mocks won't fail when requests include query strings.

* [eas-cli] Fix age rating API and update @expo/apple-utils to 2.1.16

Move age rating from appStoreVersions to appInfos — Apple removed the
ageRatingDeclaration relationship from appStoreVersions. Also apply
formatting fixes from oxfmt.

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

* Update CHANGELOG.md

* [eas-cli] Improve screenshots/previews tasks: native fs, fileSize matching, parallel fetching

- Replace fs-extra with native fs/fs.promises
- Add fileSize check alongside filename matching to detect changed files
- Parallelize locale fetching in prepareAsync with Promise.all
- Skip reorder API call when screenshot order is unchanged
- Remove unnecessary runtime method check for getAppPreviewSetsAsync
- Use consistent index-based fallback filenames for previews

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 17:46:55 -07:00
Quinlan Jung 🦥 59ad2c2ca0 Reapply "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534) (#3543)
This reverts commit 92ad6b5ba3.
2026-03-30 14:04:44 -07:00
Evan BaconandClaude Opus 4.6 4e95a29797 Support non-interactive metadata commands with ASC API key auth (#3548)
* Support non-interactive metadata commands

Add a --non-interactive flag to metadata pull/push and thread nonInteractive through context and helper calls. Propagate the flag to ensureProjectConfiguredAsync, getProfilesAsync, getAppStoreAuthAsync, downloadMetadataAsync and uploadMetadataAsync so prompts are skipped or fail fast when non-interactive.

Improve App Store Connect auth resolution: try ASC API key from submit profile, then EAS credentials service (via GraphQL lookup), then environment variables; fall back to interactive cookie auth only when allowed. In non-interactive mode, throw a clear error if no ASC API key is available. Also update upload/download to auto-overwrite in non-interactive mode and make config validation in upload fail instead of prompting.

Minor: import/fs usage and GraphQL client types added, small logging improvements and error messages to aid non-interactive workflows.

* Add metadata tests and enhance App Store auth

Add unit tests for metadata auth, download, and upload flows and update App Store auth resolution to include team metadata.

- New tests: src/metadata/__tests__/auth.test.ts, download.test.ts, upload.test.ts covering API key resolution, interactive vs non-interactive behavior, telemetry, and validation prompts.
- auth.ts: refactor tryResolveAscApiKeyAsync to return ascApiKey plus optional teamId/teamName, read profile-provided keyP8 and prefer profile keys over stored keys, and include teamId/teamName/teamType (defaulting to COMPANY_OR_ORGANIZATION) when calling ensureAuthenticatedAsync.

These changes improve handling of ASC API keys and team info during authentication and add coverage for metadata CLI flows and edge cases.

* Use fs.promises.readFile instead of fs-extra

Replace import of fs-extra with Node's built-in fs and call fs.promises.readFile when loading the ASC API key. Update tests to mock fs.promises.readFile accordingly. This removes the dependency on fs-extra for this code path and aligns the readFile usage with the fs.promises API.

* [eas-cli] Add CHANGELOG entry for metadata non-interactive support

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-30 11:09:41 -07:00
Stanisław Chmiela 66d8e3d6b8 Add test precompiled iOS dependency preparation support (#3556)
## Summary
- export precompiled modules helpers from `@expo/build-tools` and start preparation when flagged builds enable precompiled modules
- wait for precompiled module preparation before running `pod install` in both build-tools pod flows
- set worker env and config for precompiled module downloads, including proxy-aware download handling and coverage for the new behavior

## Testing
<img width="812" height="325" alt="Zrzut ekranu 2026-03-28 o 22 26 35" src="https://github.com/user-attachments/assets/9e4deb10-d233-4bb6-ac5d-48cef31c17ed" />

<img width="1032" height="548" alt="Zrzut ekranu 2026-03-28 o 22 27 27" src="https://github.com/user-attachments/assets/a6bbcb75-4845-4dce-9f31-5097ece800ae" />
2026-03-30 19:37:41 +02:00
Stanisław Chmiela 5e4a88094f [build-tools] Disable Android emulator window transitions (#3557)
## Summary
- disable Android emulator window and transition animation scales once the emulator is booted and ready
- apply the setting for the initial emulator and any cloned emulator instances used by the start step
- add unit coverage for the new adb settings commands and the startup orchestration

## Why
Maestro test runs already avoid the emulator boot animation, but Android window and transition animations still run after boot. Those system animations add avoidable UI transition latency and can introduce test flake without affecting in-app animator timing.

See https://bsky.app/profile/rdperottoni.bsky.social/post/3mfnhnt77mk2f.

## Validation
- `yarn fmt packages/build-tools/src/utils/AndroidEmulatorUtils.ts packages/build-tools/src/steps/functions/startAndroidEmulator.ts packages/build-tools/src/steps/functions/__tests__/startAndroidEmulator.test.ts packages/build-tools/src/utils/__tests__/AndroidEmulatorUtils.test.ts`
- `yarn --cwd packages/build-tools jest-unit -- runInBand src/steps/functions/__tests__/startAndroidEmulator.test.ts src/utils/__tests__/AndroidEmulatorUtils.test.ts`
2026-03-30 19:04:46 +02:00
Kudo Chien 7a4c81f4e3 [build-tools] resolve repack version (#3549) 2026-03-28 01:48:50 +08:00
Mustafa Abban c9cdfd1eb5 [build-tools] support Gradle build cache for Android builds (#3510)
* gradle cache

* cache modules-2

* cache modules-2

* proper path

* set properties, rename

* rem modules-2

* set org.gradle.cache.cleanup to ALWAYS

* set org.gradle.cache.cleanup to ALWAYS

* consolidate caches in restore/save step

* restore gradle

* cleanup nullthrows

* always set properties if env set, cleanup

* consistent path, clean log

* modify gradle props in trycatch

* eas-cache-cleanup script

* shorter prune
2026-03-26 12:34:18 -05:00
Quinlan Jung 🦥 8115dc8766 [eas-cli] bump apple-utils (#3545)
* [eas-cli] bump apple-utils

* Temporary Commit at 3/25/2026, 6:42:37 PM

* Temporary Commit at 3/25/2026, 7:18:01 PM
2026-03-25 19:27:14 -07:00
Byron Karlen 113467c91d Reapply "Use auth code flow (#3398)" (#3422) (#3542)
This reverts commit 0fdf2dd44a.
2026-03-25 12:32:58 -07:00
Phil Pluckthun be967f4b9a [build-tools] Read app config with expo config CLI invocation before falling back to @expo/config (#3536)
* Add `expo config` app config reading to build-tools

* Align with other CLI invocations

* Switch to turtle-spawn for consistency

* Update `appConfig` reading for async calls

* Add CHANGELOG entry

* Update worker

* Fix typo

* Add unit tests
2026-03-25 15:13:48 +00:00
Stanisław Chmiela 660303b0c3 [steps] Use counter-based generated step ids (#3541)
## Summary
- replace UUID-based autogenerated step ids with a counter in `BuildStep.getNewId()`
- keep explicit user-defined ids unchanged
- rename the step-id test helper so tests no longer talk about UUIDs

## Testing
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts BuildFunction-test.ts BuildConfigParser-test.ts StepsConfigParser-test.ts AbstractConfigParser-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 15:02:30 +01:00
Stanisław Chmiela 5514289648 [steps][local-build-plugin] Remove buildStepInternalId (#3540)
## Summary
- remove `buildStepInternalId` emission from `BuildStep`
- stop generating the extra UUID entirely
- drop the now-unused field from local build log sanitization

Just want to make logs smaller.

## Testing
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 13:36:12 +00:00
Stanisław Chmiela 7924e33e96 [eas-cli] Key workflow logs by buildStepId (#3539)
## Summary
- key workflow log groups by `buildStepId` instead of display text (this might cause two steps named the same to appear as one)
- treat `buildStepInternalId` as nonexistent in the CLI parser (want to remove it)
- keep display names for prompts and status output while preserving stable selection keys

## Testing
- yarn --cwd packages/eas-cli test src/__tests__/commands/workflow-logs-test.ts src/commandUtils/workflow/__tests__/utils-test.ts
- yarn --cwd packages/eas-cli typecheck
2026-03-25 14:31:11 +01:00
Stanisław Chmiela e699a06c07 [steps] Simplify name/displayName handling (#3538)
## Summary
- remove `BuildStep.getDisplayName` and inline display-name selection at the shell/function callsites
- simplify shell step naming to `name ?? first non-empty command line ?? command`
- update step tests to use explicit display names instead of the removed helper

## Verification
- yarn --cwd packages/steps test --runInBand BuildStep-test.ts BuildFunction-test.ts BuildWorkflowValidator-test.ts BuildStepOutput-test.ts BuildStepInput-test.ts
- yarn --cwd packages/steps typecheck
2026-03-25 13:42:41 +01:00
Stanisław Chmiela ce819c1a14 [eas-cli] Add optional Sentry error reporting (#3537)
## Summary
- add a dedicated `packages/eas-cli/src/sentry.ts` module that initializes `@sentry/node` for eas-cli
- capture command failures from `EasCommand`, including `EasCommandError`, and flush Sentry on exit
- propagate `EAS_CLI_SENTRY_DSN` into the worker child-process environment so worker-run eas-cli commands can report errors too

https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1773432161848589?thread_ts=1773417343.042269&cid=C06EFBQK3B7

## Validation
- `yarn test src/commandUtils/__tests__/EasCommand-test.ts --runInBand`
- `yarn test:unit --runInBand src/__unit__/env.test.ts`
- `yarn build` in `packages/eas-cli`
- `node ./bin/run --version`
- `node ./bin/run --help`
- `node ./bin/run analytics`
- `EAS_CLI_SENTRY_DSN=https://[email protected]/1 node ./bin/run analytics`

## Manual Sentry Verification
- ran the compiled CLI against the test Sentry project `expo-stanley-test/test-local` with a real DSN
- forced an `EasCommandError` from `eas analytics` and confirmed the CLI failed locally as expected
- verified that Sentry ingested the event and created issue `TEST-LOCAL-1`
- confirmed the event in Sentry at `https://expo-stanley-test.sentry.io/issues/TEST-LOCAL-1`
2026-03-25 13:37:09 +01:00
Quinlan Jung 🦥 92ad6b5ba3 Revert "[eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)" (#3534)
This reverts commit d63d51f548.
2026-03-24 17:44:58 -07:00
Quinlan Jung 🦥 d63d51f548 [eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins (#3527)
* [eas-cli][build-tools][worker] bump @expo/config and @expo/config-plugins

* Temporary Commit at 3/23/2026, 5:55:24 PM

* Temporary Commit at 3/23/2026, 6:10:13 PM
2026-03-24 11:56:19 -07:00
Stanisław Chmiela bfb12f0317 [repo] Standardize clean scripts on rimraf (#3520)
## Summary
- standardize workspace clean/rebuild/prepack deletion steps on `rimraf`
- add `rimraf` where package-local scripts invoke it directly
- keep `worker` and root clean flows working from a clean install

Also adds Codex config for new workspace.

And the problem it solves is that sometimes `yarn clean` would fail if there were no `*.tsbuildinfo` files. Something weird about zsh in package.json

## Validation
- mise exec -- yarn clean
- mise exec -- yarn install --immutable
- mise exec -- yarn build
- mise exec -- yarn typecheck
- mise exec -- yarn clean (in packages/worker)
- mise exec -- yarn clean (in packages/logger)
2026-03-23 17:33:02 +01:00
Douglas LowderandClaude Opus 4.6 b162162007 [eas-cli] Fix workflow:logs for builds using built-in EAS build steps (#3523)
# Why

Logs from EAS build jobs using the built-in flow are in a different form than those from build jobs using custom YAML. Because of this, `eas workflow:logs` and `eas workflow:status` were not working correctly for workflow runs with these jobs.

# How

Analyze the differences in format, and modify the utils that parse logs to handle both formats.

# Test Plan

- Tested with recent workflow runs from expo/expo
- Unit tests updated

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 08:11:33 +01:00
Stanisław Chmiela 6ed7e14a69 [eas-cli] Update code for the new PartnerActor (#3521)
<!-- 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

After new union type addition GQL fails TSC in `eas-cli`.

# How

Added fetching of `username` for `PartnerActor` and used it where we would previously use `username` of a `User`.

Removed `getUsernameForBuildMetadataAndBuildJob` in favor of `getActorUsername`, I don't think these will every be different?

Removed 

# Test Plan

CI should pass.
2026-03-19 15:31:43 +01:00
Kudo Chien 37910b17e1 [build-tools] add js_bundle_only to repack function (#3517)
# Why

close ENG-17737

# How

adding `js_bundle_only` support to repack
2026-03-19 20:01:06 +08:00
Stanisław Chmiela 0d3c56f4d6 [build-tools] Improve ASC app ID error guidance (#3512)
## Summary
- surface example visible App Store Connect apps for invalid bundle-id upload failures
- reuse shared ASC app summary formatting for not-found and invalid-bundle-id errors
- add unit coverage for visible app summaries and message contents

Should help debug https://discord.com/channels/695411232856997968/1483454279204343910/1483492265312780340

## Testing
- ./node_modules/.bin/jest --config packages/build-tools/jest/unit-config.ts packages/build-tools/src/steps/utils/ios/__tests__/AscApiUtils.test.ts packages/build-tools/src/steps/functions/__tests__/uploadToAsc.test.ts
- ./node_modules/.bin/tsc -p packages/build-tools/tsconfig.json --noEmit
2026-03-17 16:59:49 +01:00
Stanisław Chmiela bc8e60d385 [build-tools] Handle ASC SDK version issue (90725) (#3464)
## Why
UNKNOWN submit failures include App Store Connect SDK compatibility error `90725`.

This should be surfaced as actionable EAS guidance (rebuild with newer iOS SDK/Xcode image) instead of generic unknown.

## How
- add `90725` detection in `upload_to_asc` failed-state handling
- throw `EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE` with EAS-specific remediation
- include docs URL to iOS build image/Xcode configuration docs
- add unit tests for `isSdkVersionIssueError`

## Examples
- https://expo.dev/uuid/019cbbe7-9d71-7c90-80b5-a98870478265
- https://expo.dev/uuid/019cbf52-3cbd-7491-b35e-c3fc2f24afc9
- https://expo.dev/uuid/019cbf4f-41d9-7ab7-8e85-e53596526a82

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
- `yarn fmt`
2026-03-17 16:35:00 +01:00
Expo CI 99b8d67d3f update CHANGELOG.md 2026-03-16 20:24:39 +00:00
Expo CI 4e202db843 v18.4.0 2026-03-16 20:21:54 +00:00
Stanisław Chmiela ab209463ab [worker] Prevent forwarding HTTP log failures to GCS logstream (#3507)
Makes sure we don't log "Failed to upload batch of logs" to user-facing log as we did [here](https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/workflows/019cf7b3-19d6-7b57-bb6f-41d5c63491e3).
2026-03-16 19:02:25 +01:00
Stanisław Chmiela 99e9d367a1 [worker] Add ITSAppUsesNonExemptEncryption flag to worker app config (#3506)
Summary
- add the missing `ITSAppUsesNonExemptEncryption` boolean to `packages/worker/app.json` so the worker app can declare non-exempt encryption usage and avoid iOS config errors

Should fix system tests!
2026-03-16 18:23:50 +01:00
Stanisław Chmiela a6f68462c9 Enable staging HTTP log streaming (#3505)
## Summary
- enable worker HTTP log streaming for staging using https://staging-logs.expo.dev/logs/


This is not tied to anything in UI yet. Just want to see the logs flowing.
2026-03-16 16:41:07 +01:00
Szymon ŚwierkandStanisław Chmiela 1c58823991 [eas cli] Builds: wait before showing the concurrency limit message (#3493)
* [eas cli] builds: wait before showing the concurrency limit message

* Update packages/eas-cli/src/build/build.ts

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

* apply style/naming CR suggestions

* remove the unnecessarily defensive clean-ups

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-03-16 15:11:46 +01:00
Ash Wu 6a804ab8d7 [build-tools] Store per-attempt results for Maestro test retries (#3498)
Signed-off-by: Ash Wu <[email protected]>
2026-03-16 21:01:42 +08:00
Stanisław Chmiela 74eff9bf67 [worker] Stop mutating errors in Sentry reporting (#3502)
## Summary
- stop mutating the original error instance in worker Sentry reporting
- attach the worker message to Sentry as an extra instead of rewriting the exception message — matches `universe/server-logging`
- keep the launcher-facing error payload unchanged
- fixes integration tests

## Testing
- yarn test:integration:watch stateSync
- yarn test:unit sentry
2026-03-13 12:42:54 +01:00
Stanisław Chmiela d20c465ddf [eas-build-job] Introduce SystemError and replace BuildError (#3471)
## Summary
- introduce `SystemError` as the explicit system-failure type
- replace in-repo `BuildError` usage with `ExpoError`, `UserError`, and `SystemError`
- keep unknown errors on the user-error side

## Changes
- remove the old `BuildError` class from the in-repo error flow
- make `SystemError` always publish `SERVER_ERROR` while keeping `trackingCode` for internal use
- update worker handling, analytics, and state sync to use the shared `ExpoError` contract
- avoid mutating the user-facing error object during Sentry reporting

## Testing
- `packages/eas-build-job`: `npm exec -- corepack yarn build && npm exec -- corepack yarn test src/__tests__/errors.test.ts --runInBand`
- `packages/build-tools`: `npm exec -- corepack yarn jest-unit src/buildErrors/__tests__/detectError.test.ts --runInBand`
- `packages/worker`: `npm exec -- corepack yarn test:integration src/__integration__/stateSync.test.ts`
2026-03-13 09:20:09 +01:00
Evan Bacon 0c17846e67 Auto-detect non-interactive mode in CI / non-TTY environments (#3486)
# Why

`eas build -p android` prompts for keystore generation and fails in CI or non-TTY environments:

```
Input is required, but stdin is not readable. Failed to display prompt: Generate a new Android Keystore?
```

Passing `--non-interactive` works around this, but it should be auto-detected when `CI=1` or stdin is not a TTY.

# How

Set a `default` function on both `--non-interactive` flag definitions (`EasNonInteractiveAndJsonFlags` and `EASNonInteractiveFlag`) in `packages/eas-cli/src/commandUtils/flags.ts`. The default checks `CI` env var (via `boolish` from `getenv`) and `process.stdin.isTTY`.

- oclif evaluates `default` only when the user does NOT pass `--non-interactive` on CLI
- `noCacheDefault: true` prevents baking the developer's environment into `oclif.manifest.json`
- All ~60 commands using these flag definitions automatically get CI/TTY auto-detection with no other changes needed

# Test Plan

- Added 5 unit tests for `isNonInteractiveByDefault()` covering: TTY without CI (false), CI=1 (true), CI=true (true), non-TTY stdin (true), both CI and non-TTY (true)
- All existing `resolveNonInteractiveAndJsonFlags` tests pass unchanged
- `yarn typecheck` passes
- `yarn lint` passes (0 errors)

```
PASS src/commandUtils/__tests__/flags-test.ts
  isNonInteractiveByDefault
    ✓ returns false when stdin is a TTY and CI is not set
    ✓ returns true when CI=1
    ✓ returns true when CI=true
    ✓ returns true when stdin is not a TTY
    ✓ returns true when both CI is set and stdin is not a TTY
  resolveNonInteractiveAndJsonFlags
    ✓ returns both false when no flags are set
    ✓ --json implies --non-interactive
    ✓ --non-interactive alone does not enable json
    ✓ both flags explicitly set
    ✓ both flags explicitly false
    ✓ --json false with --non-interactive true

Test Suites: 1 passed, 1 total
Tests:       11 passed, 11 total
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-12 12:29:04 -07:00
Douglas Lowder d5a3fc06f9 Enable manual testing of changelog GitHub workflow (#3500)
# Why

There have been failures recently with the GitHub CHANGELOG check. Adjusting the workflow so that it can be manually tested.

# How

Add `workflow_dispatch:`

# Test Plan

CI should pass
2026-03-12 10:57:58 -07:00
Matheus MichelsandDouglas Lowder c51ac27e25 [eas-cli] add skip-build-if-not-found option to eas build:dev (#3495)
* add skip-build-if-not-found to build:dev

* add changelog entry for build:dev skip flag

* Remove duplicate line in CHANGELOG

* Adjust CHANGELOG

* Bump add-pr-comment workflow dep

---------

Co-authored-by: Douglas Lowder <[email protected]>
2026-03-11 17:57:44 -07:00
Jacob KaplanandClaude Opus 4.6 88506d01a6 fix(build-tools): check all certificates in provisioning profile during verification (#3484)
* [build-tools] Check all certificates in provisioning profile during verification

Apple provisioning profiles can contain multiple developer certificates
(e.g., when a team has multiple developers). The previous code only
checked DeveloperCertificates[0], causing false "profile and
distribution certificate don't match" errors when the signing
certificate was at a different index.

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

* [build-tools] Add CHANGELOG entry for multi-cert provisioning profile fix

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 15:06:29 -07:00
Stanisław Chmiela 77e36e0743 [build-tools] Enforce C.utf8 locale for Gradle invocations (#3494)
# Why

Some environments that run Gradle do not have `en_US.UTF-8` available, which can lead to locale warnings and Gradle failures when Java falls back to a non-UTF-8 default charset.

In the affected environment, `locale -a` returned only:

```sh
C
C.utf8
POSIX
```

Setting `LC_ALL=C.UTF-8` fixed the Gradle issue. This matches the behavior described in [gradle/gradle#23391](https://github.com/gradle/gradle/issues/23391#issuecomment-1878979127), where Gradle/archive handling fails under a non-UTF-8 locale.

# How

Inline `LC_ALL: 'C.UTF-8'` at the two Android Gradle subprocess call sites in `@expo/build-tools`:

- `packages/build-tools/src/android/gradle.ts`
- `packages/build-tools/src/steps/utils/android/gradle.ts`

This keeps the change narrowly scoped to Gradle execution instead of changing the broader worker environment or unrelated iOS/macOS build paths.

# Test Plan

- Verified the affected environment does not support `en_US.UTF-8`:
  ```sh
  locale -a
  ```
  Output:
  ```sh
  C
  C.utf8
  POSIX
  ```

- Confirmed that setting `LC_ALL=C.UTF-8` fixes the Gradle issue locally.

- Ran a targeted TypeScript check after the code change:
  ```sh
  ./node_modules/.bin/tsc -p packages/build-tools/tsconfig.json --noEmit
  ```
2026-03-11 13:39:38 +01:00
Stanisław Chmiela ce8f438610 [build-tools] Inject Android version without credentials (#3482)
# Why

The Android builder currently only injects Gradle config during `PREPARE_CREDENTIALS`. That means `job.version.versionCode` and `job.version.versionName` are ignored for builds that do not provide Android build credentials, even though those version values are present on the job.

This ports the Android version injection change from `expo/eas-build#425`, now on top of the merged enum-backed `CONFIGURE_ANDROID_VERSION` build phase.

# How

- switch the Android builder from the legacy `eas-build.gradle` shim to the existing direct Gradle injection helpers
- keep credential injection gated on build credentials
- inject the Android version Gradle config whenever `job.version` includes `versionCode` or `versionName`, even if there are no credentials
- run that injection in `BuildPhase.CONFIGURE_ANDROID_VERSION`
- remove the now-unused legacy shim files
- warn when deprecated `eas-build.gradle` still exists, matching the previous conservative behavior
- add focused Android builder coverage for the no-credentials version case
- add helper coverage for the legacy warning path

# Test Plan

- `mise exec -- yarn workspace @expo/build-tools jest-unit src/steps/utils/android/__tests__/gradleConfig.test.ts src/builders/__tests__/android.test.ts`
- `mise exec -- yarn workspace @expo/build-tools test`
- `mise exec -- yarn workspace @expo/build-tools typecheck`
- `git diff --check`
2026-03-11 13:16:44 +01:00
Stanisław Chmiela f3fd05d2dc [eas-build-job] Rename UserFacingError to UserError (#3468)
## Summary
- rename `UserFacingError` to `UserError` across the stack
- keep payloads and behavior unchanged
- move unknown user-visible errors under the new name

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

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

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

* address comments

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

* move reportStepMetric to customBuildContext

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-03-10 14:57:53 +08:00
Expo CI efbb61b088 update CHANGELOG.md 2026-03-10 02:04:35 +00:00
Expo CI 3984c9a938 v18.3.0 2026-03-10 02:02:33 +00:00
Brent Vatne a3c9e6727e [eas-cli] Show user email in account:view and whoami (#3485) 2026-03-09 16:43:53 -07:00
Expo CI e06a5babcf update CHANGELOG.md 2026-03-09 22:20:37 +00:00
217 changed files with 13305 additions and 11579 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
+2 -1
View File
@@ -1,6 +1,7 @@
name: CHANGELOG.md PR check
on:
workflow_dispatch:
pull_request:
types:
- opened
@@ -29,7 +30,7 @@ jobs:
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@v2.1.0
- uses: mshick/add-pr-comment@v2.8.2
if: always()
with:
message-id: changelog-entry-check
+1
View File
@@ -16,6 +16,7 @@
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/eas-cli/src/graphql/generated.ts",
"packages/build-tools/schema.graphql"
]
}
+1
View File
@@ -3,3 +3,4 @@
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+49 -1
View File
@@ -8,12 +8,61 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
### 🧹 Chores
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
@@ -1658,7 +1707,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.2.0",
"version": "18.5.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+2 -1
View File
@@ -17,7 +17,7 @@
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rm -rf node_modules coverage"
"clean": "lerna run clean && rimraf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
@@ -30,6 +30,7 @@
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"typescript": "5.5.4"
},
+13 -12
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.2.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -24,30 +24,30 @@
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
"@expo/downloader": "18.0.1",
"@expo/eas-build-job": "18.2.0",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.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.2.0",
"@expo/template-file": "18.0.1",
"@expo/turtle-spawn": "18.0.1",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
@@ -75,7 +75,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.2.5",
"@expo/repack-app": "~0.4.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -91,6 +91,7 @@
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
@@ -1,9 +1,14 @@
import { BuildTrigger, Ios, Metadata } from '@expo/eas-build-job';
import { StepMetric } from '@expo/steps';
import { createTestIosJob } from './utils/job';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { uploadStepMetricsToWwwAsync } from '../utils/stepMetrics';
jest.mock('../utils/stepMetrics');
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
describe(CustomBuildContext, () => {
it('should not lose workflowInterpolationContext', () => {
@@ -34,4 +39,122 @@ describe(CustomBuildContext, () => {
foo: 'bar',
});
});
describe('reportStepMetric', () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
const workflowJobId = 'test-workflow-job-id';
const robotAccessToken = 'test-robot-token';
const sampleMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'success',
durationMs: 1000,
platform: 'linux',
};
function createCustomBuildContext(
overrides: {
expoApiV2BaseUrl?: string | undefined;
includeRobotAccessToken?: boolean;
includeWorkflowJobId?: boolean;
} = {}
): CustomBuildContext {
const job = createTestIosJob();
if (overrides.includeRobotAccessToken !== false) {
(job.secrets as any).robotAccessToken = robotAccessToken;
}
const env: Record<string, string> = {
__API_SERVER_URL: 'http://api.expo.test',
};
if (overrides.includeWorkflowJobId !== false) {
env.__WORKFLOW_JOB_ID = workflowJobId;
}
const ctx = new BuildContext(job, {
env,
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: jest.fn(),
workingdir: '/workingdir',
expoApiV2BaseUrl:
'expoApiV2BaseUrl' in overrides ? overrides.expoApiV2BaseUrl : expoApiV2BaseUrl,
});
return new CustomBuildContext(ctx);
}
beforeEach(() => {
jest.clearAllMocks();
mockUploadStepMetricsToWwwAsync.mockResolvedValue(undefined);
});
it('calls uploadStepMetricsToWwwAsync when all required fields are present', () => {
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledTimes(1);
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledWith(
expect.objectContaining({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [sampleMetric],
})
);
});
it('does not call upload when workflowJobId is missing', () => {
const customCtx = createCustomBuildContext({ includeWorkflowJobId: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when robotAccessToken is missing', () => {
const customCtx = createCustomBuildContext({ includeRobotAccessToken: false });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('does not call upload when expoApiV2BaseUrl is missing', () => {
const customCtx = createCustomBuildContext({ expoApiV2BaseUrl: undefined });
customCtx.reportStepMetric(sampleMetric);
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
});
it('drainPendingMetricUploads awaits all pending uploads', async () => {
let resolveUpload!: () => void;
const uploadPromise = new Promise<void>(resolve => {
resolveUpload = resolve;
});
mockUploadStepMetricsToWwwAsync.mockReturnValue(uploadPromise);
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
let drained = false;
const drainPromise = customCtx.drainPendingMetricUploads().then(() => {
drained = true;
});
await new Promise(r => setImmediate(r));
expect(drained).toBe(false);
resolveUpload();
await drainPromise;
expect(drained).toBe(true);
});
it('drainPendingMetricUploads settles even if an upload rejects', async () => {
mockUploadStepMetricsToWwwAsync.mockRejectedValue(new Error('upload failed'));
const customCtx = createCustomBuildContext();
customCtx.reportStepMetric(sampleMetric);
await expect(customCtx.drainPendingMetricUploads()).resolves.not.toThrow();
});
});
});
@@ -0,0 +1,128 @@
import { BuildPhase } from '@expo/eas-build-job';
import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
import { runGenericJobAsync } from '../generic';
import { CustomBuildContext } from '../customBuildContext';
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
jest.mock('../customBuildContext');
jest.mock('../utils/outputs');
jest.mock('../common/projectSources');
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
jest.mock('../steps/easFunctionGroups', () => ({
getEasFunctionGroups: jest.fn().mockReturnValue([]),
}));
jest.mock('@expo/steps');
jest.mock('fs/promises');
const mockUploadJobOutputsToWwwAsync = jest.mocked(uploadJobOutputsToWwwAsync);
describe(runGenericJobAsync, () => {
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
let mockDrainPendingMetricUploads: jest.Mock;
let mockCtx: any;
beforeEach(() => {
jest.clearAllMocks();
mockDrainPendingMetricUploads = jest.fn().mockResolvedValue(undefined);
(CustomBuildContext as unknown as jest.Mock).mockImplementation(() => ({
projectSourceDirectory: '/tmp/src',
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
drainPendingMetricUploads: mockDrainPendingMetricUploads,
}));
(BuildStepGlobalContext as unknown as jest.Mock).mockImplementation(() => ({
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
}));
mockCtx = {
expoApiV2BaseUrl,
job: {
secrets: { robotAccessToken: 'test-token' },
steps: [],
},
logger: {
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn().mockReturnThis(),
},
runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise<any>) => fn()),
};
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
});
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runGenericJobAsync(mockCtx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
});
it('awaits drainPendingMetricUploads even when outputs upload fails', async () => {
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
mockUploadJobOutputsToWwwAsync.mockRejectedValue(new Error('outputs upload failed'));
let resolveDrain!: () => void;
mockDrainPendingMetricUploads.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let rejected = false;
const resultPromise = runGenericJobAsync(mockCtx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('outputs upload failed');
expect(rejected).toBe(true);
});
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
mockCtx.expoApiV2BaseUrl = undefined;
const mockWorkflow = { executeAsync: jest.fn() };
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
}));
await expect(runGenericJobAsync(mockCtx)).rejects.toThrow(
'expoApiV2BaseUrl is required for generic jobs'
);
expect(CustomBuildContext).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ export async function runGradleCommand(
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
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);
@@ -1,57 +0,0 @@
import { AndroidConfig } from '@expo/config-plugins';
import { Android } from '@expo/eas-build-job';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
import { EasBuildGradle } from '../templates/EasBuildGradle';
const APPLY_EAS_BUILD_GRADLE_LINE = 'apply from: "./eas-build.gradle"';
export async function configureBuildGradle(ctx: BuildContext<Android.Job>): Promise<void> {
ctx.logger.info('Injecting signing config into build.gradle');
if (await fs.pathExists(getEasBuildGradlePath(ctx.getReactNativeProjectDirectory()))) {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
}
await deleteEasBuildGradle(ctx.getReactNativeProjectDirectory());
await createEasBuildGradle(ctx.getReactNativeProjectDirectory());
await addApplyToBuildGradle(ctx.getReactNativeProjectDirectory());
}
async function deleteEasBuildGradle(projectRoot: string): Promise<void> {
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
await fs.remove(easBuildGradlePath);
}
function getEasBuildGradlePath(projectRoot: string): string {
return path.join(projectRoot, 'android/app/eas-build.gradle');
}
async function createEasBuildGradle(projectRoot: string): Promise<void> {
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
await fs.writeFile(easBuildGradlePath, EasBuildGradle);
}
async function addApplyToBuildGradle(projectRoot: string): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot);
const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8');
if (hasLine(buildGradleContents, APPLY_EAS_BUILD_GRADLE_LINE)) {
return;
}
await fs.writeFile(
buildGradlePath,
`${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_GRADLE_LINE}\n`
);
}
function hasLine(haystack: string, needle: string): boolean {
return (
haystack
.replace(/\r\n/g, '\n')
.split('\n')
// Check for both single and double quotes
.some(line => line === needle || line === needle.replace(/"/g, "'"))
);
}
@@ -67,30 +67,26 @@ export async function resolveBuildPhaseErrorAsync(
logLines: string[],
errorContext: ErrorContext,
buildLogsDirectory: string
): Promise<errors.BuildError> {
): Promise<errors.ExpoError> {
const { phase } = errorContext;
if (error instanceof errors.BuildError) {
if (error instanceof errors.ExpoError) {
error.buildPhase ??= phase;
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userFacingError =
error instanceof errors.UserFacingError
? error
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
const userError =
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase);
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userFacingError.errorCode
? buildError.errorCode
: undefined;
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
return new errors.BuildError(userFacingError.message, {
errorCode: userFacingError.errorCode,
return new errors.UserError(userError.errorCode, userError.message, {
trackingCode,
docsUrl: userFacingError.docsUrl,
cause: error,
docsUrl: userError.docsUrl,
buildPhase: phase,
metadata: buildError?.metadata,
cause: error,
});
}
@@ -2,9 +2,9 @@ import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-bui
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import UserFacingError = errors.UserFacingError;
import UserError = errors.UserError;
export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
export const userErrorHandlers: ErrorHandler<UserError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
@@ -12,7 +12,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// example log:
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_UNSUPPORTED_COCOAPODS_VERSION_ERROR',
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
- changing the current version under key "cocoapods"
@@ -27,7 +27,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// example log:
// /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
@@ -40,7 +40,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-5787e6af-3002-4cb7-8a57-3e73d13313c2.jks": Invalid keystore format
regexp: /Invalid keystore format/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_INVALID_KEYSTORE_FORMAT_ERROR',
'The keystore used in this build is malformed or it has an unsupported type. Make sure you provided the correct file.'
),
@@ -52,7 +52,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-286069a8-4bb9-48a6-add9-acf6b58ea06d.jks": null
regexp: /Failed to read key[^\n]+from store/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
),
@@ -67,7 +67,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// [11:17:29] Error: [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist.
regexp: /Cannot copy google-services\.json/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -82,7 +82,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
regexp:
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -95,7 +95,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
// [08:44:18] ENOENT: no such file or directory, copyfile '/Users/expo/workingdir/build/managed/abc' -> '/Users/expo/workingdir/build/managed/ios/testapp/GoogleService-Info.plist'
regexp: /ENOENT: no such file or directory, copyfile .*GoogleService-Info.plist/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
@@ -113,7 +113,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
regexp:
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
createError: (_, { job }) => {
return new UserFacingError(
return new UserError(
'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR',
`Some pods require a higher minimum deployment target.
${
@@ -141,7 +141,7 @@ ${
// You should run `pod update Firebase/Core` to apply changes you've made.
regexp: /CocoaPods could not find compatible versions for pod /,
createError: () => {
return new UserFacingError(
return new UserError(
'EAS_BUILD_INCOMPATIBLE_PODS_ERROR',
`Compatible versions of some pods could not be resolved.
You are seeing this error because either:
@@ -161,7 +161,7 @@ You are seeing this error because either:
/Fix the upstream dependency conflict, or retry.*\s.*this command with --force, or --legacy-peer-deps/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserFacingError(
return new UserError(
'EAS_BUILD_NPM_CONFLICTING_PEER_DEPENDENCIES',
`Some of your peer dependencies are not compatible. The recommended approach is to fix your dependencies by resolving any conflicts listed by "npm install". As a temporary workaround you can:
- Add ".npmrc" file with "legacy-peer-deps=true" and commit that to your repo.
@@ -179,7 +179,7 @@ You are seeing this error because either:
regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
return new UserFacingError(
return new UserError(
'EAS_BUILD_YARN_LOCK_CHECKSUM_ERROR',
`Checksum for package "${matchResult[1]}" does not match value in registry. To fix that:
- run "yarn cache clean"
@@ -203,7 +203,7 @@ You are seeing this error because either:
/\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages...\s*\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages.../,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult) {
return new UserFacingError(
return new UserError(
'EAS_BUILD_YARN_MULTIPLE_INSTANCES_ERROR',
`One of project dependencies is starting new install process while the main one is still in progress, which might result in corrupted packages. Most likely the reason for error is "prepare" script in git-referenced dependency of your project. Learn more: https://github.com/yarnpkg/yarn/issues/7212#issuecomment-493720324`
);
@@ -230,7 +230,7 @@ You are seeing this error because either:
regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/,
createError: (_, { job }) =>
'type' in job && job.type === Workflow.MANAGED
? new UserFacingError(
? new UserError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
@@ -239,7 +239,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: new UserFacingError(
: new UserError(
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
@@ -254,7 +254,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
phase: BuildPhase.RUN_GRADLEW,
regexp: /.*/,
createError: () =>
new UserFacingError(
new UserError(
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
),
@@ -265,7 +265,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
mode: BuildMode.RESIGN,
regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/,
createError: () =>
new UserFacingError(
new UserError(
'EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR',
`The bundle identifier in provisioning profile used to resign the app does not match the bundle identifier of the app selected to be resigned. See logs above for more information.`
),
@@ -276,7 +276,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
mode: BuildMode.RESIGN,
regexp: /.*/,
createError: () =>
new UserFacingError(
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
`The "Run fastlane" step failed with an unknown error.`
),
@@ -297,7 +297,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
regexp: /error: .+/g,
createError: matchResult =>
new UserFacingError(
new UserError(
'XCODE_BUILD_ERROR',
`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:\n${matchResult
.map(match => `- ${match.replace('error: ', '')}`)
@@ -309,7 +309,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
phase: BuildPhase.RUN_FASTLANE,
regexp: /.*/,
createError: () =>
new UserFacingError(
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
`The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.`
),
@@ -0,0 +1,266 @@
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { restoreCredentials } from '../../android/credentials';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
} from '../../steps/utils/android/gradleConfig';
jest.mock('../common', () => ({
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
await buildFn(ctx);
return ctx.artifacts;
}),
}));
jest.mock('../custom', () => ({
runCustomBuildAsync: jest.fn(),
}));
jest.mock('../../android/credentials');
jest.mock('../../android/gradle', () => ({
ensureLFLineEndingsInGradlewScript: jest.fn(),
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
runGradleCommand: jest.fn(),
}));
jest.mock('../../common/eagerBundle', () => ({
eagerBundleAsync: jest.fn(),
shouldUseEagerBundle: jest.fn(() => false),
}));
jest.mock('../../common/prebuild', () => ({
prebuildAsync: jest.fn(),
}));
jest.mock('../../common/setup', () => ({
setupAsync: jest.fn(),
}));
jest.mock('../../steps/functions/restoreBuildCache', () => ({
cacheStatsAsync: jest.fn(),
restoreCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/functions/saveBuildCache', () => ({
saveCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/utils/android/gradleConfig', () => ({
...jest.requireActual('../../steps/utils/android/gradleConfig'),
injectConfigureVersionGradleConfig: jest.fn(),
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
},
runHookIfPresent: jest.fn(),
}));
jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
describe(androidBuilder, () => {
beforeEach(() => {
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
'/workingdir/build/package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
},
'/'
);
});
it('injects Android version config without build credentials', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
expect(restoreCredentials).not.toHaveBeenCalled();
});
it('injects Android credentials config when build credentials are present', 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(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
});
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: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__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.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
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: () => [] },
logger: createMockLogger(),
env: {
__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.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__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(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('runs the builder through hooks', 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(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
});
@@ -1,10 +1,12 @@
import { BuildJob } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { vol } from 'memfs';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -74,4 +76,60 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
let resolved = false;
const resultPromise = runCustomBuildAsync(ctx).then(() => {
resolved = true;
});
await new Promise(r => setImmediate(r));
expect(resolved).toBe(false);
resolveDrain();
await resultPromise;
expect(resolved).toBe(true);
drainSpy.mockRestore();
});
it('awaits drainPendingMetricUploads even when workflow throws', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
.mockReturnValue(
new Promise<void>(resolve => {
resolveDrain = resolve;
})
);
const executeSpy = jest
.spyOn(BuildWorkflow.prototype, 'executeAsync')
.mockRejectedValue(new Error('workflow failed'));
let rejected = false;
const resultPromise = runCustomBuildAsync(ctx).catch(err => {
rejected = true;
throw err;
});
await new Promise(r => setImmediate(r));
expect(rejected).toBe(false);
resolveDrain();
await expect(resultPromise).rejects.toThrow('workflow failed');
expect(rejected).toBe(true);
drainSpy.mockRestore();
executeSpy.mockRestore();
});
});
+37 -5
View File
@@ -10,13 +10,21 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { configureBuildGradle } from '../android/gradleConfig';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
warnIfLegacyEasBuildGradleExists,
} from '../steps/utils/android/gradleConfig';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -77,6 +85,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -89,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
@@ -101,10 +115,22 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
) {
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await restoreCredentials(ctx);
await configureBuildGradle(ctx);
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
});
}
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
versionCode: ctx.job.version?.versionCode,
versionName: ctx.job.version?.versionName,
});
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
@@ -180,6 +206,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
await saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
@@ -94,6 +94,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
// do nothing, it's a non-breaking error.
}
}
await customBuildCtx.drainPendingMetricUploads();
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
+1 -1
View File
@@ -103,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
appConfig: await ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
+14 -7
View File
@@ -1,7 +1,13 @@
import { ExpoConfig } from '@expo/config';
import { BuildJob, BuildPhase, Ios, Job, Platform } from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import {
BuildJob,
BuildPhase,
BuildTrigger,
Ios,
Job,
Platform,
UserError,
} from '@expo/eas-build-job';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
@@ -107,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
const appConfig = ctx.appConfig;
const appConfig = await ctx.appConfig;
ctx.logger.info('Using app configuration:');
ctx.logger.info(JSON.stringify(appConfig, null, 2));
await validateAppConfigAsync(ctx, appConfig);
@@ -115,12 +121,13 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
const appConfig = await ctx.appConfig;
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
throw new Error(
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
);
}
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
throw new Error(
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
);
@@ -281,7 +288,7 @@ async function validateAppConfigAsync(
extraMessage =
'If you are using environment variables to switch between projects in app.config.js/app.config.ts, make sure those variables are also set inside EAS Build. You can do that using "env" field in eas.json or EAS environment variables. ';
}
throw new UserFacingError(
throw new UserError(
'EAS_BUILD_PROJECT_ID_MISMATCH',
`The value of the "extra.eas.projectId" field (${appConfig.extra.eas.projectId}) in the app config does not match the current project id (${ctx.env.EAS_BUILD_PROJECT_ID}). ${extraMessage}Learn more: https://expo.fyi/eas-config-mismatch.`
);
+7 -4
View File
@@ -67,6 +67,7 @@ export interface BuildContextOptions {
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
}
export class SkipNativeBuildError extends Error {}
@@ -82,6 +83,7 @@ export class BuildContext<TJob extends Job = Job> {
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
public readonly skipNativeBuild?: boolean;
public readonly expoApiV2BaseUrl?: string;
public artifacts: Artifacts = {};
private readonly _isLocal: boolean;
@@ -94,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhase?: BuildPhase;
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: ExpoConfig;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
@@ -110,6 +112,7 @@ export class BuildContext<TJob extends Job = Job> {
this._job = job;
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
@@ -172,14 +175,14 @@ export class BuildContext<TJob extends Job = Job> {
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): ExpoConfig {
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).exp;
}).then(config => config.exp);
}
return this._appConfig;
}
@@ -284,7 +287,7 @@ export class BuildContext<TJob extends Job = Job> {
private async handleBuildPhaseErrorAsync(
err: any,
buildPhase: BuildPhase
): Promise<errors.BuildError> {
): Promise<errors.ExpoError> {
const buildError = await resolveBuildPhaseErrorAsync(
err,
this.logBuffer.getPhaseLogs(buildPhase),
+27 -1
View File
@@ -9,12 +9,13 @@ import {
StaticJobInterpolationContext,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
import { Client } from '@urql/core';
import assert from 'assert';
import path from 'path';
import { ArtifactToUpload, BuildContext } from './context';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
[Platform.ANDROID]: BuildRuntimePlatform.LINUX,
@@ -60,6 +61,8 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
this._env = buildCtx.env;
@@ -75,6 +78,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
@@ -142,4 +146,26 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
};
this.metadata = metadata;
}
public reportStepMetric(metric: StepMetric): void {
const workflowJobId = this._env.__WORKFLOW_JOB_ID;
const robotAccessToken = this.job.secrets?.robotAccessToken;
if (!workflowJobId || !robotAccessToken || !this.expoApiV2BaseUrl) {
return;
}
const p = uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl: this.expoApiV2BaseUrl,
stepMetrics: [metric],
logger: this.logger,
});
this.pendingMetricUploads.push(p);
}
public async drainPendingMetricUploads(): Promise<void> {
await Promise.allSettled(this.pendingMetricUploads);
}
}
+17 -18
View File
@@ -2,6 +2,7 @@ import { BuildPhase, Generic } from '@expo/eas-build-job';
import { Result, asyncResult } from '@expo/results';
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
import { prepareProjectSourcesAsync } from './common/projectSources';
import { BuildContext } from './context';
@@ -10,12 +11,17 @@ import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
import { retryAsync } from './utils/retry';
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
export async function runGenericJobAsync(
ctx: BuildContext<Generic.Job>,
{ expoApiV2BaseUrl }: { expoApiV2BaseUrl: string }
ctx: BuildContext<Generic.Job>
): Promise<{ runResult: Result<void>; buildWorkflow: BuildWorkflow }> {
// expoApiV2BaseUrl is empty when running a local build, but generic jobs
// are never executed locally, so it is always present here.
const expoApiV2BaseUrl = nullthrows(
ctx.expoApiV2BaseUrl,
'expoApiV2BaseUrl is required for generic jobs'
);
const customBuildCtx = new CustomBuildContext(ctx);
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
@@ -60,22 +66,15 @@ export async function runGenericJobAsync(
const runResult = await asyncResult(workflow.executeAsync());
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
await uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
expoApiV2BaseUrl,
});
const workflowJobId = globalContext.env.__WORKFLOW_JOB_ID;
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
if (workflowJobId && robotAccessToken) {
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: globalContext.stepMetrics,
const results = await Promise.allSettled([
uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
});
expoApiV2BaseUrl,
}),
customBuildCtx.drainPendingMetricUploads(),
]);
if (results[0].status === 'rejected') {
throw results[0].reason;
}
});
@@ -165,6 +165,52 @@ describe(configureXcodeProject, () => {
'Info.plist application target'
);
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials and versions for multi target project', async () => {
vol.fromJSON(
{
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
import path from 'path';
import { Credentials } from './credentials/manager';
import { DistributionType } from './credentials/provisioningProfile';
import { BuildContext } from '../context';
async function configureXcodeProject(
@@ -56,6 +57,9 @@ async function configureCredentialsAsync(
profileName: profile.name,
appleTeamId: profile.teamId,
buildConfiguration,
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
codeSignIdentity: profile.certificateCommonName,
}),
}
);
}
@@ -1,5 +1,6 @@
import { Ios } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import crypto from 'crypto';
import { provisioningProfile } from './fixtures';
import { BuildContext } from '../../../context';
@@ -73,5 +74,45 @@ describe('ProvisioningProfile class', () => {
await pp.destroy();
}
});
it('should match a certificate that is not at index 0 in DeveloperCertificates', async () => {
const pp = new ProvisioningProfile(
ctx,
Buffer.from(provisioningProfile.dataBase64, 'base64'),
keychain.data.path,
'testapp',
'Abc 123'
);
try {
await pp.init();
// Prepend an unrelated certificate so the real one moves to index 1
const dummyCert = Buffer.from('dummy-certificate-data');
(pp as any).developerCertificates = [dummyCert, ...(pp as any).developerCertificates];
// The original cert fingerprint should still match
expect(() => {
pp.verifyCertificate(provisioningProfile.certFingerprint);
}).not.toThrow();
// A non-matching fingerprint should still fail
expect(() => {
pp.verifyCertificate('2137');
}).toThrowError(/don't match/);
// The dummy cert's fingerprint should also match
const dummyFingerprint = crypto
.createHash('sha1')
.update(new Uint8Array(dummyCert))
.digest('hex')
.toUpperCase();
expect(() => {
pp.verifyCertificate(dummyFingerprint);
}).not.toThrow();
} finally {
await pp.destroy();
}
});
});
});
@@ -24,6 +24,7 @@ export interface ProvisioningProfileData {
export enum DistributionType {
AD_HOC = 'ad-hoc',
APP_STORE = 'app-store',
DEVELOPMENT = 'development',
ENTERPRISE = 'enterprise',
}
@@ -43,6 +44,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
private readonly profilePath: string;
private profileData?: ProvisioningProfileData;
private developerCertificates: Buffer[] = [];
constructor(
private readonly ctx: BuildContext<TJob>,
@@ -77,11 +79,12 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
}
public verifyCertificate(fingerprint: string): void {
const devCertFingerprint = this.genDerCertFingerprint();
if (devCertFingerprint !== fingerprint) {
const devCertFingerprints = this.getAllDerCertFingerprints();
if (!devCertFingerprints.includes(fingerprint)) {
throw new errors.CredentialsDistCertMismatchError(
`Provisioning profile and distribution certificate don't match.
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
`Provisioning profile and distribution certificate don't match.\n` +
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
`distribution certificate fingerprint = ${fingerprint}`
);
}
}
@@ -114,6 +117,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
] as string;
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
Buffer.from(cert, 'base64')
);
this.profileData = {
path: this.profilePath,
target: this.target,
@@ -121,7 +128,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
teamId: (plistData.TeamIdentifier as string[])[0],
uuid: plistData.UUID as string,
name: plistData.Name as string,
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
developerCertificate: this.developerCertificates[0],
certificateCommonName: this.certificateCommonName,
distributionType: this.resolveDistributionType(plistData),
};
@@ -131,17 +138,19 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
if (plistData.ProvisionsAllDevices) {
return DistributionType.ENTERPRISE;
} else if (plistData.ProvisionedDevices) {
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
if (entitlements?.['get-task-allow']) {
return DistributionType.DEVELOPMENT;
}
return DistributionType.AD_HOC;
} else {
return DistributionType.APP_STORE;
}
}
private genDerCertFingerprint(): string {
return crypto
.createHash('sha1')
.update(new Uint8Array(this.data.developerCertificate))
.digest('hex')
.toUpperCase();
private getAllDerCertFingerprints(): string[] {
return this.developerCertificates.map(cert =>
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
);
}
}
@@ -17,7 +17,6 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
import { createInstallPodsBuildFunction } from './functions/installPods';
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
import { createPrebuildBuildFunction } from './functions/prebuild';
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
import { createRepackBuildFunction } from './functions/repack';
@@ -81,8 +80,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createUploadToAscBuildFunction(),
createInternalEasMaestroTestFunction(ctx),
createReportMaestroTestResultsFunction(ctx),
];
@@ -51,7 +51,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Simulator`,
command: `
# shopt -s nullglob is necessary not to try to install
@@ -97,7 +96,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'install_app',
displayName: `Install app to Emulator`,
command: `
# shopt -s globstar is necessary to add /**/ support
@@ -134,7 +132,6 @@ export function createEasMaestroTestFunctionGroup(
steps.push(
new BuildStep(globalCtx, {
id: BuildStep.getNewId(),
name: 'maestro_test',
ifCondition: '${ always() }',
displayName: `maestro test ${flowPath}`,
command: `maestro test ${flowPath}`,
@@ -1,210 +0,0 @@
import { spawnAsync } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../../utils/retry';
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
jest.mock('@expo/steps', () => ({
...jest.requireActual('@expo/steps'),
spawnAsync: jest.fn(),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/IosSimulatorUtils');
jest.mock('../../../utils/AndroidEmulatorUtils');
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
jest.mock('../../../utils/retry', () => ({
retryAsync: jest.fn(),
}));
const mockedSpawnAsync = jest.mocked(spawnAsync);
const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
describe(createInternalEasMaestroTestFunction, () => {
const mockUploadArtifact = jest.fn();
beforeEach(() => {
vol.mkdirSync(os.tmpdir(), { recursive: true });
mockedSpawnAsync.mockResolvedValue(undefined as any);
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
mockUploadArtifact.mockResolvedValue({ artifactId: null });
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
let lastErr: unknown;
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
try {
return await fn(attemptCount);
} catch (err) {
lastErr = err;
}
}
throw lastErr;
});
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
]);
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
{ serialId: 'emulator-5554', state: 'device' } as any,
]);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.startAsync.mockResolvedValue({
serialId: 'emulator-5556',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
});
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
const ctx = {
runtimeApi: { uploadArtifact: mockUploadArtifact },
};
const fn = createInternalEasMaestroTestFunction(ctx as any);
return fn.createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
}),
{
callInputs: {
platform: 'ios',
flow_paths: JSON.stringify(['.maestro']),
...overrides?.callInputs,
},
}
);
}
it('sets junit_report_directory output when output_format is junit', async () => {
const step = createStep({
callInputs: { output_format: 'junit' },
});
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toMatch(/maestro-reports-/);
});
it('does not set junit_report_directory output when output_format is not junit', async () => {
const step = createStep();
await step.executeAsync();
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('does not set junit_report_directory when function throws before reaching output code', async () => {
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
const step = createStep({
callInputs: { output_format: 'junit' },
});
try {
await step.executeAsync();
} catch {
// Expected - no booted device found
}
const junitDir = step.getOutputValueByName('junit_report_directory');
expect(junitDir).toBeUndefined();
});
it('retries Android clone startup with increasing timeouts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce(undefined);
const step = createStep({
callInputs: { platform: 'android' },
});
await step.executeAsync();
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-attempt-1',
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-attempt-2',
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-attempt-1',
deviceName: 'eas-simulator-0-0',
})
);
});
it('fails Android flow when clone startup exhausts all attempts', async () => {
mockedAndroidUtils.startAsync
.mockResolvedValueOnce({
serialId: 'emulator-attempt-1',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-2',
emulatorPromise: Promise.resolve({}),
} as any)
.mockResolvedValueOnce({
serialId: 'emulator-attempt-3',
emulatorPromise: Promise.resolve({}),
} as any);
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
const step = createStep({
callInputs: { platform: 'android' },
});
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-attempt-3',
timeoutMs: 180_000,
})
);
});
});
@@ -217,6 +217,217 @@ describe(parseMaestroResults, () => {
]);
});
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
vol.fromJSON({
// Attempt 0: login FAILED
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: login PASSED
'/junit/junit-report-flow-1-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Should return 2 results — one per attempt
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
name: 'login',
path: '.maestro/login.yml',
status: 'failed',
errorMessage: 'Timeout',
duration: 5000,
retryCount: 0,
}),
expect.objectContaining({
name: 'login',
path: '.maestro/login.yml',
status: 'passed',
errorMessage: null,
duration: 3000,
retryCount: 1,
}),
]);
});
it('returns per-attempt results for reuse_devices=true (all flows in every attempt)', async () => {
vol.fromJSON({
// Attempt 0: home FAILED, login PASSED
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: both PASSED
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
expect(results).toEqual([
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
]);
});
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
vol.fromJSON({
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Timeout</failure>',
' </testcase>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: all passed across shards
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' </testsuite>',
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
expect(results).toHaveLength(4);
expect(results).toEqual([
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
]);
});
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
vol.fromJSON({
// Single overwritten JUnit (only has final attempt's results)
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// 2 timestamp dirs — both flows appear in both (entire suite retried)
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
// Both flows have 2 occurrences → retryCount = 1 for both
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
])
);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
@@ -238,6 +238,45 @@ describe(createReportMaestroTestResultsFunction, () => {
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
});
it('sends per-attempt results with same name but different retryCount', async () => {
vol.fromJSON({
// Two JUnit files for same flow (per-attempt)
'/junit/junit-report-flow-1-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="1">',
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
' <failure>Tap failed</failure>',
' </testcase>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults).toHaveLength(2);
expect(variables.input.testCaseResults[0]).toEqual(
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
);
expect(variables.input.testCaseResults[1]).toEqual(
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
);
});
it('uses default directories when inputs are not provided', async () => {
vol.fromJSON({
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
@@ -26,6 +26,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
cloneAsync: jest.fn(),
startAsync: jest.fn(),
waitForReadyAsync: jest.fn(),
disableWindowAndTransitionAnimationsAsync: jest.fn(),
deleteAsync: jest.fn(),
},
}));
@@ -34,14 +35,15 @@ const mockedSpawn = jest.mocked(spawn);
const mockedRetryAsync = jest.mocked(retryAsync);
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
function createStep(callInputs?: Record<string, unknown>) {
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
const logger = createMockLogger();
const fn = createStartAndroidEmulatorBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
return fn.createBuildStepFromFunctionCall(globalCtx, {
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
callInputs,
});
return Object.assign(step, { logger });
}
function createStartResult(serialId: string) {
@@ -57,7 +59,9 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
@@ -97,6 +101,12 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 120_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-2222',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
serialId: 'emulator-1111',
@@ -147,6 +157,24 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
timeoutMs: 60_000,
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
serialId: 'emulator-base',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serialId: 'emulator-clone-1-attempt-2',
})
);
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
serialId: 'emulator-clone-2-attempt-1',
})
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
expect.objectContaining({
@@ -174,4 +202,14 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
);
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
});
it('skips animation scale adjustments when opt out env var is disabled', async () => {
const step = createStep(undefined, {
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
});
await step.executeAsync();
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -58,6 +59,20 @@ describe(isMissingPurposeStringError, () => {
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
@@ -34,18 +34,20 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { env, inputs, outputs }) => {
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
}).exp;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
})
).exp;
const platform =
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
@@ -42,18 +42,20 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
const appConfig = readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
}).exp;
const appConfig = (
await readAppConfig({
projectDir: stepCtx.workingDirectory,
env: Object.keys(env).reduce(
(acc, key) => {
acc[key] = env[key] ?? '';
return acc;
},
{} as Record<string, string>
),
logger: stepCtx.logger,
sdkVersion: metadata?.sdkVersion,
})
).exp;
const channelInput = inputs.channel.value as string | undefined;
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -56,7 +56,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const interpolationContext = stepsCtx.global.getInterpolationContext();
if (!('workflow' in interpolationContext)) {
throw new UserFacingError(
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
'No workflow found in the interpolation context.'
);
@@ -64,7 +64,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
if (!robotAccessToken) {
throw new UserFacingError(
throw new UserError(
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
'No robot access token found in the job secrets.'
);
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -134,7 +134,7 @@ export async function downloadBuildAsync({
});
if (matchingFiles.length === 0) {
throw new UserFacingError(
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES',
`No ${extensions.map(ext => `.${ext}`).join(', ')} entries found in the archive.`
);
@@ -1,686 +0,0 @@
import { GenericArtifactType } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { Result, asyncResult, result } from '@expo/results';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
spawnAsync,
} from '@expo/steps';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
AndroidDeviceSerialId,
AndroidEmulatorUtils,
AndroidVirtualDeviceName,
} from '../../utils/AndroidEmulatorUtils';
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
import { retryAsync } from '../../utils/retry';
import { PlatformToProperNounMap } from '../../utils/strings';
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: '__maestro_test',
__metricsId: 'eas/__maestro_test',
inputProviders: [
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'platform',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
id: 'flow_paths',
required: true,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'retries',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'include_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'exclude_tags',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
id: 'shards',
defaultValue: 1,
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
id: 'output_format',
required: false,
}),
BuildStepInput.createProvider({
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
id: 'record_screen',
defaultValue: false,
required: false,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'test_reports_artifact_id',
required: false,
}),
BuildStepOutput.createProvider({
id: 'junit_report_directory',
required: false,
}),
],
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
const {
platform,
flow_paths,
retries,
include_tags,
exclude_tags,
shards,
output_format,
record_screen,
} = z
.object({
platform: z.enum(['ios', 'android']),
flow_paths: z.array(z.string()),
retries: z.number().default(1),
include_tags: z.string().optional(),
exclude_tags: z.string().optional(),
shards: z.number().default(1),
output_format: z.string().optional(),
record_screen: z.boolean().default(false),
})
.parse(
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
);
const flowPathsToExecute: string[] = [];
for (const flowPath of flow_paths) {
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: stepCtx.workingDirectory,
flowPath,
logger: stepCtx.logger,
includeTags: include_tags ? include_tags.split(',') : undefined,
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
});
if (flowPaths.length === 0) {
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
continue;
}
stepCtx.logger.info(
`Marking for execution:\n- ${flowPaths
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
stepCtx.logger.info('');
flowPathsToExecute.push(...flowPaths);
}
// TODO: Add support for shards. (Shouldn't be too difficult.)
if (shards > 1) {
stepCtx.logger.warn(
'Sharding support has been temporarily disabled. Running tests on a single shard.'
);
}
// eas/__maestro_test does not start devices, it expects a single device to be already running
// and configured with the app. Here we find the booted device and stop it.
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
switch (platform) {
case 'ios': {
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
env,
filter: 'booted',
});
if (bootedDevices.length === 0) {
throw new Error('No booted iOS Simulator found.');
} else if (bootedDevices.length > 1) {
throw new Error('Multiple booted iOS Simulators found.');
}
const device = bootedDevices[0];
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
stepCtx.logger.info(`Preparing Simulator for tests...`);
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
logger: stepCtx.logger,
stdio: 'pipe',
});
sourceDeviceIdentifier = device.name;
break;
}
case 'android': {
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
if (connectedDevices.length === 0) {
throw new Error('No booted Android Emulator found.');
} else if (connectedDevices.length > 1) {
throw new Error('Multiple booted Android Emulators found.');
}
const { serialId } = connectedDevices[0];
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
mode: PipeMode.COMBINED,
env,
});
const avdName = adbEmuAvdNameResult.stdout
.replace(/\r\n/g, '\n')
.split('\n')[0] as AndroidVirtualDeviceName;
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
stepCtx.logger.info(`Preparing Emulator for tests...`);
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
stdio: 'pipe',
});
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
await setTimeout(1000);
sourceDeviceIdentifier = avdName;
break;
}
}
// During tests we generate reports and device logs. We store them in temporary directories
// and upload them once all tests are done. When a test is retried, new reports overwrite
// the old ones. The files are named "flow-${index}" for easier identification.
const maestroReportsDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'maestro-reports-')
);
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
const failedFlows: string[] = [];
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
stepCtx.logger.info('');
// If output_format is empty or noop, we won't use this.
const outputPath = path.join(
maestroReportsDir,
[
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
]
.filter(Boolean)
.join('.')
);
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
| IosSimulatorName
| AndroidVirtualDeviceName;
// If the test passes, but the recording fails, we don't want to make the test fail,
// so we return two separate results.
const {
fnResult: { fnResult, recordingResult },
logsResult,
} = await withCleanDeviceAsync({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger: stepCtx.logger,
fn: async ({ deviceIdentifier }) => {
return await maybeWithScreenRecordingAsync({
shouldRecord: record_screen,
platform,
deviceIdentifier,
env,
logger: stepCtx.logger,
fn: async () => {
stepCtx.logger.info('');
const [command, ...args] = getMaestroTestCommand({
flow_path: flowPath,
output_format,
output_path: outputPath,
});
try {
await spawnAsync(command, args, {
logger: stepCtx.logger,
cwd: stepCtx.workingDirectory,
env,
stdio: 'pipe',
});
} finally {
stepCtx.logger.info('');
}
},
});
},
});
// Move device logs to the device logs directory.
if (logsResult?.ok) {
try {
const extension = path.extname(logsResult.value.outputPath);
const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
await fs.promises.rm(destinationPath, {
force: true,
recursive: true,
});
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
}
} else if (logsResult?.reason) {
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
}
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
if (isLastAttempt && recordingResult.value) {
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
name: `Screen Recording (${flowIndex}-${path.basename(
flowPath,
path.extname(flowPath)
)})`,
paths: [recordingResult.value],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
}
}
if (fnResult.ok) {
stepCtx.logger.info(`Test passed.`);
// Break out of the retry loop.
break;
}
if (attemptCount < retries - 1) {
stepCtx.logger.info(`Retrying test...`);
stepCtx.logger.info('');
continue;
}
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
failedFlows.push(flowPath);
}
}
stepCtx.logger.info('');
// When all tests are done, we upload the reports and device logs.
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
if (generatedMaestroReports.length === 0) {
stepCtx.logger.warn('No reports were generated.');
} else {
stepCtx.logger.info(`Uploading reports...`);
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
paths: [maestroReportsDir],
type: GenericArtifactType.OTHER,
},
});
if (artifactId) {
outputs.test_reports_artifact_id.set(artifactId);
}
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload reports.');
}
}
if (output_format === 'junit') {
outputs.junit_report_directory.set(maestroReportsDir);
}
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
if (generatedDeviceLogs.length === 0) {
stepCtx.logger.warn('No device logs were successfully collected.');
} else {
stepCtx.logger.info(`Uploading device logs...`);
try {
await ctx.runtimeApi.uploadArtifact({
logger: stepCtx.logger,
artifact: {
name: `Maestro Test Device Logs`,
paths: [deviceLogsDir],
type: GenericArtifactType.OTHER,
},
});
} catch (err) {
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
}
}
stepCtx.logger.info('');
// If any tests failed, we throw an error to mark the step as failed.
if (failedFlows.length > 0) {
throw new Error(
`Some Maestro tests failed:\n- ${failedFlows
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
.join('\n- ')}`
);
} else {
stepCtx.logger.info('All Maestro tests passed.');
}
},
});
}
export function getMaestroTestCommand(params: {
flow_path: string;
output_format: string | undefined;
/** Unused if `output_format` is undefined */
output_path: string;
}): [command: string, ...args: string[]] {
let outputFormatFlags: string[] = [];
if (params.output_format) {
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
}
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
command: string,
...args: string[],
];
}
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
junit: 'xml',
html: 'html',
};
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
async function withCleanDeviceAsync<TResult>({
platform,
sourceDeviceIdentifier,
localDeviceName,
env,
logger,
fn,
}: {
env: BuildStepEnv;
logger: bunyan;
platform: 'ios' | 'android';
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
fn: ({
deviceIdentifier,
}: {
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
}) => Promise<TResult>;
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
// Clone and start the device
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
switch (platform) {
case 'ios': {
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
await IosSimulatorUtils.cloneAsync({
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
destinationDeviceName: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
const { udid } = await IosSimulatorUtils.startAsync({
deviceIdentifier: localDeviceName as IosSimulatorName,
env,
});
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
await IosSimulatorUtils.waitForReadyAsync({
udid,
env,
});
localDeviceIdentifier = localDeviceName as IosSimulatorName;
break;
}
case 'android': {
await retryAsync(
async attemptCount => {
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
const attempt = attemptCount + 1;
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
let serialId: AndroidDeviceSerialId | null = null;
try {
logger.info(
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
await AndroidEmulatorUtils.cloneAsync({
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
env,
logger,
});
logger.info(
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
);
const startResult = await AndroidEmulatorUtils.startAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
serialId = startResult.serialId;
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
await AndroidEmulatorUtils.waitForReadyAsync({
serialId,
env,
timeoutMs,
logger,
});
localDeviceIdentifier = serialId;
} catch (err) {
logger.warn(
{ err },
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
);
try {
if (serialId) {
await AndroidEmulatorUtils.deleteAsync({
serialId,
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
} else {
await AndroidEmulatorUtils.deleteAsync({
deviceName: localDeviceName as AndroidVirtualDeviceName,
env,
});
}
} catch (cleanupErr) {
logger.warn(
{ err: cleanupErr },
`Failed to clean up Android Emulator ${localDeviceName}.`
);
}
throw err;
}
},
{
logger,
retryOptions: {
retries: ANDROID_STARTUP_RETRIES_COUNT,
retryIntervalMs: 1_000,
},
}
);
break;
}
}
if (!localDeviceIdentifier) {
throw new Error('Device did not return an identifier after startup.');
}
// Run the function
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
// Stop the device
let logsResult: Result<{ outputPath: string }> | null = null;
try {
switch (platform) {
case 'ios': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
IosSimulatorUtils.collectLogsAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await IosSimulatorUtils.deleteAsync({
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
env,
});
break;
}
case 'android': {
logger.info(`Collecting logs from ${localDeviceName}...`);
logsResult = await asyncResult(
AndroidEmulatorUtils.collectLogsAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
})
);
logger.info(`Cleaning up ${localDeviceName}...`);
await AndroidEmulatorUtils.deleteAsync({
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
env,
});
break;
}
}
} catch (err) {
logger.error(`Error cleaning up device: ${err}`);
}
return { fnResult: fnResult.enforceValue(), logsResult };
}
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
async function maybeWithScreenRecordingAsync<TResult>({
shouldRecord,
platform,
deviceIdentifier,
env,
logger,
fn,
}: {
// As weird as it is, it's more convenient to have this function like `maybeWith...`
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
shouldRecord: boolean;
platform: 'ios' | 'android';
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
env: BuildStepEnv;
logger: bunyan;
fn: () => Promise<TResult>;
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
if (!shouldRecord) {
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
}
let recordingResult: Result<{
recordingSpawn: SpawnPromise<SpawnResult>;
outputPath?: string;
}>;
// Start screen recording
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
recordingResult = await asyncResult(
IosSimulatorUtils.startScreenRecordingAsync({
deviceIdentifier: deviceIdentifier as IosSimulatorName,
env,
})
);
break;
}
case 'android': {
recordingResult = await asyncResult(
AndroidEmulatorUtils.startScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
env,
})
);
break;
}
}
if (!recordingResult.ok) {
logger.warn('Failed to start screen recording.', recordingResult.reason);
}
// Run the function
const fnResult = await asyncResult(fn());
// If recording failed there's nothing to stop, so we return the results
if (!recordingResult.ok) {
return { fnResult, recordingResult: result(recordingResult.reason) };
}
// If recording started, finish it
try {
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
switch (platform) {
case 'ios': {
await IosSimulatorUtils.stopScreenRecordingAsync({
recordingSpawn: recordingResult.value.recordingSpawn,
});
return {
fnResult,
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
recordingResult: result(recordingResult.value.outputPath!),
};
}
case 'android': {
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
serialId: deviceIdentifier as AndroidDeviceSerialId,
recordingSpawn: recordingResult.value.recordingSpawn,
env,
});
return { fnResult, recordingResult: result(outputPath) };
}
}
} catch (err) {
logger.warn('Failed to stop screen recording.', err);
return { fnResult, recordingResult: result(err as Error) };
}
}
@@ -38,6 +38,79 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// Internal helper — not exported. Parses a single JUnit XML file.
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
const results: JUnitTestCaseResult[] = [];
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
return results;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
}
return results;
}
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
let entries: string[];
try {
@@ -52,83 +125,9 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
}
const results: JUnitTestCaseResult[] = [];
for (const xmlFile of xmlFiles) {
try {
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
const parsed = xmlParser.parse(content);
const testsuites = parsed?.testsuites?.testsuite;
if (!Array.isArray(testsuites)) {
continue;
}
for (const suite of testsuites) {
const testcases = suite?.testcase;
if (!Array.isArray(testcases)) {
continue;
}
for (const tc of testcases) {
const name = tc['@_name'];
if (!name) {
continue;
}
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
// Use @_status as primary indicator (more robust than checking <failure> presence)
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
// Extract error message from <failure> or <error> elements
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;
// Extract properties
const rawProperties: { '@_name': string; '@_value': string }[] =
tc.properties?.property ?? [];
const properties: Record<string, string> = {};
for (const prop of rawProperties) {
const propName = prop['@_name'];
const value = prop['@_value'];
if (typeof propName !== 'string' || typeof value !== 'string') {
continue;
}
properties[propName] = value;
}
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
const tagsValue = properties['tags'];
const tags: string[] = tagsValue
? tagsValue
.split(',')
.map(t => t.trim())
.filter(Boolean)
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
}
}
} catch {
// Skip malformed XML files
continue;
}
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
}
return results;
}
@@ -167,9 +166,32 @@ export async function parseMaestroResults(
testsDirectory: string,
projectRoot: string
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files (primary source)
const junitResults = await parseJUnitTestCases(junitDirectory);
if (junitResults.length === 0) {
// 1. Parse JUnit XML files, tracking which file each result came from
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
return [];
}
interface JUnitResultWithSource {
result: JUnitTestCaseResult;
sourceFile: string;
}
const junitResultsWithSource: JUnitResultWithSource[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
junitResultsWithSource.push({ result, sourceFile: xmlFile });
}
}
if (junitResultsWithSource.length === 0) {
return [];
}
@@ -217,25 +239,63 @@ export async function parseMaestroResults(
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
for (const junit of junitResults) {
const flowFilePath = flowPathMap.get(junit.name);
// Parse attempt index from filename pattern: *-attempt-N.*
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
group.push(entry);
resultsByName.set(entry.result.name, group);
}
for (const [flowName, flowEntries] of resultsByName) {
const flowFilePath = flowPathMap.get(flowName);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: junit.name; // fallback: use flow name if ai-*.json not found
: flowName;
const occurrences = flowOccurrences.get(junit.name) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
if (flowEntries.length === 1) {
// Single result for this flow — use ai-*.json occurrence count for retryCount
// (backward compat with old-style single JUnit file that gets overwritten)
const { result } = flowEntries[0];
const occurrences = flowOccurrences.get(flowName) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
results.push({
name: junit.name,
path: relativePath,
status: junit.status,
errorMessage: junit.errorMessage,
duration: junit.duration,
retryCount,
tags: junit.tags,
properties: junit.properties,
});
results.push({
name: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount,
tags: result.tags,
properties: result.properties,
});
} else {
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
const sorted = flowEntries
.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: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
}
return results;
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -51,10 +51,7 @@ export function createReadIpaInfoBuildFunction(): BuildFunction {
const ipaPathInput = z.string().parse(inputs.ipa_path.value);
const ipaPath = path.resolve(stepCtx.workingDirectory, ipaPathInput);
if (!(await fs.pathExists(ipaPath))) {
throw new UserFacingError(
'EAS_READ_IPA_INFO_FILE_NOT_FOUND',
`IPA file not found: ${ipaPath}`
);
throw new UserError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
}
const ipaInfo = await readIpaInfoAsync(ipaPath);
@@ -72,7 +69,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
const bundleIdentifier = infoPlist.CFBundleIdentifier;
if (typeof bundleIdentifier !== 'string') {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist'
);
@@ -80,7 +77,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
const bundleShortVersion = infoPlist.CFBundleShortVersionString;
if (typeof bundleShortVersion !== 'string') {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist'
);
@@ -88,7 +85,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
const bundleVersion = infoPlist.CFBundleVersion;
if (typeof bundleVersion !== 'string') {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist'
);
@@ -100,11 +97,11 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
bundleVersion,
};
} catch (error) {
if (error instanceof UserFacingError) {
if (error instanceof UserError) {
throw error;
}
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_FAILED',
`Failed to read IPA info: ${(error as Error).message}`,
{ cause: error }
@@ -118,10 +115,7 @@ function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
const parsedBinaryPlists = bplistParser.parseBuffer(data);
const parsedBinaryPlist = parsedBinaryPlists[0];
if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
throw new UserFacingError(
'EAS_READ_IPA_INFO_INVALID_BINARY_PLIST',
'Invalid binary plist in IPA'
);
throw new UserError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
}
return parsedBinaryPlist as Record<string, unknown>;
}
@@ -135,7 +129,7 @@ async function readInfoPlistBufferFromIpaAsync(ipaPath: string): Promise<Buffer>
const entries = Object.values(await zip.entries());
const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
if (!infoPlistEntry) {
throw new UserFacingError(
throw new UserError(
'EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND',
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
);
@@ -23,6 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -51,6 +52,11 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'js_bundle_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -82,7 +88,10 @@ export function createRepackBuildFunction(): BuildFunction {
);
}
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
const repackSpawnAsync = createSpawnAsyncStepAdapter({
verbose,
logger: stepsCtx.logger,
});
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
const workingDirectory = path.join(tmpDir, 'working-directory');
@@ -98,13 +107,20 @@ export function createRepackBuildFunction(): BuildFunction {
sourcemapOutput: undefined,
}
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
version: inputs.repack_version.value as string,
version: resolvedRepackVersion,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -118,6 +134,7 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
@@ -146,6 +163,7 @@ export function createRepackBuildFunction(): BuildFunction {
outputPath,
workingDirectory,
exportEmbedOptions,
jsBundleOnly,
androidSigningOptions,
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -64,14 +64,21 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
return;
}
// Maestro allows overriding flow names via config, so different flow files can share
// the same name. JUnit XML only contains names (not file paths), making it impossible
// to map duplicates back to their original flow files. Skip and let the user fix it.
const names = flowResults.map(r => r.name);
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
if (duplicates.length > 0) {
// 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).
const seen = new Set<string>();
const conflicting = new Set<string>();
for (const r of flowResults) {
const key = `${r.name}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
}
seen.add(key);
}
if (conflicting.size > 0) {
logger.error(
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
);
@@ -7,7 +7,10 @@ import {
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -16,6 +19,7 @@ import {
generateDefaultBuildCacheKeyAsync,
getCcachePath,
} from '../../utils/cacheKey';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
@@ -50,6 +54,15 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -176,6 +189,106 @@ export async function restoreCcacheAsync({
}
}
export async function restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
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`
);
// 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');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
[
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
'',
'beforeSettings { settings ->',
' try {',
' settings.caches {',
' cleanup = Cleanup.ALWAYS',
' buildCache {',
' setRemoveUnusedEntriesAfterDays(3)',
' }',
' }',
' println "[EAS] Configured Gradle cache cleanup via init script"',
' } catch (Exception e) {',
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
' }',
'}',
'',
'gradle.buildFinished {',
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
'}',
'',
].join('\n')
);
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [buildCachePath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
});
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
await decompressCacheAsync({
archivePath,
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
logger.info(
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
);
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
} else {
logger.warn('Failed to restore Gradle cache: ', err);
}
}
}
export async function cacheStatsAsync({
logger,
env,
@@ -10,8 +10,13 @@ import {
import fs from 'fs';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
return new BuildFunction({
@@ -46,6 +51,15 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
},
});
}
@@ -135,3 +149,66 @@ export async function saveCcacheAsync({
logger.error({ err }, 'Failed to save cache');
}
}
export async function saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
return;
}
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
try {
await fs.promises.access(buildCachePath);
} catch {
logger.warn('No Gradle build cache found, skipping save');
return;
}
try {
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
logger.info(`Saving Gradle cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing Gradle build cache...');
const { archivePath } = await compressCacheAsync({
paths: [buildCachePath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const { size } = await fs.promises.stat(archivePath);
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath],
size,
platform: Platform.ANDROID,
});
} catch (err) {
logger.error({ err }, 'Failed to save Gradle cache');
}
}
@@ -57,6 +57,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
const systemImagePackage = `${inputs.system_image_package.value}`;
// We can cast because allowedValueTypeName validated this is a string.
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
const shouldAdjustAnimationScale =
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
if (!shouldAdjustAnimationScale) {
logger.info(
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
);
}
logger.info('Making sure system image is installed');
await retryAsync(
@@ -107,6 +116,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: attemptSerialId,
});
}
logger.info(`${deviceName} is ready.`);
serialId = attemptSerialId;
@@ -191,6 +207,13 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
timeoutMs,
logger,
});
if (shouldAdjustAnimationScale) {
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
env,
logger,
serialId: cloneSerialId,
});
}
logger.info(`${cloneIdentifier} is ready.`);
} catch (err) {
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
@@ -266,25 +266,49 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
throw new UserFacingError(
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
@@ -303,7 +327,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
@@ -331,6 +355,10 @@ export function isClosedVersionTrainError(messages: { code: string }[]): boolean
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -33,7 +33,7 @@ export async function runGradleCommand({
return line;
}
},
env: { ...env, ...extraEnv },
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
});
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
@@ -1,4 +1,5 @@
import { AndroidConfig } from '@expo/config-plugins';
import { Android } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { templateString } from '@expo/template-file';
import fs from 'fs-extra';
@@ -6,6 +7,7 @@ import path from 'path';
import { EasBuildConfigureVersionGradleTemplate } from '../../../templates/EasBuildConfigureVersionGradle';
import { EasBuildInjectAndroidCredentialsGradle } from '../../../templates/EasBuildInjectAndroidCredentialsGradle';
import { BuildContext } from '../../../context';
const APPLY_EAS_BUILD_INJECT_CREDENTIALS_GRADLE_LINE =
'apply from: "./eas-build-inject-android-credentials.gradle"';
@@ -51,6 +53,26 @@ async function deleteEasBuildConfigureVersionGradle(workingDir: string): Promise
await fs.remove(targetPath);
}
let legacyEasBuildGradleWarningEmitted = false;
export async function warnIfLegacyEasBuildGradleExists(
ctx: BuildContext<Android.Job>
): Promise<void> {
const legacyGradlePath = getLegacyEasBuildGradlePath(ctx.getReactNativeProjectDirectory());
if (
(await fs.pathExists(legacyGradlePath)) &&
(process.env.NODE_ENV === 'test' || !legacyEasBuildGradleWarningEmitted)
) {
legacyEasBuildGradleWarningEmitted = true;
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
ctx.markBuildPhaseHasWarnings();
}
}
function getLegacyEasBuildGradlePath(projectRoot: string): string {
return path.join(projectRoot, 'android/app/eas-build.gradle');
}
function getEasBuildInjectCredentialsGradlePath(workingDir: string): string {
return path.join(workingDir, 'android/app/eas-build-inject-android-credentials.gradle');
}
@@ -113,7 +135,7 @@ function hasLine(haystack: string, needle: string): boolean {
haystack
.replace(/\r\n/g, '\n')
.split('\n')
// Check for both single and double quotes
// Check for both single and double quotes.
.some(line => line === needle || line === needle.replace(/"/g, "'"))
);
}
@@ -1,4 +1,4 @@
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
import { UserError } from '@expo/eas-build-job';
import {
AscApiClient,
@@ -34,12 +34,14 @@ export namespace AscApiUtils {
let visibleAppsSummary: string | null = null;
try {
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
throw error;
}
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
@@ -94,7 +96,7 @@ export namespace AscApiUtils {
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
if (isDuplicateVersionError) {
throw new UserFacingError(
throw new UserError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
@@ -108,19 +110,26 @@ export namespace AscApiUtils {
throw error;
}
}
}
async function getVisibleAppsSummaryAsync(
client: Pick<AscApiClient, 'getAsync'>
): Promise<string | null> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: 10,
});
if (appsResponse.data.length === 0) {
return ' (none)';
export async function getAppsAsync({
client,
limit,
}: {
client: Pick<AscApiClient, 'getAsync'>;
limit?: number;
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
const appsResponse = await client.getAsync('/v1/apps', {
'fields[apps]': ['bundleId', 'name'],
limit: limit ?? 10,
});
return appsResponse.data;
}
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
return (
apps
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n') || ' (none)'
);
}
return appsResponse.data
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
.join('\n');
}
@@ -24,7 +24,7 @@ describe('AscApiUtils', () => {
).resolves.toEqual(response);
});
it('throws UserFacingError with visible apps when app id is not found', async () => {
it('throws UserError with visible apps when app id is not found', async () => {
const notFoundPayload = {
errors: [
{
@@ -60,8 +60,8 @@ describe('AscApiUtils', () => {
expect.objectContaining({
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
docsUrl: 'https://expo.fyi/asc-app-id',
message: expect.stringContaining(
'App Store Connect app for application identifier 1234567890 was not found'
message: expect.stringMatching(
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
),
})
);
@@ -96,8 +96,33 @@ describe('AscApiUtils', () => {
});
});
describe('formatAppsList', () => {
it('formats visible apps', () => {
expect(
AscApiUtils.formatAppsList([
{
type: 'apps',
id: '1111111111',
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
},
{
type: 'apps',
id: '2222222222',
attributes: { name: 'Second App', bundleId: 'com.second.app' },
},
])
).toBe(
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
);
});
it('returns a placeholder when no visible apps are found', () => {
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
});
});
describe('createBuildUploadAsync', () => {
it('throws UserFacingError when ASC duplicate version error is returned', async () => {
it('throws UserError when ASC duplicate version error is returned', async () => {
const payload = {
errors: [
{
@@ -50,6 +50,47 @@ describe(configureCredentialsAsync, () => {
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
});
it('configures credentials with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile,
},
buildConfiguration: 'Release',
};
await configureCredentialsAsync({ info: jest.fn() } as any, '/app', options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials for multi target project', async () => {
vol.fromJSON(
{
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
import path from 'path';
import { Credentials } from './credentials/manager';
import { DistributionType } from './credentials/provisioningProfile';
export async function configureCredentialsAsync(
logger: bunyan,
@@ -29,6 +30,9 @@ export async function configureCredentialsAsync(
profileName: profile.name,
appleTeamId: profile.teamId,
buildConfiguration,
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
codeSignIdentity: profile.certificateCommonName,
}),
});
}
}
@@ -23,6 +23,7 @@ export interface ProvisioningProfileData {
export enum DistributionType {
AD_HOC = 'ad-hoc',
APP_STORE = 'app-store',
DEVELOPMENT = 'development',
ENTERPRISE = 'enterprise',
}
@@ -42,6 +43,7 @@ export default class ProvisioningProfile {
private readonly profilePath: string;
private profileData?: ProvisioningProfileData;
private developerCertificates: Buffer[] = [];
constructor(
private readonly profile: Buffer,
@@ -73,11 +75,12 @@ export default class ProvisioningProfile {
}
public verifyCertificate(fingerprint: string): void {
const devCertFingerprint = this.genDerCertFingerprint();
if (devCertFingerprint !== fingerprint) {
const devCertFingerprints = this.getAllDerCertFingerprints();
if (!devCertFingerprints.includes(fingerprint)) {
throw new errors.CredentialsDistCertMismatchError(
`Provisioning profile and distribution certificate don't match.
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
`Provisioning profile and distribution certificate don't match.\n` +
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
`distribution certificate fingerprint = ${fingerprint}`
);
}
}
@@ -110,6 +113,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
] as string;
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
Buffer.from(cert, 'base64')
);
this.profileData = {
path: this.profilePath,
target: this.target,
@@ -117,7 +124,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
teamId: (plistData.TeamIdentifier as string[])[0],
uuid: plistData.UUID as string,
name: plistData.Name as string,
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
developerCertificate: this.developerCertificates[0],
certificateCommonName: this.certificateCommonName,
distributionType: this.resolveDistributionType(plistData),
};
@@ -127,17 +134,19 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
if (plistData.ProvisionsAllDevices) {
return DistributionType.ENTERPRISE;
} else if (plistData.ProvisionedDevices) {
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
if (entitlements?.['get-task-allow']) {
return DistributionType.DEVELOPMENT;
}
return DistributionType.AD_HOC;
} else {
return DistributionType.APP_STORE;
}
}
private genDerCertFingerprint(): string {
return crypto
.createHash('sha1')
.update(new Uint8Array(this.data.developerCertificate))
.digest('hex')
.toUpperCase();
private getAllDerCertFingerprints(): string[] {
return this.developerCertificates.map(cert =>
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
);
}
}
@@ -1,54 +0,0 @@
export const EasBuildGradle = `// Build integration with EAS
import java.nio.file.Paths
def versionCodeFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_CODE")
def versionNameFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_NAME")
android {
defaultConfig {
if (versionCodeFromEnv) {
versionCode = Integer.parseInt(versionCodeFromEnv)
}
if (versionNameFromEnv) {
versionName = versionNameFromEnv
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeFromEnv) {
output.versionCodeOverride = Integer.parseInt(versionCodeFromEnv)
}
if (versionNameFromEnv) {
output.versionNameOverride = versionNameFromEnv
}
}
}
signingConfigs {
release {
def credentialsJson = Paths.get(System.getenv("EAS_BUILD_WORKINGDIR")).resolve("credentials.json").toFile();
def credentials = new groovy.json.JsonSlurper().parse(credentialsJson)
def keystorePath = Paths.get(credentials.android.keystore.keystorePath);
storeFile keystorePath.toFile()
storePassword credentials.android.keystore.keystorePassword
keyAlias credentials.android.keystore.keyAlias
if (credentials.android.keystore.containsKey("keyPassword")) {
keyPassword credentials.android.keystore.keyPassword
} else {
// key password is required by Gradle, but PKCS keystores don't have one
// using the keystore password seems to satisfy the requirement
keyPassword credentials.android.keystore.keystorePassword
}
}
}
buildTypes {
release {
signingConfig android.signingConfigs.release
}
debug {
signingConfig android.signingConfigs.release
}
}
}`;
@@ -391,6 +391,38 @@ export namespace AndroidEmulatorUtils {
);
}
export async function disableWindowAndTransitionAnimationsAsync({
serialId,
env,
logger,
}: {
serialId: AndroidDeviceSerialId;
env: NodeJS.ProcessEnv;
logger: bunyan;
}): Promise<void> {
logger.info('Disabling Android emulator window animations.');
try {
await spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'],
{ env }
);
} catch (err) {
logger.warn({ err }, 'Failed to disable Android emulator window animations.');
}
logger.info('Disabling Android emulator transition animations.');
try {
await spawn(
'adb',
['-s', serialId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'],
{ env }
);
} catch (err) {
logger.warn({ err }, 'Failed to disable Android emulator transition animations.');
}
}
async function hasNetworkConnectionAsync({
serialId,
env,
@@ -1,400 +0,0 @@
import spawnAsync from '@expo/spawn-async';
import bunyan from 'bunyan';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { createMockLogger } from '../../__tests__/utils/logger';
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
// We use node:fs
jest.unmock('node:fs');
// and fg uses fs.
jest.unmock('fs');
jest.setTimeout(15000);
/**
*
* You need to have a Simulator running to run these tests.
*
*/
describe('findMaestroPathsFlowsToExecuteAsync', () => {
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
let logger: bunyan;
beforeAll(() => {
logger = createMockLogger();
});
// Helper function to run maestro test and determine which flows it executed
// by parsing the output
async function getMaestroFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const args = ['test', flowPath, '--no-ansi'];
if (includeTags.length > 0) {
args.push('--include-tags', includeTags.join(','));
}
if (excludeTags.length > 0) {
args.push('--exclude-tags', excludeTags.join(','));
}
try {
const result = await spawnAsync('maestro', args, {
stdio: 'pipe',
});
return parseMaestroExecutedFlows(result.stdout + result.stderr);
} catch (error: any) {
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
return parseMaestroExecutedFlows(output);
}
}
function parseMaestroExecutedFlows(output: string): string[] {
return output
.split('\n')
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
.map(line => {
if (line.includes('[Passed]')) {
return line.split('[Passed]')[1].trim().split(' ')[0];
}
return line.split('> Flow')[1].trim();
})
.sort();
}
// Helper function to run our implementation
async function getOurFlowList(
flowPath: string,
includeTags: string[] = [],
excludeTags: string[] = []
): Promise<string[]> {
const result = await findMaestroPathsFlowsToExecuteAsync({
workingDirectory: process.cwd(),
flowPath: path.relative(process.cwd(), flowPath),
includeTags,
excludeTags,
logger,
});
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
}
describe('Directory-based flow discovery', () => {
it('should discover all flows when no tags are specified', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir);
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should filter flows by both include and exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple include tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should handle multiple exclude tags', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should not include nested flows in subdirectories', async () => {
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
expect(ourFlows).toEqual(maestroFlows);
});
it('should exclude config files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
});
it('should exclude non-YAML files from flow discovery', async () => {
const ourFlows = await getOurFlowList(fixturesDir);
expect(ourFlows).not.toContain('non-flow-file');
});
});
describe('Single file scenarios', () => {
it('should handle single file input', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
const maestroFlows = await getMaestroFlowList(singleFile);
const ourFlows = await getOurFlowList(singleFile);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
});
it('should handle single file with tag filtering (include)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be included
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
it('should handle single file with tag filtering (exclude)', async () => {
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
// This file has 'smoke' tag, so should be excluded
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toHaveLength(1);
});
});
describe('Edge cases', () => {
it('should handle non-existent paths gracefully', async () => {
const nonExistentPath = '/path/that/does/not/exist';
// Our implementation should return empty array for non-existent paths
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
});
it('should handle empty directories', async () => {
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
await fs.mkdir(emptyDir, { recursive: true });
try {
const ourFlows = await getOurFlowList(emptyDir);
expect(ourFlows).toEqual([]);
} finally {
await fs.rmdir(emptyDir);
}
});
it('should handle flows that cannot be parsed', async () => {
// The invalid-flow.yaml has extra fields that might cause parsing issues
// but should still be discovered if tag filtering passes
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
// The important thing is that it doesn't crash
expect(Array.isArray(ourFlows)).toBe(true);
});
});
describe('Performance comparison', () => {
it('should complete discovery in reasonable time', async () => {
const startTime = Date.now();
await getOurFlowList(fixturesDir);
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
});
});
describe('Detailed flow content verification', () => {
it('should correctly identify flows with specific tag combinations', async () => {
// Test complex tag filtering scenarios
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
// Verify specific files are included/excluded correctly
expect(authFlows).toContain('tagged-flow-auth');
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
expect(smokeFlows).toContain('tagged-flow-smoke');
expect(criticalFlows).toContain('tagged-flow-smoke');
});
it('should handle flows without tags correctly', async () => {
// When no filters are applied, flows without tags should be included
const allFlows = await getOurFlowList(fixturesDir);
expect(allFlows).toContain('no-tags-flow');
expect(allFlows).toContain('basic-flow');
// When include tags are specified, flows without tags should be excluded
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
expect(taggedFlows).not.toContain('no-tags-flow');
expect(taggedFlows).not.toContain('basic-flow');
});
});
describe('config.yaml flow patterns', () => {
it('should discover flows using config.yaml patterns', async () => {
const maestroFlows = await getMaestroFlowList(configFlowsDir);
const ourFlows = await getOurFlowList(configFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Verify specific files are included based on config patterns
expect(ourFlows).toContain('login'); // features/auth/login.yaml
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
// Verify top-level file is excluded (not matching patterns)
expect(ourFlows).not.toContain('ignored-top-level');
});
it('should handle tag filtering with config.yaml patterns', async () => {
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
expect(authFlows).toEqual(maestroAuthFlows);
expect(authFlows).toContain('login');
expect(authFlows).toContain('logout');
expect(authFlows).not.toContain('navigation');
expect(authFlows).not.toContain('api.spec');
});
it('should handle exclude tags with config.yaml patterns', async () => {
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
});
it('should handle multiple include tags with config.yaml patterns', async () => {
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
'auth',
'integration',
]);
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
expect(multiTagFlows).toContain('login'); // auth tag
expect(multiTagFlows).toContain('logout'); // auth tag
expect(multiTagFlows).toContain('api.spec'); // integration tag
expect(multiTagFlows).toContain('ui.spec'); // integration tag
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
});
});
describe('config.yaml includeTags/excludeTags merging', () => {
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir);
const ourFlows = await getOurFlowList(configTagsDir);
expect(ourFlows).toEqual(maestroFlows);
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-cli-include');
});
it('should merge CLI includeTags with config includeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
expect(ourFlows).toEqual(maestroFlows);
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).toContain('flow-cli-include');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-cli-exclude');
});
it('should merge CLI excludeTags with config excludeTags', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
expect(ourFlows).toEqual(maestroFlows);
// Exclude both cfgExclude and cliExclude
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-cli-exclude');
expect(ourFlows).not.toContain('flow-inc-and-exc');
});
it('should apply both merged include and exclude tags correctly', async () => {
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
expect(ourFlows).toEqual(maestroFlows);
expect(ourFlows).toContain('flow-inc-only');
expect(ourFlows).not.toContain('flow-exc-only');
expect(ourFlows).not.toContain('flow-inc-and-exc');
expect(ourFlows).not.toContain('flow-neutral');
});
});
describe('backward compatibility (no config.yaml)', () => {
it('should use default "*" pattern when no config.yaml exists', async () => {
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
const ourFlows = await getOurFlowList(noConfigFlowsDir);
expect(ourFlows).toEqual(maestroFlows);
// Should include top-level files
expect(ourFlows).toContain('top-level-flow');
// Should exclude subdirectory files (default "*" behavior)
expect(ourFlows).not.toContain('nested-flow');
});
it('should handle tag filtering without config.yaml', async () => {
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
expect(basicFlows).toEqual(maestroBasicFlows);
expect(basicFlows).toContain('top-level-flow');
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
expect(noIncludeTagsFlows).toContain('top-level-flow');
expect(noIncludeTagsFlows).not.toContain('nested-flow');
});
});
});
@@ -1,3 +0,0 @@
flows:
- 'features/**/*.yaml'
- 'specs/*.spec.yaml'
@@ -1,5 +0,0 @@
appId: com.example.app
name: "login"
tags: ["auth", "smoke"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "logout"
tags: ["auth"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "navigation"
tags: ["core", "smoke"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "ignored-top-level"
tags: ["ignored"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "api.spec"
tags: ["api", "integration"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "ui.spec"
tags: ["ui", "integration"]
---
- assertTrue: true
@@ -1,4 +0,0 @@
appId: com.example.app
name: "basic-flow"
---
- assertTrue: true
@@ -1,6 +0,0 @@
appId: com.example.app
name: "invalid-flow"
tags:
- invalid
---
- assertTrue: true
@@ -1,4 +0,0 @@
appId: com.example.app
name: "no-tags-flow"
---
- assertTrue: true
@@ -1 +0,0 @@
This is not a YAML file and should be ignored by flow discovery.
@@ -1,7 +0,0 @@
appId: com.example.app
name: "nested-flow"
tags:
- nested
- auth
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-auth"
tags:
- auth
- login
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-regression"
tags:
- regression
- slow
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "tagged-flow-smoke"
tags:
- smoke
- critical
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "nested-flow"
tags: ["nested"]
---
- assertTrue: true
@@ -1,5 +0,0 @@
appId: com.example.app
name: "top-level-flow"
tags: ["basic"]
---
- assertTrue: true
@@ -1,7 +0,0 @@
appId: com.example.app
name: "standalone-flow"
tags:
- standalone
- smoke
---
- assertTrue: true
@@ -1,5 +1,6 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
import { retryAsync } from '../retry';
@@ -110,6 +111,87 @@ describe('AndroidEmulatorUtils', () => {
});
});
describe(AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync, () => {
it('sets window and transition animation scales to zero', async () => {
const logger = createMockLogger();
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
serialId: 'emulator-5554' as any,
env: process.env,
logger,
});
expect(logger.info).toHaveBeenNthCalledWith(
1,
'Disabling Android emulator window animations.'
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'adb',
[
'-s',
'emulator-5554',
'shell',
'settings',
'put',
'global',
'window_animation_scale',
'0',
],
{ env: process.env }
);
expect(logger.info).toHaveBeenNthCalledWith(
2,
'Disabling Android emulator transition animations.'
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
'adb',
[
'-s',
'emulator-5554',
'shell',
'settings',
'put',
'global',
'transition_animation_scale',
'0',
],
{ env: process.env }
);
});
it('logs and swallows failures when disabling animations', async () => {
const logger = createMockLogger();
mockedSpawn
.mockRejectedValueOnce(new Error('window failed'))
.mockRejectedValueOnce(new Error('transition failed'));
await expect(
AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
serialId: 'emulator-5554' as any,
env: process.env,
logger,
})
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
err: expect.objectContaining({ message: 'window failed' }),
}),
'Failed to disable Android emulator window animations.'
);
expect(logger.warn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
err: expect.objectContaining({ message: 'transition failed' }),
}),
'Failed to disable Android emulator transition animations.'
);
});
});
describe(AndroidEmulatorUtils.stopAsync, () => {
it('kills emulator and waits for it to detach', async () => {
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
@@ -0,0 +1,108 @@
import { bunyan } from '@expo/logger';
import { readAppConfig } from '../appConfig';
jest.mock('@expo/env', () => ({
load: jest.fn(() => ({ FROM_DOTENV: 'true' })),
}));
jest.mock('@expo/config', () => ({
getConfig: jest.fn(() => ({
exp: { name: 'fallback-app', slug: 'fallback-app' },
})),
}));
jest.mock('../expoCli');
const { expoCommandAsync } = jest.requireMock('../expoCli') as {
expoCommandAsync: jest.Mock;
};
const { getConfig } = jest.requireMock('@expo/config') as {
getConfig: jest.Mock;
};
const { load: loadEnv } = jest.requireMock('@expo/env') as {
load: jest.Mock;
};
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan;
const baseParams = {
projectDir: '/project',
env: { NODE_ENV: 'production' },
logger,
};
describe(readAppConfig, () => {
it('returns config from expo CLI when it succeeds', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
const result = await readAppConfig(baseParams);
expect(result).toEqual(config);
expect(expoCommandAsync).toHaveBeenCalledWith(
'/project',
['config', '--json', '--full', '--type', 'public'],
{ env: baseParams.env }
);
expect(getConfig).not.toHaveBeenCalled();
});
it('falls back to @expo/config when expo CLI fails', async () => {
expoCommandAsync.mockRejectedValue(new Error('expo not found'));
const result = await readAppConfig(baseParams);
expect(result).toEqual({ exp: { name: 'fallback-app', slug: 'fallback-app' } });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('expo not found'));
expect(getConfig).toHaveBeenCalledWith('/project', {
skipSDKVersionRequirement: true,
isPublicConfig: true,
});
});
it('throws when expo CLI returns invalid JSON and @expo/config also fails', async () => {
expoCommandAsync.mockResolvedValue({ stdout: 'not json' });
getConfig.mockImplementation(() => {
throw new Error('@expo/config failed');
});
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse JSON output from 'expo config'")
);
});
it('throws when expo CLI output is missing exp field and @expo/config also fails', async () => {
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify({ other: 'data' }) });
getConfig.mockImplementation(() => {
throw new Error('@expo/config failed');
});
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 'exp' field"));
});
it('loads env vars from dotenv for SDK >= 49', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
await readAppConfig({ ...baseParams, sdkVersion: '49.0.0' });
expect(loadEnv).toHaveBeenCalledWith('/project');
expect(expoCommandAsync).toHaveBeenCalledWith('/project', expect.any(Array), {
env: { NODE_ENV: 'production', FROM_DOTENV: 'true' },
});
});
it('does not load env vars from dotenv for SDK < 49', async () => {
const config = { exp: { name: 'test-app', slug: 'test-app' } };
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
await readAppConfig({ ...baseParams, sdkVersion: '48.0.0' });
expect(loadEnv).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import resolveFrom from 'resolve-from';
import spawnAsync from '@expo/turtle-spawn';
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
jest.mock('resolve-from', () => ({
__esModule: true,
default: Object.assign(jest.fn(), {
silent: jest.fn(),
}),
}));
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue({ stdout: '', stderr: '' }),
}));
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
silent: jest.MockedFunction<typeof resolveFrom.silent>;
};
describe(expoCommandAsync, () => {
it('resolves expo CLI and spawns with correct args', async () => {
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
await expoCommandAsync('/project', ['config', '--json'], { env: { FOO: 'bar' } });
expect(spawnAsync).toHaveBeenCalledWith(
'/project/node_modules/expo/bin/cli',
['config', '--json'],
expect.objectContaining({
cwd: '/project',
stdio: 'pipe',
env: { FOO: 'bar', EXPO_DEBUG: '0' },
})
);
});
it('falls back to expo/bin/cli.js when silent resolve returns null', async () => {
mockedResolveFrom.silent.mockReturnValue(undefined);
mockedResolveFrom.mockReturnValue('/project/node_modules/expo/bin/cli.js');
await expoCommandAsync('/project', ['config'], { env: {} });
expect(spawnAsync).toHaveBeenCalledWith(
'/project/node_modules/expo/bin/cli.js',
['config'],
expect.any(Object)
);
});
it('throws ExpoCLIModuleNotFoundError when expo is not installed', async () => {
mockedResolveFrom.silent.mockReturnValue(undefined);
const moduleError = new Error('Cannot find module');
(moduleError as any).code = 'MODULE_NOT_FOUND';
mockedResolveFrom.mockImplementation(() => {
throw moduleError;
});
await expect(expoCommandAsync('/project', ['config'], { env: {} })).rejects.toThrow(
ExpoCLIModuleNotFoundError
);
});
it('overrides EXPO_DEBUG to 0', async () => {
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
await expoCommandAsync('/project', ['config'], { env: { EXPO_DEBUG: '1' } });
expect(spawnAsync).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({ EXPO_DEBUG: '0' }),
})
);
});
});
@@ -1,11 +1,14 @@
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -68,6 +71,33 @@ describe(resolvePackageManager, () => {
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as bunyan;
it('should resolve version from package name and dist tag', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'latest',
});
expect(semver.valid(version)).toBeTruthy();
});
it('should return null if version cannot be resolved', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'nonexistent-dist-tag',
});
expect(version).toBeNull();
});
});
describe(findPackagerRootDir, () => {
beforeEach(() => {
vol.reset();
@@ -2,18 +2,24 @@ import { ExpoConfig } from '@expo/config';
import { Android } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import { Readable } from 'stream';
import { instance, mock, when } from 'ts-mockito';
import { BuildContext } from '../../context';
import { PackageManager } from '../packageManager';
import { readEasJsonContents, runExpoCliCommand } from '../project';
import { PackageManager, findPackagerRootDir } from '../packageManager';
import { isUsingModernYarnVersion, readEasJsonContents, runExpoCliCommand } from '../project';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
findPackagerRootDir: jest.fn((dir: string) => dir),
}));
describe(runExpoCliCommand, () => {
describe('Expo SDK >= 46', () => {
@@ -24,7 +30,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -38,7 +44,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -52,7 +58,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.PNPM);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -66,7 +72,7 @@ describe(runExpoCliCommand, () => {
const mockCtx = mock<BuildContext<Android.Job>>();
when(mockCtx.packageManager).thenReturn(PackageManager.BUN);
when(mockCtx.appConfig).thenReturn(expoConfig);
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
const ctx = instance(mockCtx);
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
@@ -75,6 +81,61 @@ describe(runExpoCliCommand, () => {
});
});
describe(isUsingModernYarnVersion, () => {
const projectDir = '/project';
const mockedFindPackagerRootDir = findPackagerRootDir as jest.MockedFunction<
typeof findPackagerRootDir
>;
beforeEach(() => {
mockedFindPackagerRootDir.mockReturnValue(projectDir);
jest.spyOn(fs, 'createReadStream').mockImplementation(((
filePath: string,
options?: { start?: number; end?: number }
) => {
const content = fs.readFileSync(filePath, 'utf8');
const start = options?.start ?? 0;
const end = options?.end != null ? options.end + 1 : content.length;
return Readable.from(Buffer.from(content.slice(start, end)));
}) as any);
});
it('returns true when .yarnrc.yml exists in project directory', async () => {
vol.fromJSON({ [path.join(projectDir, '.yarnrc.yml')]: '' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
it('returns true when .yarnrc.yml exists in workspace root directory', async () => {
const workspaceRoot = '/workspace-root';
mockedFindPackagerRootDir.mockReturnValue(workspaceRoot);
vol.fromJSON({ [path.join(workspaceRoot, '.yarnrc.yml')]: '' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
it('returns false when no .yarnrc.yml and no yarn.lock exist', async () => {
vol.fromJSON({ [projectDir]: null });
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
});
it('returns false when yarn.lock contains classic v1 header', async () => {
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(false);
});
it('returns true when yarn.lock does not contain classic v1 header', async () => {
vol.fromJSON({ [path.join(projectDir, 'yarn.lock')]: '__metadata:\n version: 8\n' });
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
it('returns true when .yarnrc.yml exists even with classic yarn.lock', async () => {
vol.fromJSON({
[path.join(projectDir, '.yarnrc.yml')]: '',
[path.join(projectDir, 'yarn.lock')]: '# yarn lockfile v1\n',
});
expect(await isUsingModernYarnVersion(projectDir)).toBe(true);
});
});
describe(readEasJsonContents, () => {
it('returns the raw eas.json contents even when it is not valid JSON', async () => {
const projectDir = '/project';
@@ -1,10 +1,13 @@
import { StepMetricsCollection } from '@expo/steps';
import { StepMetric } from '@expo/steps';
import { randomUUID } from 'crypto';
import { uploadStepMetricsToWwwAsync } from '../stepMetrics';
import { turtleFetch } from '../turtleFetch';
jest.mock('../turtleFetch');
jest.mock('../turtleFetch', () => ({
...jest.requireActual('../turtleFetch'),
turtleFetch: jest.fn(),
}));
const mockTurtleFetch = turtleFetch as jest.MockedFunction<typeof turtleFetch>;
describe(uploadStepMetricsToWwwAsync, () => {
@@ -23,44 +26,52 @@ describe(uploadStepMetricsToWwwAsync, () => {
jest.clearAllMocks();
});
it('uploads step metrics to www', async () => {
const stepMetrics: StepMetricsCollection = [
{ metricsId: 'eas/checkout', result: 'success', durationMs: 1000, platform: 'linux' },
{
metricsId: 'eas/install_node_modules',
result: 'failed',
durationMs: 5000,
platform: 'linux',
},
];
it('uploads step metrics', async () => {
const stepMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'success',
durationMs: 1000,
platform: 'linux',
};
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics,
stepMetrics: [stepMetric],
logger: mockLogger,
});
expect(mockTurtleFetch).toHaveBeenCalledTimes(1);
expect(mockTurtleFetch).toHaveBeenCalledWith(
`${expoApiV2BaseUrl}workflows/${workflowJobId}/metrics`,
'POST',
expect.objectContaining({
json: { stepMetrics },
json: { stepMetrics: [stepMetric] },
headers: { Authorization: `Bearer ${robotAccessToken}` },
retries: 2,
})
);
});
it('does nothing when stepMetrics is empty', async () => {
await uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [],
logger: mockLogger,
});
it('silently gives up on failure', async () => {
mockTurtleFetch.mockRejectedValue(new Error('persistent failure'));
expect(mockTurtleFetch).not.toHaveBeenCalled();
const stepMetric: StepMetric = {
metricsId: 'eas/checkout',
result: 'failed',
durationMs: 500,
platform: 'darwin',
};
await expect(
uploadStepMetricsToWwwAsync({
workflowJobId,
robotAccessToken,
expoApiV2BaseUrl,
stepMetrics: [stepMetric],
logger: mockLogger,
})
).resolves.not.toThrow();
});
});
+60 -11
View File
@@ -3,18 +3,70 @@ import { Env } from '@expo/eas-build-job';
import { load } from '@expo/env';
import { LoggerLevel, bunyan } from '@expo/logger';
import semver from 'semver';
import { expoCommandAsync } from './expoCli';
export function readAppConfig({
projectDir,
env,
logger,
sdkVersion,
}: {
interface ReadAppConfigParams {
projectDir: string;
env: Env;
logger: bunyan;
sdkVersion?: string;
}): ProjectConfig {
}
export async function readAppConfig(params: ReadAppConfigParams): Promise<ProjectConfig> {
const shouldLoadEnvVarsFromDotenvFile =
params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49');
if (shouldLoadEnvVarsFromDotenvFile) {
const envVarsFromDotenvFile = load(params.projectDir) as Env;
const env = { ...params.env, ...envVarsFromDotenvFile };
params = { ...params, env };
}
// Reading the app config is done in two steps/attempts. We first attempt to run `expo config` as a CLI,
try {
return await getAppConfigFromExpo(params);
} catch (error: any) {
params.logger.warn(
'Failed to read the app config file with `expo config` command:\n' +
`${error?.message || error}`
);
}
// If this fails, we fall back to directly using `@expo/config`
// This can fail, since it's tied to a specific SDK version, so reading for older SDKs isn't guaranteed to work
return getAppConfigFromExpoConfig(params);
}
async function getAppConfigFromExpo({
projectDir,
env,
}: ReadAppConfigParams): Promise<ProjectConfig> {
const result = await expoCommandAsync(
projectDir,
['config', '--json', '--full', '--type', 'public'],
{ env }
);
let parsed: any;
try {
parsed = JSON.parse(result.stdout);
} catch {
throw new Error(
`Failed to parse JSON output from 'expo config'.\nOutput: ${result.stdout.slice(0, 500)}`
);
}
if (!('exp' in parsed)) {
throw new Error(`Unexpected output from 'expo config': missing 'exp' field.`);
}
return parsed;
}
function getAppConfigFromExpoConfig({
projectDir,
env,
logger,
}: ReadAppConfigParams): ProjectConfig {
const originalProcessExit = process.exit;
const originalProcessCwd = process.cwd;
const originalStdoutWrite = process.stdout.write;
@@ -22,11 +74,8 @@ export function readAppConfig({
const originalProcessEnv = process.env;
const stdoutStore: { text: string; level: LoggerLevel }[] = [];
const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49');
const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {};
const newEnvsToUse = { ...env, ...envVarsFromDotenvFile };
try {
process.env = newEnvsToUse;
process.env = env;
process.exit = () => {
throw new Error('Failed to evaluate app config file');
};
+36
View File
@@ -0,0 +1,36 @@
import resolveFrom from 'resolve-from';
import spawnAsync, { type SpawnOptions } from '@expo/turtle-spawn';
export class ExpoCLIModuleNotFoundError extends Error {}
function resolveExpoCLI(projectRoot: string): string {
try {
return (
resolveFrom.silent(projectRoot, 'expo/bin/cli') ?? resolveFrom(projectRoot, 'expo/bin/cli.js')
);
} catch (e: any) {
if (e.code === 'MODULE_NOT_FOUND') {
throw new ExpoCLIModuleNotFoundError(`The \`expo\` package was not found.`);
}
throw e;
}
}
export async function expoCommandAsync(
projectDir: string,
args: string[],
options: Omit<SpawnOptions, 'cwd'>
) {
const expoCliPath = resolveExpoCLI(projectDir);
return spawnAsync(expoCliPath, args, {
cwd: projectDir,
stdio: 'pipe',
...options,
env: {
...options.env,
// NOTE: If we're reading user configs, if a user has set this, it might cause excessive output
// that can stop the command from being readable
EXPO_DEBUG: '0',
},
});
}
@@ -120,7 +120,7 @@ This would cause any updates published on the local machine to not be compatible
);
}
if (isEASUpdateConfigured(ctx)) {
if (await isEASUpdateConfigured(ctx)) {
if (ctx.job.updates?.channel !== undefined) {
await configureEASExpoUpdatesAsync(ctx);
} else {
@@ -134,7 +134,7 @@ This would cause any updates published on the local machine to not be compatible
} else if (isDevelopmentClient) {
// NO-OP: Development clients don't need to have a channel set
} else {
const easUpdateUrl = ctx.appConfig.updates?.url ?? null;
const easUpdateUrl = (await ctx.appConfig).updates?.url ?? null;
const jobProfile = ctx.job.buildProfile ?? null;
ctx.logger.warn(
`This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${
@@ -220,8 +220,8 @@ export async function getRuntimeVersionAsync(ctx: BuildContext<Job>): Promise<st
}
}
export function isEASUpdateConfigured(ctx: BuildContext<Job>): boolean {
const rawUrl = ctx.appConfig.updates?.url;
export async function isEASUpdateConfigured(ctx: BuildContext<Job>): Promise<boolean> {
const rawUrl = (await ctx.appConfig).updates?.url;
if (!rawUrl) {
return false;
}
@@ -1,230 +0,0 @@
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fg from 'fast-glob';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { z } from 'zod';
const FlowConfigSchema = z.object({
name: z.string().optional(),
tags: z.array(z.string()).optional(),
});
const WorkspaceConfigSchema = z.object({
flows: z.array(z.string()).optional(),
executionOrder: z.record(z.string(), z.unknown()).optional(),
includeTags: z.array(z.string()).optional(),
excludeTags: z.array(z.string()).optional(),
});
type FlowConfig = z.infer<typeof FlowConfigSchema>;
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
export async function findMaestroPathsFlowsToExecuteAsync({
workingDirectory,
flowPath,
includeTags: _includeTags,
excludeTags: _excludeTags,
logger,
}: {
workingDirectory: string;
flowPath: string;
includeTags: string[] | undefined;
excludeTags: string[] | undefined;
logger: bunyan;
}): Promise<string[]> {
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
// If it's a file, just return it (no validation needed)
const stat = await fs.stat(absoluteFlowPath);
if (stat.isFile()) {
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
return [absoluteFlowPath];
}
// It's a directory - discover flow files
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
// Check for workspace config
logger.info(`Searching for workspace config...`);
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
dirPath: absoluteFlowPath,
workingDirectory,
logger,
});
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
if (workspaceConfig?.executionOrder) {
logger.warn(`Execution order is not supported yet. Ignoring.`);
}
logger.info(`Searching for flow files...`);
const { flows } = await findAndParseFlowFilesAsync({
dirPath: absoluteFlowPath,
workingDirectory,
workspaceConfig,
logger,
});
if (flows.length === 0) {
logger.info(
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
);
return [];
}
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
if (includeTags.length === 0 && excludeTags.length === 0) {
logger.info(`No tags provided, returning all flows.`);
return flows.map(({ path }) => path);
}
logger.info(
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
JSON.stringify(excludeTags) ?? 'none'
}.`
);
return flows
.filter(({ config, path: flowPath }) => {
const shouldInclude = matchesTags({
flowTags: config?.tags ?? [],
includeTags,
excludeTags,
});
logger.info(
shouldInclude
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
);
return shouldInclude;
})
.map(({ path }) => path);
}
async function findAndParseWorkspaceConfigAsync({
dirPath,
workingDirectory,
logger,
}: {
dirPath: string;
workingDirectory: string;
logger: bunyan;
}): Promise<WorkspaceConfig | null> {
const configPaths = await fg(['config.yaml', 'config.yml'], {
cwd: dirPath,
absolute: true,
});
if (configPaths.length === 0) {
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
for (const configPath of configPaths) {
try {
const content = await fs.readFile(configPath, 'utf-8');
const configDoc = yaml.parse(content);
if (!configDoc) {
logger.warn(
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
return WorkspaceConfigSchema.parse(configDoc);
} catch (err) {
logger.warn(
{ err },
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
);
continue;
}
}
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
return null;
}
async function findAndParseFlowFilesAsync({
workingDirectory,
dirPath,
workspaceConfig,
logger,
}: {
workingDirectory: string;
dirPath: string;
workspaceConfig: WorkspaceConfig | null;
logger: bunyan;
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
const flows: { config: FlowConfig; path: string }[] = [];
// Determine flow patterns from config or use default
const flowPatterns = workspaceConfig?.flows ?? ['*'];
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
// Use fast-glob to find matching files
const matchedFiles = await fg(flowPatterns, {
cwd: dirPath,
absolute: true,
onlyFiles: true,
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
});
logger.info(`Found ${matchedFiles.length} potential flow files`);
// Parse each matched file
for (const filePath of matchedFiles) {
// Skip non-YAML files
const ext = path.extname(filePath);
if (ext !== '.yaml' && ext !== '.yml') {
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
continue;
}
const result = await asyncResult(parseFlowFile(filePath));
if (result.ok) {
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
flows.push({ config: result.value, path: filePath });
} else {
logger.info(
{ err: result.reason },
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
);
}
}
return { flows };
}
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
const content = await fs.readFile(filePath, 'utf-8');
const documents = yaml.parseAllDocuments(content);
const configDoc = documents[0];
if (!configDoc) {
throw new Error(`No config section found in ${filePath}`);
}
return FlowConfigSchema.parse(configDoc.toJS());
}
function matchesTags({
flowTags,
includeTags,
excludeTags,
}: {
flowTags: string[];
includeTags: string[];
excludeTags: string[];
}): boolean {
// Include logic: if includeTags is empty OR flow has any of the include tags
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
return includeMatch && excludeMatch;
}
@@ -0,0 +1,21 @@
import * as PackageManagerUtils from '@expo/package-manager';
import { hashFiles } from '@expo/steps';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
const packagerRunDir = findPackagerRootDir(workingDirectory);
const manager = PackageManagerUtils.createForProject(packagerRunDir);
const lockPath = path.join(packagerRunDir, manager.lockFile);
try {
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
} catch (err: unknown) {
throw new Error(
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
);
}
}
@@ -1,3 +1,4 @@
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
@@ -27,6 +28,34 @@ export function resolvePackageManager(directory: string): PackageManager {
}
}
/**
* Get the version of a package from the dist-tags.
* Returns null if the version cannot be resolved.
*/
export async function resolvePackageVersionAsync({
logger,
packageName,
distTag,
}: {
logger: bunyan;
packageName: string;
distTag: string;
}): Promise<string | null> {
try {
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
stdio: 'pipe',
});
const distTags = JSON.parse(stdout);
if (distTag in distTags) {
return distTags[distTag];
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
}
return null;
}
export function findPackagerRootDir(currentDir: string): string {
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
}
+32 -2
View File
@@ -4,13 +4,43 @@ import path from 'path';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
async function readFirstChars(filePath: string, chars: number): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
const stream = fs.createReadStream(filePath, {
start: 0,
end: chars - 1,
});
stream.on('error', reject);
stream.on('data', chunk => {
chunks.push(chunk as Uint8Array);
});
stream.on('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
});
}
/**
* check if .yarnrc.yml exists in the project dir or in the workspace root dir
* check if .yarnrc.yml exists in the project dir or in the workspace root dir,
* or if the `yarn.lock` file is classic one, not a modern one
*/
export async function isUsingModernYarnVersion(projectDir: string): Promise<boolean> {
const rootDir = findPackagerRootDir(projectDir);
const yarnrcPath = path.join(projectDir, '.yarnrc.yml');
const yarnrcRootPath = path.join(findPackagerRootDir(projectDir), '.yarnrc.yml');
return (await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath));
if ((await fs.pathExists(yarnrcPath)) || (await fs.pathExists(yarnrcRootPath))) {
return true;
}
const yarnlockPath = path.join(rootDir, 'yarn.lock');
if (!(await fs.pathExists(yarnlockPath))) {
return false;
}
// The yarn.lock file is for Yarn Classic, not Modern, if it contains "# yarn lockfile v1"
const startOfLockfile = await readFirstChars(yarnlockPath, 100);
return !/yarn lockfile v1/i.test(startOfLockfile);
}
export function runExpoCliCommand({
+6 -13
View File
@@ -1,5 +1,5 @@
import { bunyan } from '@expo/logger';
import { StepMetricsCollection } from '@expo/steps';
import { StepMetric } from '@expo/steps';
import { turtleFetch } from './turtleFetch';
@@ -13,29 +13,22 @@ export async function uploadStepMetricsToWwwAsync({
workflowJobId: string;
robotAccessToken: string;
expoApiV2BaseUrl: string;
stepMetrics: StepMetricsCollection;
stepMetrics: StepMetric[];
logger: bunyan;
}): Promise<void> {
if (stepMetrics.length === 0) {
logger.debug('No step metrics to upload');
return;
}
try {
await turtleFetch(
new URL(`workflows/${workflowJobId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { stepMetrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 20000,
headers: { Authorization: `Bearer ${robotAccessToken}` },
timeout: 5000,
retries: 2,
logger,
}
);
logger.info(`Uploaded ${stepMetrics.length} step metrics`);
} catch {
// Don't display unactionable error to the user, let's send it to Sentry in the future
// Don't fail the build for metrics — silently give up
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.2.0",
"version": "18.5.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.2.0"
"@expo/steps": "18.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "18.0.1",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -19,10 +19,10 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"fs-extra": "^11.2.0",
@@ -33,6 +33,7 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "18.0.2",
"version": "18.5.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -26,9 +26,9 @@
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
"typecheck": "tsc",
"prepack": "yarn rebuild",
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
"test": "jest --passWithNoTests",
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
},
"dependencies": {
"@babel/code-frame": "7.23.5",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.2.0",
"version": "18.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -19,13 +19,13 @@
"watch": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
"test": "jest --config jest.config.ts",
"test:watch": "jest --config jest.config.ts --watch",
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "18.0.1",
"@expo/logger": "18.5.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -34,6 +34,7 @@
"@types/jest": "^29.5.12",
"@types/node": "20.14.2",
"jest": "^29.7.0",
"rimraf": "3.0.2",
"ts-jest": "^29.1.4",
"typescript": "^5.5.4"
}
@@ -24,6 +24,98 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('rejects invalid app_store_connect build_upload context', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: 'not-a-uuid',
state: 'invalid-state',
},
},
};
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
});
it('rejects invalid app and account context', () => {
const context = {
after: {},
@@ -1,8 +1,10 @@
import { BuildError, ExpoError, UserFacingError } from '../errors';
import { ErrorCode, ExpoError, SystemError, UserError } from '../errors';
import { BuildPhase } from '../logs';
type ExpoErrorDetails = ConstructorParameters<typeof ExpoError>[1];
class TestExpoError extends ExpoError {
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
constructor(message: string, details: ExpoErrorDetails) {
super(message, details);
}
}
@@ -27,7 +29,7 @@ describe(ExpoError, () => {
});
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
expect(error.format()).toEqual({
expect(error.toExternalExpoError()).toEqual({
errorCode: 'ERR_CODE',
message: 'canonical message',
docsUrl: 'https://docs.example.dev',
@@ -36,29 +38,9 @@ describe(ExpoError, () => {
});
});
describe(BuildError, () => {
it('formats using canonical message and errorCode', () => {
const error = new BuildError('canonical message', {
errorCode: 'ERR_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@expo/config' },
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.metadata).toEqual({ packageName: '@expo/config' });
expect(error.format()).toEqual({
errorCode: 'ERR_CODE',
message: 'canonical message',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
});
});
});
describe(UserFacingError, () => {
describe(UserError, () => {
it('supports docsUrl in options', () => {
const error = new UserFacingError('ERR_CODE', 'message', {
const error = new UserError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
});
@@ -69,14 +51,24 @@ describe(UserFacingError, () => {
it('supports cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', { cause });
const error = new UserError('ERR_CODE', 'message', { cause });
expect(error.cause).toBe(cause);
});
it('supports trackingCode and buildPhase in options', () => {
const error = new UserError('ERR_CODE', 'message', {
trackingCode: 'TRACKING_CODE',
buildPhase: BuildPhase.PREBUILD,
});
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
});
it('supports docsUrl, metadata, and cause in options', () => {
const cause = new Error('root cause');
const error = new UserFacingError('ERR_CODE', 'message', {
const error = new UserError('ERR_CODE', 'message', {
docsUrl: 'https://docs.example.dev',
metadata: { packageName: 'expo' },
cause,
@@ -87,3 +79,30 @@ describe(UserFacingError, () => {
expect(error.cause).toBe(cause);
});
});
describe(SystemError, () => {
it('always formats with SERVER_ERROR and preserves trackingCode', () => {
const cause = new Error('root cause');
const error = new SystemError('system message', {
trackingCode: 'TRACKING_CODE',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
metadata: { packageName: '@expo/config' },
cause,
});
expect(error).toBeInstanceOf(ExpoError);
expect(error.errorCode).toBe(ErrorCode.SERVER_ERROR);
expect(error.trackingCode).toBe('TRACKING_CODE');
expect(error.docsUrl).toBe('https://docs.example.dev');
expect(error.metadata).toEqual({ packageName: '@expo/config' });
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
expect(error.cause).toBe(cause);
expect(error.toExternalExpoError()).toEqual({
errorCode: ErrorCode.SERVER_ERROR,
message: 'system message',
docsUrl: 'https://docs.example.dev',
buildPhase: BuildPhase.PREBUILD,
});
});
});
+13
View File
@@ -259,6 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
id: z.string(),
name: z.string(),
}),
app_store_connect: z
.looseObject({
app: z.looseObject({
id: z.string(),
}),
build_upload: z
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
})
.optional(),
})
.optional(),
});
export type StaticWorkflowInterpolationContext = z.infer<
+42 -22
View File
@@ -54,7 +54,10 @@ export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata>
this.buildPhase = details.buildPhase;
}
public format(): ExternalBuildError {
/**
* Serialized error payload used by the orchestrator-worker API.
*/
public toExternalExpoError(): ExternalBuildError {
return {
errorCode: this.errorCode,
message: this.message,
@@ -64,67 +67,84 @@ export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata>
}
}
export class BuildError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
super(message, details);
}
}
export class UserFacingError<
export class UserError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
public errorCode: string,
public message: string,
message: string,
options?: {
trackingCode?: string;
docsUrl?: string;
buildPhase?: BuildPhase;
metadata?: TMetadata;
cause?: unknown;
}
) {
super(message, {
errorCode,
trackingCode: options?.trackingCode,
docsUrl: options?.docsUrl,
buildPhase: options?.buildPhase,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
export class UnknownError extends UserFacingError {
export class SystemError<
TMetadata extends ErrorMetadata = ErrorMetadata,
> extends ExpoError<TMetadata> {
constructor(
message: string,
options?: {
trackingCode?: string;
docsUrl?: string;
buildPhase?: BuildPhase;
metadata?: TMetadata;
cause?: unknown;
}
) {
super(message, {
errorCode: ErrorCode.SERVER_ERROR,
trackingCode: options?.trackingCode,
docsUrl: options?.docsUrl,
buildPhase: options?.buildPhase,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
export class UnknownError extends UserError {
constructor(buildPhase?: BuildPhase) {
super(
ErrorCode.UNKNOWN_ERROR,
buildPhase
? `Unknown error. See logs of the ${buildPhaseDisplayName[buildPhase]} build phase for more information.`
: 'Unknown error. See logs for more information.'
: 'Unknown error. See logs for more information.',
{ buildPhase }
);
}
}
export class UnknownBuildError extends BuildError {
export class UnknownBuildError extends UserError {
constructor() {
const errorCode = ErrorCode.UNKNOWN_ERROR;
const message = 'Unknown error. See logs for more information.';
super(message, {
errorCode,
});
super(errorCode, message);
}
}
export class UnknownCustomBuildError extends BuildError {
export class UnknownCustomBuildError extends UserError {
constructor() {
const errorCode = ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR;
const message = 'Unknown custom build error. See logs for more information.';
super(message, {
errorCode,
});
super(errorCode, message);
}
}
export class CredentialsDistCertMismatchError extends UserFacingError {
export class CredentialsDistCertMismatchError extends UserError {
constructor(message: string) {
super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message);
}
+1
View File
@@ -20,6 +20,7 @@ export {
export { Metadata, sanitizeMetadata } from './metadata';
export * from './job';
export * from './logs';
export * from './errors';
export * as errors from './errors';
export * from './artifacts';
export * from './context';
+98 -95
View File
@@ -178,7 +178,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +195,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +217,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +234,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +248,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +300,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +321,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +341,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +361,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +384,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -422,7 +422,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -442,7 +442,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -460,7 +460,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +480,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -488,19 +488,20 @@ run dev client simulator/emulator build with matching fingerprint or create a ne
```
USAGE
$ eas build:dev [-p ios|android] [-e PROFILE_NAME]
$ eas build:dev [-p ios|android] [-e PROFILE_NAME] [--skip-build-if-not-found]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
emulator/simulator internal distribution dev client builds. The "development-simulator"
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. It must be a profile allowing to create
emulator/simulator internal distribution dev client builds. The "development-simulator"
build profile will be selected by default.
-p, --platform=<option> <options: ios|android>
--skip-build-if-not-found Skip build if no successful build with matching fingerprint is found.
DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -521,7 +522,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -559,7 +560,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -599,7 +600,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -629,7 +630,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -655,7 +656,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -707,7 +708,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -725,7 +726,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -743,7 +744,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -760,7 +761,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -781,7 +782,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -802,7 +803,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -824,7 +825,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -844,7 +845,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -866,7 +867,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -888,7 +889,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -923,7 +924,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -946,7 +947,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -967,7 +968,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -984,7 +985,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1002,7 +1003,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1031,7 +1032,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1056,7 +1057,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1077,7 +1078,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1098,7 +1099,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1135,7 +1136,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1155,7 +1156,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1176,7 +1177,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1197,7 +1198,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1211,7 +1212,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1225,7 +1226,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1257,7 +1258,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1283,7 +1284,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1305,7 +1306,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1333,7 +1334,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1361,7 +1362,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1384,7 +1385,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1406,7 +1407,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1440,7 +1441,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1484,7 +1485,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1515,7 +1516,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1624,7 +1625,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1632,16 +1633,17 @@ generate the local store configuration from the app stores
```
USAGE
$ eas metadata:pull [-e <value>]
$ eas metadata:pull [-e <value>] [--non-interactive]
FLAGS
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1649,16 +1651,17 @@ sync the local store configuration to the app stores
```
USAGE
$ eas metadata:push [-e <value>]
$ eas metadata:push [-e <value>] [--non-interactive]
FLAGS
-e, --profile=<value> Name of the submit profile from eas.json. Defaults to "production" if defined in eas.json.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1710,7 +1713,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1724,7 +1727,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1747,7 +1750,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1771,7 +1774,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1789,7 +1792,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1822,7 +1825,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1865,7 +1868,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1886,7 +1889,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1907,7 +1910,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1932,7 +1935,7 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:list`
@@ -1959,7 +1962,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1997,7 +2000,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2025,7 +2028,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2055,7 +2058,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2076,7 +2079,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2096,7 +2099,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2117,7 +2120,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2139,7 +2142,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2159,7 +2162,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2178,7 +2181,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2201,7 +2204,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2218,7 +2221,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2339,7 +2342,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2359,7 +2362,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2383,7 +2386,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2423,7 +2426,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2445,7 +2448,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2473,7 +2476,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2493,7 +2496,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2515,5 +2518,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.2.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.5.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+3067 -359
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more