Compare commits

...
Author SHA1 Message Date
evanbaconandClaude Opus 4.6 608bb669c2 feat(metadata): allow up to 3 previews per device type
Apple supports up to 3 video previews per device type, but the
metadata:push/pull pipeline previously hard-coded a single preview per
PreviewType. Lift that limitation by accepting an array of preview
configs alongside the existing single-config form (which still works
for backwards compat). Sync semantics now mirror screenshots: match by
filename + size, upload only changed entries, delete removed ones, and
reorder the set to match config order.

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

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

* Preserve placeholder assets and app clip fallback

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

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

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

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

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

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

---------

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

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

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

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

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

* Fix changelog PR link

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

---------

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

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

* update package

* Add missing fields to test fixtures and CHANGELOG entry

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix changelog

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

---------

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

* Add CHANGELOG entry

* Fix typo

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

* limit the possible states

* make the id type uuid

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

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

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

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

* remove no longer valid test

---------

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

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

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

## Testing

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

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

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

## How

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

## Test Plan

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

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

* Update apple-utils and improve asset file handling

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

* Use original type names for preview and screenshot dirs

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

* Improve Apple metadata tasks error handling and schema

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

* Use original filenames for Apple previews and screenshots

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

* Update tests to skip on missing context instead of abort

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

* wip -- unit tests for metadata

* Include .query(true) in apple metadata tests

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

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

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

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

* Update CHANGELOG.md

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

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

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

---------

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

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

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

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

* Add metadata tests and enhance App Store auth

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* cache modules-2

* cache modules-2

* proper path

* set properties, rename

* rem modules-2

* set org.gradle.cache.cleanup to ALWAYS

* set org.gradle.cache.cleanup to ALWAYS

* consolidate caches in restore/save step

* restore gradle

* cleanup nullthrows

* always set properties if env set, cleanup

* consistent path, clean log

* modify gradle props in trycatch

* eas-cache-cleanup script

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

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

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

* Align with other CLI invocations

* Switch to turtle-spawn for consistency

* Update `appConfig` reading for async calls

* Add CHANGELOG entry

* Update worker

* Fix typo

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

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

Just want to make logs smaller.

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

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

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

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

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

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

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

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

Also adds Codex config for new workspace.

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

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

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

# How

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

