Compare commits

...
Author SHA1 Message Date
AbbanMustafa fef31f74e5 fold into existing restore/save_build_cache 2026-03-18 01:16:41 -05:00
AbbanMustafa aa77ef6485 cleanup 2026-03-17 01:55:34 -05:00
AbbanMustafa c5a12d9c26 snapshots 2026-03-17 01:51:06 -05:00
AbbanMustafa 8a9bc57e28 cleanup 2026-03-17 01:39:01 -05:00
AbbanMustafa 94bf1ca46f cleanup 2026-03-17 01:31:28 -05:00
AbbanMustafa 8671d262f7 archive & simulator build support 2026-03-16 17:00:23 -05:00
AbbanMustafa 9aeaba46eb derived_data_path to the archive Gymfile template 2026-03-12 00:44:02 -05:00
AbbanMustafa d68a7fa34a proper path 2026-03-12 00:26:32 -05:00
AbbanMustafa b6450b28b6 proper path 2026-03-12 00:16:28 -05:00
AbbanMustafa 6090749291 run build phases 2026-03-11 19:19:49 -05:00
AbbanMustafa 10c148a359 derived data cache 2026-03-10 14:13:47 -05: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
Expo CI 5f400d7766 v18.2.0 2026-03-09 22:17:42 +00:00
Hassan Khan 4338aea702 feat(cli): add flag to prevent uploading server sourcemaps (#3478) 2026-03-09 18:45:07 +00:00
Stanisław Chmiela ecf42e68f3 [eas-build-job] Add configure Android version build phase (#3483)
# Why

Preparation for https://github.com/expo/eas-cli/pull/3483.

# How

- add `BuildPhase.CONFIGURE_ANDROID_VERSION`
- add display metadata for the new phase
- add website ID metadata for the new phase

# Test Plan

CI should pass.
2026-03-09 16:25:01 +01:00
Stanisław Chmiela f23579fa32 [eas-cli] Upgrade eas-cli to oclif v4 (#3472)
## Why

Old task I asked Codex to do.

## How

- upgrade `eas-cli` from oclif v3 to v4 using the native v4 APIs
- migrate legacy array-style `static args = [...]` declarations to native v4 object-style `Args.*` definitions
- keep command typing fully native and type-safe, including `Flags.option(...).options` const assertions where needed for inference
- update tests and shared test helpers for the v4 config/hook behavior

Notes:
- argument order is preserved in oclif v4 because `static args = { ... }` is treated as an ordered object; positional args follow object insertion order
- the bin entrypoints now use `@oclif/core` `execute(...)`, which is the supported v4 boot path
- `execute(...)` already performs the old `run().then(flush).catch(handle)` flow internally, so the explicit `flush` and `handle` chaining was removed as redundant rather than as a behavior change
- `bin/dev` keeps the dev entrypoint behavior through `execute({ development: true, ... })`, which enables the development boot path and verbose debugging behavior in v4

## Test plan

- `./node_modules/.bin/tsc --project packages/eas-cli/tsconfig.build.json`
- `node packages/eas-cli/bin/run --help`
- `node packages/eas-cli/bin/dev --help`
- `npx -y @yarnpkg/[email protected].0 workspace eas-cli test --runInBand`
2026-03-09 11:18:15 +01:00
Stanisław Chmiela afdc626fcb [build-tools] Introduce ExpoError as the shared build error base (#3470)
## Summary
- add `ExpoError` as the shared base for build-related errors
- centralize shared metadata and formatting there
- keep runtime behavior unchanged

## Changes
- introduce `ExpoError` with `errorCode`, `trackingCode`, `docsUrl`, `cause`, and `buildPhase`
- make `BuildError` and `UserFacingError` extend `ExpoError`
- add unit coverage for the shared base behavior

## 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-09 10:57:56 +01:00
Evan BaconandClaude Opus 4.5 f9f4583a39 [eas-cli] Auto-generate Android keystore in non-interactive mode (#3477)
Instead of throwing an error when running `eas build -p android --non-interactive`
without existing credentials, automatically generate a keystore using the existing
`generateRandomKeystoreAsync()` function.

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-03-07 13:11:03 -08:00
Krystof WoldrichandClaude Opus 4.6 2d955440ca feat(cli): enable --non-interactive when --json (#3476)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-07 11:45:07 +01:00
Mustafa Abban 642bb6fa4d [build-tools] Remove cache support check in restoreCache for build jobs (#3475) 2026-03-06 14:42:30 -06:00
Douglas Lowder fb1f2d2add [cli] CI check and environment flag override for update (#3442)
# Why

Provide a way to override the new requirement for the `--environment` flag in `eas update`.

# How

Modify the check to skip when the `EAS_UPDATE_SKIP_ENVIRONMENT_CHECK` variable is set to a boolish value (1 or true).

If the command is run interactively, we now prompt the user for the environment, instead of throwing an error. This is skipped if the `--auto` flag is set. A missing environment flag already throws an error if `--non-interactive` is set.

Add the `CI` flag to the tests for whether we should skip the check in a CI environment.

# Test Plan

New unit tests added.
2026-03-06 06:55:52 -08:00
Stanisław Chmiela dbcca53f1d [worker] Add HttpLogStream (#3391)
<!-- 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

We want to push logs to a logs service.

# How

Initially I wanted to set up a WebSocket connection between worker and the logs service to reduce the HTTP overhead, but I found having a persistent/reconnecting WebSocket connection is not trivial and HTTP should be ok.

Confirmed failure to upload is not logged in the GCS log.

# Test Plan

Tested manually.


https://github.com/user-attachments/assets/f80f0482-73f5-4dfb-855f-2361ed04aa4f
2026-03-06 14:43:58 +01:00
Stanisław Chmiela 4a0dc9f426 [worker] Remove duplicated user-facing fields from build errors (#3469)
## Summary
- remove the duplicated `userFacingMessage` and `userFacingErrorCode` fields from the build error flow
- keep current runtime behavior and unknown-error semantics
- preserve the current worker protocol shape

Stanley: I don't want to have separate `userFacingMessage` and `message` / `userFacingErrorCode` and `errorCode`. It looks like the reason we had them separate was to log "UNKNOWN_ERROR" to the user if we encounter an internal issue like "NPM_CORRUPTED_PACKAGE". This pull request inverts the logic — we're going to have a `trackingCode` so we keep logging "NPM_CORRUPTED_PACKAGE" to Sentry and the default `errorCode` is the one we log to the user.

## Changes
- make `BuildError.format()` use canonical `message` and `errorCode`
- make `build-tools` store internal-only classification in `trackingCode` instead of parallel public fields
- make worker derive `internalErrorCode` from `trackingCode ?? errorCode`
- delete the obsolete `userFacing...` fields from `BuildError`

## Testing
- `packages/eas-build-job`: `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-06 14:11:10 +01:00
Stanisław Chmiela 146656c7f7 [build-tools] Handle ASC 90478 as closed version train (#3460)
## Why
`submit_ios_from_linux` UNKNOWN_ERROR analysis showed App Store Connect responses that clearly indicate the same version-train failure class but with mixed codes (`90062`, `90186`, `90478`).

Current handling recognizes only `90062` and `90186`, so runs that include `90478` fall back to `UNKNOWN_ERROR`.

## How
- extend `isClosedVersionTrainError` to include ASC code `90478` while keeping strict `.every` matching
- broaden `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` user-facing message to cover both closed train and lower-than-approved version cases
- update unit test to include `90478` in positive closed-train detection

## Examples
Existing `EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN` runs:
- https://expo.dev/uuid/019cbe83-ac3b-756f-b7ef-447bae5ef4dc
- https://expo.dev/uuid/019cbe66-4f9b-7d95-a35e-06886b646434

Would-be closed-train runs currently persisted as `UNKNOWN_ERROR` (log errors include `90478` with `90062`/`90186`):
- https://expo.dev/uuid/019cbe71-62fa-7fee-9e77-bdc051cc6e8b
- https://expo.dev/uuid/019cbe44-5c75-7211-af66-35fe96a86903

## 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-05 16:29:48 +01:00
Phil Pluckthun e37411040f [build-tools] Add --include=dev for npm install and --production false for Yarn classic (#3459)
* Add missing `--include=dev` for regular npm install

* Override NODE_ENV globally for yarn

* Add `--production false` for Yarn Classic and remove NODE_ENV change

* Add CHANGELOG entry
2026-03-05 14:39:03 +00:00
Mustafa Abban b71226d90f [worker] Handle non-OK HTTP responses before JSON parsing in upload session (#3457) 2026-03-04 15:35:09 -06:00
Stanisław Chmiela 9a9fe11f73 [build-tools] Handle ASC missing purpose strings as user-facing error (#3456)
## Why
Uploads to App Store Connect can fail with code `90683` when required `*UsageDescription` privacy keys are missing in `Info.plist`.

Without dedicated handling this ends up as a generic upload failure, even though ASC already returns structured details that can guide the user.

## How
- add ASC error recognition for `90683` in `uploadToAsc` failed-state handling
- throw a specific `UserFacingError` with code `EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING`
- parse ASC error descriptions to extract and list missing `*UsageDescription` keys
- provide concrete remediation for both CNG (`ios.infoPlist`) and non-CNG (`Info.plist`) workflows
- set `docsUrl` on the user-facing error to Expo permissions docs
- add unit tests for detection and missing-key parsing helpers

## 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-04 21:27:59 +01:00
Stanisław Chmiela 1b62b6feae [build-tools] Add ASC invalid bundle ID user-facing error (#3455)
## Why
Some App Store Connect uploads currently end up as generic failures even when ASC returns explicit bundle identifier mismatch validation ([`90054`](https://expo.dev/accounts/dobrokruh/projects/dobrokruh/job-runs/019cb865-ef97-71d1-9137-f6ae4c05d94d)/[`90055`](https://expo.dev/accounts/central-studio/projects/hfc-fuels/job-runs/019cb882-1550-7801-9b4b-9647e1bce364)). We want a user-facing error that explains the mismatch and shows both identifiers so users can quickly determine whether they selected the wrong app or the wrong build profile/build.

## How
- add `isInvalidBundleIdentifierError` detector in `uploadToAsc` for ASC error codes `90054`/`90055`
- when matched, throw `UserFacingError` with code `EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID`
- include both identifiers in message:
  - IPA bundle identifier (best-effort read via `readIpaInfoAsync`)
  - selected ASC app bundle identifier
- provide remediation covering both paths:
  - wrong selected app identifier in submit profile
  - wrong build selected / rebuild with different profile
- use `asyncResult` for best-effort IPA introspection (no nested catch needed)
- add unit tests for invalid bundle-id detector

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`
- `yarn --cwd packages/build-tools typecheck`
2026-03-04 13:58:10 +01:00
Stanisław Chmiela ebb215db1a [build-tools] Remove -z from nc (#3454)
<!-- 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

Older (default) Android system image package does not support `-z`.

```
  Forward stdin/stdout to a file or network connection.

  -4	Force IPv4
  -6	Force IPv6
  -L	Listen for multiple incoming connections (server mode)
  -U	Use a UNIX domain socket
  -W	SECONDS timeout for more data on an idle connection
  -f	Use FILENAME (ala /dev/ttyS0) instead of network
  -l	Listen for one incoming connection
  -p	Local port number
  -q	Quit SECONDS after EOF on stdin, even if stdout hasn't closed yet
  -s	Local source address
  -t	Allocate tty (must come before -l or -L)
  -u	Use UDP
  -w	SECONDS timeout to establish connection
```

# How

Replaced `-z` with a closed stdin.

# Test Plan

System tests should work.

Deployed worker from this branch at https://github.com/expo/eas-cli/actions/runs/22666833774/job/65700594158.

Confirmed works at https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/workflows/019cb88e-f710-74d3-87ba-3c17bfe45c00.
2026-03-04 12:31:52 +01:00
Stanisław Chmiela cdf49377cd [build-tools] Replace ping with nc for Android network readiness check (#3453)
<!-- 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

`ping` is blocked on Linux VMs. https://expo.dev/accounts/layerztec/projects/layerzwallet/workflows/019cb835-cf4a-7b9f-af32-db474797eb85

# How

Replaced `ping` check with `nc`.

# Test Plan

Tested manually on staging that it works. https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019cb862-4ea6-7966-9148-5cb8dbfb1724
2026-03-04 10:50:17 +00:00
Stanisław Chmiela 8fb84e86bb [build-tools] Harden Android emulator startup readiness and retries (#3450)
# Why

Android emulators started for workflow/Maestro runs can report boot completion while still lacking functional networking. This makes Maestro tests more flaky.

# How

- Extend `AndroidEmulatorUtils.waitForReadyAsync` to require both:
  - `sys.boot_completed == 1`
  - successful guest-side network probe (`ping 1.1.1.1`)
- Add reusable `AndroidEmulatorUtils.stopAsync` and compose `deleteAsync` from stop+delete logic.
- Add startup retries with escalating readiness timeouts (60s, 120s, 180s) in:
  - `eas/start_android_emulator` base emulator startup
  - `eas/start_android_emulator` clone startup path
  - Android startup path inside `eas/__maestro_test` (`withCleanDeviceAsync`)
- Keep retries at 3 total attempts (initial + 2 retries).

# Test Plan

Tested with integration tests.
2026-03-04 10:18:43 +01:00
Stanisław ChmielaandCopilot d660e3602d [worker] Preserve UserFacingError instead of UNKNOWN_ERROR (#3445)
## Why
Generic/custom workflow steps can throw `UserFacingError`, but worker-level error handling currently maps any non-`BuildError` to `UnknownBuildError` / `UnknownCustomBuildError`. That causes actionable step errors to be persisted and exposed as `UNKNOWN_ERROR`.

## How
- add a `toBuildError` conversion in worker service catch path
- preserve existing `BuildError` values
- convert `UserFacingError` into `BuildError` with the same code/message/docsUrl
- keep unknown fallbacks only for truly unstructured errors
- add integration coverage in `stateSync` to assert generic job `UserFacingError` is exposed in `externalBuildError` and `internalErrorCode`

We should make a bigger refactor to error handling, but it's more involved — https://github.com/expo/eas-cli/pull/3447.

## Test Plan

CI should pass.


```
 PASS  src/__integration__/stateSync.test.ts
  State sync mechanism
    query new worker
      ✓ should return 'new' state (210 ms)
    query worker after dispatch
      ✓ should return 'in-progress' state (170 ms)
    query worker after build
      ✓ should return 'success' state (2062 ms)
    query worker after error
      ✓ should return 'error' state with artifacts from error (88 ms)
      ✓ should expose user-facing error from generic job (60 ms)
```

Co-authored-by: Copilot <[email protected]>
2026-03-04 09:38:36 +01:00
Expo CI 5b9d47c02b update CHANGELOG.md 2026-03-04 08:36:00 +00:00
Expo CI 61b601de88 v18.1.0 2026-03-04 08:33:39 +00:00
Byron KarlenandClaude Sonnet 4.6 c5867c8c07 [eas-cli] Fix login spinner interfering with prompts in eas go command (#3451)
https://github.com/user-attachments/assets/37835e4d-fca0-48a3-87e7-8c26e0c80a32



## Summary

- When running `eas go` while not logged in, an `ora` spinner ("Logging in to Expo...") was started before `getContextAsync` was called
- `getContextAsync` triggers interactive login prompts when unauthenticated, but the active spinner animation conflicts with readline input display, causing garbled/invisible prompts
- Fix: remove the spinner before `getContextAsync` and replace with `Log.withTick(...)` after it completes

## Test plan

- [ ] Run `eas go` while not logged in and verify login prompts are visible and interactive
- [ ] Run `eas go` while already logged in and verify "Logged in as ..." tick appears as expected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-04 09:25:12 +01:00
Stanisław Chmiela f2d5c210a7 [eas-cli] Enable merge when parsing workflow YAML (#3435)
## Summary

https://exponent-internal.slack.com/archives/C02VC9BLMQW/p1772224345536829?thread_ts=1763747642.435979&cid=C02VC9BLMQW

I think it'll be nice to add support for aliases and YAML merge. We already "support" it if the user specifies YAML 1.1 preamble.

## Test plan

Added unit tests. CI should pass. Ran `workflow:run shell.yml` with https://staging.expo.dev/accounts/expo-services/projects/workflows-testing/workflows/019cb7ed-5765-720e-8c2d-ee047af84933.
2026-03-04 09:19:12 +01:00
Stanisław Chmiela 6e70f7ee4b [build-tools] Improve ASC build upload error handling and diagnostics (#3444)
## Why
App Store Connect build upload failures were often surfaced as generic errors, which made remediation unclear for common cases like duplicate build numbers. We also needed stricter and safer response parsing so JSON parsing failures don’t leak raw parse errors.

## How
- Added typed `AscApiRequestError` in `AscApiClient` for structured ASC error payloads only.
- Kept non-structured ASC failures as regular `Error`.
- Hardened response parsing in `AscApiClient`:
  - parse response text with `asyncResult` + `JSON.parse`,
  - convert invalid success JSON into a controlled `Malformed JSON response...` error.
- Added `AscApiUtils.createBuildUploadAsync` and switched `uploadToAsc` to call it.
- Added duplicate build-number recognition for `/v1/buildUploads` failures when all ASC error codes are `ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE`.
- Added specific `UserFacingError` (`EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE`) with concrete remediation:
  - increment `ios.buildNumber` (or set `"autoIncrement": true`),
  - rebuild and resubmit,
  - include docs URL.
- Inlined closed-train constants in `uploadToAsc` as requested.

## Test Plan
- `yarn --cwd packages/build-tools jest-unit src/steps/utils/ios/__tests__/AscApiClient.test.ts`
- `yarn --cwd packages/build-tools jest-unit src/steps/utils/ios/__tests__/AscApiUtils.test.ts`
- `yarn --cwd packages/build-tools jest-unit src/steps/functions/__tests__/uploadToAsc.test.ts`

All commands above pass on this branch.
2026-03-03 14:18:34 +01:00
Douglas Lowder 1d36f4f636 [cli] Remove environment requirement for update:configure (#3440)
# Why

This adjusts the change in #3418 that added a requirement for the `--environment` flag in `eas update` for SDK 55 and greater.

The requirement should not have applied to the `update:configure` subcommand. This PR removes the requirement there.

# How

Remove the check in `configure.ts`, and adjust flag descriptions.

# Test Plan

Existing tests should pass.
2026-02-28 11:36:58 -08:00
Expo CI f8deba78a5 update CHANGELOG.md 2026-02-27 22:36:57 +00:00
Expo CI df8ebc8f84 v18.0.6 2026-02-27 22:34:25 +00:00
Tomek Czajecki 62ab329f39 [eas-cli] Fix invisible credential prompts in eas go command (#3429) 2026-02-25 19:22:47 +02:00
Ash Wu 198b796804 [build-tools] Wrap parseMaestroResults in try-catch to prevent step failure (#3433)
Signed-off-by: Ash Wu <[email protected]>
2026-02-25 21:32:18 +08:00
Tomek Czajecki 110055d943 Add time estimates to go command spinners (#3428)
* [eas-cli] Add time estimates to go command spinners

* Fix formatting
2026-02-25 12:27:36 +02:00
Tomek Czajecki 06e2ba5bdb Update eas go fyi link (#3430) 2026-02-25 12:27:11 +02:00
Ash Wu 15690e7f6b [build-tools] Add eas/report_maestro_test_results build function (#3388)
Signed-off-by: Ash Wu <[email protected]>
2026-02-25 17:31:18 +08:00
Expo CI 134e74f50b update CHANGELOG.md 2026-02-25 00:42:47 +00:00
Expo CI 4b3dcda99d v18.0.5 2026-02-25 00:40:34 +00:00
Douglas Lowder 0c32aa3a39 [release] Work around the removal of yarn --silent (#3424)
# Why

When yarn moved to version 4, they removed the `--silent` flag. Because of this change, any use of yarn to call scripts and get output will also get yarn debugging garbage, and there is no way to suppress it.

The release scripts are mostly ok with this, except for calling the `next-version` script. The output of this script has to be clean for it to be used as input for `lerna version`.

# How

Replaced the yarn call with a direct invocation of the node Typescript source, bypassing yarn.

# Test Plan

Since the `trigger-release.yml` workflow supports a dry run mode, it's safe to modify it to check out a test branch and run the workflow many times to debug the issue.

Once this change is merged, a dry run invocation of the workflow should succeed even if no version string is passed in.
2026-02-24 03:37:51 -08:00
Tomek Czajecki 59ac8ae77a [eas-cli] Add go command (#3376) 2026-02-23 15:23:22 -08:00
Douglas Lowder 3e13df470e [eas-cli] Require --environment flag for eas update when SDK >= 55 (#3418)
# Why

Problems can occur if an update intended for a development build is published with the environment defaulting to or set to production, and vice versa.

To prevent such problems in the future, this PR makes the `--environment` flag a required flag for projects using SDK 55 and greater.

# How

Add a new utility function to throw an error if sdkVersion does not meet the requirement, and use that function in `eas update:configure` and `eas update`.

Skip the check if EAS_BUILD env is set.

# Test Plan

Unit tests added.

Testing locally with different projects.
2026-02-23 13:48:35 -08:00
Expo CI 83877f6285 update CHANGELOG.md 2026-02-23 20:47:53 +00:00
Expo CI 56018338e7 v18.0.4 2026-02-23 20:45:25 +00:00
Byron Karlen 0fdf2dd44a Revert "Use auth code flow (#3398)" (#3422)
This reverts commit 1604b7bc20.
2026-02-23 12:30:47 -08:00
Expo CI 0dcd6d465a update CHANGELOG.md 2026-02-20 08:02:18 +00:00
Expo CI d5da4f4167 v18.0.3 2026-02-20 07:59:25 +00:00
Stanisław Chmiela d0b9281bb2 [chore] Fix Yarn v4 prepack commands (#3417)
## Why
Release publish failed in `Publish to npm` for `v18.0.2`.

- `packages/eas-cli/scripts/prepack.js` invoked `yarn oclif manifest`, which fails on Yarn v4 (`Couldn't find a script named \"oclif\"`).
- `packages/create-eas-build-function` `clean` script used an unquoted `*.tsbuildinfo` glob, which fails when no files match.

## How
- Update `packages/eas-cli/scripts/prepack.js` to run `yarn run -T oclif manifest`.
- Quote the tsbuildinfo glob in `packages/create-eas-build-function/package.json`:
  - `rimraf ./build/ "*.tsbuildinfo"`

## Test plan
- `cd packages/eas-cli && mise exec [email protected].4 -- node ./scripts/prepack.js`
- `cd packages/create-eas-build-function && mise exec [email protected].4 -- yarn run clean`
- CI
2026-02-20 08:48:21 +01:00
Expo CI 77e04a3bad v18.0.2 2026-02-19 12:34:59 +00:00
Stanisław Chmiela 92ff6a0da5 [eas-build-job] Add hooks schema (#3408)
## Summary
- introduce a shared `Hooks` type/schema with reusable Zod/Joi validation so any string key maps to step arrays
- wire the new hooks definition through the generic, Android, and iOS job schemas and exports
- expand existing tests to cover accepting arbitrary hook names, empty hook arrays, and invalid hook step payloads

Not sure if it makes sense to make this "smart". I think let's just roll this out manually? Like, have `eas/install_node_modules` steps be sort of manually wrapped with hooks? Or maybe we'll sprinkle them when parsing the steps. I'll see. What I think is right is _not_ to limit the keys to only a few known values.

## Test plan

CI should pass.
2026-02-19 12:07:07 +01:00
Stanisław Chmiela 25d945b63f [worker] Move Yarn version from Mise to Corepack (#3412)
## Why

In https://github.com/expo/eas-cli/pull/3406 EAS workflow building the worker was **not** updated to Yarn v4. This breaks building worker.

I tried adding `defaults.tools.yarn: 4.12.0`, but this unfortunately [is **not** the way to use Yarn v4 on EAS as I've learned](https://docs.expo.dev/more/create-expo/#yarn-2-modern). The right way is using Corepack.

However, previously we explicitly did **not** use Corepack, because `pnpm --version` check the worker does in the beginning of its run was failing because it was run without `cwd`, so it was running within worker package which was configured with Yarn.

Since we want to use Yarn v4, we need to figure out a way to use Corepack.

## How

- Enabled Corepack.
- Set up `yarn` executable like the one we have in `universe`.
- Changed `pnpm --version` to **not** run inside worker (so it does not error).
- Removed `setup-node` for specific Node versions in favor of a Mise override.

## Test plan
- Corepack + Yarn resolution checks (local):
  - `mise exec [email protected].4 -- bash -lc 'corepack enable; yarn --version'` => `4.12.0`
  - same check from `scripts/` => `4.12.0`
- Worker runtime unit test (local):
  - `mise exec [email protected].4 -- bash -lc 'corepack enable; yarn workspace @expo/worker test:unit -- runtimeEnvironment.test.ts'`
- GitHub workflow runs:
  - success on Corepack worker workflow update: https://github.com/expo/eas-cli/actions/runs/22133707462
  - latest run for full set of changes: https://github.com/expo/eas-cli/actions/runs/22133932635
2026-02-19 10:17:57 +01:00
Stanisław Chmiela 37165428d1 [chore] Upgrade repository to Yarn 4 with Mise (#3406)
## Why
- Repository maintenance: standardize on Yarn 4 managed via Mise.
- Prepare for introducing minimum package-age constraints in dependency policy.

## How
- Migrated repo tooling to Yarn 4 with Mise.
- Updated CI and scripts for Yarn 4 behavior:
  - Lerna invoked via `yarn run -T lerna`.
  - Coverage args forwarded correctly (`-- --coverage`).
  - Removed unsupported Yarn 4 `--silent` usage.
- Added missing package-local script dependencies surfaced by Yarn 4 (`jest`, `oxlint`, `rimraf`).
- Increased `EasCommand` coverage test timeout from 30s to 60s. (Was consistently failing in CI.)

## Test Plan
- CI only: `Run tests` matrix (including Node 20 coverage) and other touched workflows.
- Release behavior will be verified during the normal release run.
2026-02-18 09:50:28 +01:00
Stanisław Chmiela 50b9be975e [build-tools] Add IPA info reader build function (#3405)
# Why

We need a build function that can read metadata directly from an IPA so later steps (for example App Store Connect upload flows) can consume canonical bundle values without requiring manual input.

# How

- Added a new build function `eas/read_ipa_info` in `packages/build-tools/src/steps/functions/readIpaInfo.ts`.
- The function accepts `ipa_path` and outputs:
  - `bundle_identifier`
  - `bundle_short_version`
  - `bundle_version`
- IPA inspection reads `Payload/*.app/Info.plist` from the zip and supports both plist formats commonly seen in IPAs:
  - binary plist (`bplist00`) via `bplist-parser`
  - XML plist via `plist`
- Added `UserFacingError` handling for parsing failures/invalid binary plist.
- Added the function to the EAS function registry in `packages/build-tools/src/steps/easFunctions.ts`.
- Added fixture-based tests using `SmallestAppExample.ipa` under `packages/build-tools/src/steps/functions/__tests__/fixtures/`.
- Added `node-stream-zip` and pinned `bplist-parser` to `0.3.2`.

# Test Plan

- Automated tests:
  - `yarn --cwd packages/build-tools test src/steps/functions/__tests__/readIpaInfo.test.ts`
- Typecheck:
  - `yarn --cwd packages/build-tools typecheck`

Coverage in `readIpaInfo.test.ts`:
- reads bundle identifier/short version/version from the fixture IPA
- fails when `Info.plist` is missing
- fails when required plist keys are missing
- verifies build-function outputs are populated correctly
2026-02-17 16:24:45 +01:00
Stanisław Chmiela 2655482f74 [worker] Logs improvements (#3397)
<!-- 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

For the new logs experience we need to identify every log with a unique ID.

Also, in the new logs experience we're going to be handling logs in plaintext, uncompressed, so size starts to matter.

# How

Decorating every log with a unique ID is surprisingly difficult with bunyan. It only runs serializers on the object passed in, I tried adding a `log_id` serializer `serializers: { log_id: () => uuidv7() }` and a non-empty value to build logger, but it was getting serialized in the moment of logger creation.

Instead, I'm adding `log_id` in the `Transform` that also masks secrets. The overall architecture looks like this now:

<img width="1742" height="1126" alt="Bez tytułu-2026-02-04-1436-3" src="https://github.com/user-attachments/assets/956e1b58-75ca-41c5-92ba-8229dd981ff6" />

Also removes `service: 'worker'` and `hostname` from logs. `service` was only being used [here](https://github.com/expo/universe/blob/3246198a3f913bf6fd0fd82e8e3df402cb59e82e/server/website/common/logs-utils.ts#L218) which I don't think is a thing right now.

# Test Plan

Added unit test that ensures `Transform` adds `logId`.

<img width="1257" height="1102" alt="Zrzut ekranu 2026-02-12 o 16 02 32" src="https://github.com/user-attachments/assets/4cfbbf43-744f-4af0-9faf-de0cab54f178" />
2026-02-16 09:31:09 +01:00
Stanisław Chmiela dc103acf06 [build-tools] Increase ASC build processing wait timeout (#3403)
## Summary
- extend the `upload_to_asc` build status polling window from 10 minutes to 30 minutes so slowly processed builds have time to reach TestFlight
- keep the rest of the polling logic and `wait_for_processing` default untouched
2026-02-16 09:02:00 +01:00
Mustafa Abban b87bc5bf8f [worker] unknown error log std error/output (#3402)
* [worker] unknown error log std error/output

* [worker] unknown error log std error/output

* check token
2026-02-13 14:19:04 -05:00
Byron Karlen 1604b7bc20 Use auth code flow (#3398) 2026-02-12 17:24:19 -08:00
Stanisław Chmiela 13a6356fca [chore] Fix post-release lint (#3396)
<!-- 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 a new release is cut, `lerna.json` and `README.md` want to be reformatted by Oxfmt.

# How

Excluded them from being formatted.

# Test Plan

`yarn fmt` changes no files.
2026-02-12 15:59:27 +01:00
Expo CI 0823a9d675 update CHANGELOG.md 2026-02-12 11:18:30 +00:00
Expo CI 772d4dd654 v18.0.1 2026-02-12 11:14:44 +00:00
Stanisław Chmiela e8da383de4 [chore] Move to Oxlint (#3384)
<!-- 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

We're moving to Oxc in universe too. The toolset is faster than ESLint / Prettier.

# How

Asked Codex to migrate the repository. I don't think all rules got migrated, unfortunately, but also the lint are in a bit weird state (different rules in `eas-cli` vs the rest of the repo). I hope we can unify the rules soon.

# Test Plan

CI should pass.
2026-02-12 11:44:19 +01:00
Stanisław Chmiela d08f9cfdcb [chore] Remove .tsbuildinfo when deleting dist/build (#3394)
<!-- 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

Release failed because in `prepack` in some packages we delete `dist`, but don't delete `.tsbuildinfo` file.

# How

Made Codex fix all the package scripts. I _think_ we also could skip `yarn build` before publishing.

# Test Plan

Try to release and see if it works.
2026-02-12 11:32:37 +01:00
Expo CI ac6569aa58 v18.0.0 2026-02-11 21:21:31 +00:00
Stanisław Chmiela 960ce7dbf5 [infra] Stop preparing tarballs (#3361)
<!-- 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

Our GitHub released executable Oclif bundles of `eas-cli` are… not too popular. v16.28.0 was downloaded 154k times in the last 7 days from NPM. Since its release (2 months ago) it was downloaded from GitHub 82 times.

<img width="1247" height="491" alt="Zrzut ekranu 2026-01-30 o 15 15 35" src="https://github.com/user-attachments/assets/9c5a904a-e00e-43ee-9875-d1cea15b2108" />

# How

Asked Gemini to remove this. This should speed up release process significantly. This release process has been created 5 years ago (https://github.com/expo/eas-cli/pull/54).

Also removed size comparison workflow. It's not going to make comments like https://github.com/expo/eas-cli/pull/3361#issuecomment-3824036144.

Closes https://github.com/expo/eas-cli/pull/3346.

# Test Plan

CI should pass.
2026-02-11 21:13:47 +00:00
Mustafa Abban 029bbdd007 [worker] - log unknown build error logs in Datadog (#3386)
* log unknown build error logs in Datadog

* import order

* tag build phase, error code

* relay to www

* cleanup, stringify
2026-02-10 16:14:44 -05:00
Byron Karlen 7061a6743a fix changelog (#3389) 2026-02-09 16:24:10 -08:00
Byron Karlen 42940b7c4c [eas-cli] Add --browser option to eas login (#3312) 2026-02-06 09:51:37 -08:00
Stanisław Chmiela d29a88eafd [chore] Move all CI to Mise (#3379)
<!-- 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

I noticed I didn't migrate all workflows to Mise (because not all worklfows were using Volta lol).

# How

Codex added a shared Mise action (in other repos we have it set up like this too) and used it in all places where we require Node related to code (there's one workflow which does not use checkout, it remains on `setup-node` too). `test.yml` where we test against different Node versions still uses `setup-node`.

Note: this does downgrade Node in a few workflows from 22 to 20, but if 20 is what we decide to use for our development maybe CI should use it too?

# Test Plan

CI should pass.
2026-02-06 11:58:10 +01:00
Stanisław Chmiela 67087e9471 [chore] Fix pretarball-ci script (#3381)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

When oclif is building tarballs with executables it does so in a directory separate from the monorepo which requires us to change `eas-cli` dependencies from the monorepo to `file`.

# How

Asked Gemini to update the script to include all local dependencies of `eas-cli`.

Or… we can land https://github.com/expo/eas-cli/pull/3361 …

# Test Plan

Release should pass. Validated locally. (Hmm but on CI it failed? Will see.)

Ok, so size failed because it ran on `main` which is broken because it lacks this pull request.

<img width="944" height="1031" alt="Zrzut ekranu 2026-02-5 o 19 05 41" src="https://github.com/user-attachments/assets/1134493c-bbdb-4f08-85d6-07e59fa28750" />
2026-02-06 11:57:57 +01:00
Stanisław Chmiela 96dc437217 [build-tools] Move uploading IPAs to build-tools (#3383)
<!-- 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

I want to iterate on this faster.

# How

Asked Codex to move this to build functions.

# Test Plan

Unit tests were moved too. Land and test on staging.
2026-02-05 21:29:52 +01:00
Expo CI f68cbb48d7 v17.0.0 2026-02-05 14:58:59 +00:00
Stanisław Chmiela 50f1269a9c [eas-cli] Some polish to submit:upload-to-asc command (#3362)
# How

I like these flag names a bit better (AI preferred them too). Also made it so we don't wait for build processing by default.

# Test Plan

Tested manually.
2026-02-05 14:23:30 +01:00
Karol Rzemiński 3bc8a8e3e1 [chore] Upgrade tar to v7 (#3327)
# Why

After fixing [GHSA-8qq5-rm4j-mr97](https://github.com/advisories/GHSA-8qq5-rm4j-mr97) by setting resolution of `tar` to 7.5.3 EAS build started failing
<img width="776" height="144" alt="Zrzut ekranu 2026-01-20 o 20 17 57" src="https://github.com/user-attachments/assets/02f8d82e-6316-43c4-95e2-6434599456fd" />
Link to github issue: https://github.com/expo/eas-cli/issues/3320

# How

`tar` 7.x changed from default exports to named exports for better tree-shaking and ESM/CommonJS hybrid support
EAS CLI code still used the old import pattern which no longer exists

**Solution:**
Updated the TypeScript sources to import tar as a namespace instead of a default export
No functional changes besides fixing the broken tar calls, all call sites still use the same options and behavior as before

Additionally, updated `tar` from version 7.5.3 to 7.5.4 to address CVE-2026-23950

Closes https://github.com/expo/eas-cli/pull/3353, https://github.com/expo/eas-cli/pull/3360, https://github.com/expo/eas-cli/pull/3354.

# Test Plan

Run EAS build with tar 7.5.3 to reproduce
After this changes EAS build uploads tarball successfully
2026-02-05 13:53:32 +01:00
Stanisław Chmiela 510de5de4f [build-tools] Poll lockscreen state and retry unlock until it clears (#3377)
# Why

The Cuttlefish device may remain locked after an initial `KEYCODE_MENU` event which blocks reaching the home screen, so the unlock flow must verify the real lock state instead of blindly sending HOME. See https://exponent-internal.slack.com/archives/C02VC9BLMQW/p1769703880840729?thread_ts=1763747642.435979&cid=C02VC9BLMQW.

# How
- Updated `packages/build-tools/src/steps/functions/startCuttlefishDevice.ts` to poll `adb shell dumpsys window` and check `mDreamingLockscreen` before deciding the device is unlocked.
- Implemented a retry loop that sends `adb shell input keyevent 82` while the lockscreen remains active, waits 1s between attempts, and uses a 30 second deadline for the unlock attempts.
- When unlock is detected the code now sends `adb shell input keyevent 3` to return to the home screen and logs success, and it logs warnings on query failures or on timeout.

# Test Plan

- Ran `yarn typecheck` which completed successfully.
- Ran `yarn lint --fix` which completed successfully.

------
[Codex Task](https://chatgpt.com/codex/tasks/task_e_698374b75b88832890754e697e58e5ea)
2026-02-05 13:20:01 +01:00
Stanisław Chmiela 0deeb469d6 [chore] Use Mise for Yarn too (#3378)
<!-- 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

I knew https://github.com/expo/eas-cli/pull/3366 won't work! ([Failure](https://github.com/expo/eas-cli/actions/runs/21709140672/job/62608616635).)

Using corepack for Yarn breaks EAS Build because when we run `pnpm --version` when setting up the environment, it runs in default cwd which is the worker directory and if worker has `packageManager` different than PNPM, PNPM throws an error. This was originally found in February 2025 by @szdziedzic.

# How

Use Mise for Yarn installation (instead of corepack).

# Test Plan

Merge and see if system tests work.
2026-02-05 13:08:24 +01:00
Stanisław Chmiela 9b91bc37c9 [chore] Migrate from Volta to Mise (#3366)
<!-- 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

I think we migrated from Volta to Mise in… most if not all places?

# How

I think I let Codex do all the work, nothing too difficult. Grepped repo for "volta" leftover afterwards.

One thing I'm not sure of is whether worker is going to work. I remember trying to remove Volta in `turtle-v2` and it wasn't working for some reason? We'll see.

# Test Plan

Compared these changes with https://github.com/expo/universe/commit/acca8169746c5cb4d708add083f77b927341ae4a.
2026-02-05 12:08:12 +01:00
Stanisław Chmiela 952b275c90 [chore] Revamp testing (#3375)
<!-- 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

We moved more packages into this monorepo and the existing test setup didn’t scale well:
- the root Jest config only covered a subset of packages,
- PRs ran heavier “all tests” workflows,
- worker builds ran on PRs even though typecheck + tests were enough.

This PR aligns testing with the dependency graph, reduces unnecessary CI work, and standardizes Jest config handling.

# How

- Switched root testing to Lerna and updated docs.
- CI now runs affected+dependent tests on PRs and all packages on main.
- Coverage runs via Lerna with per-package reports (no merge) and is kept for PRs.
- Worker builds are skipped on PRs; worker workflow triggers include dependency paths on main.
- All Jest configs are now TypeScript with a shared base (`jest/jest.shared.config.ts`) and use `@jest/types` to avoid TS config parsing errors.
- CI stays unit-only (no integration tests).

# Test Plan

Not run locally. CI covers:
- `yarn lerna run test` (affected on PRs, all on main)
- `yarn lerna run test -- --coverage`
2026-02-04 21:34:56 +01:00
Colin MacKenzieandClaude Opus 4.5 9dd75445c3 Don't show build credits warning bar at 100% usage (#3371)
* [eas-cli] Don't show build credits warning bar at 100% usage

The warning bar is meant to alert users when they're approaching their
limit. At 100%, they've already hit it, so the bar is redundant.

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

* Update CHANGELOG for #3371

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

* [eas-cli] Hide progress bar (not warning) at 100% usage

The warning should still show at 100% to inform users they've hit the
limit, but the progress bar is redundant when completely full.

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-03 15:15:17 -06:00
Stanisław Chmiela da6703706a [build-tools] Add repack_package (#3367)
https://github.com/expo/universe/pull/24634

# Test plan

No values defined

<img width="460" height="199" alt="Zrzut ekranu 2026-02-3 o 15 22 43" src="https://github.com/user-attachments/assets/4a45d554-af83-476e-a221-68dc29f84290" />


With overrides

<img width="694" height="356" alt="Zrzut ekranu 2026-02-3 o 15 11 38" src="https://github.com/user-attachments/assets/2ca1fa76-612f-4a3d-896e-67119fb1f676" />
2026-02-03 20:35:08 +01:00
Evan BaconandClaude Opus 4.5 673d8c3712 List available options in non-interactive mode errors (#3359)
* Improve error message for Apple team selection in non-interactive mode

Enhanced the error thrown when selecting an Apple team in non-interactive mode to include a list of available teams and guidance on using the --apple-team-id flag. This provides better context and instructions for users running the CLI in non-interactive environments.

* List available options in non-interactive mode errors across all selection prompts

Non-interactive mode errors now include the available options so agents and
scripts can discover valid values and retry with the correct flag. This applies
to selection prompts for Apple teams, Apple devices, builds, branches, channels,
update groups, runtimes, rollout actions, and environment variable type/visibility/environment.

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

* Fix test expectation and update changelog PR number

Update test for selectUpdateGroupOnBranchAsync to match the new error
message when no update groups are found in non-interactive mode. Update
changelog entry with PR #3359.

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

* Fix prettier formatting

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-03 10:46:30 -08:00
Stanisław Chmiela c6463af3b9 [worker] Copy bin directory too (#3370)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://staging.expo.dev/accounts/expo-services/projects/worker-system-tests/builds/59628168-4fe6-4cef-981a-fb74527c47ed

# How

Added copying of missing `bin`.

# Test Plan

Merge and observe system tests.
2026-02-03 19:42:50 +01:00
Stanisław Chmiela f94dc6bd59 [worker] Copy all local packages required by @expo/worker (#3368)
`package.sh` currently does not copy workspace packages into the temporary build folder which makes worker pull packages from NPM.

Instead, we want to use local versions from workspace.

Added `copy_package` calls for all dependencies of `@expo/worker`. 

It was done in a similar way in `turtle-v2` — https://github.com/expo/turtle-v2/blob/245eaf23492f2dcd9204a4606bb95c109208d29e/src/services/worker/package.sh#L46-L47.

Ran `./package.sh` and confirmed `@expo/eas-build-job` and other packages are symlinked.
2026-02-03 17:06:53 +01:00
Stanisław Chmiela 51926d13db [chore] Update yarn.lock (#3365)
Missed outdated yarn.lock. Weird that `yarn --frozen-lockfile` didn't fail?
2026-02-03 15:59:50 +01:00
Stanisław Chmiela 789cf62bd6 [chore] Add eas-build packages (#3290)
## Consolidate eas-build packages into eas-cli monorepo

This PR completes the migration of packages from the `eas-build` repository into `eas-cli`, consolidating all EAS Build tooling into a single monorepo.

### Background

Previously, the EAS Build libraries lived in a separate `expo/eas-build` repository. This made development and releases more complex, requiring coordination between two repos. By consolidating into `eas-cli`, we can:

- Develop and test changes across CLI and build tools in a single PR
- Simplify the release process
- Reduce dependency version drift between packages

### Migrated Packages

The following packages have been moved from `eas-build`:

| Package | npm Name | License |
|---------|----------|---------|
| `build-tools` | `@expo/build-tools` | BUSL-1.1 |
| `eas-build-job` | `@expo/eas-build-job` | MIT |
| `steps` | `@expo/steps` | BUSL-1.1 |
| `logger` | `@expo/logger` | BUSL-1.1 |
| `downloader` | `@expo/downloader` | BUSL-1.1 |
| `turtle-spawn` | `@expo/turtle-spawn` | BUSL-1.1 |
| `template-file` | `@expo/template-file` | BUSL-1.1 |
| `local-build-plugin` | `eas-cli-local-build-plugin` | BUSL-1.1 |
| `create-eas-build-function` | `create-eas-build-function` | MIT |

### Changes

**Package Configuration:**
- Updated `repository` and `bugs` URLs in all migrated package.json files to point to `expo/eas-cli`
- Updated README repository links
- Aligned dependency versions across `eas-cli`, `eas-json`, and `worker` to use workspace packages

**Documentation:**
- Expanded `CLAUDE.md` with comprehensive documentation covering all packages, architectural patterns, and development workflows
- Added `AGENTS.md` for AI agent guidance

**Licensing:**
- Added `LICENSE-BUSL` for BUSL-1.1 licensed packages
- Added `LICENSE-eas-build-job` for MIT licensed packages

**Build Infrastructure:**
- Added `.gitignore` entries for build artifacts: `dist`, `*.tsbuildinfo` (needed for `tsconfig.json` and `tsconfig.build.json`), vim swap files
- Added `bin/eas-cli-local-build-plugin` symlink for local build testing

**Dependency Cleanup:**
- Removed stale npm registry entries from `yarn.lock` - workspace packages now resolve locally

### Testing

- [x] `yarn install` completes successfully
- [x] `yarn build` builds all packages
- [x] `yarn test` passes
- [x] Worker builds and runs correctly against workspace packages

### Follow-up

After this PR is merged:
- Archive or deprecate the `expo/eas-build` repository
- Update any CI/CD pipelines that referenced the old repo
- Update internal documentation pointing to eas-build
2026-02-03 14:30:15 +01:00
Vojtech Novak a476d44066 [eas-build] warn when creating a production build from Expo Go (#3073) 2026-02-02 08:51:26 +00:00
Stanisław Chmiela d8a87a6195 [chore] Require Node 20+ (#3343)
<!-- 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

Noticed we're not running tests on Node 18 anymore (since https://github.com/expo/eas-cli/commit/6bb09c4e99950a6b46b0b908a93e8fcd9b3002aa). I think we probably want to officially drop support for Node 18 too.

# How

Looked at https://github.com/expo/eas-cli/commit/eb4c5e073d02f38d7f90fbd6a1b2b5491dc40464, pinned Node to a newer version, updated types, adjusted `engines`.

# Test Plan

CI should pass.
2026-01-30 15:09:44 +01:00
Expo CI 846aacd320 update CHANGELOG.md 2026-01-30 10:20:52 +00:00
Expo CI 079aa5dc66 v16.32.0 2026-01-30 10:11:55 +00:00
Stanisław Chmiela fa8980759f [eas-cli] Add submit:upload-to-asc command (#3358)
<!-- 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

We want to move iOS submissions to Linux machines, which requires us to either:
- have Builds generate not only an IPA but also an Xcode asset file for use with Transporter
- reimplement upload ourselves with App Store Connect API.

# How

As much as I fear the unknown (does this support App Clips? do App Clips come in a different package files? what about macOS apps?) I wonder if, by any chance, this would… just work?

Planning to wire this to submissions in some opt-in way and ask for early feedback in Discord.

# Test Plan

Tested manually — uploaded a file as IPA. It obviously failed to process, but at least it uploaded all right.

```
Generating JWT...
Reading App information...
Uploading Build to "turtle-cli-example" (com.expo.turtle.cli.example)...
Creating Build Upload...
Build Upload initialized (ID: 6a3f6a72-d088-4ac8-9cdd-7d1fb83f7c8b). Preparing IPA upload...
IPA Upload initialized (ID: 0374ffc4-c51b-42df-9b2f-48e9a81a62eb). Uploading IPA...
  Uploading chunk 1/1 (offset: 0, length: 359)...
  Chunk 1/1 uploaded.
Committing upload...
Checking upload file status...
File upload (ID: 0374ffc4-c51b-42df-9b2f-48e9a81a62eb) complete!
Checking build upload status...
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)
Waiting for build upload to finish... (status = PROCESSING)


Errors:
- Invalid package. The uploaded package is corrupt. Verify that you have sufficient disk space on your computer and are uploading only one package at a time. If you created the package manually, verify that it‘s a valid ZIP archive file. (90168)

Build upload (ID: 6a3f6a72-d088-4ac8-9cdd-7d1fb83f7c8b) failed.
    Error: submit:upload-to-asc command failed.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```
2026-01-30 11:08:07 +01:00
Stanisław Chmiela 790085ea9e [worker] Bump build-tools (#3357)
https://github.com/expo/eas-build/pull/668
2026-01-29 11:31:46 -05:00
Will Schurman 🦦 9c7ac49f3e [eas-cli] Remove unnecessary method re-declaration (#3352) 2026-01-28 15:48:17 -08:00
Evan Bacon fa244d6410 Add eas account:usage command (#3334)
## Summary

Add a new `eas account:usage` command that displays account usage and billing information for the current billing cycle. The command is hidden for now, until we fully test it in production.

```
USAGE
  $ eas account:usage [ACCOUNT_NAME] [--json --non-interactive]

ARGUMENTS
  ACCOUNT_NAME  Account name to view usage for. If not provided, the account will be selected
                interactively (or defaults to the only account if there is just one)

FLAGS
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.

DESCRIPTION
  view account usage and billing estimates for the current cycle

```

- View EAS Build usage with iOS/Android platform breakdown
- View EAS Update usage (MAU and bandwidth)
- Display billing period dates and progressOptional account name argument
- Display upcoming invoice, similarly to the billing website
- If no account name passed in, and user has more than one account, a selection list is shown

- `--json` flag for JSON output (optimized for agents/automation)
- Supports `--non-interactive`
    - Forces JSON output
    - Requires account name if user has more than one account

## Example Output

```
✔ Usage data loaded for example-customer

Account: example-customer
Plan: Production
Concurrencies: 6 total (iOS: 6, Android: 6)
Billing Period: Jan 9, 2026 - Feb 9, 2026
Days: 18 elapsed / 31 total (14 remaining)

EAS Build
  Builds (plan): 10,000/10,000 builds
  ████████████████████ 100%
  Builds (overage): 781 builds ($1462.00)
  Breakdown by platform/worker:
    Android large: 86 builds
    iOS medium: 695 builds

EAS Update
  Unique Updaters (plan): 50,000/50,000 users
  ████████████████████ 100%
  Unique Updaters (overage): 1,021,888 users ($5109.44)
  Bandwidth (plan): 17.7 TiB/40.0 TiB
  █████████░░░░░░░░░░░ 44%

Billing
  Addons:
    Additional Concurrency: 4

  Upcoming invoice:
    EAS Build - Plan credit: $-100.00
    EAS Build - Build (Android: 64 large builds; iOS: 628 medium builds): $1384.00
    EAS Update - 1,094,102 monthly active users: $3857.30
    EAS Workflows - 5,347 jobs (3,970.4 minutes): $71.46
    1 × Expo Application Services - Production (at $99.00 / month): $99.00
    4 Concurrency × EAS Build - Additional Concurrency (at $50.00 / month): $200.00

  Invoice total: $5511.76

View detailed billing: https://expo.dev/accounts/example-customer/settings/billing)
```

## Test plan

- [x] Build passes (`yarn build`)
- [x] TypeScript type check passes (`yarn typecheck`)
- [x] Lint passes for new files
- [x] Unit tests pass (`yarn test packages/eas-cli/src/commands/account/__tests__/usage-test.ts`)
- [x] Manual testing with real account

Slack thread: https://exponent-internal.slack.com/archives/C017N0N99RA/p1769054123592549?thread_ts=1768439335.822519&cid=C017N0N99RA

Closes ENG-18827
2026-01-27 14:22:56 -08:00
Stanisław Chmiela db8a3770f8 [worker] Set DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS in Xcode 26.0 (#3350)
<!-- 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

> Apple's altool in Xcode 26.0 has a bug where it incorrectly selects the wrong app when multiple apps share a common bundle ID prefix (e.g., `com.example`, `com.example.app1`, `com.example.app2`). This causes builds to be uploaded to the wrong app on App Store Connect.

> See: https://github.com/fastlane/fastlane/issues/29698

https://github.com/expo/universe/pull/23310 + https://exponent-internal.slack.com/archives/C06EFBQK3B7/p1765365928911459

# How

If `xcodebuild -version` matches "Xcode 26.0", we'll add `DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS='--use-old-altool'`. This helps if the code does not re-override `$DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS` further, in which case it should do `DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS="overrides $DELIVER_ALTOOL_ADDITIONAL_UPLOAD_PARAMETERS"` or sth like that.

I hope this fixes Launch.

# Test Plan

Tested manually on my computer

```
> spawnSync("xcodebuild", ["-version"]).stdout.includes("Xcode")
true

> spawnSync("xcodebuild", ["-version"]).stdout.includes("Xcode 26.0")
false

(below did not throw)
> spawnSync("abc")
{
  error: Error: spawnSync abc ENOENT
      at Object.spawnSync (node:internal/child_process:1123:20)
      at spawnSync (node:child_process:877:24)
      at REPL12:1:1
      at ContextifyScript.runInThisContext (node:vm:137:12)
      at REPLServer.defaultEval (node:repl:598:22)
      at bound (node:domain:433:15)
      at REPLServer.runBound [as eval] (node:domain:444:12)
      at REPLServer.onLine (node:repl:927:10)
      at REPLServer.emit (node:events:536:35)
      at REPLServer.emit (node:domain:489:12) {
    errno: -2,
    code: 'ENOENT',
    syscall: 'spawnSync abc',
    path: 'abc',
    spawnargs: []
  },
  status: null,
  signal: null,
  output: null,
  pid: 0,
  stdout: null,
  stderr: null
}
```
2026-01-27 23:08:35 +01:00
Stanisław Chmiela be16b0032f [chore] Bring back tagging as it was before (#3349)
https://github.com/expo/eas-cli/commit/15b690c85763fb8428a4255a9ded7e7e9126d4e8 did not work. Maybe `NODE_AUTH_TOKEN` is a special variable or something.
2026-01-27 17:51:53 +01:00
Expo CI 3ef14b5eb7 v16.31.0 2026-01-27 14:09:37 +00:00
Stanisław Chmiela c777b25e92 Add no-index-store Xcode setting (#3348)
See https://stackoverflow.com/questions/71109718/what-is-the-compiler-index-store-enable-xcode-build-settings-default-value#71710412 and https://github.com/flutter/flutter/issues/37231.
2026-01-27 14:51:28 +01:00
Stanisław Chmiela 15b690c857 [chore] Use NPM_ACCESS_TOKEN in steps moving tags between eas-cli versions (#3347)
We still need `NPM_ACCESS_TOKEN` despite OIDC for publishing. https://github.com/expo/eas-cli/actions/runs/21363225445/job/61513769796

------
[Codex Task](https://chatgpt.com/codex/tasks/task_e_6977d0214f5c832898a83f54ba0a68fd)
2026-01-27 12:25:40 +01:00
Will Schurman 🦦 4d04463714 [eas-cli] Remove commented-out code in eas update command (#3344) 2026-01-26 22:08:30 -08:00
Expo CI 1c31702653 v16.30.0 2026-01-26 15:26:12 +00:00
Stanisław Chmiela b053e0926a [chore] Switch to publishing with OIDC (#3342)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://exponent-internal.slack.com/archives/C017N0N99RA/p1769306804484099

# How

Applied a few of the changes we've done to `eas-build`:
- https://github.com/expo/eas-build/commit/784e37c96300b20e3f812083443f4334a77fc17e
- https://github.com/expo/eas-build/commit/ae9dbea0f994d2c43fdf96a885778168a60fa4f7
- https://github.com/expo/eas-build/commit/ec824f1deddef6b5a1459329a3f9273bf9d48832
- https://github.com/expo/eas-build/commit/54ff4e9017beed4e2f98b0794e47612f8f7f6d19

# Test Plan

Merge and try releasing a new version.
2026-01-26 16:21:18 +01:00
Brent Vatne 6eeb15bdce [eas-cli] Fix --source-maps flag compatibility with expo CLI (#3341) 2026-01-25 17:32:09 -08:00
Brent Vatne 279eb1595c [eas-cli] Fix changelog format 2026-01-25 14:08:27 -08:00
Brent Vatne 256b02d3e9 [eas-cli] Fix lint error 2026-01-25 13:09:06 -08:00
Brent Vatne 9f4fef7626 Add missing CHANGELOG entry 2026-01-24 19:00:09 -08:00
Brent Vatne 8cc324e11e [eas-cli] Use --dump-sourcemaps if --source-maps is not provided, for compat 2026-01-24 18:41:22 -08:00
Expo CI f33b4c131d v16.29.0 2026-01-25 01:48:46 +00:00
Brent Vatne e3eb3ad5ea [eas-cli] Add --no-bytecode and --inline-source-maps flags to eas update (#3339) 2026-01-24 17:45:02 -08:00
Stanisław Chmiela 1292465c57 [worker] Bump eas-build packages (#3337)
https://github.com/expo/eas-build/pull/658
2026-01-23 21:54:19 +01:00
Quinlan Jung 🦥 00d2022680 [eas-cli] add warning to channel:delete (#3335)
* Ad[eas-cli] add warning to channel:delete

* Temporary Commit at 1/22/2026, 3:11:19 PM
2026-01-22 15:25:25 -08:00
Stanisław Chmiela 5aaaf1b224 [worker] Bump @expo/build-tools (#3336)
https://github.com/expo/eas-build/pull/667
2026-01-22 21:08:07 +01:00
Stanisław Chmiela aaf14b8bab Add fallback repos to init.gradle (#3329)
<!-- 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

It _looks_ like if cache is configured, it replaces all other repositories, meaning if downloading an artifact from the cache fails, the whole build immediately fails. Instead, I wish Gradle tried downloading the artifact from other repositories.

We expect it to work like this already — https://github.com/expo/turtle-v2/blob/1c44f6e6206cdc703ac9f09ef813f5a0ad93ecb8/internal-docs/maven-cache.md#L7, since the beginning (https://github.com/expo/turtle-v2/pull/139).

# How

Gradle documentation is not clear regarding whether `repositories` add to or replace lists. [Here](https://docs.gradle.org/current/userguide/declaring_repositories.html) it says "add", but I'm not sure the same thing applies to `init.gradle` or if it applies to all `repositories` sections — of dependencies, plugins and initscript.

I did two things to inspect what's happening.

### `./gradlew help --scan`

[Without our Maven cache enabled](https://scans.gradle.com/s/pbd3vg3le6o3u/dependencies/repositories). [With our Maven cache enabled](https://scans.gradle.com/s/vl2ayuzigam2k/dependencies/repositories).

If you go to "Repositories" sections you'll see the one with our Maven cache enabled has our Maven cache as repository PLUS the ones that are used when Maven cache is not enabled.

#### Without cache enabled

<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 14 47" src="https://github.com/user-attachments/assets/33919a95-4f26-427d-9b3c-dca16848ae25" />
<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 14 55" src="https://github.com/user-attachments/assets/b79b951b-321b-4b15-b417-59358c4c5329" />

#### With cache enabled

<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 15 57" src="https://github.com/user-attachments/assets/ebed5389-bd49-4a19-bf49-93d2f622d502" />
<img width="1392" height="775" alt="Zrzut ekranu 2026-01-21 o 22 16 09" src="https://github.com/user-attachments/assets/27f32964-b318-4f50-8e62-b9a0a50457df" />

### Gradle script printing repositories

Inside the VM added

```gradle
gradle.beforeSettings { settings ->
  settings.pluginManagement.repositories.each { r ->
    println "pluginManagement repo: ${r.name} -> ${r.url}"
  }
  settings.dependencyResolutionManagement.repositories.each { r ->
    println "settings repo: ${r.name} -> ${r.url}"
  }
}

gradle.projectsLoaded {
  gradle.allprojects { p ->
    p.buildscript.repositories.each { r ->
      println "buildscript repo (${p.path}): ${r.name} -> ${r.url}"
    }
    p.repositories.each { r ->
      println "project repo (${p.path}): ${r.name} -> ${r.url}"
    }
  }
}
```

With `init.gradle` containing _only_ our Maven repo, we see

```
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
```

With fallbacks included (with this PR)

```
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:): Google -> https://dl.google.com/dl/android/maven2/
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:react-native-gradle-plugin): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:react-native-gradle-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:react-native-gradle-plugin): Google -> https://dl.google.com/dl/android/maven2/
project repo (:react-native-gradle-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:react-native-gradle-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:react-native-gradle-plugin): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:settings-plugin): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:settings-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:settings-plugin): Google -> https://dl.google.com/dl/android/maven2/
project repo (:settings-plugin): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:settings-plugin): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:settings-plugin): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:shared): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:shared): Google -> https://dl.google.com/dl/android/maven2/
project repo (:shared): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:shared): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:shared-testutil): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:shared-testutil): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:shared-testutil): Google -> https://dl.google.com/dl/android/maven2/
project repo (:shared-testutil): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:shared-testutil): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:shared-testutil): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:): Google -> https://dl.google.com/dl/android/maven2/
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:app): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:app): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:app): Google -> https://dl.google.com/dl/android/maven2/
project repo (:app): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:app): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:app): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-asset): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-asset): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-asset): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-asset): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-asset): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-asset): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-constants): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-constants): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-constants): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-constants): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-constants): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-constants): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-eas-client): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-eas-client): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-eas-client): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-eas-client): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-eas-client): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-eas-client): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-file-system): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-file-system): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-file-system): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-file-system): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-file-system): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-file-system): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-font): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-font): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-font): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-font): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-font): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-font): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-json-utils): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-json-utils): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-json-utils): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-json-utils): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-json-utils): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-json-utils): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-keep-awake): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-keep-awake): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-keep-awake): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-keep-awake): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-keep-awake): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-keep-awake): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-manifests): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-manifests): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-manifests): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-manifests): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-manifests): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-manifests): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-modules-core): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-modules-core): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-modules-core): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-modules-core): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-modules-core): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-modules-core): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-structured-headers): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-structured-headers): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-structured-headers): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-structured-headers): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-structured-headers): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-structured-headers): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-updates): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-updates): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-updates): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-updates): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:expo-updates-interface): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:expo-updates-interface): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:expo-updates-interface): Google -> https://dl.google.com/dl/android/maven2/
project repo (:expo-updates-interface): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:expo-updates-interface): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:expo-updates-interface): Google -> https://dl.google.com/dl/android/maven2/
buildscript repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
buildscript repo (:): Gradle Central Plugin Repository -> https://plugins.gradle.org/m2
buildscript repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
buildscript repo (:): Google -> https://dl.google.com/dl/android/maven2/
project repo (:): maven -> http://maven.production.caches.eas-build.internal/artifactory/libs-release
project repo (:): MavenRepo -> https://repo.maven.apache.org/maven2/
project repo (:): Google -> https://dl.google.com/dl/android/maven2/
```

You can see how repository fallbacks get configured.

---

Codex also said `repositories` does different things depending on where is it being used — settings/plugins repositories get replaced and project/buildscript get appended. It would explain the outages — settings/plugins are resolved before projects, so a single failing repository there breaks the build either way. It feels like the best course of action now is… test this live?

A reason why Gradle Scan shows something different than the Gradle script might be that Gradle script tries to print stuff as it is configured as soon as possible (`beforeSettings` and stuff). I can imagine `build.gradle`s of projects being able to add repositories to the list considered (which Gradle Scan would show).

One more thing to consider here is `RepositoriesMode`. Default value seems to be [`PREFER_PROJECT`](https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api.initialization.resolve/-repositories-mode/-p-r-e-f-e-r_-p-r-o-j-e-c-t/index.html) which should be causing project to use repositories declared by the project, ignoring those declared in settings. So, theoretically, all projects should ignore our Maven cache, because `build.gradle` always includes a few repositories for dependencies? The other setting is [`PREFER_SETTINGS`](https://docs.gradle.org/current/kotlin-dsl/gradle/org.gradle.api.initialization.resolve/-repositories-mode/-p-r-e-f-e-r_-s-e-t-t-i-n-g-s/index.html) which **ignores** project repositories which I think we definitely don't want.

I even wonder now if with `PREFER_PROJECT` we even use our Maven cache for build dependencies if build comes with `repositories`… May we be using our Maven cache only for buildscript/plugins dependencies which the project does _not_ change?

# Test Plan

Planning to enable Maven cache in staging and merge this. Maven cache in staging doesn't work, which means if the fallbacks work, the build will pass either way.

We should also see no decrease in Maven cache usage in Datadog.
2026-01-22 16:10:31 +01:00
Stanisław Chmiela 65413d1640 [worker] Upgrade @expo/build-tools (#3330)
https://github.com/expo/eas-build/pull/666
2026-01-22 13:12:29 +01:00
Stanisław Chmiela 71ee9469aa [worker] Bump @expo/build-tools (#3326)
https://github.com/expo/eas-build/pull/665
2026-01-21 15:25:53 +01:00
Mustafa Abban 7223a19fc8 [worker] bump build-tools 1.0.267 (#3324) 2026-01-20 17:35:36 -05:00
Mustafa Abban 3f4e826315 [worker] bump build-tools (#3321)
* [worker] bump build-tools

* rem 1.7
2026-01-19 12:57:22 -05:00
Ash Wu ea7cc6b1ea [worker] bump build-tools, eas-build-job & steps to 1.0.265 (#3315)
Signed-off-by: Ash Wu <[email protected]>
2026-01-15 20:01:10 +08:00
Mustafa Abban b5281d64d1 [worker] bump build-tools (#3313)
* [worker] bump build-tools

* [worker] bump build-tools
2026-01-14 09:33:22 -05:00
Mustafa Abban e51fe11aca [worker] bump build-tools (#3306) 2026-01-12 15:43:11 -05:00
Evan Bacon 16767f3feb feat(cli): add app clip registration for multi-target (#3300)
* bump apple utils

* Add App Clip support to iOS bundle ID registration

Detects App Clips via entitlements and registers App Clip bundle identifiers with parent app relationships. Updates related interfaces and logic to handle App Clip-specific requirements during bundle ID creation.

* Update CHANGELOG.md

* Update ensureAppExists.ts
2026-01-12 12:39:11 -08:00
Stanisław ChmielaandClaude c42b1a004c [gha] Merge setup, build, and deploy into single job (#3303)
## Why

The build-and-deploy-worker workflow had three separate jobs (setup, build-workers, deploy) with complex inter-job dependencies and redundant conditional logic. I think if we put it all in one job it should make stuff faster.

## How

Consolidated all three jobs into a single `build-and-deploy` job with conditional steps:

1. **Check**: Verify if tarballs for the current SHA exist in GCS (when deploying)
2. **Build**: Execute EAS workflow if needed (PR testing or cache miss)
3. **Upload**: Upload tarballs to GCS with SHA-based naming
4. **Deploy**: Copy tarballs to environment-specific names (when deploying)

Key simplifications:
- **Assumption**: Main branch always specifies an environment (staging/production), so `environment == ''` only occurs on PR builds
- **SHA-based caching**: Check cache by SHA regardless of branch - if the code is identical (same SHA), reuse existing tarballs
- **Clearer conditions**: Each step has a single clear purpose without overlapping branch-specific logic

I also added back building Linux tarball on Linux not to mix CPU architectures accidentally.

## Test Plan

[Build on PR](https://github.com/expo/eas-cli/actions/runs/20920297113/job/60104039095?pr=3303). [Deployment from a branch](https://github.com/expo/eas-cli/actions/runs/20921082109/job/60106516235).

Co-authored-by: Claude <[email protected]>
2026-01-12 21:21:38 +01:00
Stanisław Chmiela 8a4bf6f068 [worker] Coalesce worker builds in EAS (#3296)
# Why
- Build Android and iOS worker tarballs together in a single EAS workflow run
- Avoid split builds across different machines and simplify uploads

# How
- Update the EAS workflow to build and upload both iOS and Android tarballs
- Update the GitHub Actions workflow to run the EAS workflow once and upload both artifacts
- Cancel the EAS workflow run when the GitHub Actions job is canceled

# Test plan
- Verified auto-cancel behavior: https://github.com/expo/eas-cli/actions/runs/20849238272/job/59900072125
2026-01-12 13:26:04 +01:00
Stanisław Chmiela 03e1c18d34 [infra] Cancel EAS runs when system-tests workflow is canceled (#3298)
# Why

- Canceling the GitHub Actions workflow should cancel the underlying EAS build/workflow runs to avoid wasting resources.

# How

- Start the iOS build and Android workflow with `--no-wait` and capture their IDs from JSON.
- Poll the EAS build/workflow statuses to determine success/failure.
- Persist IDs to `GITHUB_ENV` and cancel them in a `if: cancelled()` step.

# Test Plan

https://github.com/expo/eas-cli/actions/runs/20859556010/job/59935355509
2026-01-12 12:24:21 +01:00
Ash Wu 2bf8822b1d [worker] add yarn-link script for worker/eas-build local dev (#3302)
Signed-off-by: Ash Wu <[email protected]>
2026-01-12 18:54:40 +08:00
Evan Bacon 460f98da76 fix(metadata): Handle missing Apple metadata gracefully in tasks (#3299)
* Handle missing Apple metadata gracefully in tasks

Updated Apple metadata tasks to skip operations when app version or info is not available, instead of throwing errors. This improves support for cases where the app or version has not been set up yet, particularly for metadata:pull. Tests have been updated to reflect the new behavior.

* Update CHANGELOG.md
2026-01-10 12:47:43 -08:00
Stanisław Chmiela 3a7182bb82 [infra] Add system tests to worker deployment pipeline (#3286)
This PR adds system tests that run after staging deployment and gate production deployment.

## Changes
- Add `system-tests.yml` workflow (can be dispatched manually or called from worker.yml)
- Add EAS workflow fixture for Android build
- Gate production deployment on system tests passing
- Run iOS build (`eas build`) and Android build (`eas workflow:run`) concurrently

## Testing
Once merged, the workflow can be manually dispatched to test, or it will run automatically on the next push to main that touches worker files.
2026-01-09 11:46:38 +00:00
Stanisław Chmiela f963dc711b [worker] Upgrade koa to v3 in worker (#3294)
Closes https://github.com/expo/eas-cli/pull/3293
2026-01-09 11:31:21 +01:00
Stanisław Chmiela a059852bd9 [worker] Build worker in production (#3295)
<!-- 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

Gives us more concurrencies for building worker (which is not a huge problem, but may be nice). Also, IF we botched worker staging, we won't break the whole pipeline (system tests won't let worker deploy to production so worker in production will still work and let us rebuild worker).

# How

Changed app ID and token to expo.dev ones.

# Test Plan

CI ran ok.
2026-01-09 11:17:26 +01:00
Stanisław Chmiela 33e0b0901c Bump TypeScript to 5.5.4 and @types/node to 20.14.8 (#3291)
## Summary

Upgrades TypeScript and `@types/node` across the monorepo to align versions and prepare for future package consolidation.

### Changes

| Package | TypeScript | @types/node |
|---------|-----------|-------------|
| Root | 5.3.3 → 5.5.4 | 20.11.0 → 20.14.8 |
| eas-build-cache-provider | 5.3.3 → 5.5.4 | - |
| eas-cli | 5.3.3 → 5.5.4 | - |
| eas-json | 5.3.3 → 5.5.4 | - |
| worker | 5.4.5 → 5.5.4 | (unchanged at 20.14.8) |

### TypeScript 5.5 Highlights

- Inferred type predicates for cleaner code
- Better control flow narrowing for indexed accesses
- JSDoc `@import` tag support

### Breaking Changes Reviewed

- ✅ No enum members using `Infinity`, `-Infinity`, or `NaN`
- ✅ No issues with mapped type behavior changes
- ✅ No issues with stricter `lib.d.ts` definitions

### Testing

- ✅ `yarn install` - passes
- ✅ `yarn build` - all packages build successfully
- ✅ `yarn test` - all 1221 tests pass
2026-01-09 10:41:55 +01:00
Stanisław Chmiela 8ba877ab81 [worker] Simplify tests (#3285)
# Why

The worker integration tests were slow and required platform-specific CI runners (macOS for iOS, Linux for Android) because they attempted to build real React Native apps. This made the CI pipeline complex, slow, and resource-intensive.

# How

- **Replace full-build tests with fast mocked tests**: Instead of running actual Android/iOS builds, we now mock `@expo/build-tools` at the library boundary. This lets us test the worker's WebSocket communication, job dispatch, state sync, abort handling, and hanging worker detection without the overhead of real builds.

- **Add new `jobExecution.test.ts`**: A new integration test that verifies the worker accepts jobs, executes them (via mocked builders), and returns success/error responses correctly.

- **Update existing tests**: `abortBuild.test.ts`, `stateSync.test.ts`, and `possiblyHangingWorker.test.ts` now mock `@expo/build-tools` instead of the worker's internal `build.ts` module, allowing more of the real code path to execute.

- **Simplify CI**: Consolidated the separate `worker-android-checks` and `worker-ios-checks` jobs into a single `worker-checks` job running on `ubuntu-latest`. Removed dependencies on `direnv`, `setup-gcloud`, and platform-specific scripts.

- **Cleanup**: Deleted unused files including `__mocks__/build.ts`, macOS-specific Jest config, CI shell scripts, and the `direnv` internal action.

The build-build part is going to be tested in system tests running in CI on `main`.

# Test Plan

cd packages/worker
yarn test:integrationAll 19 tests pass in ~6 seconds (down from minutes with real builds).

```bash
cd packages/worker
yarn test:integration
```

All 19 tests pass in ~6 seconds (down from minutes with real builds).
2026-01-08 16:11:06 +01:00
Stanisław Chmiela 9f6e387888 [worker] Bump packages (#3289)
Lands https://github.com/expo/turtle-v2/commit/df16c54eb74583fd2ac6effbe0ccec9010ae6525.
2026-01-08 16:10:46 +01:00
Stanisław Chmiela c10691c339 [infra] Build worker on EAS instead of GHA's macos-latest (#3287)
## Why

The `worker.yml` GitHub Actions workflow was recently moved from a private repository to this public one. This means we lost access to private GitHub Actions runners and now have to wait in long queues for public `macos-latest` runners when building the iOS worker tarball.

## How

Instead of building the iOS worker tarball on GitHub Actions `macos-latest`, we now:

1. **Trigger an EAS Workflow** from GitHub Actions that builds the tarball on EAS's macOS runners
2. **Upload the tarball as an EAS artifact** using `eas/upload_artifact`
3. **Uploads the artifact to tarball bucket** from GitHub Actions

### Changes

- Added `packages/worker/app.json` - minimal Expo app config required for EAS
- Added `packages/worker/eas.json` - EAS configuration
- Added `packages/worker/.eas/workflows/build-worker.yml` - EAS Workflow that builds the iOS worker tarball and uploads it as an artifact
- Updated `.github/workflows/build-and-deploy-worker.yml`:
  - Split the matrix build into separate `build-android` and `build-ios` jobs
  - `build-ios` now runs on `ubuntu-latest` and triggers the EAS Workflow using `eas workflow:run --ref --wait --json`
  - Extracts the artifact download URL from the workflow output and streams it directly to GCS

## Test Plan

https://github.com/expo/eas-cli/actions/runs/20796579975 Don't mind the CI failure on the PR, I dispatched the workflow manually later.
2026-01-08 11:16:17 +01:00
Stanisław Chmiela 155b65c8fc [chore\ Add basic CLAUDE.mds (#3284)
Used Claude Code to generate CLAUDE.mds. I hope they help with AI "startup".
2026-01-07 15:15:40 +01:00
Stanisław Chmiela cdbc95295c [worker] Add worker from turtle-v2 (#3268)
<!-- 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

We want to streamline DX of the worker part of EAS. Part of it is colocating all code in one repository.

# How

Copied code over to `eas-cli`. Adjusted code — each commit represents a single piece of work towards adjusting code to this repository. The main changes are around removing dependency on `turtle-v2` internal libraries.

One thing to figure out still is deployment process. I've added `build-and-deploy-worker` which is capable of deploying to staging and production. The question is how to automate it. In `turtle-v2` we have system tests. Should we just copy them over to here? (Don't like that.) Should we streamline system tests now and instead of having this weird `turtle-v2-example` repo, create project on the go? Should we deploy worker manually for now?

I think I'd rather deploy manually for now?

# Test Plan

Deployed worker to staging. Ran system tests. They succeeded!! https://github.com/expo/turtle-v2/actions/runs/20042956545/job/57535011098
2026-01-07 11:44:26 +01:00
Quinlan Jung 🦥 414ccb92c0 [eas-cli] delete channel in background (#3278)
* [eas-cli] delete channel in background

* Temporary Commit at 1/5/2026, 3:02:00 PM
2026-01-05 15:09:32 -08:00
Douglas Lowder aabf519a9d eas init should fix and validate project name/slug before creating a project on the server (#3277)
# Why

The CLI does not validate the project name and slug before creating the project on the server, leading to unnecessary GraphQL errors.

# How

Copy the server's self-contained validation code to the CLI. Eventually, this code should move to `eas-build` repo for use by both CLI and server. Add code to run the same validation checks that the server GraphQL resolver uses, and add a method to strip invalid characters from the project name before suggesting it at the prompt.

# Test Plan

The server's validation unit tests have been added here as well.
2025-12-18 10:50:21 -08:00
Douglas Lowder 88d9627286 Allow exit from credentials command (#3269)
# Why

The command `eas credentials` does not provide a way to exit other than control-C, if no platform flag is passed in.

# How

Add an "Exit" choice to the platform prompt.

# Test Plan

Tested locally.
2025-12-03 14:44:21 -08:00
Will Schurman 🦦 59ce6d0b65 [ci] Test with node 24 (#3270)
<!-- 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

Node 24 is in LTS, it's time to officially support it and test for it.

# How

Add 24 to CI test matrix.

# Test Plan

CI
2025-12-02 14:48:13 -08:00
HarelSultan 8e64a9c73e feat: add --runtime-version and --platform filters to eas update:list (#3261) 2025-12-02 11:08:18 -08:00
Expo CI 4476c87962 update CHANGELOG.md 2025-11-20 19:58:01 +00:00
Expo CI 18ecd0844a v16.28.0 2025-11-20 19:39:20 +00:00
Colin MacKenzie cc3e7cdbe4 remove package json changes (#3263) 2025-11-20 13:36:15 -06:00
Colin MacKenzie aa7fecb9fe [eas-cli] Fix usage warning bug in eas:build (#3260)
* fix overages bug

* stub changelog

* update pr number

* fix test
2025-11-20 13:24:00 -06:00
Douglas Lowder c73a3e3a1e Update GraphQL from server, fix type error in test (#3262)
# Why

GraphQL test started failing today after a recent server change in updates GraphQL queries. Failed test run at https://github.com/expo/eas-cli/actions/runs/19529909252

# How

- Updated GraphQL generated code
- Fixed a TS type error in one test

# Test Plan

- CI should pass
2025-11-20 11:18:21 -08:00
Quinlan Jung 🦥 37ca2ebd55 [ENG-17987][eas-cli] delete branch in background (#3258)
* [eas-cli] delete branch in background

* Temporary Commit at 11/18/2025, 4:28:38 PM
2025-11-18 16:33:40 -08:00
Colin MacKenzie dead11964e [eas-cli] Sort organization accounts first in eas:new (#3256)
* sort accounts

* update changelog

* fix failing test
2025-11-17 13:44:50 -06:00
Expo CI b66d31d7d7 update CHANGELOG.md 2025-11-13 02:21:00 +00:00
Expo CI ed8170047b v16.27.0 2025-11-13 02:14:02 +00:00
Quinlan Jung 🦥 0e4056ba82 [eas-cli] kick off update patch generation if enabled (#3250)
* [eas-cli] kick off update patch generation if enabled

* Temporary Commit at 11/5/2025, 3:09:27 PM

* Temporary Commit at 11/5/2025, 3:19:31 PM

* Temporary Commit at 11/5/2025, 3:25:14 PM

* Temporary Commit at 11/11/2025, 2:06:29 PM

* Temporary Commit at 11/11/2025, 2:51:21 PM

* Temporary Commit at 11/11/2025, 2:53:51 PM

* Temporary Commit at 11/11/2025, 4:27:57 PM

* Temporary Commit at 11/12/2025, 5:31:14 PM
2025-11-12 18:05:53 -08:00
Kudo Chien 6182ea5a1d [eas-cli] remove plugins evaluation from workflow:run (#3254) 2025-11-12 01:46:16 +08:00
Douglas Lowder c9e7b649ad Fix workflow:status when run ID passed in (#3253)
# Why

In `workflow:status`, the argument name for the passed-in workflow run ID is incorrect. Instead of showing the status of that run, it shows the dialog to select from recent runs.

# How

Fixed the argument name.

# Test Plan

Works when testing locally.
2025-11-10 01:55:11 -08:00
Quinlan Jung 🦥 7a6b796242 [eas-cli] fix yarn watch (#3251)
* [eas-cli] fix yarn watch

* Temporary Commit at 11/6/2025, 12:22:09 PM
2025-11-06 12:37:12 -08:00
Kadi Kraman 804eb9a326 [eas-cli] Fix updating file env vars with env:update --non-interactive (#3249) 2025-11-05 12:12:40 +00:00
Expo CI 8da14f0101 update CHANGELOG.md 2025-10-29 19:26:44 +00:00
Expo CI dc62d22816 v16.26.0 2025-10-29 19:17:21 +00:00
Colin MacKenzie 258c625611 [eas-cli] eas:new - remove hidden tag; update output and project README (#3243)
* fix typo in the readme, change output formatting

* remove hidden flag

* update readme

* remove dev code
2025-10-29 14:10:30 -05:00
Douglas Lowder e84340774d New command workflow:status (#3242)
# Why

Shows the status of a workflow run that has already started.

If the run is in progress, and the `--wait` flag is set, the command will show periodic updates on running jobs, the current job steps, and recent log lines, similarly to `eas workflow:run --wait`.

Otherwise, the command immediately returns with information on the run result, exactly as `eas workflow:run --wait` will show at the end of a run. If the run failed, the failed jobs, failed steps, and last few log lines are shown.

```bash
USAGE
  $ eas workflow:status [WORKFLOW_RUN_ID] [--non-interactive] [--wait] [--json]

ARGUMENTS
  WORKFLOW_RUN_ID  A workflow run ID.

FLAGS
  --json             Enable JSON output, non-JSON messages will be printed to stderr.
  --non-interactive  Run the command in non-interactive mode.
  --[no-]wait        Wait for workflow run to complete. Defaults to false.

DESCRIPTION
  show the status of an existing workflow run. If no run ID is provided, you will be prompted to select from recent
  workflow runs for the current project.

FLAG DESCRIPTIONS
  --[no-]wait  Wait for workflow run to complete. Defaults to false.

    Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```

# How

Leverages utility code already written for `eas workflow:run` and `eas workflow:logs`.

# Test Plan

- Tested on running and completed workflow runs.
- CI should pass.
2025-10-29 12:00:07 -07:00
Expo CI fbc23f7a7d update CHANGELOG.md 2025-10-28 15:05:45 +00:00
Expo CI 97255ec90d v16.25.1 2025-10-28 13:14:43 +00:00
Phil Pluckthun cfccc87b82 [eas-cli] Remove preview log output from EAS Hosting commands (#3241)
* Remove preview labels from EAS Hosting commands

* Add CHANGELOG entry
2025-10-27 22:25:44 +00:00
Expo CI 09b4f8367e update CHANGELOG.md 2025-10-27 16:55:04 +00:00
Expo CI 45481f9758 v16.25.0 2025-10-27 15:57:32 +00:00
Colin MacKenzie 1119008317 [eas-cli] Display usage warnings on eas:build (#3236)
* first pass

* cleanup

* clean up query

* show warning when builds are close to threshold

* cleaned up query

* tweak test

* more test tweaks
2025-10-27 10:34:40 -05:00
Douglas Lowder 123a0c1b7e Remove outdated update-local-plugin from release workflow (#3240)
# Why

The manual release workflow is broken after #3237, since the local plugin version is no longer hardcoded, but tied to the `@expo/eas-build-job` dependency.

# How

Remove this step from the workflow.

# Test Plan

Workflow should succeed in dry run mode.
2025-10-27 05:51:52 -07:00
Douglas Lowder 2f21fa62b1 New workflow to move latest-eas-build tag (#3233)
# Why

Provide a way to move the "latest-eas-build" version tag added in the EAS CLI release process. After https://github.com/expo/universe/pull/22530, this is the EAS CLI version that will be used by EAS workflows.

# Test Plan

Test with different version inputs, ensure failures are handled correctly.
2025-10-27 04:32:34 -07:00
Colin MacKenzieandDouglas Lowder 8ad449c6b7 [eas-cli] New command - incorporate more user feedback (#3235)
* incorporated feedback

* more feedback

* add changelog entry

* prompt for name

* update agents.md

* fix test

* add PR to changelog

* Update CHANGELOG.md

Co-authored-by: Douglas Lowder <[email protected]>

* remove dev warning

---------

Co-authored-by: Douglas Lowder <[email protected]>
2025-10-24 10:08:43 -05:00
Douglas Lowder fe8ec50806 Add missing env for local builds with latest plugin (#3237)
# Why

Currently, the CLI is using an old version of `eas-cli-local-build-plugin` for builds with `eas build --local`. This is not ideal, as this means that local builds are not using the most recent `build-tools` and other packages.

# How

- Modify `local.ts` so that the version of the plugin matches the version of `@expo/eas-build-job` used by the CLI
- Add some missing environment variables that are required in the most recent releases of `@expo/build-tools`

# Test Plan

- Testing with local builds on my dev machine
- CI should pass
2025-10-23 14:08:22 -07:00
Douglas Lowder 86cefeb259 workflow:run can use --wait and --json together (#3239)
# Why

Customers would like to wait for a workflow run to complete, and get JSON output.

# How

Remove the restriction on --wait and --json flags together, and ensure that when --json is enabled, all messages other than the final JSON result will go to stderr.

# Test Plan

- Tested locally by running workflows
- CI should pass
2025-10-23 12:00:41 -07:00
Kadi Kraman 034e0a1e7f [eas-json] Add default values for android track and releaseStatus (#3229) 2025-10-20 17:55:27 +01:00
Expo CI a6b776915f update CHANGELOG.md 2025-10-19 21:51:50 +00:00
Expo CI 4ae52d0e3d v16.24.1 2025-10-19 21:45:41 +00:00
Douglas Lowder f62720ef5a fix (regression): revert ora dependency change (#3231) 2025-10-19 14:42:20 -07:00
Expo CI af09aaedb3 update CHANGELOG.md 2025-10-19 20:05:21 +00:00
Expo CI 184616e874 v16.24.0 2025-10-19 19:59:27 +00:00
Douglas Lowder 6bb09c4e99 feat: workflow:run --wait should show status updates (#3227)
# Why

If a user executes `eas workflow:run --wait`, it would be desirable to show status updates for the individual jobs and steps in the workflow while the workflow run is in progress.

# How

Added code that leverages some of the same utilities used by `workflow:logs`. 

During the run, the logs for the various workflow jobs are retrieved and parsed, and the progress of each job is shown in the text of a spinner, showing the current step and the last few lines of the step's logs.

If the workflow run fails, the output will show jobs that failed, along with the failed step for each, and any logs for the failed steps.

Other changes:

- Bumped the "ora" package to the latest version, to pick up bug fixes
- Removed deprecated Node 18 from test runs
- Refactored the code in `workflow:run` that parses inputs, moving that code to a file in `commandUtils`
- Modified the Zod type for inputs to align with server side changes in https://github.com/expo/universe/pull/21950

# Test Plan

- Testing with different workflows
- CI should pass
2025-10-19 12:52:46 -07:00
Douglas Lowder 81add86c61 fix: workflow:logs should show build and repack logs (#3226)
# Why

Build and repack jobs do not have logs at the same URL as other workflow jobs, so no logs were showing for these job types.

# How

- Update BuildFragment to include the log file URL
- Check for build jobs, and add the correct queries and fetch calls for those
- Update and fix tests
- Bump `@expo/eas-build-job` to 1.0.239 to fix TS error in environment type

# Test Plan

- Tested with various workflow runs, including ones with custom, build, repack, and update jobs.
- CI should pass.
2025-10-16 14:05:26 -07:00
Expo CI 4bb1c784d8 update CHANGELOG.md 2025-10-16 15:42:51 +00:00
Expo CI 322082c28b v16.23.1 2025-10-16 15:32:16 +00:00
Colin MacKenzieandDouglas Lowder 5db1d21aa5 [eas-cli] Incorporating feedback on eas new command (#3205)
* incorporating feedback

* small cleanup

* moved some files around

* moved tests

* remove unused line

* tweak one line

* unit tests for all the helper functions

* more tests pass

* last batch of tests for now

* update changelog

* fix changelog

* move src/new to src/commandUtils/new

* pr feedback

* update changelog

* update path in copy-new-templates command

* fix test import

* Update packages/eas-cli/package.json

Co-authored-by: Douglas Lowder <[email protected]>

* feat: add --package-manager flag to project:new command

- Add --package-manager (-p) flag with options: npm, yarn, pnpm, bun
- Default to npm when flag not provided
- Remove promptForPackageManagerAsync call
- Add corresponding tests

* feat: add --no-install flag to skip dependency installation

- Add --no-install flag to skip installing dependencies
- Update README with installation instructions when --no-install is used
- Pass skipInstall flag through to updateReadmeAsync
- Add corresponding tests

* feat: add hideOutput option to suppress dependency installation logs

- Add hideOutput parameter to runCommandAsync to use stdio: 'ignore'
- Update installDependenciesAsync to hide npm/yarn/pnpm/bun output
- Show only spinner during installation instead of verbose logs
- Apply hideOutput to all dependency installation steps
- Update tests to reflect flag definition changes

* revert: remove --no-install flag

Remove the --no-install flag and related skipInstall functionality as requested

* feat: remove prompts and use smart defaults for project name and directory

- Remove project name prompt, use smart fallback logic:
  - Default: new-expo-project
  - If exists: new-expo-project-USERNAME-DATE
  - If exists: new-expo-project-SHORTID (using nanoid)

- Remove target directory prompt, use cwd/projectName as default

- Refactor naming to reflect generation vs prompting:
  - Rename prompts.ts → configs.ts
  - Combine generateDefaultProjectNameAsync + promptForProjectNameAsync → generateProjectNameAsync
  - Rename promptForTargetDirectoryAsync → generateDirectoryAsync
  - Rename promptForConfigsAsync → generateConfigsAsync
  - Rename promptedConfigs → initialConfigs

- Add nanoid and fs-extra dependencies for smart defaults
- Update all tests to reflect new behavior (11 tests in configs.test.ts passing)
- All existing tests continue to pass

* refactor: simplify CLI args for project:new command

- Remove --name flag, always use smart generation logic
- Change --directory flag to positional 'path' argument
  - Defaults to current working directory
  - If no slashes: treated as subdirectory of cwd
  - If relative path with slashes: resolved from cwd
  - If absolute path: used as-is

- Update generateProjectNameAsync to remove optional name parameter
- Update generateDirectoryAsync to handle path logic
- Add 4 new tests for path handling scenarios
- Remove 1 test for name arg (no longer supported)
- All 17 tests passing

* more feedback

* one more big refactor

* more cleanup

* one last round of cleanup

* one last round of cleanup

* fix changelog

* fix tests

---------

Co-authored-by: Douglas Lowder <[email protected]>
2025-10-16 10:20:22 -05:00
Expo CI 0db5585b20 update CHANGELOG.md 2025-10-14 19:13:02 +00:00
Expo CI 10ec54345a v16.23.0 2025-10-14 15:53:39 +00:00
Kadi Kraman 27b4710264 [eas-cli] [eas-json] support custom environment variable environments (#3220) 2025-10-14 13:56:42 +01:00
Kadi Kraman 4132e92da5 Add tests for eas env:push --force (#3223) 2025-10-14 11:31:36 +01:00
Giorgio FellipeandKadi Kraman 264340a717 Add --force parameter to env:push (#3135)
Co-authored-by: Kadi Kraman <[email protected]>
2025-10-14 10:40:22 +01:00
Expo CI 1d09c2c97d update CHANGELOG.md 2025-10-13 20:55:14 +00:00
Expo CI 72cff1df1a v16.22.0 2025-10-13 20:47:55 +00:00
Douglas Lowder 6912e453ef Improvements to workflow:create (#3194)
# Why

The existing `workflow:create` command simply creates a very bare-bones workflow, that is not very useful to developers who need to know how to fully use workflows for managing builds, updates, and other EAS operations.

# How

- Define four different workflow templates, initialized as JSON objects.
- After the user selects which type of workflow (build, update, deploy, custom), sanity checks are made, EAS build and EAS update are configured if needed, and any missing build profiles are created in `eas.json`.
- Once the user has made their selections, the final workflow YAML is dynamically generated and written to disk with an explanatory header.
- After writing the workflow, next steps are provided for any further configuration that may be needed, especially if build credentials or app store credentials are needed.

In future, we can easily add more templates that use other workflow job types (fingerprint, repack, etc.).

# Test Plan

- Tested against different projects
- CI should pass
2025-10-13 13:42:00 -07:00
Expo CI 839cbac47b update CHANGELOG.md 2025-10-13 15:45:46 +00:00
Expo CI 5ba16dbc67 v16.21.0 2025-10-13 15:31:06 +00:00
Phil Pluckthun 896b456dd3 [eas-cli] Pass _expo/.routes.json for SSG exports as routes.json to Hosting deployments (#3211)
* Derive `routes.json` payload from static export `_expo/.routes.json`

* Add CHANGELOG entry
2025-10-10 15:22:32 +01:00
Kadi Kraman f2c0202f06 Add tests for the env commands (#3217) 2025-10-09 15:18:43 +01:00
Expo CI 497bac1cbf update CHANGELOG.md 2025-10-08 05:36:29 +00:00
Expo CI 5b9734b60b v16.20.4 2025-10-08 05:25:57 +00:00
Kadi Kraman 3ac70d8977 Switch to lowercase in exec and pull (#3216) 2025-10-08 06:23:06 +01:00
Douglas Lowder 32adc788d0 Support workflow:run from a Git ref (#3203)
# Why

Add a `--ref` flag so that `eas workflow:run` can initiate a workflow run from an existing ref in the project's GitHub repository.

- Avoids the need to upload the entire project from a local machine
- Ensures that the workflow starts from a known reference point in the project's source control, with no unexpected local changes

# How

- If the ref flag is set, the run command will ensure that the ref is valid, and that the workflow can be used for that ref (using the new mutation for this from https://github.com/expo/universe/pull/22132)
- If the ref flag is set, the run command starts the run from that ref (using https://github.com/expo/universe/pull/20161)
- If the user chooses ref = HEAD, `git rev-parse` is called to convert that to a commit
- The flow is designed to work the same for running from GitHub ref as running from local, so inputs and waiting for results are supported 

# Test Plan

- Tested in an existing project with workflows with and without inputs
- CI should pass
2025-10-07 15:06:24 -07:00
Expo CI e465ed57cb update CHANGELOG.md 2025-10-07 20:04:20 +00:00
Expo CI df819fc232 v16.20.3 2025-10-07 18:42:28 +00:00
Kadi Kraman 539e201972 Lower instead of uppercase environment (#3213) 2025-10-07 19:39:06 +01:00
Expo CI c14b3e179b update CHANGELOG.md 2025-10-07 15:23:38 +00:00
Expo CI f093197dea v16.20.2 2025-10-07 13:29:54 +00:00
Kadi Kraman 13834039a8 [eas-cli] Use local EnvironmentVariableEnvironment enum (#3208) 2025-10-07 14:18:07 +01:00
Expo CI 4d3023f7f1 update CHANGELOG.md 2025-10-03 15:51:00 +00:00
Expo CI eb7d3b5ecd v16.20.1 2025-10-03 15:14:03 +00:00
Stanisław Chmiela 8c721759bd [chore] Remove FingerprintSource (#3200)
<!-- 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

In https://github.com/expo/eas-build/pull/604 I removed `fingerprintSource` from metadata passed to a build (1). It was previously used to inform the build process of, well, the fingerprint source so it can optionally diff current fingerprint and fingerprint calculated by the `eas build` command.

In `eas build`, the fingerprint would get calculated and uploaded. Then it was included in the build mutation as `{ type: GCS, bucketKey }` (2). Worker expected that somewhere in the process of starting a job, something (`www` or `turtle-v2`) would swap out the persisted `fingerprintSource` to `{ type: URL, url }` (because worker can't download arbitrary GCS objects).

In https://github.com/expo/eas-build/pull/587 I changed the flow so that now worker does not expect any changes to the job or metadata and instead fetches the fingerprint sources straight from `www`.

This made `fingerprintSource` in `metadata` obsolete (see (1)). However, we still need to be able to send fingerprint sources information to `www` (see (2)).

# How

This pull request:
- bumps `@expo/eas-build-job` (removing `FingerprintSource` type)
- `FingerprintSource` gets replaced with `FingerprintSourceInput` generated by GraphQL schema
- this lets us remove `transformFingerprintSource` (it's being passed around in its final form)

# Test Plan

Set up my staging test app to use runtime version policy = fingerprint, installed and configured `expo-updates`, [ran a build](https://staging.expo.dev/accounts/sjchmiela/projects/staging-app/builds/7a44f4c3-e448-4657-a8f3-71e24a2a29c8).

I had a few `console.log`s in place:

<img width="943" height="779" alt="Zrzut ekranu 2025-09-30 o 17 39 14" src="https://github.com/user-attachments/assets/2b117a17-8c0a-4445-bf56-27f93a590f87" />

and the build has fingerprint:

<img width="1256" height="161" alt="Zrzut ekranu 2025-09-30 o 17 39 33" src="https://github.com/user-attachments/assets/b6133402-9910-42d2-b351-836ae9c10c95" />

Before I installed node dependencies, fingerprint was being set to `file:fingerprint` (probably from https://github.com/expo/expo/pull/26901, see https://github.com/expo/eas-cli/blob/b447194c8521270ba8b551296c4329c3edb7aa1c/packages/eas-cli/src/project/resolveRuntimeVersionAsync.ts#L88).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Replaces internal FingerprintSource with GraphQL FingerprintSourceInput across build/update flows, removes transformFingerprintSource, adjusts metadata wiring, and bumps related packages.
> 
> - **Build & GraphQL**:
>   - Replace `Metadata.fingerprintSource` usage with GraphQL `FingerprintSourceInput`; update builders to accept `BuildMetadataInput` and pass pre-transformed metadata.
>   - Remove `transformFingerprintSource` and associated imports/usages; simplify `transformMetadata` to accept `{ metadata, fingerprintSource }`.
>   - Update types for fingerprint source across build paths (`computeAndMaybeUpload*`, `sendBuildRequestAsync`, Android/iOS build senders).
> - **Update/Publish**:
>   - Stop transforming fingerprint source in `update publish`; pass through GraphQL `FingerprintSourceInput`.
>   - Adjust runtime fingerprint computation/assembly to use `FingerprintSourceInput`.
> - **Fingerprint upload**:
>   - `maybeUploadFingerprintAsync` now returns `FingerprintSourceInput`; use GraphQL enum casing (`Gcs`).
>   - Mutation `createFingerprintAsync` accepts `FingerprintSourceInput`.
> - **Dependencies**:
>   - Bump `@expo/eas-build-job` and `@expo/steps`; update yarn.lock accordingly.
> 
> <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5a61fdee68f3b0bbda043b8f3160c4c2f1ea3a71. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-10-03 13:06:42 +02:00
Vojtech Novak c6a741bb8c [eas-cli] skip syncing capabilities if they don't contain 'enabled' attribute (#3201) 2025-10-01 23:56:44 +02:00
Expo CI af5b76fe9f update CHANGELOG.md 2025-09-30 15:55:00 +00:00
Expo CI aa428b7571 v16.20.0 2025-09-30 15:39:44 +00:00
Colin MacKenzie ba68f40922 [eas-cli] Create new command to configure a new project (#3196)
* incorporate pr feedback

* playing around with adding build

* create expo project

* clean up getAccountChoices

* update unit test

* removed some unused code

* wip

* working on test files

* fix previews, add more end comments

* small tweaks to tests

* update changelog
2025-09-30 10:32:12 -05:00
Stanisław Chmiela 1380b023ba [eas-cli] Deprecate isGlobal GraphQL input property of environment variables (#3190)
<!-- 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

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

# How

- Updated GraphQL code.
- Adjusted code to the updated GraphQL code (no `isGlobal`).
- Updated types in `EnvironmentVariableMutation` so they don't re-implement what GraphQL is generating for us.
- Updated types in `env:push`.

# Test Plan

Adjusted tests.
2025-09-25 18:42:44 +02:00
Colin MacKenzie adfc79abd4 [eas-cli] Scaffolding out the new command (#3177)
* scaffolding out new command

* remove a newline

* incorporate pr feedback

* add unit test for package manager functions

* get rid of unnecessary lockfile git add

* remove unused test
2025-09-23 10:36:23 -05:00
Douglas Lowder 92e6ecd03c Improvements to workflow:validate (#3165)
# Why

Improve the validation of workflow files:

- Add client-side checks for valid syntax, and compatibility with the user's EAS build profiles
- Make validation usable with future improvements to `workflow:create`

# How

- New `validation.ts` in `commandUtils` with the various validation checks separated cleanly into different functions
- `workflow:validate` command refactored to use the new utility and wrap errors
- The new code reads the workflow schema from `<ExpoAPIBaseURL>/v2/workflows/schema`
- For testing purposes only, a local copy of the schema can be used by setting EXPO_TESTING_WORKFLOW_SCHEMA_PATH
- An existing check in `workflowFile.ts` has been modified to remove the requirement for a Github repository, as that is no longer required to run workflows

# Test Plan

- Tested against different workflow templates, and against workflows in a real project.
- Tested against the www server from the main branch running locally, to verify recent server side changes (e.g. https://github.com/expo/universe/pull/21950)
2025-09-19 02:45:58 -07:00
Expo CI 05546f0bfd update CHANGELOG.md 2025-09-17 06:43:24 +00:00
Expo CI 6c5c99b5d1 v16.19.3 2025-09-17 06:36:33 +00:00
Brent Vatne f27ea08839 [cli] Temporarily remove warnings when no compatible build found, need to refine the message (#3182) 2025-09-16 23:21:34 -07:00
Expo CI 15f47aea81 update CHANGELOG.md 2025-09-15 19:24:30 +00:00
Expo CI fd23f031b6 v16.19.2 2025-09-15 19:16:48 +00:00
Douglas Lowder a74c434718 Add artifact URLs to workflow:view (#3175)
# Why

To allow CLI users to get artifacts of builds or workflow jobs directly using the CLI, and make the URLs available in JSON output also (for easier use by automation scripts).

# How

- Add artifact info to WorkflowJobFragment
- Add this info to the results of `workflow:view`
- For workflow jobs that are of type=BUILD, use the existing build query to get the build artifact URLs for the build and show those as well
- Make some improvements to the formatting of workflow job outputs

# Test Plan

- Tested against different EAS projects
- CI should pass
2025-09-15 11:42:09 -07:00
Expo CI 29950a7629 update CHANGELOG.md 2025-09-12 11:54:08 +00:00
Expo CI 0fd33a95f5 v16.19.1 2025-09-12 11:48:48 +00:00
Stanisław Chmiela 843ed0d0aa [eas-cli] Upgrade Zod to v4 (#3173)
<!-- 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

Unify packages versions across the Expo package ecosystem (Zod, eas-build-job)

# How

Copied Zod definitions from `www` to `run.ts` where we are parsing workflow to get inputs definitions.

# Test Plan

There are, fortunately, tests for this part of the code.
2025-09-12 13:38:49 +02:00
Expo CI bf2cde45c6 update CHANGELOG.md 2025-09-12 10:35:59 +00:00
Expo CI ecab803cfa v16.19.0 2025-09-12 10:29:03 +00:00
Stanisław Chmiela 570ff126a0 [eas-cli] Allow freeform Android release track (#3163)
<!-- 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

[exponent-internal.slack.com/archives/C011ZMXQ6SZ/p1755530483378679](https://exponent-internal.slack.com/archives/C011ZMXQ6SZ/p1755530483378679)

# How

Updated GraphQL code which removed `SubmissionAndroidTrack` enum (it's a scalar now). Adjusted code to pass track around as string.

Removed `AndroidReleaseTrack` from `eas-json`. Updated `eas.schema.json` to allow any string as `track`.

Technically, to support `track: string` we'll need to update `eas-json` in `www`. I think it's ok to merge this, publish releases and then update `eas-json` in `www`. Alternatively we could publish `eas-json` from this branch as prerelease, bump in `www` and then merge this. This would be more _correct_, but I think it's ok to have this 1 hour of unannounced discrepancy between `www` and `eas-cli` (users not being able to use custom tracks _yet_). 

# Test Plan


Tested along with all the pull requests in the mix.

<img width="372" height="90" alt="Zrzut ekranu 2025-09-9 o 17 03 38" src="https://github.com/user-attachments/assets/0b260ad2-2379-4597-aa1c-dae8bf86446d" />
<img width="243" height="72" alt="Zrzut ekranu 2025-09-9 o 16 59 37" src="https://github.com/user-attachments/assets/4eef353d-ac7e-4b16-9413-a304a574b685" />
<img width="449" height="110" alt="Zrzut ekranu 2025-09-9 o 17 00 40" src="https://github.com/user-attachments/assets/58a44b7a-16bb-46b4-8d98-f233c196406a" />
<img width="366" height="85" alt="Zrzut ekranu 2025-09-9 o 17 00 35" src="https://github.com/user-attachments/assets/71013cd6-5f5e-4e38-951c-008642fbee8c" />
2025-09-12 12:08:56 +02:00
Expo CI e24b21bd1e update CHANGELOG.md 2025-09-10 11:09:48 +00:00
Expo CI 20da7f8778 v16.18.1 2025-09-10 10:54:43 +00:00
Stanisław Chmiela 14e002274f [eas-cli] Support unknown Android release tracks in submit:internal (#3170)
<!-- 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

Preparation for merging the rest of `track`-related pull requests.

Currently, the only place fetching `track` from `www` for display is `submit:internal` command. There, we fetch the submission config to know what parameters to supply to `fastlane supply` in the Submit workflow job.

Currently, we're using a mapping of `{ PRODUCTION: production, INTERNAL: internal, ... }` to transform the GraphQL value (enum key) to config value ("enum value").

# How

Since we're planning to make `androidConfig.track` be a plain string (e.g. `internal`), we should fall back to the actual value if the mapping does not produce a match.

This currently should not change anything (`track` is guaranteed to be a GraphQL enum value). This is a preparation for landing of https://github.com/expo/universe/pull/21827 which (somewhat breakily, but we expect to be the only consumers of our GraphQL API) changes `track` to be any string.

This `track` value is being used [here](https://github.com/expo/universe/blob/2f143aa8d51821c7e1c306768787c46b18bdd544/server/www/src/data/entities/workflow/job/WorkflowSubmitJob.ts#L399-L403).

# Test Plan

This is currently a no-op. Tested along with all the pull requests in the mix.

<img width="372" height="90" alt="Zrzut ekranu 2025-09-9 o 17 03 38" src="https://github.com/user-attachments/assets/0b260ad2-2379-4597-aa1c-dae8bf86446d" />
<img width="243" height="72" alt="Zrzut ekranu 2025-09-9 o 16 59 37" src="https://github.com/user-attachments/assets/4eef353d-ac7e-4b16-9413-a304a574b685" />
<img width="449" height="110" alt="Zrzut ekranu 2025-09-9 o 17 00 40" src="https://github.com/user-attachments/assets/58a44b7a-16bb-46b4-8d98-f233c196406a" />
<img width="366" height="85" alt="Zrzut ekranu 2025-09-9 o 17 00 35" src="https://github.com/user-attachments/assets/71013cd6-5f5e-4e38-951c-008642fbee8c" />
2025-09-10 12:37:37 +02:00
Douglas Lowder 3b8dc6b7aa CLI debug log should respect DEBUG=eas:* (#3162)
# Why

It would be great if we could use the environment setting `DEBUG=eas:*` to enable debug logs when running the EAS CLI, similarly to the way `DEBUG=expo:*` is used in the Expo CLI.

This is a first step towards implementing that. Ideally, different commands and utility modules would set different tags for the debug log messages, but this will at least allow the debug logs to appear for existing `Log.debug()` calls.

# How

Modify `src/log.ts` to write debug logs if DEBUG is set to a regex that matches `eas:log:debug`.

# Test Plan

CI should pass.
2025-09-05 11:02:56 -07:00
Douglas Lowder 445a48a8dd Sync GraphQL schema and generated code with server schema (#3161)
# Why

Sync up the schema to reflect recent additions to the server GraphQL schema

# How

`yarn --cwd packages/eas-cli verify-graphql-code`, then inspect changes in schema and generated code.

# Test Plan

CI should pass
2025-09-05 11:02:45 -07:00
Douglas Lowder f6436319ed Add better description to update:rollback (#3154)
# Why

Make it easier for customers to use `eas update:rollback` in CI.

# How

Add description text on the underlying commands that do support the non-interactive flag.
2025-08-28 12:52:15 -07:00
Expo CI bd1e32622d update CHANGELOG.md 2025-08-27 18:02:36 +00:00
905 changed files with 117465 additions and 21056 deletions

No files matched your search

+7 -1
View File
@@ -1,8 +1,14 @@
**/build/**
**/dist/**
**/coverage/**
**/fixtures/**
!packages/eas-cli/src/commands/build/**
!packages/eas-cli/src/build/**
packages/eas-cli/src/graphql/generated.ts
packages/eas-cli/schema/**
packages/eas-cli/graphql.schema.json
packages/build-tools/schema.graphql
node_modules
ts-declarations
ts-declarations
-44
View File
@@ -1,44 +0,0 @@
module.exports = {
root: true,
extends: ['universe/node'],
plugins: ['async-protect', 'node'],
rules: {
'no-console': 'warn',
'no-constant-condition': ['warn', { checkLoops: false }],
'sort-imports': [
'warn',
{
ignoreDeclarationSort: true,
},
],
curly: 'warn',
'import/no-cycle': 'error',
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: ['**/__tests__/**/*', '**/__mocks__/**/*'] },
],
'import/no-relative-packages': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'lodash',
message: "Don't use lodash, it's heavy!",
},
],
},
],
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
'async-protect/async-suffix': 'error',
'node/no-sync': 'error',
},
overrides: [
{
files: ['**/__tests__/**/*.ts', '**/__mocks__/**/*.ts'],
rules: {
'async-protect/async-suffix': 'off',
},
},
],
};
+11
View File
@@ -0,0 +1,11 @@
name: setup-mise
description: Install tools via mise
runs:
using: "composite"
steps:
- uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1
with:
install: true
cache: true
experimental: true
@@ -0,0 +1,19 @@
name: build-android
on:
workflow_dispatch: {}
jobs:
build:
name: Build Android
type: build
runs_on: linux-large
params:
platform: android
profile: system-test
custom_job:
name: Custom Job
runs_on: linux-medium-nested-virtualization
steps:
- name: Echo
run: echo "This is a custom job step"
- uses: eas/start_android_emulator
@@ -0,0 +1,16 @@
name: setup-gcloud
runs:
using: "composite"
steps:
- name: Auth gcloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: 'projects/321830142373/locations/global/workloadIdentityPools/github/providers/expo'
project_id: exponentjs
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2
with:
version: 548.0.0
project_id: exponentjs
@@ -0,0 +1,205 @@
name: build-and-deploy-worker
# Assumes main branch always specifies an environment (staging/production).
# If environment is empty: PR testing build (no upload, no deploy)
# If environment is set: Build (unless cached on main), upload, and deploy
on:
workflow_dispatch:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
workflow_call:
inputs:
environment:
description: '"staging", "production" or empty'
type: string
required: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- uses: ./.github/internal-actions/setup-gcloud
- name: Check if worker already exists
id: check_worker_exists
# Check if tarballs for this SHA already exist
# If they do, we can skip building regardless of branch
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
if ! gsutil stat gs://eas-build-worker-tarballs/$GCP_TARBALL; then
echo "worker_exists=false" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "worker_exists=true" >> "$GITHUB_OUTPUT"
- name: Start EAS Workflow to build worker tarballs
id: eas_workflow
# Build if: PR testing (no env), or deploying but tarballs don't exist
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
# Only upload artifacts from EAS if GHA will upload them to GCS
# This avoids unnecessary artifact uploads for PR builds
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
# Run workflow and capture output (includes run ID)
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
--no-wait \
--json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_OUTPUT" | jq -r '.id')
if [ -z "$WORKFLOW_RUN_ID" ] || [ "$WORKFLOW_RUN_ID" = "null" ]; then
echo "Error: Could not determine workflow run ID"
echo "Workflow output:"
echo "$WORKFLOW_OUTPUT" | jq .
exit 1
fi
echo "workflow_run_id=$WORKFLOW_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for EAS workflow and capture artifacts
id: eas_artifacts
if: inputs.environment == '' || steps.check_worker_exists.outputs.worker_exists == 'false'
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: Missing workflow run ID"
exit 1
fi
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
case "$STATUS" in
SUCCESS)
break
;;
FAILURE)
echo "EAS workflow failed."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
CANCELED)
echo "EAS workflow canceled."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
ACTION_REQUIRED)
echo "EAS workflow requires action."
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
NEW|IN_PROGRESS)
sleep 7
;;
*)
echo "Unexpected workflow status: $STATUS"
echo "$VIEW_OUTPUT" | jq .
exit 1
;;
esac
done
# Skip artifact extraction if we didn't request uploads
if [ "$SHOULD_UPLOAD_ARTIFACT" == "false" ]; then
echo "Skipping artifact download - upload was disabled for this run"
exit 0
fi
# Extract artifact download URLs from workflow output
# Artifacts are in jobs[].artifacts[] for custom jobs
DOWNLOAD_IOS_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-ios-tarball")
| .downloadUrl
' | head -1)
DOWNLOAD_ANDROID_URL=$(echo "$VIEW_OUTPUT" | jq -r '
.jobs[]
| .artifacts[]?
| select(.name == "worker-android-tarball")
| .downloadUrl
' | head -1)
if [ -z "$DOWNLOAD_IOS_URL" ] || [ "$DOWNLOAD_IOS_URL" = "null" ]; then
echo "Error: Could not find iOS artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
if [ -z "$DOWNLOAD_ANDROID_URL" ] || [ "$DOWNLOAD_ANDROID_URL" = "null" ]; then
echo "Error: Could not find Android artifact download URL"
echo "$VIEW_OUTPUT" | jq .
exit 1
fi
# Mask the download URLs
echo "::add-mask::$DOWNLOAD_IOS_URL"
echo "::add-mask::$DOWNLOAD_ANDROID_URL"
echo "download_ios_url=$DOWNLOAD_IOS_URL" >> "$GITHUB_OUTPUT"
echo "download_android_url=$DOWNLOAD_ANDROID_URL" >> "$GITHUB_OUTPUT"
- name: Cancel EAS workflow on GitHub cancellation
if: cancelled()
working-directory: packages/worker
env:
EXPO_TOKEN: ${{ secrets.EXPO_DEV_EXPO_GITHUB_ROBOT_ACCESS_TOKEN }}
run: |
set -euo pipefail
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
# Upload when deploying if we built something new
# Don't upload PR builds (no environment) or when using cached tarballs
if: inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false'
run: |
IOS_GCP_TARBALL="worker-ios-sha1-$GITHUB_SHA.tar.gz"
ANDROID_GCP_TARBALL="worker-android-sha1-$GITHUB_SHA.tar.gz"
curl -L "${{ steps.eas_artifacts.outputs.download_ios_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$IOS_GCP_TARBALL
curl -L "${{ steps.eas_artifacts.outputs.download_android_url }}" | gsutil cp - gs://eas-build-worker-tarballs/$ANDROID_GCP_TARBALL
- name: Promote worker to ${{ inputs.environment }}
if: inputs.environment != ''
run: |
for platform in android ios; do
GCP_TARBALL="worker-$platform-sha1-$GITHUB_SHA.tar.gz"
WORKER_STAGING_TARBALL="worker-$platform-${{ inputs.environment }}.tar.gz"
gsutil cp gs://eas-build-worker-tarballs/$GCP_TARBALL gs://eas-build-worker-tarballs/$WORKER_STAGING_TARBALL
done
+64
View File
@@ -0,0 +1,64 @@
on:
workflow_dispatch:
inputs:
staging_only:
type: boolean
required: true
default: false
description: "Only move the latest-eas-build-staging tag, not the latest-eas-build tag"
version:
type: string
required: true
description: "Version number to move the tag to"
dry_run:
type: boolean
required: true
default: true
description: "Run the workflow in dry run mode, without making any NPM changes"
name: Move NPM tags used by EAS builds and workflows
jobs:
move-tag:
name: Move NPM tags used by EAS builds and workflows
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
INPUT_STAGING_ONLY: ${{ github.event.inputs.staging_only }}
steps:
- uses: actions/setup-node@v2
with:
registry-url: "https://registry.npmjs.org/"
scope: "expo"
node-version: 22
- name: Resolve version (fails if the version does not exist)
run: |
set -o pipefail
npm pack --dry-run eas-cli@$INPUT_VERSION
- name: Add latest-eas-build-staging tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build-staging"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
- name: Add latest-eas-build tag
run: |
cmd="npm dist-tag add eas-cli@$INPUT_VERSION latest-eas-build"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
echo "Running in dry run mode, skipping version check"
echo "cmd = $cmd"
exit 0
fi
if [[ "$INPUT_STAGING_ONLY" == "true" ]]; then
echo "Only moving the staging tag, skipping the production tag"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
+21 -151
View File
@@ -1,7 +1,7 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release workflow
@@ -9,6 +9,8 @@ jobs:
release:
name: Create a release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
@@ -20,186 +22,54 @@ jobs:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
build-linux:
name: Build for Linux
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Ensure GraphQL schema and generated code is up-to-date
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn verify-graphql-code
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm
run: echo "filename=$(ls eas-*-arm.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload linux-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload linux-arm tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm.outputs.filename }}
asset_name: ${{ steps.arm.outputs.filename }}
asset_content_type: application/gzip
build-mac:
name: Build for macOS
# Use macOS to get the correct platform specific dependencies
runs-on: macos-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
- run: brew install python-setuptools
env:
HOMEBREW_NO_AUTO_UPDATE: 1
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
yarn pretarball-ci
yarn oclif pack:tarballs --no-xz --targets darwin-x64,darwin-arm64
- id: x64
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- id: arm64
run: echo "filename=$(ls eas-*-arm64.tar.gz)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
- name: Upload darwin-x64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload darwin-arm64 tarball
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/${{ steps.arm64.outputs.filename }}
asset_name: ${{ steps.arm64.outputs.filename }}
asset_content_type: application/gzip
build-windows:
name: Build for Windows
runs-on: ubuntu-latest
needs: release
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: |
yarn pretarball-ci
yarn oclif pack:win
working-directory: ${{ github.workspace }}/packages/eas-cli
- id: x64
run: echo "filename=$(ls eas-*-x64.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- id: x86
run: echo "filename=$(ls eas-*-x86.exe)" >> $GITHUB_OUTPUT
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
- name: Upload x64 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x64.outputs.filename }}
asset_name: ${{ steps.x64.outputs.filename }}
asset_content_type: application/gzip
- name: Upload x86 installer
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: packages/eas-cli/dist/win32/${{ steps.x86.outputs.filename }}
asset_name: ${{ steps.x86.outputs.filename }}
asset_content_type: application/gzip
publish-to-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: [build-linux, build-mac, build-windows]
needs: release
permissions:
id-token: write
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
registry-url: 'https://registry.npmjs.org/'
scope: 'expo'
node-version: 22
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-mise
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
run: yarn install --immutable
- name: Build
run: yarn build
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
run: yarn run -T lerna publish from-package --yes --no-verify-access
- name: Add latest-eas-build-staging tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build-staging tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build-staging
- name: Add latest-eas-build tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
- name: Add latest-eas-build tag
run: |
VERSION=$(cat lerna.json | jq -r .version)
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
npm dist-tag add eas-cli@$VERSION latest-eas-build
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
release-changelog:
name: Update changelog and publish release
runs-on: ubuntu-latest
needs: publish-to-npm
permissions:
contents: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 22
- uses: ./.github/actions/setup-mise
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
run: yarn install --immutable
- name: Resolve version
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Update changelog
working-directory: ./scripts
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
- name: Publish release
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
env:
@@ -226,8 +96,8 @@ jobs:
SLACK_CHANNEL: eas-cli
SLACK_COLOR: ${{ job.status }}
SLACK_ICON: https://github.com/expo-bot.png?size=48
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
SLACK_TITLE: "Released eas-cli@${{ env.EAS_CLI_VERSION }}"
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
SLACK_USERNAME: 'EAS CLI CI'
SLACK_USERNAME: "EAS CLI CI"
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_FOOTER: ''
SLACK_FOOTER: ""
-23
View File
@@ -1,23 +0,0 @@
name: Size
on:
pull_request:
types: [synchronize, opened]
concurrency:
group: size-${{ github.ref }}
cancel-in-progress: true
jobs:
compare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 20
- uses: preactjs/compressed-size-action@v2
with:
build-script: 'build:tarballs:linux'
repo-token: '${{ secrets.GITHUB_TOKEN }}'
pattern: './packages/eas-cli/dist/*.tar.gz'
compression: 'none'
strip-hash: "v\\d+.\\d+.\\d+-\\w+-"
clean-script: 'clean'
+3 -5
View File
@@ -18,10 +18,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 22
- run: yarn install --frozen-lockfile
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+3 -5
View File
@@ -22,9 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 20
- run: yarn install --frozen-lockfile --check-files
- name: Setup tools
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
+35 -12
View File
@@ -15,40 +15,63 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: ['18', '20', '22']
node: ['20', '22', '24']
include:
- node: '20'
coverage: true
name: Test with Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- run: yarn install --frozen-lockfile --check-files
- name: Setup tools
uses: ./.github/actions/setup-mise
env:
MISE_NODE_VERSION: ${{ matrix.node }}
- name: Install dependencies
run: yarn install --immutable
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- run: yarn typecheck
- run: |
git config --global user.email "[email protected]"
git config --global user.name "Test"
- run: yarn test
if: ${{ !matrix.coverage }}
- name: Fetch base commit for PR diffing
if: ${{ github.event_name == 'pull_request' }}
# Lerna needs the base commit to compute affected packages.
run: git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
- name: Run tests (affected)
# PRs: only run tests for changed packages + dependents.
if: ${{ !matrix.coverage && github.event_name == 'pull_request' }}
run: yarn run -T lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
env:
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn test --coverage
- name: Run tests (all)
# Pushes to main: run all package tests.
if: ${{ !matrix.coverage && github.event_name == 'push' }}
run: yarn run -T lerna run test
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Run tests (coverage)
# Coverage on PRs and main; per-package reports are uploaded to Codecov.
if: ${{ matrix.coverage }}
run: yarn run -T lerna run test -- --coverage
env:
# Oclif seems to cause OOM errors when testing with coverage enabled.
# Remove this whenever we solved those OOM errors.
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint --max-warnings=0
- run: yarn install --frozen-lockfile --check-files
- run: yarn lint
- run: yarn fmt:check
- name: Install scripts dependencies
if: ${{ matrix.coverage }}
run: yarn install --immutable
working-directory: ./scripts
env:
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- run: yarn lint-changelog
- if: ${{ matrix.coverage }}
run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
+2 -4
View File
@@ -27,9 +27,7 @@ jobs:
ref: main
fetch-depth: 0
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: ./.github/actions/setup-mise
- run: |
# Lerna will not detect changes correctly without having all tags
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
@@ -37,7 +35,7 @@ jobs:
git checkout main
git config --global user.email "[email protected]"
git config --global user.name "Expo CI"
yarn install --frozen-lockfile --check-files
yarn install --immutable
yarn build
if [[ "$INPUT_VERSION" == "" ]]; then
echo "Releasing with default version"
@@ -1,81 +0,0 @@
name: Open a PR bumping eas-build modules
on: workflow_dispatch
jobs:
open-pr:
name: Open a PR bumping eas-build modules
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.EXPO_BOT_PAT }}
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: 20
- run: yarn install --frozen-lockfile
- name: Configure git
run: |
git config --global user.email "[email protected]"
git config --global user.name "Expo Bot"
- name: Upgrade eas-build modules
run: |
for DIR in packages/eas-cli packages/eas-json; do
pushd $DIR >/dev/null 2>&1
# packages are ordered according to output of `lerna list --toposort`
# https://github.com/expo/turtle-v2/pull/568
for PACKAGE in "@expo/downloader" "@expo/eas-build-job" "@expo/logger" "@expo/template-file" "@expo/steps" "@expo/turtle-spawn" "@expo/build-tools"; do
if grep -q $PACKAGE package.json; then
echo "[$DIR] yarn --exact add $PACKAGE"
yarn add --exact $PACKAGE
fi
done
popd >/dev/null 2>&1
done
- name: Set a branch name
id: bump-branch-name
run: |
HASH="$(echo $RANDOM | md5sum | head -c 10)"
BRANCH_NAME="@expo-bot/eas-build-bump-$HASH"
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Create a branch, commit, and push
run: |
BRANCH_NAME="${{ steps.bump-branch-name.outputs.BRANCH_NAME }}"
git checkout -b $BRANCH_NAME
git add .
git commit -m "Upgrade eas-build dependencies"
git push origin $BRANCH_NAME
- name: Open a PR to main
uses: repo-sync/pull-request@v2
id: open-pr
with:
source_branch: '${{ steps.bump-branch-name.outputs.BRANCH_NAME }}'
destination_branch: 'main'
pr_title: 'Upgrade eas-build dependencies'
pr_body: ':arrow_up: Upgrades [`eas-build`](https://github.com/expo/eas-build) dependencies.'
pr_reviewer: 'sjchmiela'
github_token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/github-script@v3
with:
github-token: ${{ secrets.EXPO_BOT_PAT }}
script: |
github.issues.createComment({
issue_number: ${{ steps.open-pr.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: "/changelog-entry chore Upgrade [`eas-build`](https://github.com/expo/eas-build) dependencies."
})
+160
View File
@@ -0,0 +1,160 @@
name: system-tests
on:
workflow_dispatch: {}
workflow_call: {}
jobs:
system-tests:
runs-on: ubuntu-latest
env:
EXPO_TOKEN: ${{ secrets.STAGING_EXPO_DEV_EXPO_SERVICES_GITHUB_ROBOT_ACCESS_TOKEN }}
EXPO_STAGING: "1"
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- name: Create test app
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
- name: Link to EAS project
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
- name: Add bundle identifiers to app.json
working-directory: ${{ runner.temp }}/test-app
run: |
jq '.expo.ios.bundleIdentifier = "dev.expo.workersystemtests" | .expo.android.package = "dev.expo.workersystemtests"' app.json > app.json.tmp
mv app.json.tmp app.json
- name: Configure EAS Build
working-directory: ${{ runner.temp }}/test-app
run: npx -y eas-cli@latest build:configure --platform all
- name: Add system-test profile to eas.json
working-directory: ${{ runner.temp }}/test-app
run: |
jq '.build["system-test"] = { "ios": { "simulator": true }, "android": { "withoutCredentials": true } }' eas.json > eas.json.tmp
mv eas.json.tmp eas.json
- name: Setup EAS workflow
working-directory: ${{ runner.temp }}/test-app
run: |
mkdir -p .eas/workflows
cp ${{ github.workspace }}/.github/fixtures/eas-workflow-build-android.yml .eas/workflows/build-android.yml
- name: Run iOS build and Android workflow concurrently
working-directory: ${{ runner.temp }}/test-app
run: |
set -euo pipefail
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
if [ -z "$BUILD_ID" ]; then
echo "Error: could not determine build ID"
echo "$BUILD_JSON" | jq .
exit 1
fi
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
echo "Resolved BUILD_ID=$BUILD_ID"
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
if [ -z "$WORKFLOW_RUN_ID" ]; then
echo "Error: could not determine workflow run ID"
echo "$WORKFLOW_JSON" | jq .
exit 1
fi
echo "WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID" >> "$GITHUB_ENV"
echo "Resolved WORKFLOW_RUN_ID=$WORKFLOW_RUN_ID"
is_terminal_build_status() {
case "$1" in
FINISHED|ERRORED|CANCELED) return 0 ;;
*) return 1 ;;
esac
}
is_terminal_workflow_status() {
case "$1" in
SUCCESS|FAILURE|CANCELED|ACTION_REQUIRED) return 0 ;;
*) return 1 ;;
esac
}
BUILD_STATUS=""
WORKFLOW_STATUS=""
TIMEOUT_SECONDS=1800
START_TIME=$(date +%s)
while true; do
ELAPSED=$(( $(date +%s) - START_TIME ))
if [ "$ELAPSED" -ge "$TIMEOUT_SECONDS" ]; then
echo "Timeout: polling exceeded 30 minutes"
exit 1
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
echo "iOS build status: $BUILD_STATUS"
echo "Android workflow status: $WORKFLOW_STATUS"
if is_terminal_build_status "$BUILD_STATUS" && is_terminal_workflow_status "$WORKFLOW_STATUS"; then
break
fi
sleep 7
done
EXIT_CODE=0
if [ "$BUILD_STATUS" != "FINISHED" ]; then
echo "Build did not finish successfully: $BUILD_STATUS"
EXIT_CODE=1
fi
if [ "$WORKFLOW_STATUS" != "SUCCESS" ]; then
echo "Workflow did not finish successfully: $WORKFLOW_STATUS"
EXIT_CODE=1
fi
exit "$EXIT_CODE"
- name: Cancel EAS runs on workflow cancellation
if: always() && (cancelled() || failure())
working-directory: ${{ runner.temp }}/test-app
run: |
set -euo pipefail
if [ -n "${BUILD_ID:-}" ]; then
echo "Canceling EAS build: $BUILD_ID"
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
echo "$BUILD_CANCEL_OUTPUT"
else
echo "build:cancel produced no output"
fi
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$BUILD_STATUS" ]; then
echo "Post-cancel build status: $BUILD_STATUS"
else
echo "Post-cancel build status unavailable"
fi
fi
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
echo "$WORKFLOW_CANCEL_OUTPUT"
else
echo "workflow:cancel produced no output"
fi
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
if [ -n "$WORKFLOW_STATUS" ]; then
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
else
echo "Post-cancel workflow status unavailable"
fi
fi
+80
View File
@@ -0,0 +1,80 @@
name: worker
on:
push:
branches: [main]
paths:
- "packages/worker/**"
- "packages/build-tools/**"
- "packages/eas-build-job/**"
- "packages/steps/**"
- "packages/logger/**"
- "packages/downloader/**"
- "packages/template-file/**"
- "packages/turtle-spawn/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
- yarn.lock
concurrency:
group: worker-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
jobs:
worker-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn typecheck
- name: Run unit tests
working-directory: packages/worker
run: yarn test:unit
- name: Run integration tests
working-directory: packages/worker
run: yarn test:integration
worker-build:
needs:
- worker-checks
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: ${{ github.ref == 'refs/heads/main' && 'staging' || '' }}
system-tests-staging:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- worker-build
- worker-checks
uses: ./.github/workflows/worker-system-tests.yml
secrets: inherit
deploy-worker-production:
if: github.ref == 'refs/heads/main' && !failure() && !cancelled()
needs:
- system-tests-staging
uses: ./.github/workflows/build-and-deploy-worker.yml
secrets: inherit
with:
environment: production
notify-slack:
runs-on: ubuntu-latest
needs:
- deploy-worker-production
if: github.ref == 'refs/heads/main' && always() && !cancelled()
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Notify Slack
uses: ./.github/internal-actions/notify-slack-on-fail-or-recover
with:
channel: turtle-bots
slack_webhook: ${{ secrets.SLACK_EAS_WORKFLOWS_WEBHOOK }}
+7
View File
@@ -3,17 +3,24 @@ packages/*/build
packages/eas-cli/dist
packages/eas-cli/tmp
scripts/build
dist
# TypeScript
**/*.tsbuildinfo
# Code editors
.idea
.history
.vscode
*.swp
*.swo
# macOS
.DS_Store
# Node
node_modules
**/.yarn/install-state.gz
*-debug.log
*-error.log
+21
View File
@@ -0,0 +1,21 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "lf",
"ignorePatterns": [
".github",
"lerna.json",
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"packages/eas-cli/README.md",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/build-tools/schema.graphql"
]
}
+77
View File
@@ -0,0 +1,77 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"restriction": "off",
"style": "off",
"nursery": "off"
},
"ignorePatterns": [
"**/build/**",
"**/dist/**",
"**/coverage/**",
"**/fixtures/**",
"**/__fixtures__/**",
".github/**",
"packages/eas-cli/schema/**",
"packages/eas-cli/graphql.schema.json",
"packages/build-tools/schema.graphql"
],
"plugins": ["import", "node", "typescript"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"no-console": "warn",
"no-constant-condition": [
"warn",
{
"checkLoops": false
}
],
"sort-imports": [
"warn",
{
"ignoreDeclarationSort": true
}
],
"curly": "warn",
"default": "off",
"named": "off",
"namespace": "off",
"no-cycle": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "lodash",
"message": "Don't use lodash, it's heavy!"
}
]
}
],
"no-underscore-dangle": [
"error",
{
"allow": ["__typename"]
}
],
"async-protect/async-suffix": "error",
"node/no-sync": "error"
},
"overrides": [
{
"files": ["**/__tests__/**/*.ts", "**/__mocks__/**/*.ts"],
"rules": {
"async-protect/async-suffix": "off"
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
{
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSameLine": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"endOfLine": "auto"
}
+1 -2
View File
@@ -1,5 +1,4 @@
{
"eslint.enable": true,
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
@@ -7,4 +6,4 @@
"**/src/**/build/**": false,
"**/src/build/**": false,
}
}
}
-1
View File
@@ -1 +0,0 @@
save-prefix ""
+2
View File
@@ -0,0 +1,2 @@
defaultSemverRangePrefix: ''
nodeLinker: node-modules
+5
View File
@@ -0,0 +1,5 @@
# AGENTS.md
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
+275 -3
View File
@@ -8,13 +8,285 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
### 🧹 Chores
## [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
- 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))
### 🧹 Chores
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
### 🎉 New features
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
### 🐛 Bug fixes
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
### 🛠 Breaking changes
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
### 🐛 Bug fixes
- Revert authorization code flow with PKCE for browser-based login. ([#3422](https://github.com/expo/eas-cli/pull/3422) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.3](https://github.com/expo/eas-cli/releases/tag/v18.0.3) - 2026-02-20
### 🎉 New features
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
## [18.0.1](https://github.com/expo/eas-cli/releases/tag/v18.0.1) - 2026-02-12
### 🛠 Breaking changes
- Drop support for Node 18 and Node 19. Require Node 20+. ([#3343](https://github.com/expo/eas-cli/pull/3343) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- Add `--browser` flag to `eas login` for browser-based authentication. ([#3312](https://github.com/expo/eas-cli/pull/3312) by [@byronkarlen](https://github.com/byronkarlen))
### 🐛 Bug fixes
- Hide progress bar in build credits warning when usage reaches 100%. ([#3371](https://github.com/expo/eas-cli/pull/3371) by [@mackenco](https://github.com/mackenco))
### 🧹 Chores
- Upgrade `tar` to v7. ([#3327](https://github.com/expo/eas-cli/pull/3327) by [@KarolRzeminski](https://github.com/KarolRzeminski))
## [16.32.0](https://github.com/expo/eas-cli/releases/tag/v16.32.0) - 2026-01-30
### 🎉 New features
- List available options in non-interactive mode errors instead of generic "unable to select" messages, making CLI usable by agents and scripts. ([#3359](https://github.com/expo/eas-cli/pull/3359) by [@EvanBacon](https://github.com/EvanBacon))
- Hidden beta version of new `eas account:usage` command for viewing account usage and billing estimates. ([#3334](https://github.com/expo/eas-cli/pull/3334) by [@douglowder](https://github.com/douglowder), [@EvanBacon](https://github.com/EvanBacon))
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
- Add App Clip bundle identifier registration support for multi-target iOS builds. ([#3300](https://github.com/expo/eas-cli/pull/3300) by [@evanbacon](https://github.com/evanbacon))
- Warn in `eas build` when creating a production build from an app that uses Expo Go for development ([#3073](https://github.com/expo/eas-cli/pull/3073) by [@vonovak](https://github.com/vonovak))
- Add `--runtime-version` and `--platform` filters to `eas update:list`. ([#3261](https://github.com/expo/eas-cli/pull/3261) by [@HarelSultan](https://github.com/HarelSultan))
### 🐛 Bug fixes
- Fix `--source-maps` flag compatibility with older SDKs that only support it as a boolean flag. ([#3341](https://github.com/expo/eas-cli/pull/3341) by [@brentvatne](https://github.com/brentvatne))
- Use `--dump-sourcemaps` as fallback when `--source-maps` is not provided to `eas update`, for backwards compatibility. ([8cc324e1](https://github.com/expo/eas-cli/commit/8cc324e1) by [@brentvatne](https://github.com/brentvatne))
- Fix `metadata:pull` failing for apps with only a live version by falling back to live app version and info. ([#3299](https://github.com/expo/eas-cli/pull/3299) by [@EvanBacon](https://github.com/EvanBacon))
- eas init should fix and validate project name and slug. ([#3277](https://github.com/expo/eas-cli/pull/3277) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Test with node 24. ([#3270](https://github.com/expo/eas-cli/pull/3270) by [@wschurman](https://github.com/wschurman))
- Delete channel in background. ([#3278](https://github.com/expo/eas-cli/pull/3278) by [@quinlanj](https://github.com/quinlanj))
- Add warning to channel:delete. ([#3335](https://github.com/expo/eas-cli/pull/3335) by [@quinlanj](https://github.com/quinlanj))
## [16.28.0](https://github.com/expo/eas-cli/releases/tag/v16.28.0) - 2025-11-20
### 🎉 New features
- `eas:new` sorts organization accounts first in account selection. ([#3256](https://github.com/expo/eas-cli/pull/3256) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
- Fix `eas:build` bug where overages are checked before config is ready. ([#3260](https://github.com/expo/eas-cli/pull/3260) by [@mackenco](https://github.com/mackenco))
### 🧹 Chores
- Delete branch in background. ([#3258](https://github.com/expo/eas-cli/pull/3258) by [@quinlanj](https://github.com/quinlanj))
## [16.27.0](https://github.com/expo/eas-cli/releases/tag/v16.27.0) - 2025-11-13
### 🎉 New features
- Kick off update patch generation if enabled - this is feature is in preview, don't use it for prod yet please. ([#3250](https://github.com/expo/eas-cli/pull/3250) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Fix workflow:status when run ID passed in. ([#3253](https://github.com/expo/eas-cli/pull/3253) by [@douglowder](https://github.com/douglowder))
- Fix updating file env vars with `env:update --non-interactive`. ([#3249](https://github.com/expo/eas-cli/pull/3249) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- Fix yarn watch. ([#3251](https://github.com/expo/eas-cli/pull/3251) by [@quinlanj](https://github.com/quinlanj))
- Remove the **node_modules** installation requirement for `workflow:run` command. ([#3254](https://github.com/expo/eas-cli/pull/3254) by [@kudo](https://github.com/kudo))
## [16.26.0](https://github.com/expo/eas-cli/releases/tag/v16.26.0) - 2025-10-29
### 🎉 New features
- New command `workflow:status`. ([#3242](https://github.com/expo/eas-cli/pull/3242) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- `eas:new` cleanup (remove `hidden` tag, fix README error, output cleanup). ([#3243](https://github.com/expo/eas-cli/pull/3243) by [@mackenco](https://github.com/mackenco))
## [16.25.1](https://github.com/expo/eas-cli/releases/tag/v16.25.1) - 2025-10-28
### 🧹 Chores
- Remove preview label from EAS Hosting commands ([#3241](https://github.com/expo/eas-cli/pull/3241) by [@kitten](https://github.com/kitten))
## [16.25.0](https://github.com/expo/eas-cli/releases/tag/v16.25.0) - 2025-10-27
### 🎉 New features
- `eas:new` command changes (project name prompt, output cleanup, additional project files, additional prompts). ([#3235](https://github.com/expo/eas-cli/pull/3235) by [@mackenco](https://github.com/mackenco))
- `eas:build` command displays a warning when account is near it's build limit. ([#3236](https://github.com/expo/eas-cli/pull/3236) by [@mackenco](https://github.com/mackenco))
### 🐛 Bug fixes
- workflow:run can use --wait and --json together. ([#3239](https://github.com/expo/eas-cli/pull/3239) by [@douglowder](https://github.com/douglowder))
- Add missing env for local builds with latest plugin. ([#3237](https://github.com/expo/eas-cli/pull/3237) by [@douglowder](https://github.com/douglowder))
### 🧹 Chores
- Document the default values for `track` and `releaseStatus`. ([#3229](https://github.com/expo/eas-cli/pull/3229) by [@kadikraman](https://github.com/kadikraman))
## [16.24.1](https://github.com/expo/eas-cli/releases/tag/v16.24.1) - 2025-10-19
### 🐛 Bug fixes
- Revert change in ora version. ([#3231](https://github.com/expo/eas-cli/pull/3231) by [@douglowder](https://github.com/douglowder))
## [16.24.0](https://github.com/expo/eas-cli/releases/tag/v16.24.0) - 2025-10-19
### 🎉 New features
- workflow:run --wait shows periodic job and step status updates. ([#3227](https://github.com/expo/eas-cli/pull/3227) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- workflow:logs should show logs from build and repack jobs. ([#3226](https://github.com/expo/eas-cli/pull/3226) by [@douglowder](https://github.com/douglowder))
## [16.23.1](https://github.com/expo/eas-cli/releases/tag/v16.23.1) - 2025-10-16
### 🐛 Bug fixes
- Multiple bugfixes and small features for the `eas:new` command: handle project name and directory generation more smoothly; support `bun` as a package manager; add `--package-manager` flag with a default to `npm`; ensure workflow files are in the correct location after build; update the generated project's README with the provided package manager; ensure `app.json`'s `scheme` is valid; silence lots of noisy output. ([#3205](https://github.com/expo/eas-cli/pull/3205) by [@mackenco](https://github.com/mackenco))
## [16.23.0](https://github.com/expo/eas-cli/releases/tag/v16.23.0) - 2025-10-14
### 🎉 New features
- Support custom environment variable environments ([#3220](https://github.com/expo/eas-cli/pull/3220) by [@kadikraman](https://github.com/kadikraman))
- Add --force flag to eas env:push command to bypass confirmation prompts. ([#3135](https://github.com/expo/eas-cli/pull/3135) by [@giorgiofellipe](https://github.com/giorgiofellipe))
## [16.22.0](https://github.com/expo/eas-cli/releases/tag/v16.22.0) - 2025-10-13
### 🎉 New features
- Improvements to workflow:create. ([#3194](https://github.com/expo/eas-cli/pull/3194) by [@douglowder](https://github.com/douglowder))
## [16.21.0](https://github.com/expo/eas-cli/releases/tag/v16.21.0) - 2025-10-13
### 🎉 New features
- Support `_expo/.routes.json` metadata file exported by `@expo/cli` for headers configuration on static deployments ([#3211](https://github.com/expo/eas-cli/pull/3211) by [@kitten](https://github.com/kitten))
### 🧹 Chores
- Add thorough tests for the env commands ([#3217](https://github.com/expo/eas-cli/pull/3217) by [@kadikraman](https://github.com/kadikraman))
## [16.20.4](https://github.com/expo/eas-cli/releases/tag/v16.20.4) - 2025-10-08
### 🎉 New features
- Allow workflow:run to run a workflow from a GitHub ref. ([#3203](https://github.com/expo/eas-cli/pull/3203) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- Fix --environment flag in eas exec and pull ([#3216](https://github.com/expo/eas-cli/pull/3216) by [@kadikraman](https://github.com/kadikraman))
## [16.20.3](https://github.com/expo/eas-cli/releases/tag/v16.20.3) - 2025-10-07
### 🐛 Bug fixes
- Fix --environment flag in eas env commands ([#3213](https://github.com/expo/eas-cli/pull/3213) by [@kadikraman](https://github.com/kadikraman))
## [16.20.2](https://github.com/expo/eas-cli/releases/tag/v16.20.2) - 2025-10-07
### 🧹 Chores
- Create an `EnvironmentVariableEnvironment` enum and use it instead of the graphQL Enum ([#3208](https://github.com/expo/eas-cli/pull/3208) by [@kadikraman](https://github.com/kadikraman))
## [16.20.1](https://github.com/expo/eas-cli/releases/tag/v16.20.1) - 2025-10-03
### 🐛 Bug fixes
- skip syncing capabilities with settings if they don't contain the `enabled` attribute ([#3201](https://github.com/expo/eas-cli/pull/3201) by [@vonovak](https://github.com/vonovak))
## [16.20.0](https://github.com/expo/eas-cli/releases/tag/v16.20.0) - 2025-09-30
### 🎉 New features
- Improvements to workflow:validate. ([#3165](https://github.com/expo/eas-cli/pull/3165) by [@douglowder](https://github.com/douglowder))
- Add `eas:new` command behind `hidden` flag. ([#3196](https://github.com/expo/eas-cli/pull/3196) by [@mackenco](https://github.com/mackenco))
## [16.19.3](https://github.com/expo/eas-cli/releases/tag/v16.19.3) - 2025-09-17
### 🧹 Chores
- Temporarily remove warnings when no compatible build found, need to refine the message. ([#3182](https://github.com/expo/eas-cli/pull/3182) by [@brentvatne](https://github.com/brentvatne))
## [16.19.2](https://github.com/expo/eas-cli/releases/tag/v16.19.2) - 2025-09-15
### 🎉 New features
- Add artifact URLs to workflow:view. ([#3175](https://github.com/expo/eas-cli/pull/3175) by [@douglowder](https://github.com/douglowder))
## [16.19.1](https://github.com/expo/eas-cli/releases/tag/v16.19.1) - 2025-09-12
## [16.19.0](https://github.com/expo/eas-cli/releases/tag/v16.19.0) - 2025-09-12
### 🎉 New features
- Allow any string as Android release `track`, not just the `production`, `internal`, `alpha`, `beta`. ([#3163](https://github.com/expo/eas-cli/pull/3163) by [@sjchmiela](https://github.com/sjchmiela))
## [16.18.1](https://github.com/expo/eas-cli/releases/tag/v16.18.1) - 2025-09-10
### 🎉 New features
- CLI debug log should respect DEBUG=`eas:*`. ([#3162](https://github.com/expo/eas-cli/pull/3162) by [@douglowder](https://github.com/douglowder))
- Better description for update:rollback. ([#3154](https://github.com/expo/eas-cli/pull/3154) by [@douglowder](https://github.com/douglowder))
## [16.18.0](https://github.com/expo/eas-cli/releases/tag/v16.18.0) - 2025-08-27
### 🎉 New features
- Show fingerprints in build:view and build:list commands. ([#3137](https://github.com/expo/eas-cli/pull/3137) by [@douglowder](https://github.com/douglowder))
### 🐛 Bug fixes
- Make EXPO_PUBLIC_ env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
### 🧹 Chores
- Make `EXPO_PUBLIC_` env vars plain text, rest sensitive ([#3121](https://github.com/expo/eas-cli/pull/3121) by [@kadikraman](https://github.com/kadikraman))
## [16.17.4](https://github.com/expo/eas-cli/releases/tag/v16.17.4) - 2025-07-31
+355
View File
@@ -0,0 +1,355 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Monorepo Structure
This is a **Lerna-based monorepo** containing the EAS CLI and all supporting build libraries.
### CLI & Configuration Packages
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
- Built on oclif (Open CLI Framework)
- Entry point: `packages/eas-cli/bin/run`
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
- Validates `eas.json` configuration files using Joi schemas
- Manages build, submit, and deploy profiles
- Supports JSON5 via golden-fleece
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
- Optimizes build caching for Expo CLI
### Build Execution Packages
4. **[packages/eas-build-job](./packages/eas-build-job)** - Build job definitions (published as `@expo/eas-build-job`)
- Defines all data structures for build operations (Job, BuildPhase, BuildMode, Platform, Workflow)
- Provides type definitions and validation schemas (Zod, Joi)
- Contains BuildPhase enum that defines the traditional build pipeline stages
- Key exports: `Job`, `BuildPhase`, `BuildMode`, `BuildTrigger`, `Workflow`, `ArchiveSource`
- **Foundation that all other build packages depend on**
5. **[packages/build-tools](./packages/build-tools)** - Build execution engine (published as `@expo/build-tools`)
- Orchestrates all build operations through `BuildContext<T extends Job>`
- Contains platform-specific builders: `androidBuilder()`, `iosBuilder()`, `runCustomBuildAsync()`
- Manages build phases, artifact uploading, caching, credentials
- Provides functions for custom builds
- Integrates with GraphQL API
6. **[packages/steps](./packages/steps)** - Custom build workflow engine (published as `@expo/steps`)
- Framework for defining and executing custom build steps
- Key abstractions:
- `BuildWorkflow`: Orchestrates sequential step execution
- `BuildStep`: Individual executable unit with inputs/outputs
- `BuildStepGlobalContext`: Manages shared state and interpolation
- `BuildStepContext`: Per-step execution context
- Supports conditional execution with `if` expressions (using jsep)
- Template interpolation: `${{ steps.step-id.outputs.outputName }}`
- Parses build configs from YAML/JSON
7. **[packages/local-build-plugin](./packages/local-build-plugin)** - Local build execution (published as `eas-cli-local-build-plugin`)
- Allows running EAS builds locally on developer machines
- Entry point: `packages/local-build-plugin/src/main.ts`
- Sets `EAS_BUILD_RUNNER=local-build-plugin` environment variable
- Reuses all build-tools logic for consistency with cloud builds
8. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
- Runs on EAS build VMs/pods
- WebSocket server for communication with Turtle Launcher
- Wraps `@expo/build-tools` to execute actual React Native builds
### Supporting Packages
9. **[packages/logger](./packages/logger)** - Bunyan-based structured logging (published as `@expo/logger`)
- Used by all build packages
10. **[packages/downloader](./packages/downloader)** - HTTP file downloading with retry logic (published as `@expo/downloader`)
11. **[packages/turtle-spawn](./packages/turtle-spawn)** - Child process spawning with error handling (published as `@expo/turtle-spawn`)
12. **[packages/template-file](./packages/template-file)** - Lodash-based template string interpolation (published as `@expo/template-file`)
13. **[packages/create-eas-build-function](./packages/create-eas-build-function)** - CLI scaffolding tool for custom build functions (published as `create-eas-build-function`)
14. **[packages/expo-cocoapods-proxy](./packages/expo-cocoapods-proxy)** - Ruby gem for CocoaPods proxy (published as `expo-cocoapods-proxy` gem)
> **Note**: Some packages have their own CLAUDE.md with package-specific guidance. Click the links above to view them.
## Common Development Commands
### Setup
```bash
yarn install # Install all dependencies
yarn build # Build all packages in the monorepo
```
### Development
```bash
yarn start # Watch mode with parallel builds across all packages
yarn watch # Alias for start
# Development with relaxed TypeScript rules (allows unused variables)
yarn start-allow-unused
```
### Testing
```bash
yarn run -T lerna run test # Run all tests across packages
yarn run -T lerna run test -- --watch # Watch mode across packages (heavier)
#
# If you use `yarn test`, remember to pass args through two separators:
# yarn test -- -- --watch
# Run tests for a specific package
cd packages/eas-cli && yarn test
cd packages/eas-json && yarn test
cd packages/worker && yarn test
cd packages/build-tools && yarn test
cd packages/steps && yarn test
```
### Type Checking & Linting
```bash
yarn typecheck # TypeScript type check all packages
yarn lint # Oxlint across all packages
yarn fmt:check # Oxfmt format check
```
### Running EAS CLI Locally
```bash
# From repository root:
yarn eas <command> # Run via root package script
# Or use the binary directly:
packages/eas-cli/bin/run <command>
# Recommended: Create an alias for development
alias easd="$(pwd)/packages/eas-cli/bin/run"
easd build --help
```
### Local Build Testing
Set up environment variables for testing local builds:
```bash
export EAS_LOCAL_BUILD_PLUGIN_PATH=$HOME/expo/eas-cli/bin/eas-cli-local-build-plugin
export EAS_LOCAL_BUILD_WORKINGDIR=$HOME/expo/eas-build-workingdir
export EAS_LOCAL_BUILD_SKIP_CLEANUP=1
export EAS_LOCAL_BUILD_ARTIFACTS_DIR=$HOME/expo/eas-build-workingdir/results
# Then run build with --local flag in eas-cli
eas build --local
```
## Build System
Each package has independent TypeScript compilation:
- `eas-cli`: `src/` → `build/`
- `eas-json`: `src/` → `build/`
- `eas-build-cache-provider`: `src/` → `build/`
- `worker`: `src/` → `dist/`
- `build-tools`: `src/` → `dist/`
- `eas-build-job`: `src/` → `dist/`
- `steps`: `src/` → `dist/` (CommonJS only)
- `local-build-plugin`: `src/` → `dist/`
- Other packages: `src/` → `dist/`
TypeScript configs:
- `tsconfig.json` - Base configuration (extends @tsconfig/node20)
- `tsconfig.build.json` - Production builds
- `tsconfig.allowUnused.json` - Development with relaxed rules
## Key Architectural Patterns
### Build Phases
Most traditional build operations are wrapped in phases for tracking:
```typescript
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
// Phase logic here
});
```
Phases can be marked as skipped, warning, or failed for granular reporting.
### Context Objects
- **BuildContext** (`build-tools`): For traditional builds, wraps Job, manages phases/artifacts/caching
- **CustomBuildContext** (`build-tools`): Implements `ExternalBuildContextProvider`, bridges BuildContext to steps framework, used in custom builds and generic jobs
- **BuildStepGlobalContext** (`steps`): Manages step outputs, interpolation, shared state
- **BuildStepContext** (`steps`): Per-step context with working directory and logger
### Custom Build Steps
Steps are defined with:
- `id`: Unique identifier
- `name`: Display name
- `run`: Command or function reference
- `if`: Optional condition (`${{ always() }}`, `${{ success() }}`, etc.)
- `inputs`: Key-value inputs to the step
- `outputs`: Named outputs accessible to later steps
Built-in step functions are in `packages/build-tools/src/steps/functions/`
### Conditional Execution
Uses jsep for expression evaluation:
```yaml
if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'production' }}
```
### Artifact Management
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Generic artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
```
eas-cli → @expo/eas-json
→ @expo/build-tools → @expo/eas-build-job
→ @expo/steps → @expo/eas-build-job
→ @expo/logger
→ @expo/turtle-spawn
→ @expo/downloader
→ @expo/template-file
local-build-plugin → @expo/build-tools → @expo/eas-build-job
→ @expo/turtle-spawn
worker → @expo/build-tools
```
Most packages depend on `@expo/eas-build-job` as the source of truth for types.
## Testing Architecture
- **Framework**: Jest with per-package configuration
- **Shared base config**: `jest/jest.shared.config.ts`
- **Test locations**: `__tests__/` directories alongside source files
- **Mocking**:
- File system: `memfs`
- HTTP: `nock`
- Utilities: `ts-mockito`, `mockdate`
Run a single test file:
```bash
cd packages/eas-cli && yarn test <path-to-test-file>
# Example:
cd packages/eas-cli && yarn test src/project/__tests__/projectUtils-test.ts
```
## Common Development Scenarios
### Adding a Built-in Step Function
1. Create file in `packages/build-tools/src/steps/functions/yourFunction.ts`
2. Export `createYourFunctionBuildFunction()` following existing patterns
3. Add to `getEasFunctions()` in `packages/build-tools/src/steps/functions/easFunctions.ts`
4. Function receives `BuildStepContext` and input/output maps
### Adding Error Detection
1. Add pattern detection in `packages/build-tools/src/buildErrors/detectError.ts`
2. Implement resolver for better error messages
3. Helps users understand and fix build failures
### Working with Platform-Specific Builders
- **Android builder** (`packages/build-tools/src/builders/android.ts`): Gradle-based, handles APK/AAB generation, also see `functionGroups/build.ts`
- **iOS builder** (`packages/build-tools/src/builders/ios.ts`): Fastlane/Xcode-based, handles IPA generation, also see `functionGroups/build.ts`
- Both use `runBuilderWithHooksAsync()` which runs build result hooks (on-success, on-error, on-complete) from package.json
### Introducing Breaking Changes to Job API (@expo/eas-build-job)
If you want to introduce breaking changes to the `@expo/eas-build-job` package, contact one of the CODEOWNERS to coordinate changes with EAS build servers and GraphQL API. Describe what changes you want to make and why. After everything is deployed to production, you can introduce a PR that relies on the new implementation.
## Environment Variables
```bash
# API Configuration
export EXPO_STAGING=1 # Use staging API (https://staging-api.expo.dev)
export EXPO_LOCAL=1 # Use local API (http://127.0.0.1:3000)
# Development
export EAS_NO_VCS=1 # Disable version control checks
export EXPO_DEBUG=1 # Enable debug logging
```
## Code Style & Conventions
### Validation
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
for touched code, then run type checks/linting and format the repo:
```bash
yarn fmt # Apply Oxfmt formatting
yarn typecheck # Validate TypeScript types
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
```
### Logging
Use the `Log` module from `@expo/logger`:
```typescript
import { Log } from '@expo/logger';
Log.log('Info message');
Log.warn('Warning message');
Log.error('Error message');
```
### Pull Request conventions
When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.github/PULL_REQUEST_TEMPLATE).
### Commit Messages
- When possible, prepend commit messages with `[PACKAGE-BEING-CHANGED]`, e.g. `[steps] Add new step function`
- Do not use prefixes like `chore:` and `feat:`
- Commit messages should be concise. Only complex changes should be longer than one line
- Commit changes in logical groups
## Release Process
1. Update `CHANGELOG.md` in the appropriate package
2. Version bump is automated based on changelog:
- Breaking changes → MAJOR
- New features → MINOR
- Otherwise → PATCH
3. GitHub Actions workflow handles release automation
4. Notifications sent to Slack #eas-cli channel
## Licensing
This repository contains packages under different licenses:
- **MIT License**: `eas-cli`, `@expo/eas-json`, `@expo/eas-build-job`, `eas-build-cache-provider`
- **BUSL-1.1 (Business Source License)**: `@expo/build-tools`, `@expo/steps`, `@expo/logger`, `@expo/downloader`, `@expo/turtle-spawn`, `@expo/template-file`, `eas-cli-local-build-plugin`
See `LICENSE` (MIT) and `LICENSE-BUSL` (BUSL-1.1) for details.
## Important Notes
- **Node Version**: Requires Node.js (managed via Mise)
- **Package Manager**: Uses Yarn 4.12.0
- **Compilation Target**: CommonJS with Node resolution
+16 -7
View File
@@ -1,12 +1,21 @@
# Development
Set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
## Getting Started
> The only exception is when you just cloned the repository or there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
If this is your first time running `eas-cli` locally, make sure you install and build the packages. In the project root, run:
```sh
yarn install
yarn build
```
From there, set up an alias for the EAS CLI so you can try it in projects all around your computer. The project is compiled on the fly so you don't need to run a build (watch) command.
> The only exception is if there have been changes in `packages/eas-json`. In that case, you'll have to run `yarn build` in the root.
Open your `.zshrc` or other config file and add:
```
```sh
alias easd="/PATH/TO/eas-cli/bin/run"
```
@@ -14,7 +23,7 @@ Then use it with `easd` like `easd build`.
If you're making changes to `packages/eas-json` or prefer/need to work with production code, start the build command in watch mode with:
```
```sh
yarn start
```
@@ -33,7 +42,7 @@ If it is easier for you to work with the code locally with compiler options set
you can consider using:
```
```sh
yarn start-allow-unused
```
@@ -56,11 +65,11 @@ For development against staging API:
## Working on local builds (`eas build --local`)
See https://github.com/expo/eas-build/blob/main/DEVELOPMENT.md for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](https://github.com/expo/eas-build/tree/main/packages/local-build-plugin) and/or [`build-tools`](https://github.com/expo/eas-build/tree/main/packages/build-tools).
See [`CLAUDE.md`](./CLAUDE.md) for how to set up your environment when making changes to [`eas-cli-local-build-plugin`](./packages/local-build-plugin) and/or [`build-tools`](./packages/build-tools).
## Testing
Run `yarn test` either in the repository root or in a package directory that you're working on.
From the repository root, run `yarn run -T lerna run test`. For a single package, run `yarn test` in that package directory.
## Releasing
+100
View File
@@ -0,0 +1,100 @@
Business Source License 1.1
Parameters
Licensor: 650 Industries, Inc.
Licensed Work: EAS Build
The Licensed Work is (c) 2021 650 Industries, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that you do
not use the Licensed Work for commercial offerings such as
a CI/CD service or application build service that allows
third parties (other than your employees and contractors)
to access the functionality of and directly benefit from the
functionality of the Licensed Work.
Change Date: 2028-04-01
Change License: MIT
For information about alternative licensing arrangements for the Software,
please visit: https://expo.dev/pricing/
Notice
The Business Source License (this document, or the "License") is not an Open
Source license. However, the Licensed Work will eventually be made available
under an Open Source License, as stated in this License.
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this License’s text to license
your works, and to refer to it using the trademark "Business Source License",
as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this License’s text and the "Business
Source License" name and trademark, Licensor covenants to MariaDB, and to all
other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later version,
or a license that is compatible with GPL Version 2.0 or a later version,
where "compatible" means that software provided under the Change License can
be included in a program with software provided under GPL Version 2.0 or a
later version. Licensor may specify additional Change Licenses without
limitation.
2. To either: (a) specify an additional grant of rights to use that does not
impose any additional restriction on the right granted in this License, as
the Additional Use Grant; or (b) insert the text "None".
3. To specify a Change Date.
4. Not to modify this License in any other way.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2020-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
../packages/local-build-plugin/bin/run
-15
View File
@@ -1,15 +0,0 @@
import type { Config } from 'jest';
// eslint-disable-next-line import/no-relative-packages
import easCliConfig from './packages/eas-cli/jest.config';
// eslint-disable-next-line import/no-relative-packages
import easJsonConfig from './packages/eas-json/jest.config';
const config: Config = {
projects: [easCliConfig, easJsonConfig],
testPathIgnorePatterns: ['.*'],
watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'],
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
};
export default config;
-9
View File
@@ -1,9 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
testRegex: '/__tests__/.*(test|spec)\\.[jt]sx?$',
};
export default config;
+8
View File
@@ -0,0 +1,8 @@
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
preset: 'ts-jest',
testEnvironment: 'node',
};
export default config;
+2 -2
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "16.18.0",
"version": "18.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
}
+6
View File
@@ -0,0 +1,6 @@
[env]
_.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
node = "20.19.4"
+13 -17
View File
@@ -6,36 +6,32 @@
],
"scripts": {
"build": "lerna run build",
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"lint": "eslint .",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
"release": "lerna run rebuild && ./scripts/bin/run release",
"test": "jest",
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rm -rf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.11.0",
"eslint": "8.56.0",
"eslint-config-universe": "13.0.0",
"@types/node": "20.14.8",
"eslint-plugin-async-protect": "3.1.0",
"eslint-plugin-node": "11.1.0",
"jest": "29.7.0",
"jest-watch-typeahead": "2.2.2",
"lerna": "8.2.3",
"oclif": "^3.9.0",
"prettier": "3.1.1",
"lerna": "9.0.3",
"oclif": "^4.22.85",
"oxfmt": "0.28.0",
"oxlint": "1.43.0",
"oxlint-tsgolint": "0.11.1",
"ts-jest": "29.1.1",
"typescript": "5.3.3"
"typescript": "5.5.4"
},
"volta": {
"node": "20.11.0",
"yarn": "1.22.21"
}
"packageManager": "[email protected]"
}
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.secrets*
!.secrets.example
!.secrets.test
src/graphql-env.d.ts
schema.graphql
+17
View File
@@ -0,0 +1,17 @@
{
"extends": ["../../.oxlintrc.json"],
"jsPlugins": [
{
"name": "async-protect",
"specifier": "eslint-plugin-async-protect"
}
],
"rules": {
"async-protect/async-suffix": "off",
"no-underscore-dangle": "off",
"node/no-sync": "off",
"no-console": "off",
"no-void": "off",
"no-restricted-imports": "off"
}
}
+7
View File
@@ -0,0 +1,7 @@
# @expo/build-tools
`@expo/build-tools` is the core library for the EAS Build service. It implements the build process for React Native projects and for managed Expo applications.
## Repository
https://github.com/expo/eas-cli/tree/main/packages/build-tools
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -eo pipefail
NAME=$1
VALUE=$2
if [[ -z "$__EAS_BUILD_ENVS_DIR" ]]; then
echo "Set __EAS_BUILD_ENVS_DIR"
exit 1
fi
if [[ -z "$NAME" || -z "$VALUE" ]]; then
echo "Usage: set-env NAME VALUE"
exit 2
fi
if [[ "$NAME" == *"="* ]]; then
echo "Environment name can't include ="
exit 1
fi
echo -n $VALUE > $__EAS_BUILD_ENVS_DIR/$NAME
@@ -0,0 +1,13 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: ['**/__integration-tests__/*.test.ts'],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+20
View File
@@ -0,0 +1,20 @@
import { fs, vol } from 'memfs';
import os from 'node:os';
if (process.env.NODE_ENV !== 'test') {
throw new Error("NODE_ENV environment variable must be set to 'test'.");
}
// Always mock:
jest.mock('fs');
jest.mock('node:fs', () => fs);
jest.mock('fs/promises');
jest.mock('node:fs/promises', () => fs.promises);
jest.mock('node-fetch');
beforeEach(() => {
vol.reset();
vol.fromNestedJSON({
[os.tmpdir()]: {},
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { Config } from '@jest/types';
import baseConfig from '../../../jest/jest.shared.config';
const config: Config.InitialOptions = {
...baseConfig,
rootDir: '../src',
testMatch: [
'**/__tests__/*.test.ts',
...(process.platform === 'darwin' ? ['**/__tests__/*.test.ios.ts'] : []),
],
clearMocks: true,
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
};
export default config;
+100
View File
@@ -0,0 +1,100 @@
{
"name": "@expo/build-tools",
"version": "18.2.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
"repository": {
"type": "git",
"url": "https://github.com/expo/eas-cli.git",
"directory": "packages/build-tools"
},
"files": [
"dist",
"resources",
"templates",
"bin"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"start": "yarn watch",
"prewatch": "yarn gql",
"watch": "tsc --watch --preserveWatchOutput",
"prebuild": "yarn gql",
"build": "tsc",
"typecheck": "tsc",
"prepack": "rm -rf 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",
"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/env": "^0.4.0",
"@expo/logger": "18.0.1",
"@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/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
"bplist-parser": "0.3.2",
"fast-glob": "^3.3.2",
"fast-xml-parser": "^4.4.1",
"fs-extra": "^11.2.0",
"gql.tada": "^1.8.13",
"joi": "^17.13.1",
"jose": "5.10.0",
"lodash": "^4.17.21",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
"promise-limit": "^2.7.0",
"promise-retry": "^2.0.1",
"resolve-from": "^5.0.0",
"retry": "^0.13.1",
"semver": "^7.6.2",
"tar": "7.5.7",
"uuid": "9.0.1",
"yaml": "^2.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.2.5",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
"@types/node": "20.14.2",
"@types/node-fetch": "^2.6.11",
"@types/node-forge": "^1.3.11",
"@types/plist": "^3.0.5",
"@types/promise-retry": "^1.1.6",
"@types/retry": "^0.12.5",
"@types/semver": "^7.5.8",
"@types/uuid": "^9.0.8",
"bunyan": "^1.8.15",
"jest": "^29.7.0",
"memfs": "^4.17.1",
"nock": "13.4.0",
"ts-jest": "^29.1.4",
"ts-mockito": "^2.6.1",
"tslib": "^2.6.3",
"typescript": "^5.5.4",
"uuid": "^9.0.1"
}
}
@@ -0,0 +1,25 @@
import bunyan from 'bunyan';
export function createLogger(): bunyan {
const logger = {
info: () => {},
debug: () => {},
error: () => {},
warn: () => {},
child: _fields => logger,
} as bunyan;
return logger;
}
export enum LoggerLevel {
TRACE = 'trace',
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
FATAL = 'fatal',
}
export enum PipeMode {
COMBINED = 'combined',
}
+21
View File
@@ -0,0 +1,21 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (
path: string,
options: object,
callback: (err: NodeJS.ErrnoException | null) => void
): void => {
fs.promises
.rm(path, options)
.then(() => {
callback(null);
})
.catch(err => {
callback(err);
});
};
module.exports = { ...fs, realpath: fsRealpath, rm: fsRm };
@@ -0,0 +1,10 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (path: string, options: object): Promise<void> => {
return fs.promises.rm(path, options);
};
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
@@ -0,0 +1,119 @@
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
import { vol } from 'memfs';
import { createMockLogger } from './utils/logger';
import { BuildContext } from '../context';
jest.mock('fs');
jest.mock('fs-extra');
describe('BuildContext', () => {
it('should merge secrets', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {
robotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'test-secret-value',
},
],
},
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateJobInformation({} as Job, {} as Metadata);
expect(ctx.job.secrets).toEqual({
robotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'test-secret-value',
},
],
});
const newRobotAccessToken = randomUUID();
ctx.updateJobInformation(
{
secrets: {
robotAccessToken: newRobotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'new-test-secret-value',
},
{
name: 'TEST_SECRET_2',
value: 'test-secret-value-2',
},
],
},
} as Job,
{} as Metadata
);
expect(ctx.job.secrets).toEqual({
robotAccessToken: newRobotAccessToken,
environmentSecrets: [
{ name: 'TEST_SECRET', value: 'test-secret-value' },
{ name: 'TEST_SECRET', value: 'new-test-secret-value' },
{ name: 'TEST_SECRET_2', value: 'test-secret-value-2' },
],
});
});
it('should not lose workflowInterpolationContext', async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
secrets: {
robotAccessToken,
environmentSecrets: [
{
name: 'TEST_SECRET',
value: 'test-secret-value',
},
],
},
workflowInterpolationContext: {
foo: 'bar',
} as any,
} as Job,
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.updateJobInformation({} as Job, {} as Metadata);
expect(ctx.job.workflowInterpolationContext).toEqual({
foo: 'bar',
});
});
});
@@ -0,0 +1,160 @@
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', () => {
const contextUploadArtifact = jest.fn();
const ctx = new BuildContext(
createTestIosJob({
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
workflowInterpolationContext: {
foo: 'bar',
} as unknown as Ios.Job['workflowInterpolationContext'],
}),
{
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
uploadArtifact: contextUploadArtifact,
workingdir: '',
}
);
const customContext = new CustomBuildContext(ctx);
expect(customContext.job.workflowInterpolationContext).toStrictEqual({
foo: 'bar',
});
customContext.updateJobInformation({} as Ios.Job, {} as Metadata);
expect(customContext.job.workflowInterpolationContext).toStrictEqual({
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();
});
});
@@ -0,0 +1,100 @@
import { bunyan } from '@expo/logger';
import {
BuildRuntimePlatform,
BuildStepContext,
BuildStepEnv,
BuildStepGlobalContext,
ExternalBuildContextProvider,
} from '@expo/steps';
import os from 'os';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { createMockLogger } from './logger';
export class MockContextProvider implements ExternalBuildContextProvider {
private _env: BuildStepEnv = {};
constructor(
public readonly logger: bunyan,
public readonly runtimePlatform: BuildRuntimePlatform,
public readonly projectSourceDirectory: string,
public readonly projectTargetDirectory: string,
public readonly defaultWorkingDirectory: string,
public readonly buildLogsDirectory: string,
public readonly staticContextContent: Record<string, any> = {}
) {}
public get env(): BuildStepEnv {
return this._env;
}
public staticContext(): any {
return { ...this.staticContextContent };
}
public updateEnv(env: BuildStepEnv): void {
this._env = env;
}
}
interface BuildContextParams {
buildId?: string;
logger?: bunyan;
skipCleanup?: boolean;
runtimePlatform?: BuildRuntimePlatform;
projectSourceDirectory?: string;
projectTargetDirectory?: string;
relativeWorkingDirectory?: string;
staticContextContent?: Record<string, any>;
}
export function createStepContextMock({
buildId,
logger,
skipCleanup,
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
relativeWorkingDirectory,
staticContextContent,
}: BuildContextParams = {}): BuildStepContext {
const globalCtx = createGlobalContextMock({
buildId,
logger,
skipCleanup,
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
relativeWorkingDirectory,
staticContextContent,
});
return new BuildStepContext(globalCtx, {
logger: logger ?? createMockLogger(),
relativeWorkingDirectory,
});
}
export function createGlobalContextMock({
logger,
skipCleanup,
runtimePlatform,
projectSourceDirectory,
projectTargetDirectory,
relativeWorkingDirectory,
staticContextContent,
}: BuildContextParams = {}): BuildStepGlobalContext {
const resolvedProjectTargetDirectory =
projectTargetDirectory ?? path.join(os.tmpdir(), 'eas-build', uuidv4());
return new BuildStepGlobalContext(
new MockContextProvider(
logger ?? createMockLogger(),
runtimePlatform ?? BuildRuntimePlatform.LINUX,
projectSourceDirectory ?? '/non/existent/dir',
resolvedProjectTargetDirectory,
relativeWorkingDirectory
? path.resolve(resolvedProjectTargetDirectory, relativeWorkingDirectory)
: resolvedProjectTargetDirectory,
'/non/existent/dir',
staticContextContent ?? {}
),
skipCleanup ?? false
);
}
@@ -0,0 +1,94 @@
import {
Android,
ArchiveSourceType,
BuildMode,
BuildTrigger,
Ios,
Platform,
Workflow,
} from '@expo/eas-build-job';
import { randomUUID } from 'crypto';
const androidCredentials: Android.BuildSecrets['buildCredentials'] = {
keystore: {
dataBase64: 'MjEzNwo=',
keystorePassword: 'pass1',
keyAlias: 'alias',
keyPassword: 'pass2',
},
};
const iosCredentials: Ios.BuildCredentials = {
testapp: {
provisioningProfileBase64: '',
distributionCertificate: {
dataBase64: '',
password: '',
},
},
};
export function createTestAndroidJob({
buildCredentials = androidCredentials,
}: {
buildCredentials?: Android.BuildSecrets['buildCredentials'];
} = {}): Android.Job {
return {
mode: BuildMode.BUILD,
platform: Platform.ANDROID,
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.GENERIC,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz',
},
projectRootDirectory: '.',
applicationArchivePath: './android/app/build/outputs/apk/release/*.apk',
cache: {
clear: false,
disabled: false,
paths: [],
},
secrets: {
buildCredentials,
},
appId: randomUUID(),
initiatingUserId: randomUUID(),
};
}
export function createTestIosJob({
buildCredentials = iosCredentials,
triggeredBy = BuildTrigger.EAS_CLI,
workflowInterpolationContext,
}: {
buildCredentials?: Ios.BuildCredentials;
triggeredBy?: Ios.Job['triggeredBy'];
workflowInterpolationContext?: Ios.Job['workflowInterpolationContext'];
} = {}): Ios.Job {
return {
mode: BuildMode.BUILD,
platform: Platform.IOS,
triggeredBy,
type: Workflow.GENERIC,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://turtle-v2-test-fixtures.s3.us-east-2.amazonaws.com/project.tar.gz',
},
scheme: 'turtlebareproj',
buildConfiguration: 'Release',
applicationArchivePath: './ios/build/*.ipa',
projectRootDirectory: '.',
cache: {
clear: false,
disabled: false,
paths: [],
},
secrets: {
buildCredentials,
},
appId: randomUUID(),
initiatingUserId: randomUUID(),
workflowInterpolationContext,
};
}
@@ -0,0 +1,12 @@
import { bunyan } from '@expo/logger';
export function createMockLogger({ logToConsole = false } = {}): bunyan {
const logger = {
info: jest.fn(logToConsole ? console.info : () => {}),
debug: jest.fn(logToConsole ? console.debug : () => {}),
error: jest.fn(logToConsole ? console.error : () => {}),
warn: jest.fn(logToConsole ? console.warn : () => {}),
child: jest.fn().mockImplementation(() => createMockLogger({ logToConsole })),
} as unknown as bunyan;
return logger;
}
@@ -0,0 +1,147 @@
import { AndroidConfig } from '@expo/config-plugins';
import { vol } from 'memfs';
import path from 'path';
import {
AndroidMetadataName,
androidGetNativelyDefinedChannelAsync,
androidGetNativelyDefinedRuntimeVersionAsync,
androidSetChannelNativelyAsync,
androidSetRuntimeVersionNativelyAsync,
} from '../expoUpdates';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
const channel = 'easupdatechannel';
const manifestPath = '/app/android/app/src/main/AndroidManifest.xml';
afterEach(() => {
vol.reset();
});
describe(androidSetChannelNativelyAsync, () => {
it('sets the channel', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: { updates: { channel } },
logger: { info: () => {} },
};
await androidSetChannelNativelyAsync(ctx as any);
const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
newAndroidManifest,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
expect(newValue).toBeDefined();
expect(JSON.parse(newValue!)).toEqual({ 'expo-channel-name': channel });
});
});
describe(androidGetNativelyDefinedChannelAsync, () => {
it('gets the channel', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/AndroidManifestWithChannel.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: () => {} },
};
await expect(androidGetNativelyDefinedChannelAsync(ctx as any)).resolves.toBe('staging-123');
});
});
describe(androidGetNativelyDefinedRuntimeVersionAsync, () => {
it('gets the native runtime version', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: {},
logger: { info: () => {} },
};
const nativelyDefinedRuntimeVersion = await androidGetNativelyDefinedRuntimeVersionAsync(
ctx as any
);
expect(nativelyDefinedRuntimeVersion).toBe('exampleruntimeversion');
});
});
describe(androidSetRuntimeVersionNativelyAsync, () => {
it('sets the runtime version when nothing is set natively', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/NoMetadataAndroidManifest.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: {},
logger: { info: () => {} },
};
await androidSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3');
const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
newAndroidManifest,
AndroidMetadataName.RUNTIME_VERSION
);
expect(newValue).toBe('1.2.3');
});
it('updates the runtime version when value is already set natively', async () => {
vol.fromJSON(
{
'android/app/src/main/AndroidManifest.xml': originalFs.readFileSync(
path.join(__dirname, 'fixtures/AndroidManifestWithRuntimeVersion.xml'),
'utf-8'
),
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: {},
logger: { info: () => {} },
};
await androidSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3');
const newAndroidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const newValue = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
newAndroidManifest,
AndroidMetadataName.RUNTIME_VERSION
);
expect(newValue).toBe('1.2.3');
});
});
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:launchMode="singleTask" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<meta-data android:name="expo.modules.updates.EXPO_RELEASE_CHANNEL" android:value="default"/>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;staging-123&quot;}"/>
</application>
</manifest>
@@ -0,0 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:launchMode="singleTask" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<meta-data android:name="expo.modules.updates.EXPO_RELEASE_CHANNEL" android:value="example_channel"/>
</application>
</manifest>
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme">
<activity android:name=".MainActivity" android:launchMode="singleTask" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
<meta-data android:name="expo.modules.updates.EXPO_RELEASE_CHANNEL" android:value="default"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="exampleruntimeversion"/>
</application>
</manifest>
@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.expo.mycoolapp">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>
</manifest>
@@ -0,0 +1,40 @@
import { Android } from '@expo/eas-build-job';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { BuildContext } from '../context';
async function restoreCredentials(ctx: BuildContext<Android.Job>): Promise<void> {
const { buildCredentials } = nullthrows(
ctx.job.secrets,
'Secrets must be defined for non-custom builds'
);
if (!buildCredentials) {
// TODO: sentry (should be detected earlier)
throw new Error('secrets are missing in the job object');
}
ctx.logger.info("Writing secrets to the project's directory");
const keystorePath = path.join(ctx.buildDirectory, `keystore-${uuidv4()}`);
await fs.writeFile(
keystorePath,
new Uint8Array(Buffer.from(buildCredentials.keystore.dataBase64, 'base64'))
);
const credentialsJson = {
android: {
keystore: {
keystorePath,
keystorePassword: buildCredentials.keystore.keystorePassword,
keyAlias: buildCredentials.keystore.keyAlias,
keyPassword: buildCredentials.keystore.keyPassword,
},
},
};
await fs.writeFile(
path.join(ctx.buildDirectory, 'credentials.json'),
JSON.stringify(credentialsJson)
);
}
export { restoreCredentials };
@@ -0,0 +1,106 @@
import { AndroidConfig } from '@expo/config-plugins';
import { BuildJob, Job } from '@expo/eas-build-job';
import assert from 'assert';
import fs from 'fs-extra';
import { BuildContext } from '../context';
export enum AndroidMetadataName {
UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY',
RUNTIME_VERSION = 'expo.modules.updates.EXPO_RUNTIME_VERSION',
}
export async function androidSetRuntimeVersionNativelyAsync(
ctx: BuildContext<Job>,
runtimeVersion: string
): Promise<void> {
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
throw new Error(`Couldn't find Android manifest at ${manifestPath}`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest);
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApp,
AndroidMetadataName.RUNTIME_VERSION,
runtimeVersion,
'value'
);
await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest);
}
export async function androidSetChannelNativelyAsync(ctx: BuildContext<BuildJob>): Promise<void> {
assert(ctx.job.updates?.channel, 'updates.channel must be defined');
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
throw new Error(`Couldn't find Android manifest at ${manifestPath}`);
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const mainApp = AndroidConfig.Manifest.getMainApplicationOrThrow(androidManifest);
const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApp,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY,
JSON.stringify({
...JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}'),
'expo-channel-name': ctx.job.updates.channel,
}),
'value'
);
await AndroidConfig.Manifest.writeAndroidManifestAsync(manifestPath, androidManifest);
}
export async function androidGetNativelyDefinedChannelAsync(
ctx: BuildContext<Job>
): Promise<string | null> {
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
return null;
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
const stringifiedUpdatesRequestHeaders = AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY
);
try {
const updatesRequestHeaders = JSON.parse(stringifiedUpdatesRequestHeaders ?? '{}');
return updatesRequestHeaders['expo-channel-name'] ?? null;
} catch (err: any) {
throw new Error(
`Failed to parse ${AndroidMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY} from AndroidManifest.xml: ${err.message}`
);
}
}
export async function androidGetNativelyDefinedRuntimeVersionAsync(
ctx: BuildContext<Job>
): Promise<string | null> {
const manifestPath = await AndroidConfig.Paths.getAndroidManifestAsync(
ctx.getReactNativeProjectDirectory()
);
if (!(await fs.pathExists(manifestPath))) {
return null;
}
const androidManifest = await AndroidConfig.Manifest.readAndroidManifestAsync(manifestPath);
return AndroidConfig.Manifest.getMainApplicationMetaDataValue(
androidManifest,
AndroidMetadataName.RUNTIME_VERSION
);
}
+118
View File
@@ -0,0 +1,118 @@
import { Android, Env, Job, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import assert from 'assert';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
export async function ensureLFLineEndingsInGradlewScript<TJob extends Job>(
ctx: BuildContext<TJob>
): Promise<void> {
const gradlewPath = path.join(ctx.getReactNativeProjectDirectory(), 'android', 'gradlew');
const gradlewContent = await fs.readFile(gradlewPath, 'utf8');
if (gradlewContent.includes('\r')) {
ctx.logger.info('Replacing CRLF line endings with LF in gradlew script');
await fs.writeFile(gradlewPath, gradlewContent.replace(/\r\n/g, '\n'), 'utf8');
}
}
export async function runGradleCommand(
ctx: BuildContext<Job>,
{
logger,
gradleCommand,
androidDir,
extraEnv,
}: { logger: bunyan; gradleCommand: string; androidDir: string; extraEnv?: Env }
): Promise<void> {
logger.info(`Running 'gradlew ${gradleCommand}' in ${androidDir}`);
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
cwd: androidDir,
logger,
lineTransformer: (line?: string) => {
if (!line || /^\.+$/.exec(line)) {
return null;
} else {
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
});
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
}
await spawnPromise;
}
/**
* OOM Killer sometimes kills worker server while build is exceeding memory limits.
* `oom_score_adj` is a value between -1000 and 1000 and it defaults to 0.
* It defines which process is more likely to get killed (higher value more likely).
*
* This function sets oom_score_adj for Gradle process and all its child processes.
*/
function adjustOOMScore(spawnPromise: SpawnPromise<SpawnResult>, logger: bunyan): void {
setTimeout(
async () => {
try {
assert(spawnPromise.child.pid);
const pids = await getParentAndDescendantProcessPidsAsync(spawnPromise.child.pid);
await Promise.all(
pids.map(async (pid: number) => {
// Value 800 is just a guess here. It's probably higher than most other
// process. I didn't want to set it any higher, because I'm not sure if OOM Killer
// can start killing processes when there is still enough memory left.
const oomScoreOverride = 800;
await fs.writeFile(`/proc/${pid}/oom_score_adj`, `${oomScoreOverride}\n`);
})
);
} catch (err: any) {
logger.debug({ err, stderr: err?.stderr }, 'Failed to override oom_score_adj');
}
},
// Wait 20 seconds to make sure all child processes are started
20000
);
}
// Version envs should be set at the beginning of the build, but when building
// from github those values are resolved later.
function resolveVersionOverridesEnvs(ctx: BuildContext<Job>): Env {
const extraEnvs: Env = {};
if (
ctx.job.platform === Platform.ANDROID &&
ctx.job.version?.versionCode &&
!ctx.env.EAS_BUILD_ANDROID_VERSION_CODE
) {
extraEnvs.EAS_BUILD_ANDROID_VERSION_CODE = ctx.job.version.versionCode;
}
if (
ctx.job.platform === Platform.ANDROID &&
ctx.job.version?.versionName &&
!ctx.env.EAS_BUILD_ANDROID_VERSION_NAME
) {
extraEnvs.EAS_BUILD_ANDROID_VERSION_NAME = ctx.job.version.versionName;
}
return extraEnvs;
}
export function resolveGradleCommand(job: Android.Job): string {
if (job.gradleCommand) {
return job.gradleCommand;
} else if (job.developmentClient) {
return ':app:assembleDebug';
} else if (!job.buildType) {
return ':app:bundleRelease';
} else if (job.buildType === Android.BuildType.APK) {
return ':app:assembleRelease';
} else {
return ':app:bundleRelease';
}
}
@@ -0,0 +1,57 @@
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, "'"))
);
}
@@ -0,0 +1,312 @@
import { BuildMode, BuildPhase, Job, Platform, errors } from '@expo/eas-build-job';
import { vol } from 'memfs';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from '../detectError';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
afterEach(() => {
vol.reset();
});
describe(resolveBuildPhaseErrorAsync, () => {
it('detects log for corrupted npm package', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'[stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.metadata).toEqual({
packageName: '@typescript-eslint/[email protected]',
});
});
it('detects log for invalid bundler and reports it to user', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
"/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)",
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.message).toBe(
'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.'
);
expect(err.trackingCode).toBeUndefined();
expect(err.metadata).toBeUndefined();
});
it('does not detect errors if they show up in different build phase', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
"/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)",
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES, // it should be in RUN_FASTLANE
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects npm cache error if cache is enabled', async () => {
const mockEnv = {
EAS_BUILD_NPM_CACHE_URL: 'https://dominik.sokal.pl/npm/cache',
};
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`Blah blah Error ... ${mockEnv.EAS_BUILD_NPM_CACHE_URL}`],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: mockEnv,
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
});
it('does not detect npm cache error if cache is disabled', async () => {
const mockEnv = {
EAS_BUILD_NPM_CACHE_URL: 'https://dominik.sokal.pl/npm/cache',
};
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`Blah blah Error ... ${mockEnv.EAS_BUILD_NPM_CACHE_URL}`],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.message).toBe(
'Unknown error. See logs of the Install dependencies build phase for more information.'
);
expect(err.trackingCode).toBeUndefined();
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
path.resolve('./src/buildErrors/__tests__/fixtures/xcode.log'),
'utf-8'
),
});
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[''],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/path/to/'
);
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects minimum deployment target error correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'CocoaPods could not find compatible versions for pod "react-native-google-maps":16 In Podfile:17 react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)18Specs satisfying the `react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)` dependency were found, but they required a higher minimum deployment target.19Error: Compatible versions of some pods could not be resolved.',
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.INSTALL_PODS,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
`No provisioning profile for application: '_floatsignTemp/Payload/EcoBatteryPREVIEW.app' with bundle identifier 'com.ecobattery.ecobattery-preview'`,
],
{
job: { platform: Platform.IOS, mode: BuildMode.RESIGN } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects generic "Run Fastlane" error for resign correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`other error`],
{
job: { platform: Platform.IOS, mode: BuildMode.RESIGN } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects build error in "Run Fastlane" phase correctly', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`some build error`],
{
job: { platform: Platform.IOS, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
expect(err.trackingCode).toBeUndefined();
});
it('detects provisioning profile mismatch error correctly', async () => {
const xcodeLogs = `Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Objects-normal/arm64/82b82416624d2658e5098eb0a28c15c5-common-args.resp
-target arm64-apple-ios15.0-simulator '-std=gnu++14' '-stdlib=libc++' -fmodules '-fmodules-cache-path=/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/ModuleCache.noindex' '-fmodule-name=yoga' -fpascal-strings -Os -fno-common '-DPOD_CONFIGURATION_RELEASE=1' '-DCOCOAPODS=1' -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator18.1.sdk -g -iquote /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-generated-files.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-own-target-headers.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-all-non-framework-target-headers.hmap -ivfsoverlay /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Pods-8699adb1dd336b26511df848a716bd42-VFS-iphonesimulator/all-product-headers.yaml -iquote /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/Yoga-project-headers.hmap -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/Yoga/include -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Private -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Private/Yoga -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Public -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods/Headers/Public/Yoga -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources-normal/arm64 -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources/arm64 -I/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Intermediates.noindex/Pods.build/Release-iphonesimulator/Yoga.build/DerivedSources -F/Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/Yoga
MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-menu/EXDevMenu.bundle (in target 'expo-dev-menu-EXDevMenu' from project 'Pods')
cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods
/bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-menu/EXDevMenu.bundle
MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle (in target 'expo-dev-launcher-EXDevLauncher' from project 'Pods')
cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods
/bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle
ProcessXCFramework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/ProgrammaticAccessLibrary.framework ios simulator
cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios
builtin-process-xcframework --xcframework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework --platform ios --environment simulator --target-path /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator
/Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework:1:1: error: The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
note: A sealed resource is missing or invalid
note: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework: a sealed resource is missing or invalid
file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64_x86_64-simulator/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
error: Some other error
log
log
log
error: The last one
log
note`;
vol.fromJSON({
'/path/to/xcode.log': xcodeLogs,
});
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[`some logs`],
{
job: { platform: Platform.IOS, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_FASTLANE,
env: {},
},
'/path/to'
);
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message)
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
- Some other error
- The last one
Refer to "Xcode Logs" below for additional, more detailed logs.`);
});
it('detects MAVEN_CACHE_ERROR correctly', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[`https://szymon.pl/maven/cache`],
{
job: { platform: Platform.ANDROID, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_GRADLEW,
env: {
EAS_BUILD_MAVEN_CACHE_URL: 'https://szymon.pl/maven/cache',
},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
it('does not throw MAVEN_CACHE_ERROR if "Could not find BlurView-version-2.0.3.jar" log is present', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[`Could not find BlurView-version-2.0.3.jar sth sthelse https://szymon.pl/maven/cache`],
{
job: { platform: Platform.ANDROID, mode: BuildMode.BUILD } as Job,
phase: BuildPhase.RUN_GRADLEW,
env: {
EAS_BUILD_MAVEN_CACHE_URL: 'https://szymon.pl/maven/cache',
},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
expect(err.trackingCode).toBeUndefined();
expect(err.message).toBe(
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
});
@@ -0,0 +1 @@
/Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXConstants-EXConstants" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXConstants-EXConstants' from project 'Pods')
@@ -0,0 +1,364 @@
import { BuildPhase, Platform } from '@expo/eas-build-job';
import escapeRegExp from 'lodash/escapeRegExp';
import { ErrorContext, ErrorHandler } from './errors.types';
export class TrackedBuildError extends Error {
constructor(
public errorCode: string,
public message: string,
public metadata?: Record<string, unknown>
) {
super(message);
}
}
export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// example log:
// CDN: trunk URL couldn't be downloaded: https://cdn.jsdelivr.net/cocoa/Specs/2/a/e/MultiplatformBleAdapter/0.0.3/MultiplatformBleAdapter.podspec.json Response: 429 429: Too Many Requests
regexp: /CDN: trunk URL couldn't be downloaded.* Response: 429 429: Too Many Requests/,
createError: () =>
new TrackedBuildError('COCOAPODS_TO_MANY_REQUEST', 'cocoapods: too many requests'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// Host key verification failed.
// fatal: Could not read from remote repository.
regexp: /Host key verification failed\.\nfatal: Could not read from remote repository/,
createError: () => new TrackedBuildError('NPM_INSTALL_SSH_AUTHENTICATION', 'Missing ssh key.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// error [email protected]: The engine "node" is incompatible with this module. Expected version "14". Got "16.13.2"
// error Found incompatible module.
regexp: /The engine "node" is incompatible with this module\. Expected version/,
createError: () =>
new TrackedBuildError('NODE_ENGINE_INCOMPATIBLE', 'node: Incompatible engine field.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// error An unexpected error occurred: "https://registry.yarnpkg.com/@react-native/normalize-color/-/normalize-color-2.0.0.tgz: Request failed \"500 Internal Server Error\"".
// or
// error An unexpected error occurred: "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz: Request failed \"503 Service Unavailable\"".
regexp: /An unexpected error occurred: "https:\/\/registry.yarnpkg.com\/.*Request failed \\"5/,
createError: () => new TrackedBuildError('YARN_REGISTRY_5XX_RESPONSE', 'yarn: 5xx response.'),
},
{
phase: BuildPhase.PREBUILD,
// Input is required, but Expo CLI is in non-interactive mode
// or
// CommandError: Input is required, but 'npx expo' is in non-interactive mode.
regexp: /Input is required, but .* is in non-interactive mode/,
createError: () =>
new TrackedBuildError(
'EXPO_CLI_INPUT_REQUIRED_ERROR',
`expo-cli: Input required in non-interactive mode.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.PREBUILD,
// [03:03:05] [ios.infoPlist]: withIosInfoPlistBaseMod: GoogleService-Info.plist is empty
regexp: /withIosInfoPlistBaseMod: GoogleService-Info\.plist is empty/,
createError: () =>
new TrackedBuildError(
'EXPO_CLI_EMPTY_GOOGLE_SERVICES_PLIST_ERROR',
`expo-cli: Empty GoogleService-Info.plist.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.PREBUILD,
// [01:52:04] [ios.xcodeproj]: withIosXcodeprojBaseMod: Path to GoogleService-Info.plist is not defined. Please specify the `expo.ios.googleServicesFile` field in app.json.
regexp: /withIosXcodeprojBaseMod: Path to GoogleService-Info\.plist is not defined/,
createError: () =>
new TrackedBuildError(
'EXPO_CLI_NOT_DEFINED_GOOGLE_SERVICES_PLIST_ERROR',
`expo-cli: Path to GoogleService-Info.plist is not defined.`
),
},
{
phase: BuildPhase.PREBUILD,
// Error: [android.dangerous]: withAndroidDangerousBaseMod: ENOENT: no such file or directory, open './assets/adaptive-icon.png'
// at Object.openSync (node:fs:585:3)
// at readFileSync (node:fs:453:35)
// at calculateHash (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:14:91)
// at createCacheKey (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:19:18)
// at Object.createCacheKeyWithDirectoryAsync (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:24:33)
// at generateImageAsync (/home/expo/workingdir/build/node_modules/@expo/image-utils/build/Image.js:151:34)
// at async generateIconAsync (/home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:369:11)
// at async /home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:310:21
// at async Promise.all (index 0)
// at async generateMultiLayerImageAsync (/home/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withAndroidIcons.js:306:3)
// or
// Error: [ios.dangerous]: withIosDangerousBaseMod: ENOENT: no such file or directory, open './assets/images/app_icon_staging.png'
// at Object.openSync (fs.js:497:3)
// at readFileSync (fs.js:393:35)
// at calculateHash (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:14:91)
// at createCacheKey (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:19:18)
// at Object.createCacheKeyWithDirectoryAsync (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Cache.js:24:33)
// at generateImageAsync (/Users/expo/workingdir/build/node_modules/@expo/image-utils/build/Image.js:151:34)
// at async setIconsAsync (/Users/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withIosIcons.js:169:15)
// at async /Users/expo/workingdir/build/node_modules/@expo/prebuild-config/build/plugins/icons/withIosIcons.js:71:5
// at async action (/Users/expo/workingdir/build/node_modules/@expo/config-plugins/build/plugins/withMod.js:235:23)
// at async interceptingMod (/Users/expo/workingdir/build/node_modules/@expo/config-plugins/build/plugins/withMod.js:126:21)
regexp:
/ENOENT: no such file or directory[\s\S]*prebuild-config\/build\/plugins\/icons\/with(Android|Ios)Icons\.js/,
createError: () => new TrackedBuildError('EXPO_CLI_MISSING_ICON', 'expo-cli: Missing icon.'),
},
{
phase: BuildPhase.PREBUILD,
// Cannot determine which native SDK version your project uses because the module `expo` is not installed. Please install it with `yarn add expo` and try again.
regexp:
/Cannot determine which native SDK version your project uses because the module `expo` is not installed/,
createError: () =>
new TrackedBuildError('EXPO_CLI_EXPO_PACKAGE_MISSING', 'expo-cli: "expo" package missing.'),
},
{
phase: BuildPhase.INSTALL_PODS,
// The Swift pod `FirebaseCoreInternal` depends upon `GoogleUtilities`, which does not define modules. To opt into those targets generating module maps (which is necessary to import them from Swift when building as static
regexp: /The Swift pod .* depends upon .* which does not define modules/,
createError: () =>
new TrackedBuildError(
'SWIFT_POD_INCOMPATIBLE_DEPENDENCY',
'pod: Swift pod depends on a pod that does not define modules.'
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// Adding spec repo `24-repository-cocoapods-proxy` with CDN `http://10.254.24.7:8081/repository/cocoapods-proxy/`
// [!] No podspec exists at path `/Users/expo/.cocoapods/repos/24-repository-cocoapods-proxy/Specs/1/9/2/libwebp/1.2.0/libwebp.podspec.json`.
regexp: /Adding spec repo .* with CDN .*\n\s*\[!\] No podspec exists at path `(.*)`/,
// Some pods are hosted on git registries that are not supported e.g. chromium.googlesource.com
createError: (match: RegExpMatchArray) =>
new TrackedBuildError(
'COCOAPODS_CACHE_INCOMPATIBLE_REPO_ERROR',
`cocoapods: Missing podspec ${match[1]}.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// [!] Invalid `Podfile` file: 783: unexpected token at 'info Run CLI with --verbose flag for more details.
regexp:
/\[!\] Invalid `Podfile` file: .* unexpected token at 'info Run CLI with --verbose flag for more details./,
createError: () =>
new TrackedBuildError('NODE_ENV_PRODUCTION_DEFINED', 'npm: NODE_ENV=production was defined.'),
},
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
// example log:
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
createError: (match: RegExpMatchArray) =>
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
packageName: match[1],
}),
})),
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
phase,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_NPM_CACHE_URL
? new RegExp(escapeRegExp(env.EAS_BUILD_NPM_CACHE_URL))
: undefined,
createError: () => new TrackedBuildError('NPM_CACHE_ERROR', `npm: cache error`),
})),
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_COCOAPODS_CACHE_URL ? /Error installing/ : undefined,
createError: () =>
new TrackedBuildError(
'COCOAPODS_CACHE_INSTALLING_POD_ERROR',
`cocoapods: error installing a pod using internal cache instance`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_COCOAPODS_CACHE_URL ? /No podspec exists at path/ : undefined,
createError: () =>
new TrackedBuildError(
'COCOAPODS_CACHE_NO_PODSPEC_EXISTS_AT_PATH_ERROR',
`cocoapods: error fetching a podspec through internal cache instance`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_COCOAPODS_CACHE_URL
? new RegExp(escapeRegExp(env.EAS_BUILD_COCOAPODS_CACHE_URL))
: undefined,
createError: () => new TrackedBuildError('COCOAPODS_CACHE_ERROR', `cocoapods: cache error`),
},
{
phase: BuildPhase.INSTALL_PODS,
// [!] Invalid `Podfile` file: uninitialized constant Pod::Podfile::FlipperConfiguration.
regexp: /\[!\] Invalid `Podfile` file/,
createError: () => new TrackedBuildError('INVALID_PODFILE', 'pod: Invalid Podfile file.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// info There appears to be trouble with your network connection. Retrying...
regexp: /info There appears to be trouble with your network connection. Retrying/,
createError: () =>
new TrackedBuildError(
'YARN_INSTALL_TROUBLE_WITH_NETWORK_CONNECTION',
'yarn: There appears to be trouble with your network connection'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8
regexp: /Android Gradle plugin requires Java .* to run. You are currently using Java/,
createError: () =>
new TrackedBuildError('INCOMPATIBLE_JAVA_VERSION', 'gradle: Incompatible java version.'),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// /home/expo/workingdir/build/android/app/src/main/AndroidManifest.xml:27:9-33:20 Error:
// android:exported needs to be explicitly specified for element <activity#androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity>. Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported` when the corresponding component has an intent filter defined. See https://developer.android.com/guide/topics/manifest/activity-element#exported for details.
regexp:
/Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported`/,
createError: () =>
new TrackedBuildError(
'REQUIRE_EXPLICIT_EXPORTED_ANDROID_12',
'Apps targeting Android 12 and higher are required to specify an explicit value for `android:exported`.'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// > A failure occurred while executing com.android.build.gradle.internal.res.Aapt2CompileRunnable
// > Android resource compilation failed
// ERROR:/home/expo/workingdir/build/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: AAPT: error: failed to read PNG signature: file does not start with PNG signature.
regexp: /AAPT: error: failed to read PNG signature: file does not start with PNG signature/,
createError: () =>
new TrackedBuildError('INVALID_PNG_SIGNATURE', 'gradle: Invalid PNG signature.'),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Execution failed for task ':app:processReleaseGoogleServices'.
// > Malformed root json
regexp: /Execution failed for task ':app:process.*GoogleServices'.*\s.*Malformed root json/,
createError: () =>
new TrackedBuildError(
'GRADLE_MALFORMED_GOOGLE_SERVICES_JSON',
'gradle: Malformed google-services.json.'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Execution failed for task ':app:processDebugGoogleServices'.
// > Missing project_info object
regexp:
/Execution failed for task ':app:process.*GoogleServices'.*\s.*Missing project_info object/,
createError: () =>
new TrackedBuildError(
'GRADLE_MALFORMED_GOOGLE_SERVICES_JSON',
'gradle: Missing project_info object.'
),
},
{
phase: BuildPhase.RUN_GRADLEW,
platform: Platform.ANDROID,
// Execution failed for task ':app:bundleReleaseJsAndAssets'.
// > Process 'command 'node'' finished with non-zero exit value 1
regexp:
/Execution failed for task ':app:bundleReleaseJsAndAssets'.*\s.*Process 'command 'node'' finished with non-zero exit value/,
createError: () =>
new TrackedBuildError(
'GRADLE_BUILD_BUNDLER_ERROR',
"gradle: ':app:bundleReleaseJsAndAssets' failed."
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// > Could not resolve org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.5.10.
// Required by:
// project :expo-updates
// > Could not resolve org.jetbrains.kotlin:kotlin-annotation-processing-gradle:1.5.10.
// > Could not get resource 'https://lsdkfjsdlkjf.com/android/releases/org/jetbrains/kotlin/kotlin-annotation-processing-gradle/1.5.10/kotlin-annotation-processing-gradle-1.5.10.pom'.
// > Could not HEAD 'https://slkdfjldskjfl.com/android/releases/org/jetbrains/kotlin/kotlin-annotation-processing-gradle/1.5.10/kotlin-annotation-processing-gradle-1.5.10.pom'.
// > Connect to sdlkfjsdlkf.com:443 [slkdfjdslk.com/38.178.101.5] failed: connect timed out
regexp: /Could not get resource.*\n.*Could not HEAD 'https/,
createError: () =>
new TrackedBuildError('MAVEN_REGISTRY_CONNECTION_ERROR', `maven: registry connection error`),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
regexp: ({ env }: ErrorContext) =>
env.EAS_BUILD_MAVEN_CACHE_URL
? // The BlurView 2.0.3 jar was removed from jitpack.io
// and it caused false positive MAVEN_CACHE_ERROR errors being reported.
new RegExp(
`^(?!.*Could not find BlurView-version-2\\.0\\.3\\.jar).*${escapeRegExp(
env.EAS_BUILD_MAVEN_CACHE_URL
)}`
)
: undefined,
createError: () => new TrackedBuildError('MAVEN_CACHE_ERROR', `maven: cache error`),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// error: exportArchive: exportOptionsPlist error for key "iCloudContainerEnvironment": expected one of {Development, Production}, but no value was provided
regexp: /exportArchive: exportOptionsPlist error for key "iCloudContainerEnvironment"/,
createError: () =>
new TrackedBuildError(
'MISSING_ICLOUD_CONTAINER_ENVIRONMENT',
'fastlane: Missing iCloudContainerEnvironment in exportOptionsPlist.'
),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// The following build commands failed:
// PhaseScriptExecution [CP-User]\ Generate\ app.manifest\ for\ expo-updates /Users/expo/Library/Developer/Xcode/DerivedData/Kenkohub-eqseedlxbgrzjqagscbclhbtstwh/Build/Intermediates.noindex/ArchiveIntermediates/Kenkohub/IntermediateBuildFilesPath/Pods.build/Release-iphoneos/EXUpdates.build/Script-BB6B5FD28815C045A20B2E5E3FEEBD6E.sh (in target 'EXUpdates' from project 'Pods')
regexp:
/The following build commands failed.*\s.*\[CP-User\]\\ Generate\\ app\.manifest\\ for\\ expo-updates/,
createError: () =>
new TrackedBuildError(
'XCODE_BUILD_UPDATES_PHASE_SCRIPT',
'fastlane: Generating app.manifest for expo-updates failed.'
),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// The following build commands failed:
// CompileC /Users/expo/Library/Developer/Xcode/DerivedData/Docent-amxxhphjfdtkpxecgidgzvwvnvtc/Build/Intermediates.noindex/ArchiveIntermediates/Docent/IntermediateBuildFilesPath/Pods.build/Release-iphoneos/Flipper-Folly.build/Objects-normal/arm64/SSLErrors.o /Users/expo/workingdir/build/ios/Pods/Flipper-Folly/folly/io/async/ssl/SSLErrors.cpp normal arm64 c++ com.apple.compilers.llvm.clang.1_0.compiler (in target 'Flipper-Folly' from project 'Pods')
regexp: /in target 'Flipper-Folly' from project 'Pods'/,
createError: () =>
new TrackedBuildError(
'FLIPPER_FOLLY_COMPILE_ERROR',
'fastlane: Flipper-Folly compile error.'
),
},
{
phase: BuildPhase.RUN_FASTLANE,
platform: Platform.IOS,
// The following build commands failed:
// PhaseScriptExecution Bundle\ React\ Native\ code\ and\ images /Users/expo/Library/Developer/Xcode/DerivedData/cnaxwpahkhcjluhigkcwrturapmm/Build/Intermediates.noindex/ArchiveIntermediates/Test/IntermediateBuildFilesPath/Test.build/Release-iphoneos/Test.build/Script-00DD1BFF151E006B06BC.sh (in target 'Test' from project 'Test')
regexp:
/The following build commands failed.*\s.*PhaseScriptExecution Bundle\\ React\\ Native\\ code\\ and\\ images \/Users\/expo/,
createError: () =>
new TrackedBuildError(
'XCODE_BUILD_BUNDLER_ERROR',
'fastlane: Bundle React Native code and images failed.'
),
},
];
@@ -0,0 +1,94 @@
import { BuildPhase, errors } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { buildErrorHandlers } from './buildErrorHandlers';
import { ErrorContext, ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { userErrorHandlers } from './userErrorHandlers';
import { findXcodeBuildLogsPathAsync } from '../ios/xcodeBuildLogs';
async function maybeReadXcodeBuildLogs(
phase: BuildPhase,
buildLogsDirectory: string
): Promise<string | undefined> {
if (phase !== BuildPhase.RUN_FASTLANE) {
return;
}
try {
const xcodeBuildLogsPath = await findXcodeBuildLogsPathAsync(buildLogsDirectory);
if (!xcodeBuildLogsPath) {
return;
}
return await fs.readFile(xcodeBuildLogsPath, 'utf-8');
} catch {
return undefined;
}
}
function resolveError<TError extends Error>(
errorHandlers: ErrorHandler<TError>[],
logLines: string[],
errorContext: ErrorContext,
xcodeBuildLogs?: string
): TError | undefined {
const { job, phase } = errorContext;
const { platform } = job;
const logs = logLines.join('\n');
const handlers = errorHandlers
.filter(handler => handler.platform === platform || !handler.platform)
.filter(
handler =>
(handler.phase === XCODE_BUILD_PHASE && phase === BuildPhase.RUN_FASTLANE) ||
handler.phase === phase ||
!handler.phase
)
.filter(handler => ('mode' in job && handler.mode === job.mode) || !handler.mode);
for (const handler of handlers) {
const regexp =
typeof handler.regexp === 'function' ? handler.regexp(errorContext) : handler.regexp;
if (!regexp) {
continue;
}
const match =
handler.phase === XCODE_BUILD_PHASE ? xcodeBuildLogs?.match(regexp) : logs.match(regexp);
if (match) {
return handler.createError(match, errorContext);
}
}
return undefined;
}
export async function resolveBuildPhaseErrorAsync(
error: any,
logLines: string[],
errorContext: ErrorContext,
buildLogsDirectory: string
): Promise<errors.BuildError> {
const { phase } = errorContext;
if (error instanceof errors.BuildError) {
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userError =
error instanceof errors.UserError
? error
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
new errors.UnknownError(errorContext.phase));
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
const trackingCode =
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
return new errors.BuildError(userError.message, {
errorCode: userError.errorCode,
trackingCode,
docsUrl: userError.docsUrl,
cause: error,
buildPhase: phase,
metadata: buildError?.metadata,
});
}
@@ -0,0 +1,17 @@
import { BuildMode, BuildPhase, Env, Job, Platform } from '@expo/eas-build-job';
export interface ErrorContext<TJob extends Job = Job> {
phase: BuildPhase;
job: TJob;
env: Env;
}
export const XCODE_BUILD_PHASE = 'XCODE_BUILD';
export interface ErrorHandler<T extends Error> {
regexp: RegExp | ((ctx: ErrorContext) => RegExp | undefined);
platform?: Platform;
phase?: BuildPhase | typeof XCODE_BUILD_PHASE;
mode?: BuildMode;
createError: (matchResult: RegExpMatchArray, errCtx: ErrorContext) => T | undefined;
}
@@ -0,0 +1,317 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import UserError = errors.UserError;
export const userErrorHandlers: ErrorHandler<UserError>[] = [
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
regexp: /requires CocoaPods version/,
// example log:
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
createError: () =>
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"
- switching to an image that supports that version under key "image"`,
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
regexp: /Could not find 'bundler' (.*) required by your/,
// 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 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/' }
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// example log:
// > 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 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.'
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// example log:
// > 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 UserError(
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.PREBUILD,
// example log:
// [15:42:05] Error: Cannot copy google-services.json from /build/workingdir/build/managed/abc to /build/workingdir/build/managed/android/app/google-services.json
// or
// [11:17:29] [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.
// [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 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' }
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
// Execution failed for task ':app:processReleaseGoogleServices'.
// > File google-services.json is missing. The Google Services Plugin cannot function without it.
// Searched Location:
regexp:
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
createError: () =>
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' }
),
},
{
platform: Platform.IOS,
phase: BuildPhase.PREBUILD,
// example log:
// [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 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' }
),
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// example log:
// [!] CocoaPods could not find compatible versions for pod "react-native-google-maps"
// In Podfile:
// react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)
// Specs satisfying the `react-native-google-maps (from `/Users/expo/workingdir/build/node_modules/react-native-maps`)` dependency were found, but they required a higher minimum deployment target.
// Error: Compatible versions of some pods could not be resolved.
regexp:
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
createError: (_, { job }) => {
return new UserError(
'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR',
`Some pods require a higher minimum deployment target.
${
'type' in job && job.type === Workflow.MANAGED
? 'You can use the expo-build-properties config plugin (https://docs.expo.dev/versions/latest/sdk/build-properties/) to override the default native build properties and set a different minimum deployment target.'
: 'You need to manually update the minimum deployment target in your project to resolve this issue.'
}
`
);
},
},
{
platform: Platform.IOS,
phase: BuildPhase.INSTALL_PODS,
// example log:
// [!] CocoaPods could not find compatible versions for pod "Firebase/Core":
// In snapshot (Podfile.lock):
// Firebase/Core (= 6.14.0)
// In Podfile:
// EXFirebaseCore (from `../node_modules/expo-firebase-core/ios`) was resolved to 3.0.0, which depends on
// Firebase/Core (= 7.7.0)
// You have either:
// * out-of-date source repos which you can update with `pod repo update` or with `pod install --repo-update`.
// * changed the constraints of dependency `Firebase/Core` inside your development pod `EXFirebaseCore`.
// 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 UserError(
'EAS_BUILD_INCOMPATIBLE_PODS_ERROR',
`Compatible versions of some pods could not be resolved.
You are seeing this error because either:
- Some of the pods used in your project depend on different versions of the same pod. See logs for more information.
- If you are caching Podfile.lock using "cache" field in eas.json, then versions there might not match required values in Podspecs of some installed libraries. To fix this, you can re-run build command with "--clear-cache" option, or select "Clear cache and retry build" on the build page.
`
);
},
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// example log:
// [stderr] npm ERR! Fix the upstream dependency conflict, or retry
// [stderr] npm ERR! this command with --force, or --legacy-peer-deps
// [stderr] npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
regexp:
/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 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.
- Delete package-lock.json and use yarn instead. It does not enforce peer dependencies.
- Downgrade to older version of npm on EAS Build, by adding "npm install -g npm@version" in "eas-build-pre-install" script in package.json.`
);
}
return undefined;
},
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// example log:
// [stderr] error https://github.com/expo/react-native/archive/sdk-41.0.0.tar.gz: Integrity check failed for "react-native" (computed integrity doesn't match our records, got "sha512-3jHI2YufrJi7eIABRf/DN/I2yOkmIZ0vAyezTz+PAUJiEs4v//5LLojWEU+W53AZsnuaEMcl/4fVy4bd+OuUbA== sha1-o9QuQTXIkc8VozXPaZIullB9a40=")
regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/,
createError: (matchResult: RegExpMatchArray) => {
if (matchResult.length >= 2) {
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"
- remove yarn.lock (or only the section for that package)
- run "yarn install --force"`
);
}
return undefined;
},
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// example log:
// yarn install v1.22.17
// [1/4] Resolving packages...
// [2/4] Fetching packages...
// [1/4] Resolving packages...
// [2/4] Fetching packages...
// [stderr] error https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz: Extracting tar content of undefined failed, the file appears to be corrupt: "ENOENT: no such file or directory, chmod '/Users/expo/Library/Caches/Yarn/v6/npm-jest-util-26.6.2-907535dbe4d5a6cb4c47ac9b926f6af29576cbc1-integrity/node_modules/jest-util/build/pluralize.d.ts'"
regexp:
/\[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 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`
);
}
return undefined;
},
},
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
// Prepare packages
// Computing target dependency graph and provisioning inputs
// Create build description
// Build description signature: 33a5c28977280822abe5e7bd7fe02529
// Build description path: /Users/expo/Library/Developer/Xcode/DerivedData/testapp-fazozgerxcvvfifkipojsjftgyih/Build/Intermediates.noindex/ArchiveIntermediates/testapp/IntermediateBuildFilesPath/XCBuildData/33a5c28977280822abe5e7bd7fe02529-desc.xcbuild
// note: Building targets in dependency order
// /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXConstants-EXConstants" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXConstants-EXConstants' from project 'Pods')
// /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "React-Core-AccessibilityResources" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'React-Core-AccessibilityResources' from project 'Pods')
// warning: Run script build phase '[CP-User] Generate app.manifest for expo-updates' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'EXUpdates' from project 'Pods')
// warning: Run script build phase 'Start Packager' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'testapp' from project 'testapp')
// warning: Run script build phase 'Bundle React Native code and images' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'testapp' from project 'testapp')
// warning: Run script build phase '[CP-User] Generate app.config for prebuilt Constants.manifest' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase. (in target 'EXConstants' from project 'Pods')
// /Users/expo/workingdir/build/managed/ios/Pods/Pods.xcodeproj: error: Signing for "EXUpdates-EXUpdates" requires a development team. Select a development team in the Signing & Capabilities editor. (in target 'EXUpdates-EXUpdates' from project 'Pods')
regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/,
createError: (_, { job }) =>
'type' in job && job.type === Workflow.MANAGED
? 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.`,
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
)
: 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`,
{
docsUrl:
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
}
),
},
{
platform: Platform.ANDROID,
phase: BuildPhase.RUN_GRADLEW,
regexp: /.*/,
createError: () =>
new UserError(
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
mode: BuildMode.RESIGN,
regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/,
createError: () =>
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.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
mode: BuildMode.RESIGN,
regexp: /.*/,
createError: () =>
new UserError(
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
`The "Run fastlane" step failed with an unknown error.`
),
},
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
// MkDir /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle (in target 'expo-dev-launcher-EXDevLauncher' from project 'Pods')
// cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/Pods
// /bin/mkdir -p /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/expo-dev-launcher/EXDevLauncher.bundle
// ProcessXCFramework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator/ProgrammaticAccessLibrary.framework ios simulator
// cd /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios
// builtin-process-xcframework --xcframework /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework --platform ios --environment simulator --target-path /Users/expo/workingdir/build/packages/apps/NFLNetwork/ios/build/Build/Products/Release-iphonesimulator
// /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework:1:1: error: The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
// note: A sealed resource is missing or invalid
// note: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework: a sealed resource is missing or invalid
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64_x86_64-simulator/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
regexp: /error: .+/g,
createError: matchResult =>
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: ', '')}`)
.join('\n')}\nRefer to "Xcode Logs" below for additional, more detailed logs.`
),
},
{
platform: Platform.IOS,
phase: BuildPhase.RUN_FASTLANE,
regexp: /.*/,
createError: () =>
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,135 @@
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';
jest.mock('../../common/projectSources');
jest.mock('../../ios/xcodeBuildLogs');
const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuildLogsAsync);
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
beforeEach(() => {
const job = createTestIosJob();
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
vol.mkdirSync('/workingdir/env', { recursive: true });
vol.mkdirSync('/workingdir/temporary-custom-build', { recursive: true });
vol.fromJSON(
{
'test.yaml': `
build:
steps:
- eas/checkout
`,
},
'/workingdir/temporary-custom-build'
);
return { handled: true };
});
ctx = new BuildContext(
{
...job,
customBuildConfig: {
path: 'test.yaml',
},
},
{
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
}
);
});
it('calls findAndUploadXcodeBuildLogsAsync in an iOS job if its artifacts is empty', async () => {
await runCustomBuildAsync(ctx);
expect(findAndUploadXcodeBuildLogsAsyncMock).toHaveBeenCalled();
});
it('does not call findAndUploadXcodeBuildLogsAsync in an iOS job if artifacts is already present', async () => {
ctx.artifacts.XCODE_BUILD_LOGS = 'uploaded';
await runCustomBuildAsync(ctx);
expect(findAndUploadXcodeBuildLogsAsyncMock).not.toHaveBeenCalled();
});
it('retries checking out the project', async () => {
jest.mocked(prepareProjectSourcesAsync).mockImplementationOnce(async () => {
throw new Error('Failed to clone repository');
});
await runCustomBuildAsync(ctx);
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();
});
});
@@ -0,0 +1,192 @@
import { Android, BuildMode, BuildPhase, Workflow } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import path from 'path';
import { runBuilderWithHooksAsync } from './common';
import { runCustomBuildAsync } from './custom';
import { restoreCredentials } from '../android/credentials';
import {
ensureLFLineEndingsInGradlewScript,
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 { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
export default async function androidBuilder(ctx: BuildContext<Android.Job>): Promise<Artifacts> {
if (ctx.job.mode === BuildMode.BUILD) {
await prepareExecutableAsync(ctx);
return await runBuilderWithHooksAsync(ctx, buildAsync);
} else if (ctx.job.mode === BuildMode.RESIGN) {
throw new Error('Not implemented');
} else if (ctx.job.mode === BuildMode.CUSTOM || ctx.job.mode === BuildMode.REPACK) {
return await runCustomBuildAsync(ctx);
} else {
throw new Error('Not implemented');
}
}
async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
await setupAsync(ctx);
const evictUsedBefore = new Date();
const workingDirectory = ctx.getReactNativeProjectDirectory();
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
if (hasNativeCode) {
await ctx.runBuildPhase(BuildPhase.FIX_GRADLEW, async () => {
await ensureLFLineEndingsInGradlewScript(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.PREBUILD, async () => {
if (hasNativeCode) {
ctx.markBuildPhaseSkipped();
ctx.logger.info(
'Skipped running "expo prebuild" because the "android" directory already exists. Learn more about the build process: https://docs.expo.dev/build-reference/android-builds/'
);
return;
}
await prebuildAsync(ctx, {
logger: ctx.logger,
workingDir: ctx.getReactNativeProjectDirectory(),
});
});
await ctx.runBuildPhase(BuildPhase.RESTORE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support restoring cache');
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
await runHookIfPresent(ctx, Hook.POST_INSTALL);
});
const resolvedExpoUpdatesRuntimeVersion = await ctx.runBuildPhase(
BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION,
async () => {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
});
}
);
if (
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
) {
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
await restoreCredentials(ctx);
await configureBuildGradle(ctx);
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
resolvedFingerprintSources: resolvedExpoUpdatesRuntimeVersion?.fingerprintSources ?? null,
});
});
if (ctx.skipNativeBuild) {
throw new SkipNativeBuildError('Skipping Gradle build');
}
if (!ctx.env.EAS_BUILD_DISABLE_BUNDLE_JAVASCRIPT_STEP && shouldUseEagerBundle(ctx.metadata)) {
await ctx.runBuildPhase(BuildPhase.EAGER_BUNDLE, async () => {
await eagerBundleAsync({
platform: ctx.job.platform,
workingDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
env: {
...ctx.env,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
}
: null),
},
packageManager: ctx.packageManager,
});
});
}
await ctx.runBuildPhase(BuildPhase.RUN_GRADLEW, async () => {
const gradleCommand = resolveGradleCommand(ctx.job);
await runGradleCommand(ctx, {
logger: ctx.logger,
gradleCommand,
androidDir: path.join(ctx.getReactNativeProjectDirectory(), 'android'),
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
EXPO_UPDATES_FINGERPRINT_OVERRIDE: resolvedExpoUpdatesRuntimeVersion.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
});
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}',
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
return;
}
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
await cacheStatsAsync({
logger: ctx.logger,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
}
@@ -0,0 +1,52 @@
import { BuildJob, BuildPhase, Ios, Platform } from '@expo/eas-build-job';
import { Artifacts, BuildContext } from '../context';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
import { Hook, runHookIfPresent } from '../utils/hooks';
export async function runBuilderWithHooksAsync<T extends BuildJob>(
ctx: BuildContext<T>,
builderAsync: (ctx: BuildContext<T>) => Promise<void>
): Promise<Artifacts> {
try {
let buildSuccess = true;
try {
await builderAsync(ctx);
await ctx.runBuildPhase(BuildPhase.ON_BUILD_SUCCESS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.ON_BUILD_SUCCESS);
});
} catch (err: any) {
buildSuccess = false;
await ctx.runBuildPhase(BuildPhase.ON_BUILD_ERROR_HOOK, async () => {
await runHookIfPresent(ctx, Hook.ON_BUILD_ERROR);
});
throw err;
} finally {
await ctx.runBuildPhase(BuildPhase.ON_BUILD_COMPLETE_HOOK, async () => {
await runHookIfPresent(ctx, Hook.ON_BUILD_COMPLETE, {
extraEnvs: {
EAS_BUILD_STATUS: buildSuccess ? 'finished' : 'errored',
},
});
});
if (ctx.job.platform === Platform.IOS) {
await findAndUploadXcodeBuildLogsAsync(ctx as BuildContext<Ios.Job>, {
logger: ctx.logger,
});
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
await maybeFindAndUploadBuildArtifacts(ctx, {
logger: ctx.logger,
});
});
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
throw err;
}
return ctx.artifacts;
}
+105
View File
@@ -0,0 +1,105 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
import path from 'path';
import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
import { retryAsync } from '../utils/retry';
export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<Artifacts> {
const customBuildCtx = new CustomBuildContext(ctx);
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
await retryAsync(
async () => {
await fs.rm(customBuildCtx.projectSourceDirectory, { recursive: true, force: true });
await fs.mkdir(customBuildCtx.projectSourceDirectory, { recursive: true });
await prepareProjectSourcesAsync(ctx, customBuildCtx.projectSourceDirectory);
},
{
retryOptions: {
retries: 3,
retryIntervalMs: 1_000,
},
}
);
});
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
// We need to setup envs from eas.json
const env = await resolveEnvFromBuildProfileAsync(ctx, {
cwd: path.join(customBuildCtx.projectSourceDirectory, ctx.job.projectRootDirectory ?? '.'),
});
ctx.updateEnv(env);
customBuildCtx.updateEnv(ctx.env);
}
assert(
'steps' in ctx.job || 'customBuildConfig' in ctx.job,
'Steps or custom build config path are required in custom jobs'
);
const globalContext = new BuildStepGlobalContext(customBuildCtx, false);
const easFunctions = getEasFunctions(customBuildCtx);
const easFunctionGroups = getEasFunctionGroups(customBuildCtx);
const parser = ctx.job.steps
? new StepsConfigParser(globalContext, {
externalFunctions: easFunctions,
externalFunctionGroups: easFunctionGroups,
steps: ctx.job.steps,
})
: new BuildConfigParser(globalContext, {
externalFunctions: easFunctions,
externalFunctionGroups: easFunctionGroups,
configPath: path.join(
ctx.getReactNativeProjectDirectory(customBuildCtx.projectSourceDirectory),
nullthrows(
ctx.job.customBuildConfig?.path,
'Steps or custom build config path are required in custom jobs'
)
),
});
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
try {
return await parser.parseAsync();
} catch (parseError: any) {
ctx.logger.error('Failed to parse the custom build config file.');
if (parseError instanceof errors.BuildWorkflowError) {
for (const err of parseError.errors) {
ctx.logger.error({ err });
}
}
throw parseError;
}
});
try {
try {
await workflow.executeAsync();
} finally {
if (!ctx.artifacts.XCODE_BUILD_LOGS && ctx.job.platform === Platform.IOS) {
try {
await findAndUploadXcodeBuildLogsAsync(ctx as BuildContext<Ios.Job>, {
logger: ctx.logger,
});
} catch {
// do nothing, it's a non-breaking error.
}
}
await customBuildCtx.drainPendingMetricUploads();
}
} catch (err: any) {
err.artifacts = ctx.artifacts;
throw err;
}
return ctx.artifacts;
}
@@ -0,0 +1,4 @@
import androidBuilder from './android';
import iosBuilder from './ios';
export { androidBuilder, iosBuilder };
+354
View File
@@ -0,0 +1,354 @@
import { IOSConfig } from '@expo/config-plugins';
import { BuildMode, BuildPhase, Ios, ManagedArtifactType, Workflow } from '@expo/eas-build-job';
import plist from '@expo/plist';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import { runBuilderWithHooksAsync } from './common';
import { runCustomBuildAsync } from './custom';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext } from '../context';
import { configureXcodeProject } from '../ios/configure';
import CredentialsManager from '../ios/credentials/manager';
import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import {
cacheStatsAsync,
patchPodsXcodeprojAsync,
restoreCcacheAsync,
restoreXcodeCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveXcodeCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
} from '../utils/expoUpdates';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
class InstallPodsTimeoutError extends Error {}
export default async function iosBuilder(ctx: BuildContext<Ios.Job>): Promise<Artifacts> {
if (ctx.job.mode === BuildMode.BUILD) {
await prepareExecutableAsync(ctx);
return await runBuilderWithHooksAsync(ctx, buildAsync);
} else if (ctx.job.mode === BuildMode.RESIGN) {
return await resignAsync(ctx);
} else if (ctx.job.mode === BuildMode.CUSTOM || ctx.job.mode === BuildMode.REPACK) {
return await runCustomBuildAsync(ctx);
} else {
throw new Error('Not implemented');
}
}
async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
await setupAsync(ctx);
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let xcodeCacheHit = false;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
});
await ctx.runBuildPhase(BuildPhase.PREBUILD, async () => {
if (hasNativeCode) {
ctx.markBuildPhaseSkipped();
ctx.logger.info(
'Skipped running "expo prebuild" because the "ios" directory already exists. Learn more about the build process: https://docs.expo.dev/build-reference/ios-builds/'
);
return;
}
const extraEnvs: Record<string, string> = credentials?.teamId
? { APPLE_TEAM_ID: credentials.teamId }
: {};
await prebuildAsync(ctx, {
logger: ctx.logger,
workingDir: ctx.getReactNativeProjectDirectory(),
options: { extraEnvs },
});
});
await ctx.runBuildPhase(BuildPhase.RESTORE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support restoring cache');
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
xcodeCacheHit = await restoreXcodeCacheAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
simulator: ctx.job.simulator,
});
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
await runInstallPodsAsync(ctx);
await patchPodsXcodeprojAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
cacheHit: xcodeCacheHit,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
await runHookIfPresent(ctx, Hook.POST_INSTALL);
});
const resolvedExpoUpdatesRuntimeVersion = await ctx.runBuildPhase(
BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION,
async () => {
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
appConfig: ctx.appConfig,
platform: ctx.job.platform,
workflow: ctx.job.type,
env: ctx.env,
});
}
);
const buildConfiguration = resolveBuildConfiguration(ctx);
if (credentials) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_XCODE_PROJECT, async () => {
await configureXcodeProject(ctx, { credentials, buildConfiguration });
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
resolvedFingerprintSources: resolvedExpoUpdatesRuntimeVersion?.fingerprintSources ?? null,
});
});
if (!ctx.env.EAS_BUILD_DISABLE_BUNDLE_JAVASCRIPT_STEP && shouldUseEagerBundle(ctx.metadata)) {
await ctx.runBuildPhase(BuildPhase.EAGER_BUNDLE, async () => {
await eagerBundleAsync({
platform: ctx.job.platform,
workingDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
env: {
...ctx.env,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
}
: null),
},
packageManager: ctx.packageManager,
});
});
}
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
entitlements,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
});
});
} finally {
await ctx.runBuildPhase(BuildPhase.CLEAN_UP_CREDENTIALS, async () => {
await credentialsManager.cleanUp();
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
await uploadApplicationArchive(ctx, {
patternOrPath: resolveArtifactPath(ctx),
rootDir: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
});
});
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
if (ctx.isLocal) {
ctx.logger.info('Local builds do not support saving cache.');
return;
}
await saveXcodeCacheAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
simulator: ctx.job.simulator,
});
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
await cacheStatsAsync({
logger: ctx.logger,
env: ctx.env,
secrets: ctx.job.secrets,
});
});
}
async function readEntitlementsAsync(
ctx: BuildContext<Ios.Job>,
{ scheme, buildConfiguration }: { scheme: string; buildConfiguration: string }
): Promise<object | null> {
try {
const applicationTargetName =
await IOSConfig.BuildScheme.getApplicationTargetNameForSchemeAsync(
ctx.getReactNativeProjectDirectory(),
scheme
);
const entitlementsPath = IOSConfig.Entitlements.getEntitlementsPath(
ctx.getReactNativeProjectDirectory(),
{
buildConfiguration,
targetName: applicationTargetName,
}
);
if (!entitlementsPath) {
return null;
}
const entitlementsRaw = await fs.readFile(entitlementsPath, 'utf8');
return plist.parse(entitlementsRaw);
} catch (err) {
ctx.logger.warn({ err }, 'Failed to read entitlements');
ctx.markBuildPhaseHasWarnings();
return null;
}
}
async function resignAsync(ctx: BuildContext<Ios.Job>): Promise<Artifacts> {
const applicationArchivePath = await ctx.runBuildPhase(
BuildPhase.DOWNLOAD_APPLICATION_ARCHIVE,
async () => {
return await downloadApplicationArchiveAsync(ctx);
}
);
const credentialsManager = new CredentialsManager(ctx);
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
});
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
await runFastlaneResign(ctx, {
credentials: nullthrows(credentials),
ipaPath: applicationArchivePath,
});
});
} finally {
await ctx.runBuildPhase(BuildPhase.CLEAN_UP_CREDENTIALS, async () => {
await credentialsManager.cleanUp();
});
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => {
ctx.logger.info(`Application archive: ${applicationArchivePath}`);
await ctx.uploadArtifact({
artifact: {
type: ManagedArtifactType.APPLICATION_ARCHIVE,
paths: [applicationArchivePath],
},
logger: ctx.logger,
});
});
return ctx.artifacts;
}
async function runInstallPodsAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
let warnTimeout: NodeJS.Timeout | undefined;
let killTimeout: NodeJS.Timeout | undefined;
let timedOutToKill: boolean = false;
try {
const installPodsSpawnPromise = (
await installPods(ctx, {
infoCallbackFn: () => {
warnTimeout?.refresh();
killTimeout?.refresh();
},
})
).spawnPromise;
warnTimeout = setTimeout(() => {
ctx.logger.warn(
'"Install pods" phase takes longer then expected and it did not produce any logs in the past 15 minutes'
);
}, INSTALL_PODS_WARN_TIMEOUT_MS);
killTimeout = setTimeout(async () => {
timedOutToKill = true;
ctx.logger.error(
'"Install pods" phase takes a very long time and it did not produce any logs in the past 30 minutes. Most likely an unexpected error happened which caused the process to hang and it will be terminated'
);
const ppid = nullthrows(installPodsSpawnPromise.child.pid);
const pids = await getParentAndDescendantProcessPidsAsync(ppid);
pids.forEach(pid => {
process.kill(pid);
});
ctx.reportError?.('"Install pods" phase takes a very long time', undefined, {
extras: { buildId: ctx.env.EAS_BUILD_ID },
});
}, INSTALL_PODS_KILL_TIMEOUT_MS);
await installPodsSpawnPromise;
} catch (err: any) {
if (timedOutToKill) {
throw new InstallPodsTimeoutError('"Install pods" phase was inactive for over 30 minutes');
}
throw err;
} finally {
if (warnTimeout) {
clearTimeout(warnTimeout);
}
if (killTimeout) {
clearTimeout(killTimeout);
}
}
}
@@ -0,0 +1,575 @@
import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
Job,
Platform,
Workflow,
} from '@expo/eas-build-job';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { shallowCloneRepositoryAsync } from '../git';
import { prepareProjectSourcesAsync } from '../projectSources';
jest.mock('@expo/turtle-spawn');
jest.mock('node-fetch');
jest.mock('../git');
jest.mock('@expo/downloader');
jest.mock('@urql/core');
describe('projectSources', () => {
it('should use the refreshed repository URL', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const gitCommitHash = randomBytes(20).toString('hex');
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash,
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(
async () =>
({
ok: true,
json: async () => ({
data: {
gitRef: 'refs/heads/main',
gitCommitHash,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
type: ArchiveSourceType.GIT,
},
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
});
it('should fallback to the original repository URL if the refresh fails', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(
async () =>
({
ok: false,
text: async () => 'Failed to generate repository URL',
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
fetchMock.mockImplementation(
async () =>
({
ok: false,
json: async () => ({
// repositoryUrl is the right key
repository_url: 'https://x-access-token:[email protected]/expo/eas-cli.git',
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
fetchMock.mockImplementation(
async () =>
({
ok: false,
json: () => Promise.reject(new Error('Failed to generate repository URL')),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
expect(shallowCloneRepositoryAsync).toHaveBeenCalledTimes(3);
}, 15_000);
it('should retry fetching the repository URL', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const gitCommitHash = randomBytes(20).toString('hex');
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash,
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementationOnce(
async () =>
({
ok: false,
text: async () => 'Failed to generate repository URL',
}) as Response
);
fetchMock.mockImplementationOnce(
async () =>
({
ok: true,
json: async () => ({
data: {
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash,
type: ArchiveSourceType.GIT,
},
}),
}) as Response
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(shallowCloneRepositoryAsync).toHaveBeenLastCalledWith(
expect.objectContaining({
archiveSource: {
...ctx.job.projectArchive,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
},
})
);
}, 15_000);
it(`should fallback to the original repository URL if we're missing some config`, async () => {
const robotAccessToken = randomUUID();
await vol.promises.mkdir('/workingdir/environment-secrets/', { recursive: true });
const logger = createMockLogger();
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.GIT,
repositoryUrl: 'https://x-access-token:[email protected]/expo/eas-cli.git',
gitRef: 'refs/heads/main',
gitCommitHash: randomBytes(20).toString('hex'),
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_RUNNER: 'eas-build',
// EAS_BUILD_ID: buildId,
},
workingdir: '/workingdir',
logger,
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
expect(logger.error).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Failed to refresh project archive, falling back to the original one'
);
});
describe('uploadProjectMetadataAsFireAndForget', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should upload project metadata for build job with URL archive type', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
const bucketKey = `test-bucket-key-${randomUUID()}`;
await vol.promises.mkdir('/workingdir/build', { recursive: true });
await vol.promises.writeFile('/workingdir/build/package.json', '{}');
await vol.promises.writeFile('/workingdir/build/README.md', 'Hello, world!');
const mockGraphqlMutation = jest.fn();
mockGraphqlMutation
.mockReturnValueOnce({
toPromise: () =>
Promise.resolve({
data: {
uploadSession: {
createUploadSession: {
url: 'https://storage.example.com/upload',
bucketKey,
headers: { 'x-custom-header': 'value' },
},
},
},
}),
})
.mockReturnValueOnce({
toPromise: () =>
Promise.resolve({
data: {
build: {
updateBuildMetadata: {
id: buildId,
},
},
},
}),
});
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://example.com/project.tar.gz',
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
// Call prepareProjectSourcesAsync and don't await metadata upload
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
// Wait for the fire-and-forget async operation to complete
await setTimeout(1000);
// Verify the upload session was created
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({})
);
// Verify metadata was uploaded to storage
expect(fetchMock).toHaveBeenCalledWith(
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
})
);
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({
buildId,
projectMetadataFile: {
type: 'GCS',
bucketKey,
},
})
);
});
it('should not await the metadata upload (fire-and-forget)', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/build', { recursive: true });
await vol.promises.writeFile('/workingdir/build/app.json', '{}');
let uploadCompleted = false;
const mockGraphqlMutation = jest.fn();
mockGraphqlMutation
.mockReturnValueOnce({
toPromise: async () => {
// Simulate a slow upload
await setTimeout(100);
uploadCompleted = true;
return {
data: {
uploadSession: {
createUploadSession: {
url: 'https://storage.example.com/upload',
bucketKey: 'test-key',
headers: {},
},
},
},
};
},
})
.mockReturnValueOnce({
toPromise: () =>
Promise.resolve({
data: {
build: {
updateBuildMetadata: {
id: buildId,
},
},
},
}),
});
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://example.com/project.tar.gz',
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
const startTime = Date.now();
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
const endTime = Date.now();
// prepareProjectSourcesAsync should complete quickly without waiting for upload
expect(endTime - startTime).toBeLessThan(50);
// Upload should not be completed yet
expect(uploadCompleted).toBe(false);
// Wait for the fire-and-forget operation to complete
await setTimeout(150);
// Now the upload should be completed
expect(uploadCompleted).toBe(true);
});
it('should handle upload errors gracefully without failing the build', async () => {
const robotAccessToken = randomUUID();
const buildId = randomUUID();
await vol.promises.mkdir('/workingdir/build', { recursive: true });
await vol.promises.writeFile('/workingdir/build/index.js', 'console.log("test");');
const mockGraphqlMutation = jest.fn();
mockGraphqlMutation.mockReturnValue({
toPromise: () => Promise.reject(new Error('Upload failed')),
});
const logger = createMockLogger();
const ctx = new BuildContext(
{
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
type: Workflow.MANAGED,
mode: BuildMode.BUILD,
initiatingUserId: randomUUID(),
appId: randomUUID(),
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://example.com/project.tar.gz',
},
platform: Platform.IOS,
secrets: {
robotAccessToken,
environmentSecrets: [],
},
} as Job,
{
env: {
__API_SERVER_URL: 'https://api.expo.dev',
EXPO_TOKEN: robotAccessToken,
EAS_BUILD_ID: buildId,
EAS_BUILD_RUNNER: 'eas-build',
},
workingdir: '/workingdir',
logger,
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
uploadArtifact: jest.fn(),
}
);
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
fetchMock.mockResolvedValue({ ok: true } as Response);
// Should not throw even though upload will fail
await expect(prepareProjectSourcesAsync(ctx, ctx.buildDirectory)).resolves.not.toThrow();
// Wait for the fire-and-forget operation to complete
await setTimeout(100);
// Verify that a warning was logged
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to upload project metadata')
);
});
});
});
@@ -0,0 +1,38 @@
import { Metadata, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import semver from 'semver';
import { PackageManager } from '../utils/packageManager';
import { runExpoCliCommand } from '../utils/project';
export async function eagerBundleAsync({
platform,
workingDir,
logger,
env,
packageManager,
}: {
platform: Platform;
workingDir: string;
logger: bunyan;
env: Record<string, string | undefined>;
packageManager: PackageManager;
}): Promise<void> {
await runExpoCliCommand({
args: ['export:embed', '--eager', '--platform', platform, '--dev', 'false'],
options: {
cwd: workingDir,
logger,
env,
},
packageManager,
});
}
export function shouldUseEagerBundle(metadata: Metadata | null | undefined): boolean {
return Boolean(
!metadata?.developmentClient &&
metadata?.sdkVersion &&
semver.satisfies(metadata?.sdkVersion, '>=52')
);
}
@@ -0,0 +1,189 @@
import {
BuildJob,
EasCliNpmTags,
Env,
Metadata,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import assert from 'assert';
import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { isAtLeastNpm7Async } from '../utils/packageManager';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
metadata: Joi.object().unknown(),
});
export async function runEasBuildInternalAsync<TJob extends BuildJob>({
job,
logger,
env,
cwd,
projectRootOverride,
}: {
job: TJob;
logger: bunyan;
env: BuildStepEnv;
cwd: string;
projectRootOverride?: string;
}): Promise<{
newJob: TJob;
newMetadata: Metadata;
}> {
const { cmd, args, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
const { buildProfile, githubTriggerOptions } = job;
assert(buildProfile, 'build profile is missing in a build from git-based integration.');
const autoSubmitArgs = [];
if (githubTriggerOptions?.submitProfile) {
autoSubmitArgs.push('--auto-submit-with-profile');
autoSubmitArgs.push(githubTriggerOptions.submitProfile);
} else if (githubTriggerOptions?.autoSubmit) {
autoSubmitArgs.push('--auto-submit');
}
const result = await spawn(
cmd,
[
...args,
'build:internal',
'--platform',
job.platform,
'--profile',
buildProfile,
...autoSubmitArgs,
],
{
cwd,
env: {
...env,
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
.robotAccessToken,
...extraEnv,
EAS_PROJECT_ROOT: projectRootOverride,
},
logger,
// This prevents printing stdout with job secrets and credentials to logs.
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
}
);
const stdout = result.stdout.toString();
const parsed = JSON.parse(stdout);
return validateEasBuildInternalResult({
result: parsed,
oldJob: job,
});
}
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
ctx: BuildContext<TJob>,
{ cwd }: { cwd: string }
): Promise<Env> {
const { cmd, args, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
const { buildProfile } = ctx.job;
assert(buildProfile, 'build profile is missing in a build from git-based integration.');
let spawnResult;
try {
spawnResult = await spawn(
cmd,
[
...args,
'config',
'--platform',
ctx.job.platform,
'--profile',
buildProfile,
'--non-interactive',
'--json',
'--eas-json-only',
],
{
cwd,
env: { ...ctx.env, ...extraEnv },
}
);
} catch (err: any) {
ctx.logger.error(`Failed to the read build profile ${buildProfile} from eas.json.`);
ctx.logger.error(err.stderr?.toString());
throw Error(`Failed to read the build profile ${buildProfile} from eas.json.`);
}
const stdout = spawnResult.stdout.toString();
const parsed = JSON.parse(stdout);
const env = validateEnvs(parsed.buildProfile);
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
}: {
oldJob: TJob;
result: any;
}): { newJob: TJob; newMetadata: Metadata } {
const { value, error } = EasBuildInternalResultSchema.validate(result, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const newJob = sanitizeBuildJob({
...value.job,
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
return { newJob, newMetadata };
}
function validateEnvs(result: any): Env {
const { value, error } = Joi.object({
env: Joi.object().pattern(Joi.string(), Joi.string()),
}).validate(result, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
return value?.env;
}
@@ -0,0 +1,7 @@
export const COMMON_FASTLANE_ENV = {
FASTLANE_DISABLE_COLORS: '1',
FASTLANE_SKIP_UPDATE_CHECK: '1',
SKIP_SLOW_FASTLANE_WARNING: 'true',
FASTLANE_HIDE_TIMESTAMP: 'true',
LC_ALL: 'en_US.UTF-8',
};
+98
View File
@@ -0,0 +1,98 @@
import { ArchiveSource, ArchiveSourceType } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
export async function shallowCloneRepositoryAsync({
logger,
archiveSource,
destinationDirectory,
}: {
logger: bunyan;
archiveSource: ArchiveSource & { type: ArchiveSourceType.GIT };
destinationDirectory: string;
}): Promise<void> {
const { repositoryUrl } = archiveSource;
try {
await spawn('git', ['init'], { cwd: destinationDirectory });
await spawn('git', ['remote', 'add', 'origin', repositoryUrl], { cwd: destinationDirectory });
const { gitRef, gitCommitHash } = archiveSource;
await spawn('git', ['fetch', 'origin', '--depth', '1', '--no-tags', gitCommitHash], {
cwd: destinationDirectory,
});
await spawn('git', ['checkout', gitCommitHash], { cwd: destinationDirectory });
// If we have a gitRef, we try to add it to the repo.
if (gitRef) {
const { name, type } = getStrippedBranchOrTagName(gitRef);
switch (type) {
// If the gitRef is for a tag, we add a lightweight tag to current commit.
case 'tag': {
await spawn('git', ['tag', name], { cwd: destinationDirectory });
break;
}
// gitRef for a branch may come as:
// - qualified ref (e.g. refs/heads/feature/add-icon), detected as "branch" for a push,
// - unqualified ref (e.g. feature/add-icon), detected as "other" for a pull request.
case 'branch':
case 'other': {
await spawn('git', ['checkout', '-b', name], { cwd: destinationDirectory });
break;
}
}
}
} catch (err: any) {
const sanitizedUrl = getSanitizedGitUrl(repositoryUrl);
if (sanitizedUrl) {
logger.error(`Failed to clone git repository: ${sanitizedUrl}.`);
} else {
logger.error('Failed to clone git repository.');
}
logger.error(err.stderr);
throw err;
}
}
function getSanitizedGitUrl(maybeGitUrl: string): string | null {
try {
const url = new URL(maybeGitUrl);
if (url.password) {
url.password = '*******';
}
return url.toString();
} catch {
return null;
}
}
function getStrippedBranchOrTagName(ref: string): {
name: string;
type: 'branch' | 'tag' | 'other';
} {
const branchRegex = /(\/?refs)?\/?heads\/(.+)/;
const branchMatch = ref.match(branchRegex);
if (branchMatch) {
return {
name: branchMatch[2],
type: 'branch',
};
}
const tagRegex = /(\/?refs)?\/?tags\/(.+)/;
const tagMatch = ref.match(tagRegex);
if (tagMatch) {
return {
name: tagMatch[2],
type: 'tag',
};
}
return {
name: ref,
type: 'other',
};
}
@@ -0,0 +1,89 @@
import { Job } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { BuildContext } from '../context';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
export async function installDependenciesAsync({
packageManager,
env,
logger,
infoCallbackFn,
cwd,
useFrozenLockfile,
}: {
packageManager: PackageManager;
env: Record<string, string | undefined>;
cwd: string;
logger: Exclude<SpawnOptions['logger'], undefined>;
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
useFrozenLockfile: boolean;
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
let args: string[];
switch (packageManager) {
case PackageManager.NPM: {
args = useFrozenLockfile ? ['ci'] : ['install'];
args.push('--include=dev');
break;
}
case PackageManager.PNPM: {
args = ['install', useFrozenLockfile ? '--frozen-lockfile' : '--no-frozen-lockfile'];
break;
}
case PackageManager.YARN: {
const isModernYarnVersion = await isUsingModernYarnVersion(cwd);
if (isModernYarnVersion) {
if (env['EAS_YARN_FOCUS_WORKSPACE']) {
args = ['workspaces', 'focus', env['EAS_YARN_FOCUS_WORKSPACE']];
} else {
args = [
'install',
'--inline-builds',
useFrozenLockfile ? '--immutable' : '--no-immutable',
];
}
} else {
args = [
'install',
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
'--production',
'false',
];
}
break;
}
case PackageManager.BUN:
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
break;
default:
throw new Error(`Unsupported package manager: ${packageManager}`);
}
if (env['EAS_VERBOSE'] === '1') {
args = [...args, '--verbose'];
}
logger.info(`Running "${packageManager} ${args.join(' ')}" in ${cwd} directory`);
return {
spawnPromise: spawn(packageManager, args, {
cwd,
logger,
infoCallbackFn,
env,
}),
};
}
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
const relativeReactNativeProjectDirectory = path.relative(
ctx.buildDirectory,
ctx.getReactNativeProjectDirectory()
);
ctx.logger.info(
`We detected that '${relativeReactNativeProjectDirectory}' is a ${ctx.packageManager} workspace`
);
}
return packagerRunDir;
}
@@ -0,0 +1,66 @@
import { BuildJob } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { SpawnOptions } from '@expo/turtle-spawn';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import { BuildContext } from '../context';
import { runExpoCliCommand } from '../utils/project';
export interface PrebuildOptions {
extraEnvs?: Record<string, string>;
}
export async function prebuildAsync<TJob extends BuildJob>(
ctx: BuildContext<TJob>,
{ logger, workingDir, options }: { logger: bunyan; workingDir: string; options?: PrebuildOptions }
): Promise<void> {
const spawnOptions: SpawnOptions = {
cwd: workingDir,
logger,
env: {
EXPO_IMAGE_UTILS_NO_SHARP: '1',
...options?.extraEnvs,
...ctx.env,
},
};
const prebuildCommandArgs = getPrebuildCommandArgs(ctx);
await runExpoCliCommand({
args: prebuildCommandArgs,
options: spawnOptions,
packageManager: ctx.packageManager,
});
const installDependenciesSpawnPromise = (
await installDependenciesAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger,
cwd: resolvePackagerDir(ctx),
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
useFrozenLockfile: false,
})
).spawnPromise;
await installDependenciesSpawnPromise;
}
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
let prebuildCommand =
ctx.job.experimental?.prebuildCommand ?? `prebuild --no-install --platform ${ctx.job.platform}`;
if (!prebuildCommand.match(/(?:--platform| -p)/)) {
prebuildCommand = `${prebuildCommand} --platform ${ctx.job.platform}`;
}
const npxCommandPrefix = 'npx ';
const expoCommandPrefix = 'expo ';
const expoCliCommandPrefix = 'expo-cli ';
if (prebuildCommand.startsWith(npxCommandPrefix)) {
prebuildCommand = prebuildCommand.substring(npxCommandPrefix.length).trim();
}
if (prebuildCommand.startsWith(expoCommandPrefix)) {
prebuildCommand = prebuildCommand.substring(expoCommandPrefix.length).trim();
}
if (prebuildCommand.startsWith(expoCliCommandPrefix)) {
prebuildCommand = prebuildCommand.substring(expoCliCommandPrefix.length).trim();
}
return prebuildCommand.split(' ');
}
@@ -0,0 +1,296 @@
import downloadFile from '@expo/downloader';
import { ArchiveSource, ArchiveSourceSchemaZ, ArchiveSourceType, Job } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
ctx: BuildContext<TJob>,
destinationDirectory: string
): // Return type required to make switch exhaustive.
Promise<{ handled: boolean }> {
let projectArchive: ArchiveSource = ctx.job.projectArchive;
if (ctx.isLocal) {
console.warn('Local build, skipping project archive refresh');
} else {
const projectArchiveResult = await asyncResult(fetchProjectArchiveSourceAsync(ctx));
if (!projectArchiveResult.ok) {
ctx.logger.error(
{ err: projectArchiveResult.reason },
'Failed to refresh project archive, falling back to the original one'
);
}
projectArchive = projectArchiveResult.value ?? ctx.job.projectArchive;
}
switch (projectArchive.type) {
case ArchiveSourceType.R2:
case ArchiveSourceType.GCS: {
throw new Error('Remote project sources should be resolved earlier to URL');
}
case ArchiveSourceType.PATH: {
await prepareProjectSourcesLocallyAsync(ctx, projectArchive.path, destinationDirectory); // used in eas build --local
return { handled: true };
}
case ArchiveSourceType.NONE: {
// May be used in no-sources jobs like submission jobs.
return { handled: true };
}
case ArchiveSourceType.URL: {
await downloadAndUnpackProjectFromTarGzAsync(ctx, projectArchive.url, destinationDirectory);
uploadProjectMetadataAsFireAndForget(ctx, { projectDirectory: destinationDirectory });
return { handled: true };
}
case ArchiveSourceType.GIT: {
await shallowCloneRepositoryAsync({
logger: ctx.logger,
archiveSource: projectArchive,
destinationDirectory,
});
uploadProjectMetadataAsFireAndForget(ctx, { projectDirectory: destinationDirectory });
return { handled: true };
}
}
}
export async function downloadAndUnpackProjectFromTarGzAsync<TJob extends Job>(
ctx: BuildContext<TJob>,
projectArchiveUrl: string,
destinationDirectory: string
): Promise<void> {
const projectTarball = path.join(ctx.workingdir, 'project.tar.gz');
try {
await downloadFile(projectArchiveUrl, projectTarball, { retry: 3 });
} catch (err: any) {
ctx.reportError?.('Failed to download project archive', err, {
extras: { buildId: ctx.env.EAS_BUILD_ID },
});
throw err;
}
await unpackTarGzAsync({
destination: destinationDirectory,
source: projectTarball,
logger: ctx.logger,
});
}
async function prepareProjectSourcesLocallyAsync<TJob extends Job>(
ctx: BuildContext<TJob>,
projectArchivePath: string,
destinationDirectory: string
): Promise<void> {
const projectTarball = path.join(ctx.workingdir, 'project.tar.gz');
await fs.copyFile(projectArchivePath, projectTarball);
await unpackTarGzAsync({
destination: destinationDirectory,
source: projectTarball,
logger: ctx.logger,
});
}
async function unpackTarGzAsync({
logger,
source,
destination,
}: {
logger: bunyan;
source: string;
destination: string;
}): Promise<void> {
await spawn('tar', ['-C', destination, '--strip-components', '1', '-zxf', source], {
logger,
});
}
function uploadProjectMetadataAsFireAndForget(
ctx: BuildContext<Job>,
{ projectDirectory }: { projectDirectory: string }
): void {
void (async () => {
const uploadResult = await asyncResult(uploadProjectMetadataAsync(ctx, { projectDirectory }));
if (!uploadResult.ok) {
ctx.logger.warn(`Failed to upload project metadata: ${uploadResult.reason}`);
}
})();
}
async function uploadProjectMetadataAsync(
ctx: BuildContext<Job>,
{ projectDirectory }: { projectDirectory: string }
): Promise<void> {
if (!ctx.job.platform) {
// Not a build job, skip.
return;
} else if (
ctx.job.projectArchive.type === ArchiveSourceType.GCS &&
ctx.job.projectArchive.metadataLocation
) {
// Build already has project metadata, skip.
return;
}
const files: string[] = [];
const directoriesToScan: { dir: string; relativePath: string }[] = [
{ dir: projectDirectory, relativePath: '' },
];
while (directoriesToScan.length > 0) {
const { dir, relativePath } = directoriesToScan.shift()!;
if (relativePath === '.git') {
// Do not include whole `.git` directory in the archive, just that it exists.
files.push('.git/...');
continue;
}
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativeFilePath = path.join(relativePath, entry.name);
if (entry.isDirectory()) {
directoriesToScan.push({ dir: fullPath, relativePath: relativeFilePath });
} else {
files.push(relativeFilePath);
}
}
}
const sortedFiles = files
.map(
// Prepend entries with "project/"
f => path.join('project', f)
)
.sort(); // Sort for consistent ordering
const result = await ctx.graphqlClient
.mutation(
graphql(`
mutation {
uploadSession {
createUploadSession(type: EAS_BUILD_GCS_PROJECT_METADATA)
}
}
`),
{}
)
.toPromise();
if (result.error) {
throw result.error;
}
const uploadSession = result.data!.uploadSession.createUploadSession as {
url: string;
bucketKey: string;
headers: Record<string, string>;
};
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
});
const updateMetadataResult = await ctx.graphqlClient
.mutation(
graphql(`
mutation UpdateTurtleBuildMetadataMutation(
$buildId: ID!
$projectMetadataFile: ProjectMetadataFileInput!
) {
build {
updateBuildMetadata(
buildId: $buildId
metadata: { projectMetadataFile: $projectMetadataFile }
) {
id
}
}
}
`),
{
buildId: ctx.env.EAS_BUILD_ID,
projectMetadataFile: {
type: 'GCS',
bucketKey: uploadSession.bucketKey,
},
}
)
.toPromise();
if (updateMetadataResult.error) {
throw updateMetadataResult.error;
}
}
async function fetchProjectArchiveSourceAsync(ctx: BuildContext<Job>): Promise<ArchiveSource> {
const taskId = nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const expoApiServerURL = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
ctx.job.platform
? `/v2/turtle-builds/${taskId}/download-project-archive`
: `/v2/turtle-job-runs/${taskId}/download-project-archive`,
expoApiServerURL
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 20000,
retries: 3,
logger: ctx.logger,
}
);
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
const jsonResult = await asyncResult(response.json());
if (!jsonResult.ok) {
throw new Error(
`Expected JSON response from server (${response.status}): ${jsonResult.reason}`
);
}
const dataResult = z.object({ data: ArchiveSourceSchemaZ }).safeParse(jsonResult.value);
if (!dataResult.success) {
throw new Error(
`Unexpected data from server (${response.status}): ${z.prettifyError(dataResult.error)}`
);
}
return dataResult.data.data;
}
+298
View File
@@ -0,0 +1,298 @@
import { ExpoConfig } from '@expo/config';
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';
import path from 'path';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal';
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
import { prepareProjectSourcesAsync } from './projectSources';
import { BuildContext } from '../context';
import { deleteXcodeEnvLocalIfExistsAsync } from '../ios/xcodeEnv';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { setUpNpmrcAsync } from '../utils/npmrc';
import {
getPackageVersionFromPackageJson,
isAtLeastNpm7Async,
shouldUseFrozenLockfile,
} from '../utils/packageManager';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { readEasJsonContents, readPackageJson } from '../utils/project';
import { retryAsync } from '../utils/retry';
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
const INSTALL_DEPENDENCIES_WARN_TIMEOUT_MS = 15 * 60 * 1000;
const INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS = 30 * 60 * 1000;
class DoctorTimeoutError extends Error {}
class InstallDependenciesTimeoutError extends Error {}
export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>): Promise<void> {
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
await retryAsync(
async () => {
await fs.rm(ctx.buildDirectory, { recursive: true, force: true });
await fs.mkdir(ctx.buildDirectory, { recursive: true });
await prepareProjectSourcesAsync(ctx, ctx.buildDirectory);
},
{
retryOptions: {
retries: 3,
retryIntervalMs: 1_000,
},
}
);
await setUpNpmrcAsync(ctx, ctx.logger);
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
// We need to setup envs from eas.json before
// eas-build-pre-install hook is called.
const env = await resolveEnvFromBuildProfileAsync(ctx, {
cwd: ctx.getReactNativeProjectDirectory(),
});
ctx.updateEnv(env);
}
});
await ctx.runBuildPhase(BuildPhase.PRE_INSTALL_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_INSTALL);
});
await ctx.runBuildPhase(BuildPhase.READ_EAS_JSON, async () => {
try {
const easJsonContents = readEasJsonContents(ctx.getReactNativeProjectDirectory());
ctx.logger.info('Using eas.json:');
ctx.logger.info(easJsonContents);
} catch (err) {
ctx.logger.error({ err }, `Failed to read eas.json.`);
}
});
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
ctx.logger.info('Using package.json:');
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
ctx.logger.info(JSON.stringify(packageJson, null, 2));
return packageJson;
});
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
const expoVersion =
ctx.metadata?.sdkVersion ??
getPackageVersionFromPackageJson({
packageJson,
packageName: 'expo',
});
const reactNativeVersion =
ctx.metadata?.reactNativeVersion ??
getPackageVersionFromPackageJson({
packageJson,
packageName: 'react-native',
});
await runInstallDependenciesAsync(ctx, {
useFrozenLockfile: shouldUseFrozenLockfile({
env: ctx.env,
sdkVersion: expoVersion,
reactNativeVersion,
}),
});
});
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
const appConfig = ctx.appConfig;
ctx.logger.info('Using app configuration:');
ctx.logger.info(JSON.stringify(appConfig, null, 2));
await validateAppConfigAsync(ctx, appConfig);
});
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) {
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) {
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.'
);
}
const { newJob, newMetadata } = await runEasBuildInternalAsync({
job: ctx.job,
env: ctx.env,
logger: ctx.logger,
cwd: ctx.getReactNativeProjectDirectory(),
projectRootOverride: ctx.env.EAS_NO_VCS ? ctx.buildDirectory : undefined,
});
ctx.updateJobInformation(newJob, newMetadata);
});
}
const hasExpoPackage = !!packageJson.dependencies?.expo;
if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) {
await ctx.runBuildPhase(BuildPhase.RUN_EXPO_DOCTOR, async () => {
try {
await runExpoDoctor(ctx);
} catch (err) {
if (err instanceof DoctorTimeoutError) {
ctx.logger.error(err.message);
} else {
ctx.logger.error({ err }, 'Command "expo doctor" failed.');
}
ctx.markBuildPhaseHasWarnings();
}
});
}
}
async function runExpoDoctor<TJob extends Job>(ctx: BuildContext<TJob>): Promise<SpawnResult> {
ctx.logger.info('Running "expo doctor"');
let timeout: NodeJS.Timeout | undefined;
let timedOut = false;
const isAtLeastNpm7 = await isAtLeastNpm7Async();
try {
const argsPrefix = isAtLeastNpm7 ? ['-y'] : [];
const promise = spawn('npx', [...argsPrefix, 'expo-doctor'], {
cwd: ctx.getReactNativeProjectDirectory(),
logger: ctx.logger,
env: ctx.env,
});
timeout = setTimeout(async () => {
timedOut = true;
const ppid = nullthrows(promise.child.pid);
const pids = await getParentAndDescendantProcessPidsAsync(ppid);
pids.forEach(pid => {
process.kill(pid);
});
ctx.reportError?.(`"expo doctor" timed out`, undefined, {
extras: { buildId: ctx.env.EAS_BUILD_ID },
});
}, MAX_EXPO_DOCTOR_TIMEOUT_MS);
return await promise;
} catch (err: any) {
if (timedOut) {
throw new DoctorTimeoutError('"expo doctor" timed out, skipping...');
}
throw err;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function runInstallDependenciesAsync<TJob extends Job>(
ctx: BuildContext<TJob>,
{
useFrozenLockfile,
}: {
useFrozenLockfile: boolean;
}
): Promise<void> {
let warnTimeout: NodeJS.Timeout | undefined;
let killTimeout: NodeJS.Timeout | undefined;
let killTimedOut: boolean = false;
try {
const installDependenciesSpawnPromise = (
await installDependenciesAsync({
packageManager: ctx.packageManager,
env: ctx.env,
logger: ctx.logger,
infoCallbackFn: () => {
if (warnTimeout) {
warnTimeout.refresh();
}
if (killTimeout) {
killTimeout.refresh();
}
},
cwd: resolvePackagerDir(ctx),
useFrozenLockfile,
})
).spawnPromise;
warnTimeout = setTimeout(() => {
ctx.logger.warn(
'"Install dependencies" phase takes longer then expected and it did not produce any logs in the past 15 minutes. Consider evaluating your package.json file for possible issues with dependencies'
);
}, INSTALL_DEPENDENCIES_WARN_TIMEOUT_MS);
killTimeout = setTimeout(async () => {
killTimedOut = true;
ctx.logger.error(
'"Install dependencies" phase takes a very long time and it did not produce any logs in the past 30 minutes. Most likely an unexpected error happened with your dependencies which caused the process to hang and it will be terminated'
);
const ppid = nullthrows(installDependenciesSpawnPromise.child.pid);
const pids = await getParentAndDescendantProcessPidsAsync(ppid);
pids.forEach(pid => {
process.kill(pid);
});
ctx.reportError?.('"Install dependencies" phase takes a very long time', undefined, {
extras: { buildId: ctx.env.EAS_BUILD_ID },
});
}, INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS);
await installDependenciesSpawnPromise;
} catch (err: any) {
if (killTimedOut) {
throw new InstallDependenciesTimeoutError(
'"Install dependencies" phase was inactive for over 30 minutes. Please evaluate your package.json file'
);
}
throw err;
} finally {
if (warnTimeout) {
clearTimeout(warnTimeout);
}
if (killTimeout) {
clearTimeout(killTimeout);
}
}
}
async function validateAppConfigAsync(
ctx: BuildContext<Job>,
appConfig: ExpoConfig
): Promise<void> {
if (
appConfig?.extra?.eas?.projectId &&
ctx.env.EAS_BUILD_PROJECT_ID &&
appConfig.extra.eas.projectId !== ctx.env.EAS_BUILD_PROJECT_ID
) {
const isUsingDynamicConfig =
(await fs.pathExists(path.join(ctx.getReactNativeProjectDirectory(), 'app.config.ts'))) ||
(await fs.pathExists(path.join(ctx.getReactNativeProjectDirectory(), 'app.config.js')));
const isGitHubBuild = ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION;
let extraMessage = '';
if (isGitHubBuild && isUsingDynamicConfig) {
extraMessage =
'Make sure you connected your GitHub repository to the correct Expo project and if you are using environment variables to switch between projects in app.config.js/app.config.ts remember to set those variables in eas.json too. ';
} else if (isGitHubBuild) {
extraMessage = 'Make sure you connected your GitHub repository to the correct Expo project. ';
} else if (isUsingDynamicConfig) {
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 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.`
);
} else if (ctx.env.EAS_BUILD_PROJECT_ID && !appConfig?.extra?.eas?.projectId) {
ctx.logger.error(`The "extra.eas.projectId" field is missing from your app config.`);
ctx.markBuildPhaseHasWarnings();
}
}
+413
View File
@@ -0,0 +1,413 @@
import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
import { Client, fetchExchange } from '@urql/core';
import fs from 'fs-extra';
import path from 'path';
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
import { readAppConfig } from './utils/appConfig';
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
import { PackageManager, resolvePackageManager } from './utils/packageManager';
export type Artifacts = Partial<Record<ManagedArtifactType, string>>;
export interface CacheManager {
saveCache(ctx: BuildContext<Job>): Promise<void>;
restoreCache(ctx: BuildContext<Job>): Promise<void>;
}
export interface LogBuffer {
getLogs(): string[];
getPhaseLogs(buildPhase: string): string[];
}
export type ArtifactToUpload =
| {
type: ManagedArtifactType;
paths: string[];
}
| {
type: GenericArtifactType;
name: string;
paths: string[];
};
export interface BuildContextOptions {
workingdir: string;
logger: bunyan;
logBuffer: LogBuffer;
env: Env;
cacheManager?: CacheManager;
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<
/** Workflow Artifact ID */
| { artifactId: string | null; filename?: never }
/** This remains from the time we relied on Launcher to rename the GCS object. */
| { artifactId?: never; filename: string | null }
>;
reportError?: (
msg: string,
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
}
export class SkipNativeBuildError extends Error {}
export class BuildContext<TJob extends Job = Job> {
public readonly workingdir: string;
public logger: bunyan;
public readonly logBuffer: LogBuffer;
public readonly cacheManager?: CacheManager;
public readonly reportError?: (
msg: string,
err?: Error,
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;
private _env: Env;
private _job: TJob;
private _metadata?: Metadata;
private readonly defaultLogger: bunyan;
private readonly _uploadArtifact: BuildContextOptions['uploadArtifact'];
private buildPhase?: BuildPhase;
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: ExpoConfig;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
this.workingdir = options.workingdir;
this.defaultLogger = options.logger;
this.logger = this.defaultLogger;
this.logBuffer = options.logBuffer;
this.cacheManager = options.cacheManager;
this._uploadArtifact = options.uploadArtifact;
this.reportError = options.reportError;
this._job = job;
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
...options.env,
...job?.builderEnvironment?.env,
...environmentSecrets,
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
};
this._env.PATH = this._env.PATH
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
: this.buildExecutablesDirectory;
this._isLocal = this._env.EAS_BUILD_RUNNER !== 'eas-build';
this.graphqlClient = new Client({
url: new URL('graphql', this.env.__API_SERVER_URL).toString(),
exchanges: [fetchExchange],
preferGetMethod: false,
fetchOptions: {
headers: {
Authorization: `Bearer ${job.secrets?.robotAccessToken}`,
},
},
});
}
public get job(): TJob {
return this._job;
}
public get metadata(): Metadata | undefined {
return this._metadata;
}
public get env(): Env {
return this._env;
}
public get buildDirectory(): string {
return path.join(this.workingdir, 'build');
}
public get buildLogsDirectory(): string {
return path.join(this.workingdir, 'logs');
}
public get isLocal(): boolean {
return this._isLocal;
}
/**
* Directory used to store executables used during regular (non-custom) builds.
*/
public get buildExecutablesDirectory(): string {
return path.join(this.workingdir, 'bin');
}
/**
* Directory used to store env variables registered in the current build step.
* All values stored here will be available in the next build phase as env variables.
*/
public get buildEnvsDirectory(): string {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
}
public get appConfig(): ExpoConfig {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).exp;
}
return this._appConfig;
}
public async runBuildPhase<T>(
buildPhase: BuildPhase,
phase: () => Promise<T>,
{
doNotMarkStart = false,
doNotMarkEnd = false,
}: {
doNotMarkStart?: boolean;
doNotMarkEnd?: boolean;
} = {}
): Promise<T> {
let startTimestamp = Date.now();
try {
this.setBuildPhase(buildPhase, { doNotMarkStart });
startTimestamp = Date.now();
const result = await phase();
const durationMs = Date.now() - startTimestamp;
const buildPhaseResult: BuildPhaseResult = this.buildPhaseSkipped
? BuildPhaseResult.SKIPPED
: this.buildPhaseHasWarnings
? BuildPhaseResult.WARNING
: BuildPhaseResult.SUCCESS;
await this.endCurrentBuildPhaseAsync({ result: buildPhaseResult, doNotMarkEnd, durationMs });
return result;
} catch (err: any) {
const durationMs = Date.now() - startTimestamp;
const resolvedError = await this.handleBuildPhaseErrorAsync(err, buildPhase);
await this.endCurrentBuildPhaseAsync({ result: BuildPhaseResult.FAIL, durationMs });
throw resolvedError;
}
}
public markBuildPhaseSkipped(): void {
this.buildPhaseSkipped = true;
}
public markBuildPhaseHasWarnings(): void {
this.buildPhaseHasWarnings = true;
}
public async uploadArtifact({
artifact,
logger,
}: {
artifact: ArtifactToUpload;
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && !isGenericArtifact(artifact)) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
}
public updateEnv(env: Env): void {
if (this._job.triggeredBy !== BuildTrigger.GIT_BASED_INTEGRATION) {
throw new Error(
'Updating environment variables is only allowed when build was triggered by a git-based integration.'
);
}
this._env = {
...env,
...this._env,
__EAS_BUILD_ENVS_DIR: this.buildEnvsDirectory,
};
this._env.PATH = this._env.PATH
? [this.buildExecutablesDirectory, this._env.PATH].join(':')
: this.buildExecutablesDirectory;
}
public updateJobInformation(job: TJob, metadata: Metadata): void {
if (this._job.triggeredBy !== BuildTrigger.GIT_BASED_INTEGRATION) {
throw new Error(
'Updating job information is only allowed when build was triggered by a git-based integration.'
);
}
this._job = {
...this._job,
...job,
workflowInterpolationContext:
job.workflowInterpolationContext ?? this.job.workflowInterpolationContext,
triggeredBy: this._job.triggeredBy,
secrets: {
...this.job.secrets,
...job.secrets,
robotAccessToken: job.secrets?.robotAccessToken ?? this.job.secrets?.robotAccessToken,
environmentSecrets: [
// Latter secrets override former ones.
...(this.job.secrets?.environmentSecrets ?? []),
...(job.secrets?.environmentSecrets ?? []),
],
},
...(this._job.platform ? { expoBuildUrl: this._job.expoBuildUrl } : null),
};
this._metadata = metadata;
}
private async handleBuildPhaseErrorAsync(
err: any,
buildPhase: BuildPhase
): Promise<errors.BuildError> {
const buildError = await resolveBuildPhaseErrorAsync(
err,
this.logBuffer.getPhaseLogs(buildPhase),
{
job: this.job,
phase: buildPhase,
env: this.env,
},
this.buildLogsDirectory
);
if (buildError.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
// leaving message empty, website will display err.stack which already includes err.message
this.logger.error({ err }, '');
} else {
this.logger.error(`Error: ${buildError.message}`);
}
return buildError;
}
public getReactNativeProjectDirectory(baseDirectory = this.buildDirectory): string {
if (!this.job.platform) {
return path.join(
baseDirectory,
// NOTE: We may want to add projectRootDirectory to generic jobs in the future.
this.job.builderEnvironment.env.__EXPO_RELATIVE_BASE_DIRECTORY || '.'
);
}
return path.join(baseDirectory, this.job.projectRootDirectory ?? '.');
}
private setBuildPhase(buildPhase: BuildPhase, { doNotMarkStart = false } = {}): void {
if (this.buildPhase) {
if (this.buildPhase === buildPhase) {
return;
} else {
this.logger.info(
{ marker: LogMarker.END_PHASE, result: BuildPhaseResult.UNKNOWN },
`End phase: ${this.buildPhase}`
);
this.logger = this.defaultLogger;
}
}
this.buildPhase = buildPhase;
this.logger = this.defaultLogger.child({ phase: buildPhase });
if (!doNotMarkStart) {
this.logger.info({ marker: LogMarker.START_PHASE }, `Start phase: ${this.buildPhase}`);
}
}
private async endCurrentBuildPhaseAsync({
result,
doNotMarkEnd = false,
durationMs,
}: {
result: BuildPhaseResult;
doNotMarkEnd?: boolean;
durationMs: number;
}): Promise<void> {
if (!this.buildPhase) {
return;
}
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (!doNotMarkEnd) {
this.logger.info(
{ marker: LogMarker.END_PHASE, result, durationMs },
`End phase: ${this.buildPhase}`
);
}
this.logger = this.defaultLogger;
this.buildPhase = undefined;
this.buildPhaseSkipped = false;
this.buildPhaseHasWarnings = false;
}
private async collectAndUpdateEnvVariablesAsync(): Promise<void> {
const filenames = await fs.readdir(this.buildEnvsDirectory);
const entries = await Promise.all(
filenames.map(async basename => {
const rawContents = await fs.readFile(
path.join(this.buildEnvsDirectory, basename),
'utf-8'
);
return [basename, rawContents];
})
);
await Promise.all(
filenames.map(async basename => {
await fs.remove(path.join(this.buildEnvsDirectory, basename));
})
);
this._env = {
...this._env,
...Object.fromEntries(entries),
};
}
private getEnvironmentSecrets(job: TJob): Record<string, string> {
if (!job?.secrets?.environmentSecrets) {
return {};
}
const environmentSecretsDirectory = path.join(this.workingdir, 'eas-environment-secrets');
const environmentSecrets: Record<string, string> = {};
for (const { name, type, value } of job.secrets.environmentSecrets) {
if (type === EnvironmentSecretType.STRING) {
environmentSecrets[name] = value;
} else {
environmentSecrets[name] = createTemporaryEnvironmentSecretFile({
secretsDir: environmentSecretsDirectory,
name,
contents_base64: value,
});
}
}
return environmentSecrets;
}
}
@@ -0,0 +1,171 @@
import {
BuildJob,
BuildPhase,
BuildTrigger,
Env,
Job,
Metadata,
Platform,
StaticJobInterpolationContext,
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
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,
[Platform.IOS]: BuildRuntimePlatform.DARWIN,
};
export interface BuilderRuntimeApi {
uploadArtifact: (spec: { artifact: ArtifactToUpload; logger: bunyan }) => Promise<{
artifactId: string | null;
}>;
}
export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuildContextProvider {
/*
* Directory that contains project sources before eas/checkout.
*/
public readonly projectSourceDirectory: string;
/*
* Directory where build is executed. eas/checkout will copy sources here.
*/
public readonly projectTargetDirectory: string;
/*
* Directory where all build steps will be executed unless configured otherwise.
*/
public readonly defaultWorkingDirectory: string;
/*
* Directory where build logs will be stored unless configure otherwise.
*/
public readonly buildLogsDirectory: string;
/**
* Time of the start of the build.
*/
public readonly startTime: Date;
public readonly logger: bunyan;
public readonly graphqlClient: Client;
public readonly runtimeApi: BuilderRuntimeApi;
public job: TJob;
public metadata?: Metadata;
private _env: Env;
private readonly expoApiV2BaseUrl?: string;
private readonly pendingMetricUploads: Promise<void>[] = [];
constructor(buildCtx: BuildContext<TJob>) {
this._env = buildCtx.env;
this.job = buildCtx.job;
this.metadata = buildCtx.metadata;
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
this.graphqlClient = buildCtx.graphqlClient;
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
this.buildLogsDirectory = path.join(buildCtx.workingdir, 'logs');
this.runtimeApi = {
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
};
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
this.startTime = new Date();
}
public hasBuildJob(): this is CustomBuildContext<BuildJob> {
return Boolean(this.job.platform);
}
public get runtimePlatform(): BuildRuntimePlatform {
// Generic jobs are not per-platform.
if (!this.job.platform) {
assert(
process.platform === 'linux' || process.platform === 'darwin',
`Invalid platform, expected linux or darwin, got: ${process.platform}`
);
return {
linux: BuildRuntimePlatform.LINUX,
darwin: BuildRuntimePlatform.DARWIN,
}[process.platform];
}
return platformToBuildRuntimePlatform[this.job.platform];
}
public get env(): Env {
return this._env;
}
// We omit steps, because CustomBuildContext does not have steps.
public staticContext(): Omit<StaticJobInterpolationContext, 'steps'> {
return {
...this.job.workflowInterpolationContext,
expoApiServerURL: this.env.__API_SERVER_URL,
job: this.job,
metadata: this.metadata ?? null,
};
}
public updateEnv(env: Env): void {
this._env = env;
}
public updateJobInformation(job: TJob, metadata: Metadata): void {
if (this.job.triggeredBy !== BuildTrigger.GIT_BASED_INTEGRATION) {
throw new Error(
'Updating job information is only allowed when build was triggered by a git-based integration.'
);
}
this.job = {
...this.job,
...job,
workflowInterpolationContext:
job.workflowInterpolationContext ?? this.job.workflowInterpolationContext,
triggeredBy: this.job.triggeredBy,
secrets: {
...this.job.secrets,
...job.secrets,
robotAccessToken: job.secrets?.robotAccessToken ?? this.job.secrets?.robotAccessToken,
environmentSecrets: [
// Latter secrets override former ones.
...(this.job.secrets?.environmentSecrets ?? []),
...(job.secrets?.environmentSecrets ?? []),
],
},
...(this.job.platform ? { expoBuildUrl: this.job.expoBuildUrl } : null),
};
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);
}
}
@@ -0,0 +1,237 @@
import { bunyan } from '@expo/logger';
import assert from 'assert';
import { randomUUID } from 'crypto';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { Readable, Writable, pipeline } from 'stream';
import { promisify } from 'util';
import zlib from 'zlib';
import GCS from './client';
type PromiseResolveFn = (value?: void | PromiseLike<void> | undefined) => void;
const pipe = promisify(pipeline);
class GCSLoggerStream extends Writable {
public writable = true;
private readonly logger: bunyan;
private readonly uploadMethod?: GCSLoggerStream.UploadMethod;
private readonly options: GCSLoggerStream.Options;
private readonly temporaryLogsPath: string;
private readonly temporaryCompressedLogsPath: string;
private readonly compress: string | null;
private fileHandle?: number;
private uploadingPromise?: Promise<void>;
private hasChangesToUpload = false;
private flushInterval?: NodeJS.Timeout;
private buffer: string[] = [];
private writePromise?: Promise<any>;
private cleanUpCalled: boolean = false;
constructor({ logger, uploadMethod, options }: GCSLoggerStream.Config) {
super();
this.logger = logger;
this.uploadMethod = uploadMethod;
this.options = options;
this.compress = options?.compress ?? this.findNormalizedHeader('contentencoding');
this.temporaryLogsPath = path.join(os.tmpdir(), `logs-${randomUUID()}`);
this.temporaryCompressedLogsPath = `${this.temporaryLogsPath}.compressed`;
}
private findNormalizedHeader(name: string): string | null {
if (!this.uploadMethod || 'client' in this.uploadMethod) {
return null;
}
const normalizedName = name.toLowerCase().replace('-', '');
const fields = this.uploadMethod.signedUrl.headers;
for (const key in fields) {
if (key.toLowerCase().replace('-', '') === normalizedName) {
return fields[key];
}
}
return null;
}
public async init(): Promise<string> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
return (await this.flush(true)) as string;
}
public async cleanUp(): Promise<void> {
if (this.cleanUpCalled) {
this.logger.info('Cleanup already called');
return;
}
this.cleanUpCalled = true;
if (!this.fileHandle) {
throw new Error('You have to init the stream first!');
}
if (this.flushInterval) {
clearInterval(this.flushInterval);
this.flushInterval = undefined;
}
await this.safeWriteToFile(true);
await fs.close(this.fileHandle);
this.fileHandle = undefined;
await this.flush(true);
await fs.remove(this.temporaryLogsPath);
await fs.remove(this.temporaryCompressedLogsPath);
this.logger.info('Cleaning up GCS log stream');
}
public write(rec: any): boolean {
if (!this.fileHandle) {
return true;
}
const logLine = `${JSON.stringify(rec)}\n`;
this.buffer.push(logLine);
void this.safeWriteToFile();
return true;
}
private async safeWriteToFile(force = false): Promise<void> {
if (this.writePromise && force) {
await this.writePromise;
await this.safeWriteToFile(force);
return;
}
if (!this.fileHandle || Boolean(this.writePromise) || this.buffer.length === 0) {
return;
}
const buffer = this.buffer.slice();
try {
this.writePromise = fs.write(this.fileHandle, buffer.join(''));
await this.writePromise;
this.buffer = this.buffer.slice(buffer.length);
this.hasChangesToUpload = true;
} catch (err) {
this.logger.error({ err, origin: 'gcs-logger' }, 'Failed to write logs to file');
} finally {
this.writePromise = undefined;
}
}
private async flush(force = false): Promise<string | void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
return await this.flush(force);
}
return await this.flushInternal();
}
}
private async flushInternal(): Promise<string | void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
this.hasChangesToUpload = false;
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
return await this.upload()
.then(result => {
return result;
})
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file to GCS');
})
.then(result => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<string | void> {
if (!this.uploadMethod) {
return;
}
const { size } = await fs.stat(this.temporaryLogsPath);
const srcGeneratorAsync = async (): Promise<Readable> => {
return await this.createCompressedStream(
fs.createReadStream(this.temporaryLogsPath, { end: size })
);
};
if ('signedUrl' in this.uploadMethod) {
return await GCS.uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
}
const { Location } = await this.uploadMethod.client.uploadFile({
key: this.uploadMethod.key,
src: await srcGeneratorAsync(),
streamOptions: {
metadata: {
contentType: 'text/plain;charset=utf-8',
...(this.compress !== null ? { contentEncoding: this.compress } : {}),
customTime: this.uploadMethod.customTime,
},
},
});
return Location;
}
private async createCompressedStream(src: Readable): Promise<Readable> {
if (!this.compress) {
return src;
}
const dst = fs.createWriteStream(this.temporaryCompressedLogsPath);
const encoder =
this.compress === 'gzip'
? zlib.createGzip()
: this.compress === 'br'
? zlib.createBrotliCompress({
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 0,
},
})
: null;
assert(encoder, `unknown encoder ${encoder}`);
await pipe(src, encoder, dst);
const { size } = await fs.stat(this.temporaryCompressedLogsPath);
return fs.createReadStream(this.temporaryCompressedLogsPath, { end: size });
}
}
namespace GCSLoggerStream {
export enum CompressionMethod {
GZIP = 'gzip',
BR = 'br',
}
export interface Options {
uploadIntervalMs: number;
compress?: CompressionMethod;
}
export type UploadMethod =
| { client: GCS; key: string; customTime: Date | null }
| { signedUrl: GCS.SignedUrl };
export interface Config {
logger: bunyan;
uploadMethod?: UploadMethod;
options: Options;
}
}
export default GCSLoggerStream;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,383 @@
import { randomBytes } from 'crypto';
import fs from 'fs-extra';
import fetch, { RequestInit, Response } from 'node-fetch';
import path from 'path';
import { Readable } from 'stream';
import GCS from '../client';
jest.mock('node-fetch');
class ErrorWithCode extends Error {
protected readonly _code: string | undefined;
constructor(message: string, code?: string) {
super(message);
if (code) {
this._code = code;
}
}
public get code(): string | undefined {
return this._code;
}
}
class DNSError extends ErrorWithCode {
protected readonly _code: 'ENOTFOUND' | 'EAI_AGAIN' = 'ENOTFOUND';
}
const TEST_BUCKET = 'turtle-v2-test';
let googleApplicationCredentials: string | undefined;
beforeAll(() => {
googleApplicationCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.join(__dirname, 'mock-credentials.json');
});
afterAll(() => {
process.env.GOOGLE_APPLICATION_CREDENTIALS = googleApplicationCredentials;
});
describe('GCS client', () => {
describe('uploadWithPresignedURL function', () => {
it('should throw an error if upload fails', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: false,
status: 500,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
fetchMock.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
})
).rejects.toThrow();
});
it('should return stripped URL if successful', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
fetchMock.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
});
it('should retry upload on DNS error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes and DNS errors error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementation(async () => res);
const result = await GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on DNS error more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
const lastDNSError = new DNSError('failed thrice');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementationOnce(async () => {
throw lastDNSError;
})
.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
})
).rejects.toThrow(lastDNSError);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on retriable status code more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 504,
} as Response;
})
.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
})
).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on other error codes', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const localImagePath = path.join(__dirname, 'cat.jpg');
const key = 'cat.jpg';
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key,
expirationTime: 30000,
contentType: 'image/jpeg',
});
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
fetchMock
.mockImplementationOnce(async () => {
throw nonDNSError;
})
.mockImplementation(async () => res);
await expect(
GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => fs.createReadStream(localImagePath),
retryIntervalMs: 500,
})
).rejects.toThrow(nonDNSError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('retries upload with full stream', async () => {
const fetchMock = jest.mocked(fetch);
const receivedBodies: Buffer[] = [];
const recordBody = async (request: RequestInit | undefined): Promise<void> => {
let body = Buffer.from([]);
for await (const chunk of request!.body as Readable) {
body = Buffer.concat([body, chunk]);
}
receivedBodies.push(body);
};
fetchMock
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
throw new DNSError('failed once');
})
.mockImplementation(async (_path, request) => {
await recordBody(request);
return {
ok: true,
status: 201,
} as Response;
});
const gcs = new GCS(TEST_BUCKET);
const signedUrl = await gcs.createSignedUploadUrl({
key: 'text.txt',
expirationTime: 30000,
contentType: 'text/plain',
});
const bufferToUpload = randomBytes(16);
const result = await GCS.uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
retryIntervalMs: 500,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(receivedBodies.length).toBe(3);
for (const body of receivedBodies) {
// Here we're testing that each body received in every retry is the same.
// If we passed the same body instance to each of the retries
// each retry would consume a chunk of the same stream,
// causing the uploaded file to be corrupted (missing first bytes).
expect(body).toStrictEqual(bufferToUpload);
}
});
});
});
@@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "mock-project",
"private_key_id": "mock-private-key-id",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCMk5Qbd5E8RQeP+2vObJVJVnXWXfW/OStxkAYe7r+KVXzHyLYU\nD6Ejt94OiC3X2+tqrMIZgNAUR3VF5tSiP57Il0iWHSspmNIHz7cW6/FHiQsvN2oJ\n8Fzz03vK/FfaU50QWO+BwRnixJ0xG1kSX3MxNyRZ5omGJgcwx0l6Jt878QIDAQAB\nAoGAcGZbqO6ceNU068H6/A1D/GSeSa3NHX2np+ChlEAPdJtP7yojs7yfekC801+9\nT/gurpe9hsjBF0XflemwIJ6/5KucWHrcp2cCkdBHtkK6ScHeIVl3DcOYYhxqJrAA\nYiyGNh1+pf3FMPFBcPJ04pQT4PqOif61/W3p8eJFUke5x7UCQQDoyJ427wrUCZb+\nj2GD5uVyxQa4OJxDbRFriK5LTdwLeFeOmQhoo6kaGywILn6UtlPTUJ38ROA4Jv58\nEJf/jssDAkEAmpi/JkWlOzSM9mnb9blt/fH/DRKu9z0JPvl47V5YAzBhUtUMK15g\nSbEl+AJZyaklpJcgv6ShiGY0PLdoY7oQ+wJBAMF6VX4lKqPYIKcN9ygRlm3Q8ufV\nLZQhKBRvsyYl3Zmu+V8tNL78IEXxhaR7OHxUGtINNHKDsLUbO/NUO3GOdo8CQQCV\nV0sIIK0+JUSq4ZYvqKI9d6Fnso28noSpBfuwabvh0MGjb9Viq7eeWHeSPksYSMLp\nXWiwWMwGZJy/rnk0JVEzAkADiiARp70BZgRB8ogBx+DGTravfhPP64eFMHJnUgAE\nmDKlj3v0x3OZfdUz6Us5hfy3WqFyYbcCGLf7joVVZAEm\n-----END RSA PRIVATE KEY-----",
"client_email": "mock-client-email",
"client_id": "mock-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mock-client-email",
"universe_domain": "googleapis.com"
}
+210
View File
@@ -0,0 +1,210 @@
import { CreateWriteStreamOptions, File, Storage } from '@google-cloud/storage';
import fs from 'fs-extra';
import fetch, { FetchError } from 'node-fetch';
import stream from 'node:stream';
import { promisify } from 'node:util';
import { Readable } from 'stream';
import { URL } from 'url';
import { RetryOptions, retryOnGCSUploadFailure } from './retry';
const pipeline = promisify(stream.pipeline);
interface SignedUrlParams {
key: string;
expirationTime: number;
contentType?: string;
extensionHeaders?: { [key: string]: number | string | string[] };
}
interface UploadWithSignedUrlParams {
signedUrl: GCS.SignedUrl;
srcGeneratorAsync: () => Promise<Readable>;
retryIntervalMs?: RetryOptions['retryIntervalMs'];
retries?: RetryOptions['retries'];
}
class GCS {
private readonly client = new Storage();
constructor(private readonly bucket: string) {
this.bucket = bucket;
}
public static async uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync,
retries = 2,
retryIntervalMs = 30_000,
}: UploadWithSignedUrlParams): Promise<string> {
let resp;
try {
resp = await retryOnGCSUploadFailure(
async () => {
const src = await srcGeneratorAsync();
return await fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: src,
});
},
{
retries,
retryIntervalMs,
}
);
} catch (err: any) {
if (err instanceof FetchError) {
throw new Error(`Failed to upload the file, reason: ${err.code}`);
} else {
throw err;
}
}
if (!resp.ok) {
let body: string | undefined;
try {
body = await resp.text();
} catch {}
throw new Error(
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
);
}
const url = new URL(signedUrl.url);
return `${url.protocol}//${url.host}${url.pathname}`; // strip query string
}
public formatHttpUrl(key: string): string {
return this.client.bucket(this.bucket).file(key).publicUrl();
}
public async uploadFile({
key,
src,
streamOptions,
}: {
key: string;
src: Readable;
streamOptions?: CreateWriteStreamOptions;
}): Promise<{ Location: string }> {
const file = this.client.bucket(this.bucket).file(key);
await new Promise<void>((res, rej) => {
src.pipe(
file
.createWriteStream(streamOptions)
.on('error', err => {
rej(err);
})
.on('finish', () => {
res();
})
);
});
return { Location: file.publicUrl() };
}
public async deleteFile(key: string): Promise<void> {
try {
await this.client.bucket(this.bucket).file(key).delete();
} catch (err: any) {
if (err.response?.statusCode === 404) {
return;
}
throw err;
}
}
public async createSignedUploadUrl({
key,
expirationTime,
contentType,
extensionHeaders = {},
}: SignedUrlParams): Promise<GCS.SignedUrl> {
const config = {
version: 'v4' as const,
action: 'write' as const,
expires: Date.now() + expirationTime,
contentType,
extensionHeaders,
};
const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(config);
return {
url,
headers: {
...(contentType ? { 'content-type': contentType } : {}),
...extensionHeaders,
},
};
}
public async createSignedDownloadUrl({
key,
expirationTime,
}: {
key: string;
expirationTime: number;
}): Promise<string> {
const options = {
version: 'v4' as const,
action: 'read' as const,
expires: Date.now() + expirationTime,
};
const [url] = await this.client.bucket(this.bucket).file(key).getSignedUrl(options);
return url;
}
public async checkIfFileExists(key: string, fileHash?: string): Promise<boolean> {
let metadata;
try {
[metadata] = await this.client.bucket(this.bucket).file(key).getMetadata();
} catch (error: any) {
if (error.code === 404) {
return false;
}
throw error;
}
return fileHash ? metadata.etag === fileHash : true;
}
public async listDirectory(prefix: string): Promise<string[]> {
const [files] = await this.client.bucket(this.bucket).getFiles({ prefix });
return files.map(x => this.formatHttpUrl(x.name));
}
public async moveFile(src: string, dest: string): Promise<void> {
await this.client.bucket(this.bucket).file(src).move(dest);
}
public async deleteFiles(keys: string[]): Promise<void> {
await Promise.all(keys.map(key => this.deleteFile(key)));
}
public async downloadFile(key: string, destinationPath: string): Promise<void> {
const stream = this.client.bucket(this.bucket).file(key).createReadStream();
await pipeline(stream, fs.createWriteStream(destinationPath));
}
public getFile(key: string): File {
return this.client.bucket(this.bucket).file(key);
}
}
namespace GCS {
export interface SignedUrl {
url: string;
headers: { [key: string]: string };
}
export interface Config {
accessKeyId: string;
secretAccessKey: string;
region: string;
bucket: string;
}
}
export default GCS;
+65
View File
@@ -0,0 +1,65 @@
import { Response } from 'node-fetch';
export interface RetryOptions {
retries: number;
retryIntervalMs: number;
shouldRetryOnError: (error: any) => boolean;
shouldRetryOnResponse: (response: Response) => boolean;
}
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
export async function retryOnGCSUploadFailure(
fn: (attemptCount: number) => Promise<Response>,
{
retries,
retryIntervalMs,
}: { retries: RetryOptions['retries']; retryIntervalMs: RetryOptions['retryIntervalMs'] }
): Promise<Response> {
return await retry(fn, {
retries,
retryIntervalMs,
shouldRetryOnError: e => {
return (
e.code === 'ENOTFOUND' ||
e.code === 'EAI_AGAIN' ||
e.code === 'ECONNRESET' ||
e.code === 'ETIMEDOUT' ||
e.code === 'EPIPE'
);
},
shouldRetryOnResponse: resp => {
return [408, 429, 500, 502, 503, 504].includes(resp.status);
},
});
}
/**
* Wrapper used to execute an inner function and possibly retry it if it throws and error
* @param fn Function to be executed and retried in case of error
* @param retries How many times at most should the function be retried
* @param retryIntervalMs Time interval between the retries
* @param shouldRetryOnError Function that determines if the function should be retried based on the error thrown
* @param shouldRetryOnResponse Function that determines if the function should be retried based on the response
*/
async function retry(
fn: (attemptCount: number) => Promise<Response>,
{ retries, retryIntervalMs, shouldRetryOnError, shouldRetryOnResponse }: RetryOptions
): Promise<Response> {
let attemptCount = -1;
for (;;) {
try {
attemptCount += 1;
const resp = await fn(attemptCount);
if (attemptCount < retries && shouldRetryOnResponse(resp)) {
await new Promise(res => setTimeout(res, retryIntervalMs));
} else {
return resp;
}
} catch (err: any) {
if (attemptCount === retries || !shouldRetryOnError(err)) {
throw err;
}
await new Promise(res => setTimeout(res, retryIntervalMs));
}
}
}
+82
View File
@@ -0,0 +1,82 @@
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';
import { CustomBuildContext } from './customBuildContext';
import { getEasFunctionGroups } from './steps/easFunctionGroups';
import { getEasFunctions } from './steps/easFunctions';
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
import { retryAsync } from './utils/retry';
export async function runGenericJobAsync(
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 () => {
await retryAsync(
async () => {
await fs.rm(customBuildCtx.projectSourceDirectory, { recursive: true, force: true });
await fs.mkdir(customBuildCtx.projectSourceDirectory, { recursive: true });
await prepareProjectSourcesAsync(ctx, customBuildCtx.projectSourceDirectory);
},
{
retryOptions: {
retries: 3,
retryIntervalMs: 1_000,
},
}
);
});
const globalContext = new BuildStepGlobalContext(customBuildCtx, false);
const parser = new StepsConfigParser(globalContext, {
externalFunctions: getEasFunctions(customBuildCtx),
externalFunctionGroups: getEasFunctionGroups(customBuildCtx),
steps: ctx.job.steps,
});
const workflow = await ctx.runBuildPhase(BuildPhase.PARSE_CUSTOM_WORKFLOW_CONFIG, async () => {
try {
return await parser.parseAsync();
} catch (parseError: any) {
ctx.logger.error('Failed to parse the job definition file.');
if (parseError instanceof errors.BuildWorkflowError) {
for (const err of parseError.errors) {
ctx.logger.error({ err });
}
}
throw parseError;
}
});
const runResult = await asyncResult(workflow.executeAsync());
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
const results = await Promise.allSettled([
uploadJobOutputsToWwwAsync(globalContext, {
logger: ctx.logger,
expoApiV2BaseUrl,
}),
customBuildCtx.drainPendingMetricUploads(),
]);
if (results[0].status === 'rejected') {
throw results[0].reason;
}
});
return { runResult, buildWorkflow: workflow };
}
+23
View File
@@ -0,0 +1,23 @@
import * as Builders from './builders';
import GCSLoggerStream from './gcs/LoggerStream';
import GCS from './gcs/client';
export { Builders, GCS, GCSLoggerStream };
export {
ArtifactToUpload,
Artifacts,
BuildContext,
BuildContextOptions,
CacheManager,
LogBuffer,
SkipNativeBuildError,
} from './context';
export { PackageManager } from './utils/packageManager';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export * from './generic';
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,88 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`fastfile createFastfileForResigningBuild should create Fastfile with all variables substituted correctly 1`] = `
"lane :do_resign do
resign(
ipa: "/builds/MyApp.ipa",
signing_identity: "iPhone Distribution: Example Inc (ABC123)",
provisioning_profile: {
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
"
`;
exports[`fastfile createFastfileForResigningBuild should handle empty provisioning profiles 1`] = `
"lane :do_resign do
resign(
ipa: "/tmp/app.ipa",
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
keychain_path: "/tmp/keychain"
)
end
"
`;
exports[`fastfile createFastfileForResigningBuild should handle multiple provisioning profiles correctly 1`] = `
"lane :do_resign do
resign(
ipa: "/tmp/app.ipa",
signing_identity: "iPhone Distribution: Company Name",
provisioning_profile: {
"com.example.app" => "/profiles/main.mobileprovision",
"com.example.app.widget" => "/profiles/widget.mobileprovision",
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
keychain_path: "/tmp/keychain"
)
end
"
`;
exports[`fastfile createFastfileForResigningBuild should handle paths with special characters and spaces 1`] = `
"lane :do_resign do
resign(
ipa: "/builds/My App (Release).ipa",
signing_identity: "iPhone Distribution: Example Inc (ABC123XYZ)",
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
"
`;
exports[`fastfile createFastfileForResigningBuild should handle single provisioning profile 1`] = `
"lane :do_resign do
resign(
ipa: "/tmp/app.ipa",
signing_identity: "iPhone Distribution",
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
keychain_path: "/tmp/keychain"
)
end
"
`;
exports[`fastfile createFastfileForResigningBuild should produce valid Ruby syntax 1`] = `
"lane :do_resign do
resign(
ipa: "/tmp/app.ipa",
signing_identity: "iPhone Distribution",
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
keychain_path: "/tmp/keychain"
)
end
"
`;
@@ -0,0 +1,165 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`gymfile createGymfileForArchiveBuild should create Gymfile with all variables substituted correctly 1`] = `
"suppress_xcode_output(true)
clean(true)
scheme("MyApp")
configuration("Release")
export_options({
method: "app-store",
provisioningProfiles: {
"com.example.app" => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"com.example.app.widget" => "ffffffff-0000-1111-2222-333333333333",
}
})
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains/login.keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
output_directory("/tmp/output")
"
`;
exports[`gymfile createGymfileForArchiveBuild should create Gymfile without build configuration when not provided 1`] = `
"suppress_xcode_output(true)
clean(false)
scheme("TestScheme")
export_options({
method: "ad-hoc",
provisioningProfiles: {
"com.example.app" => "test-uuid",
}
})
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
output_directory("/tmp/output")
"
`;
exports[`gymfile createGymfileForArchiveBuild should handle multiple provisioning profiles correctly 1`] = `
"suppress_xcode_output(true)
clean(true)
scheme("MyApp")
export_options({
method: "enterprise",
provisioningProfiles: {
"com.example.app" => "main-app-uuid",
"com.example.app.widget" => "widget-uuid",
"com.example.app.extension" => "extension-uuid",
}
})
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
output_directory("/tmp/output")
"
`;
exports[`gymfile createGymfileForArchiveBuild should include iCloud container environment when provided in entitlements 1`] = `
"suppress_xcode_output(true)
clean(true)
scheme("MyApp")
configuration("Release")
export_options({
method: "app-store",
provisioningProfiles: {
"com.example.app" => "test-uuid",
},
iCloudContainerEnvironment: "Production"
})
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
output_directory("/tmp/output")
"
`;
exports[`gymfile createGymfileForSimulatorBuild should create Gymfile with all simulator variables substituted correctly 1`] = `
"suppress_xcode_output(true)
clean(true)
scheme("MyApp")
configuration("Debug")
derived_data_path("/tmp/derived-data")
skip_package_ipa(true)
skip_archive(true)
destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
"
`;
exports[`gymfile createGymfileForSimulatorBuild should create Gymfile without configuration when not provided 1`] = `
"suppress_xcode_output(true)
clean(false)
scheme("TestApp")
derived_data_path("/tmp/derived")
skip_package_ipa(true)
skip_archive(true)
destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
"
`;
exports[`gymfile createGymfileForSimulatorBuild should handle tvOS simulator destination 1`] = `
"suppress_xcode_output(true)
clean(true)
scheme("MyTVApp")
configuration("Debug")
derived_data_path("/tmp/derived")
skip_package_ipa(true)
skip_archive(true)
destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
"
`;
@@ -0,0 +1,241 @@
import { Ios } from '@expo/eas-build-job';
import { vol } from 'memfs';
import path from 'path';
import { configureXcodeProject } from '../configure';
import ProvisioningProfile, { DistributionType } from '../credentials/provisioningProfile';
jest.mock('fs');
const originalFs = jest.requireActual('fs');
afterEach(() => {
vol.reset();
});
describe(configureXcodeProject, () => {
it('configures credentials for a simple project', 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: 'Abc 123',
distributionType: DistributionType.APP_STORE,
},
},
distributionType: DistributionType.APP_STORE,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
expect(
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
});
it('configures credentials for multi target project', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/multitarget-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
shareextension: {
path: 'fake/path1.mobileprovision',
target: 'shareextension',
bundleIdentifier: 'abc.extension',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'extension profile',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Abc 123',
distributionType: DistributionType.APP_STORE,
},
multitarget: {
path: 'fake/path2.mobileprovision',
target: 'multitarget',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'multitarget profile',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Abc 123',
distributionType: DistributionType.APP_STORE,
},
},
distributionType: DistributionType.APP_STORE,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
expect(
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
});
it('configures credentials and versions for a simple project', async () => {
vol.fromJSON(
{
'ios/testapp/Info.plist': originalFs.readFileSync(
path.join(__dirname, 'fixtures/Info.plist'),
'utf-8'
),
'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: 'Abc 123',
distributionType: DistributionType.APP_STORE,
},
},
distributionType: DistributionType.APP_STORE,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {
version: {
appVersion: '1.2.3',
buildNumber: '1.2.4',
},
},
};
await configureXcodeProject(ctx as any, options);
expect(
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
expect(vol.readFileSync('/app/ios/testapp/Info.plist', 'utf-8')).toMatchSnapshot(
'Info.plist application target'
);
});
it('configures credentials and versions for multi target project', async () => {
vol.fromJSON(
{
'ios/multitarget/Info.plist': originalFs.readFileSync(
path.join(__dirname, 'fixtures/Info.plist'),
'utf-8'
),
'ios/shareextension/Info.plist': originalFs.readFileSync(
path.join(__dirname, 'fixtures/Info.plist'),
'utf-8'
),
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/multitarget-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
shareextension: {
path: 'fake/path1.mobileprovision',
target: 'shareextension',
bundleIdentifier: 'abc.extension',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'extension profile',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Abc 123',
distributionType: DistributionType.APP_STORE,
},
multitarget: {
path: 'fake/path2.mobileprovision',
target: 'multitarget',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'multitarget profile',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Abc 123',
distributionType: DistributionType.APP_STORE,
},
},
distributionType: DistributionType.APP_STORE,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {
version: {
appVersion: '1.2.3',
buildNumber: '1.2.4',
},
},
};
await configureXcodeProject(ctx as any, options);
expect(
vol.readFileSync('/app/ios/testapp.xcodeproj/project.pbxproj', 'utf-8')
).toMatchSnapshot();
expect(vol.readFileSync('/app/ios/shareextension/Info.plist', 'utf-8')).toMatchSnapshot(
'Info.plist application target'
);
expect(vol.readFileSync('/app/ios/multitarget/Info.plist', 'utf-8')).toMatchSnapshot(
'Info.plist extension'
);
});
});
@@ -0,0 +1,169 @@
import plist from '@expo/plist';
import fs from 'fs-extra';
import { vol } from 'memfs';
import {
IosMetadataName,
iosGetNativelyDefinedChannelAsync,
iosGetNativelyDefinedRuntimeVersionAsync,
iosSetChannelNativelyAsync,
iosSetRuntimeVersionNativelyAsync,
} from '../../ios/expoUpdates';
jest.mock('fs');
const expoPlistPath = '/app/ios/testapp/Supporting/Expo.plist';
const noItemsExpoPlist = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>`;
const channel = 'easupdatechannel';
afterEach(() => {
vol.reset();
});
describe(iosSetChannelNativelyAsync, () => {
it('sets the channel', async () => {
vol.fromJSON(
{
'ios/testapp/Supporting/Expo.plist': noItemsExpoPlist,
'ios/testapp.xcodeproj/project.pbxproj': 'placeholder',
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
job: { updates: { channel } },
logger: { info: () => {} },
};
await iosSetChannelNativelyAsync(ctx as any);
const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8');
expect(
plist.parse(newExpoPlist)[IosMetadataName.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY]
).toEqual({ 'expo-channel-name': channel });
});
});
describe(iosGetNativelyDefinedChannelAsync, () => {
it('gets the channel', async () => {
const expoPlist = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>staging-123</string>
</dict>
</dict>
</plist>
`;
vol.fromJSON(
{
'ios/testapp/Supporting/Expo.plist': expoPlist,
'ios/testapp.xcodeproj/project.pbxproj': 'placeholder',
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: () => {} },
};
await expect(iosGetNativelyDefinedChannelAsync(ctx as any)).resolves.toBe('staging-123');
});
});
describe(iosGetNativelyDefinedRuntimeVersionAsync, () => {
it('gets the natively defined runtime version', async () => {
const expoPlist = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>EXUpdatesRuntimeVersion</key>
<string>4.5.6</string>
</dict>
</plist>`;
vol.fromJSON(
{
'ios/testapp/Supporting/Expo.plist': expoPlist,
'ios/testapp.xcodeproj/project.pbxproj': 'placeholder',
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: () => {} },
};
const nativelyDefinedRuntimeVersion = await iosGetNativelyDefinedRuntimeVersionAsync(
ctx as any
);
expect(nativelyDefinedRuntimeVersion).toBe('4.5.6');
});
});
describe(iosSetRuntimeVersionNativelyAsync, () => {
it("sets runtime version if it's not specified", async () => {
vol.fromJSON(
{
'ios/testapp/Supporting/Expo.plist': noItemsExpoPlist,
'ios/testapp.xcodeproj/project.pbxproj': 'placeholder',
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: () => {} },
};
await iosSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3');
const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8');
expect(plist.parse(newExpoPlist)[IosMetadataName.RUNTIME_VERSION]).toEqual('1.2.3');
});
it("updates runtime version if it's already defined", async () => {
const expoPlist = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>RELEASE_CHANNEL</key>
<string>examplereleasechannel</string>
</dict>
</plist>`;
vol.fromJSON(
{
'ios/testapp/Supporting/Expo.plist': expoPlist,
'ios/testapp.xcodeproj/project.pbxproj': 'placeholder',
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: () => {} },
};
await iosSetRuntimeVersionNativelyAsync(ctx as any, '1.2.3');
const newExpoPlist = await fs.readFile(expoPlistPath, 'utf8');
expect(plist.parse(newExpoPlist)[IosMetadataName.RUNTIME_VERSION]).toEqual('1.2.3');
});
});
@@ -0,0 +1,172 @@
import { vol } from 'memfs';
import { TargetProvisioningProfiles } from '../credentials/manager';
import { createFastfileForResigningBuild } from '../fastfile';
describe('fastfile', () => {
beforeEach(() => {
vol.reset();
// Set up /tmp directory in the mock filesystem
vol.fromJSON({
'/tmp/.keep': '', // Create /tmp directory
});
});
afterEach(() => {
vol.reset();
});
describe('createFastfileForResigningBuild', () => {
it('should create Fastfile with all variables substituted correctly', async () => {
const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'test-uuid-1',
path: '/path/to/profiles/main.mobileprovision',
} as any,
'com.example.app.widget': {
bundleIdentifier: 'com.example.app.widget',
uuid: 'test-uuid-2',
path: '/path/to/profiles/widget.mobileprovision',
} as any,
};
const outputFile = '/tmp/Fastfile';
await createFastfileForResigningBuild({
outputFile,
ipaPath: '/builds/MyApp.ipa',
signingIdentity: 'iPhone Distribution: Example Inc (ABC123)',
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
targetProvisioningProfiles: mockTargetProvisioningProfiles,
});
const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string;
expect(generatedContent).toMatchSnapshot();
});
it('should handle single provisioning profile', async () => {
const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'single-uuid',
path: '/tmp/profile.mobileprovision',
} as any,
};
const outputFile = '/tmp/Fastfile';
await createFastfileForResigningBuild({
outputFile,
ipaPath: '/tmp/app.ipa',
signingIdentity: 'iPhone Distribution',
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: mockTargetProvisioningProfiles,
});
const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string;
expect(generatedContent).toMatchSnapshot();
});
it('should handle multiple provisioning profiles correctly', async () => {
const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'uuid-main',
path: '/profiles/main.mobileprovision',
} as any,
'com.example.app.widget': {
bundleIdentifier: 'com.example.app.widget',
uuid: 'uuid-widget',
path: '/profiles/widget.mobileprovision',
} as any,
'com.example.app.extension': {
bundleIdentifier: 'com.example.app.extension',
uuid: 'uuid-extension',
path: '/profiles/extension.mobileprovision',
} as any,
'com.example.app.intents': {
bundleIdentifier: 'com.example.app.intents',
uuid: 'uuid-intents',
path: '/profiles/intents.mobileprovision',
} as any,
};
const outputFile = '/tmp/Fastfile';
await createFastfileForResigningBuild({
outputFile,
ipaPath: '/tmp/app.ipa',
signingIdentity: 'iPhone Distribution: Company Name',
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: mockTargetProvisioningProfiles,
});
const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string;
expect(generatedContent).toMatchSnapshot();
});
it('should handle empty provisioning profiles', async () => {
const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {};
const outputFile = '/tmp/Fastfile';
await createFastfileForResigningBuild({
outputFile,
ipaPath: '/tmp/app.ipa',
signingIdentity: 'iPhone Distribution',
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: mockTargetProvisioningProfiles,
});
const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string;
expect(generatedContent).toMatchSnapshot();
});
it('should handle paths with special characters and spaces', async () => {
const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'test-uuid',
path: '/path/with spaces/profile (1).mobileprovision',
} as any,
};
const outputFile = '/tmp/Fastfile';
await createFastfileForResigningBuild({
outputFile,
ipaPath: '/builds/My App (Release).ipa',
signingIdentity: 'iPhone Distribution: Example Inc (ABC123XYZ)',
keychainPath: '/Users/expo/Library/Keychains/login keychain.keychain',
targetProvisioningProfiles: mockTargetProvisioningProfiles,
});
const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string;
expect(generatedContent).toMatchSnapshot();
});
it('should produce valid Ruby syntax', async () => {
const mockTargetProvisioningProfiles: TargetProvisioningProfiles = {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'test-uuid',
path: '/path/to/profile.mobileprovision',
} as any,
};
const outputFile = '/tmp/Fastfile';
await createFastfileForResigningBuild({
outputFile,
ipaPath: '/tmp/app.ipa',
signingIdentity: 'iPhone Distribution',
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: mockTargetProvisioningProfiles,
});
const generatedContent = vol.readFileSync(outputFile, 'utf-8') as string;
expect(generatedContent).toMatchSnapshot();
});
});
});
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>testapp</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>org.reactjs.native.example.testapp.turtlev2</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
Loaded 100 of 905 files, more files were not shown because too many files have changed in this diff. Show more