# Test Plan

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

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-20 08:11:33 +01:00
Stanisław Chmiela 6ed7e14a69 [eas-cli] Update code for the new PartnerActor (#3521)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

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

# How

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

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

Removed 

# Test Plan

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

close ENG-17737

# How

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

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

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

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

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

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

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

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


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

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

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

* apply style/naming CR suggestions

* remove the unnecessarily defensive clean-ups

---------

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

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

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

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

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

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

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

# How

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

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

# Test Plan

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

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

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

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

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

# How

Add `workflow_dispatch:`

# Test Plan

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

* add changelog entry for build:dev skip flag

* Remove duplicate line in CHANGELOG

* Adjust CHANGELOG

* Bump add-pr-comment workflow dep

---------

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

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

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

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

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

---------

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

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

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

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

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

# How

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

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

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

# Test Plan

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

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

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

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

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

# How

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

# Test Plan

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

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

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

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

* address comments

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

* move reportStepMetric to customBuildContext

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

* address comments

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

---------

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

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "eas-cli"
[setup]
script = '''
mise trust
mise install
yarn
yarn typecheck
'''
+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
@@ -12,6 +12,8 @@ jobs:
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
@@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ./.github/actions/setup-mise
- uses: ./.github/internal-actions/setup-gcloud
+2 -1
View File
@@ -1,6 +1,7 @@
name: CHANGELOG.md PR check
on:
workflow_dispatch:
pull_request:
types:
- opened
@@ -29,7 +30,7 @@ jobs:
with:
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/add-pr-comment@v2.1.0
- uses: mshick/add-pr-comment@v2.8.2
if: always()
with:
message-id: changelog-entry-check
+3 -3
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.EXPO_BOT_PAT }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
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 }}
@@ -46,7 +46,7 @@ jobs:
echo "cmd = $cmd"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
echo //registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN > ~/.npmrc
$cmd
- name: Add latest-eas-build tag
run: |
@@ -60,5 +60,5 @@ jobs:
echo "Only moving the staging tag, skipping the production tag"
exit 0
fi
echo //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN > ~/.npmrc
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
+34 -11
View File
@@ -22,33 +22,56 @@ jobs:
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."
})
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ./.github/actions/setup-mise
- name: Create test app
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
+12 -7
View File
@@ -5,13 +5,16 @@ on:
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
pull_request:
paths:
- "packages/worker/**"
- .github/workflows/worker.yml
- .github/workflows/build-and-deploy-worker.yml
- yarn.lock
concurrency:
group: worker-${{ github.ref }}-${{ github.event_name }}
@@ -23,9 +26,11 @@ jobs:
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: volta-cli/action@5c175f92dea6f48441c436471e6479dbc192e194 # v4
- uses: ./.github/actions/setup-mise
- run: yarn install --frozen-lockfile --check-files
- run: yarn install --immutable
- run: yarn typecheck
- name: Run unit tests
working-directory: packages/worker
+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
+22
View File
@@ -0,0 +1,22 @@
{
"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/eas-cli/src/graphql/generated.ts",
"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
+6
View File
@@ -0,0 +1,6 @@
# AGENTS.md
When working with this repository follow instructions from CLAUDE.md.
- [./CLAUDE.md](./CLAUDE.md)
- Before committing, run the formatter on modified files.
+124 -1
View File
@@ -8,11 +8,134 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Allow up to 3 video previews per device type in `metadata:push` and `metadata:pull`. Each preview type now accepts an array of preview configs in addition to the existing single-config form (which remains supported for backwards compatibility). ([@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
### 🧹 Chores
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
### 🎉 New features
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
### 🐛 Bug fixes
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
### 🧹 Chores
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
### 🎉 New features
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
### 🎉 New features
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
### 🎉 New features
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
### 🐛 Bug fixes
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
### 🧹 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))
@@ -20,6 +143,7 @@ This is the log of notable changes to EAS CLI and related packages.
- 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
@@ -1584,7 +1708,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
+204 -19
View File
@@ -4,30 +4,77 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Monorepo Structure
This is a **Lerna-based monorepo** with four packages:
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
4. **[packages/worker](./packages/worker/CLAUDE.md)** - Turtle Worker service (private, not published)
### 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
> **Note**: Each package has its own CLAUDE.md with package-specific guidance. Click the links above to view them.
### 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
@@ -51,20 +98,26 @@ yarn start-allow-unused
### Testing
```bash
yarn test # Run all tests across packages (Jest multi-project)
yarn test --watch # Run tests in watch mode
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 specific package
# 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 # ESLint across all packages
yarn lint # Oxlint across all packages
yarn fmt:check # Oxfmt format check
```
### Running EAS CLI Locally
@@ -81,6 +134,20 @@ 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:
@@ -89,17 +156,91 @@ Each package has independent TypeScript compilation:
- `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/node18)
- `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 multi-project configuration
- **Root config**: `jest.config.ts` (combines eas-cli, eas-json, worker)
- **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`
@@ -109,11 +250,36 @@ TypeScript configs:
Run a single test file:
```bash
yarn test <path-to-test-file>
cd packages/eas-cli && yarn test <path-to-test-file>
# Example:
yarn test packages/eas-cli/src/project/__tests__/projectUtils-test.ts
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
@@ -130,11 +296,14 @@ export EXPO_DEBUG=1 # Enable debug logging
### Validation
All changes should be validated with TypeScript and the linter before committing:
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 ESLint
yarn lint # Run Oxlint
yarn fmt:check # Run Oxfmt format check
```
### Logging
@@ -153,6 +322,13 @@ Log.error('Error message');
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
@@ -163,8 +339,17 @@ When creating pull requests, make sure to adhere to [PULL_REQUEST_TEMPLATE](./.g
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 >= 18.0.0 (managed via Volta)
- **Package Manager**: Uses Yarn 1.22.21
- **Node Version**: Requires Node.js (managed via Mise)
- **Package Manager**: Uses Yarn 4.12.0
- **Compilation Target**: CommonJS with Node resolution
+2 -2
View File
@@ -65,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
-17
View File
@@ -1,17 +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';
// eslint-disable-next-line import/no-relative-packages
import workerConfig from './packages/worker/jest.config.unit';
const config: Config = {
projects: [easCliConfig, easJsonConfig, workerConfig],
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.28.0",
"version": "18.5.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 -16
View File
@@ -6,36 +6,33 @@
],
"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",
"clean": "lerna run clean && rm -rf node_modules coverage"
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
"test": "lerna run test",
"clean": "lerna run clean && rimraf node_modules coverage"
},
"devDependencies": {
"@types/jest": "29.5.11",
"@types/node": "20.14.8",
"eslint": "8.56.0",
"eslint-config-universe": "13.0.0",
"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",
"rimraf": "3.0.2",
"ts-jest": "29.1.1",
"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;
+101
View File
@@ -0,0 +1,101 @@
{
"name": "@expo/build-tools",
"version": "18.5.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": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.5.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
"@google-cloud/storage": "^7.11.2",
"@urql/core": "^6.0.1",
"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.4.1",
"@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",
"rimraf": "3.0.2",
"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), LC_ALL: 'C.UTF-8' },
});
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,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,92 @@
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.ExpoError> {
const { phase } = errorContext;
if (error instanceof errors.ExpoError) {
error.buildPhase ??= phase;
return error;
}
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
const userError =
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.UserError(userError.errorCode, userError.message, {
trackingCode,
docsUrl: userError.docsUrl,
buildPhase: phase,
metadata: buildError?.metadata,
cause: error,
});
}
@@ -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,266 @@
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { restoreCredentials } from '../../android/credentials';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
} from '../../steps/utils/android/gradleConfig';
jest.mock('../common', () => ({
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
await buildFn(ctx);
return ctx.artifacts;
}),
}));
jest.mock('../custom', () => ({
runCustomBuildAsync: jest.fn(),
}));
jest.mock('../../android/credentials');
jest.mock('../../android/gradle', () => ({
ensureLFLineEndingsInGradlewScript: jest.fn(),
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
runGradleCommand: jest.fn(),
}));
jest.mock('../../common/eagerBundle', () => ({
eagerBundleAsync: jest.fn(),
shouldUseEagerBundle: jest.fn(() => false),
}));
jest.mock('../../common/prebuild', () => ({
prebuildAsync: jest.fn(),
}));
jest.mock('../../common/setup', () => ({
setupAsync: jest.fn(),
}));
jest.mock('../../steps/functions/restoreBuildCache', () => ({
cacheStatsAsync: jest.fn(),
restoreCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/functions/saveBuildCache', () => ({
saveCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/utils/android/gradleConfig', () => ({
...jest.requireActual('../../steps/utils/android/gradleConfig'),
injectConfigureVersionGradleConfig: jest.fn(),
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
},
runHookIfPresent: jest.fn(),
}));
jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
describe(androidBuilder, () => {
beforeEach(() => {
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
'/workingdir/build/package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
},
'/'
);
});
it('injects Android version config without build credentials', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
expect(restoreCredentials).not.toHaveBeenCalled();
});
it('injects Android credentials config when build credentials are present', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('runs the builder through hooks', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
});
@@ -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,224 @@
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 { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreGradleCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
warnIfLegacyEasBuildGradleExists,
} from '../steps/utils/android/gradleConfig';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
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 restoreGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
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: await 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 warnIfLegacyEasBuildGradleExists(ctx);
await restoreCredentials(ctx);
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
});
}
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
versionCode: ctx.job.version?.versionCode,
versionName: ctx.job.version?.versionName,
});
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
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 saveGradleCacheAsync({
logger: ctx.logger,
workingDirectory,
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 };
+328
View File
@@ -0,0 +1,328 @@
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, 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';
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();
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,
});
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
await runInstallPodsAsync(ctx);
});
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: await 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 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;
}
+299
View File
@@ -0,0 +1,299 @@
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 = await 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 () => {
const appConfig = await ctx.appConfig;
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
throw new Error(
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
);
}
if (!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?: Promise<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(): Promise<ExpoConfig> {
if (!this._appConfig) {
this._appConfig = readAppConfig({
projectDir: this.getReactNativeProjectDirectory(),
env: this.env,
logger: this.logger,
sdkVersion: this.metadata?.sdkVersion,
}).then(config => config.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.ExpoError> {
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,157 @@
// 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\\""
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\\""
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\\""
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\\""
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,287 @@
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 with Apple Development identity for development profile', async () => {
vol.fromJSON(
{
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
'utf-8'
),
'ios/testapp/AppDelegate.m': 'placeholder',
},
'/app'
);
const options = {
credentials: {
keychainPath: 'fake/path',
targetProvisioningProfiles: {
testapp: {
path: 'fake/path.mobileprovision',
target: 'testapp',
bundleIdentifier: 'abc',
teamId: 'ABCDEFGH',
uuid: 'abc',
name: 'profile name',
developerCertificate: Buffer.from('test'),
certificateCommonName: 'Apple Development: Test User',
distributionType: DistributionType.DEVELOPMENT,
},
},
distributionType: DistributionType.DEVELOPMENT,
teamId: 'ABCDEFGH',
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
},
buildConfiguration: 'Release',
};
const ctx = {
getReactNativeProjectDirectory: () => '/app',
logger: { info: jest.fn() },
job: {},
};
await configureXcodeProject(ctx as any, options);
const pbxproj = vol.readFileSync(
'/app/ios/testapp.xcodeproj/project.pbxproj',
'utf-8'
) as string;
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
expect(pbxproj).not.toContain('"iPhone Distribution"');
});
it('configures credentials and versions for multi target project', async () => {
vol.fromJSON(
{
'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();
});
});
});
Loaded 100 of 802 files, more files were not shown because too many files have changed in this diff. Show more