Compare commits

...
Author SHA1 Message Date
Cedric van Putten dc320de954 update CHANGELOG.md 2023-05-22 15:08:47 +00:00
Cedric van Putten d24c686133 [eas-cli] Load entitlements from prebuild config when /ios is gitignored 2023-05-22 16:49:16 +02:00
Piotr Szeremeta 118c008917 Validate CFBundleShortVersionString (#1846)
* [eas-cli][bugfix] Fix ios version-test

Tests were passing, but were not testing the right thing, essentially always defaulting to 'managed' workflow. `projectDir` should be set to the same value as EAS_PROJECT_ROOT.

* [eas-cli][ENG-8571] Validate CFBundleShortVersionString

* update CHANGELOG.md
2023-05-22 09:22:11 +02:00
Quinlan Jung 3b24d37f3c [eas-cli] remove asp prompt (#1843)
* [eas-cli] remove asp prompt

* update CHANGELOG.md
2023-05-17 11:05:06 -07:00
Szymon Dziedzic df425201b3 fix changelog 2023-05-17 15:47:14 +02:00
Piotr Szeremeta 9636507130 [eas-cli][ENG-8528] Don't print all selected devices. (#1840)
* [eas-cli][ENG-8528] Don't print all selected devices.

* update CHANGELOG.md
2023-05-17 15:04:49 +02:00
Szymon Dziedzic 2dd50ad21a v3.13.0 2023-05-17 14:28:30 +02:00
Szymon Dziedzic 30441b3db4 [eas-cli][eas-json] update @expo/steps and @expo/eas-build-job version (#1845) 2023-05-17 12:47:57 +02:00
Dominik Sokal 789c314a8d don't generate ios credentials for non-signable targets (#1835)
* don't generate ios credentials for non-signable targets

* upgrade @expo/config-plugins

* update CHANGELOG.md
2023-05-17 09:30:30 +02:00
Wojciech Kozyra 4760314ddf [eas-cli] ignore dirty workingdir when building from GitHub (#1842)
* [eas-cli] builds from GitHub are never in a dirty state

* update CHANGELOG.md
2023-05-16 16:28:44 +02:00
Quinlan Jung c8e57f000c [eas-cli] add clear cache option to updates (#1839)
* [eas-cli] add clear cache option to updates

* update CHANGELOG.md
2023-05-15 14:57:50 -07:00
Expo CI 3c82a2f8a8 update CHANGELOG.md 2023-05-15 13:26:43 +00:00
Dominik Sokal 0e7e926cec v3.12.1 2023-05-15 14:19:49 +02:00
Dominik Sokal 30364fd679 check build expiration date before submitting (#1837)
* check build expiration date before submitting

* add tests

* update CHANGELOG.md
2023-05-15 10:00:25 +02:00
Wojciech Kozyra 267fa0e43b [eas-cli] Fix random test failures (#1834)
* increase max-old-space-size

* limit number of workers
2023-05-11 12:11:45 +02:00
Wojciech Kozyra e154f19751 [eas-cli] add tests for eas build:version:* commands (#1829)
* [eas-cli] add utils for testing eas commands and write tests for build:version:*

* review feedback
2023-05-11 10:48:15 +02:00
Dominik Sokal 4bca79f828 fix CHANGELOG.md 2023-05-11 09:39:00 +02:00
dchhetri 7e01661f70 [eas-cli] Change red apple icon to green apple icon (#1828) 2023-05-11 09:29:04 +02:00
Dominik Sokal ab18e3e27e [ENG-7650] add custom workflow name to build metadata (#1830)
* generate graphql code

* upgrade @expo/eas-build-job

* add custom workflow name to build metadata
2023-05-10 10:32:55 +02:00
Expo CI 3e7a112097 update CHANGELOG.md 2023-05-08 10:26:39 +00:00
Dominik Sokal 4cd6b8d228 v3.12.0 2023-05-08 12:20:06 +02:00
Dominik Sokal 400244194b [ENG-7740][eas-cli] upgrade @expo/steps (#1824)
* [eas-cli] upgrade @expo/steps

* update CHANGELOG.md
2023-05-08 12:17:01 +02:00
Wojciech Kozyra dcf3a2d4fd [eas-cli] Add build:version:get command (#1815)
* [eas-cli] Add `build:version:get` command

* rename command class name

* review feedback

* update CHANGELOG.md
2023-05-08 11:04:45 +02:00
Expo CI 0464ac27cd update CHANGELOG.md 2023-05-05 09:48:45 +00:00
Szymon Dziedzic e0d2126f0a v3.11.0 2023-05-05 11:39:15 +02:00
Szymon Dziedzic bd46da748d [eas-cli][eas-json] add large resource class for iOS as allowed value in eas.json (#1817)
* [eas-cli][eas-json] add `large` resource class for iOS as allowed value in `eas.json`

* add deprecation warning for `m-large`

* add test

* update CHANGELOG.md
2023-05-05 11:32:22 +02:00
Radosław Krzemień d8c68561c5 [eas-cli] [ENG-8153] Print requestId and original error message on unexpected graphQL-related error (#1813)
* Change error message

Upon unexpected GraphQL-related error a new message is printed to the user. Now it also has the request ID (if returned with the error from `www`) and the original error message. This hopefully will help to identify the issues users have when unsuccessfully submitting build requests

See: https://linear.app/expo/issue/ENG-8153/if-the-build-request-fails-with-unknown-error-print-request-id

* Remove original error message

After some discussion it was decided to remove the original error message since it is not vetted against possibly leaking unintentional information to the users

See: https://linear.app/expo/issue/ENG-8153/if-the-build-request-fails-with-unknown-error-print-request-id

* Log request ID globally

After discussion under the PR it was decided to print the request ID after all graphql errors

See: https://linear.app/expo/issue/ENG-8153/if-the-build-request-fails-with-unknown-error-print-request-id

* Don't print requestId on foreseen errors

Foreseen graphQL errors with custom messages no longer print the request id to the user

See: https://linear.app/expo/issue/ENG-8153/if-the-build-request-fails-with-unknown-error-print-request-id

* update CHANGELOG.md
2023-04-28 10:22:24 +02:00
Will Schurman dfc314d5be [eas-cli] Add account list to eas whoami/eas account:view (#1814) 2023-04-27 06:52:24 -07:00
Expo CI 2285d5771a update CHANGELOG.md 2023-04-25 14:31:02 +00:00
Szymon Dziedzic aa22163688 v3.10.2 2023-04-25 16:23:04 +02:00
Szymon Dziedzic 5e5395b0c6 [ENG-7676][eas-cli] don't resolve default resource class for iOS based on metadata in CLI (#1734)
* [eas-cli] don't resolve default resource class for iOS based on metadata

* don't set m1-medium as default in `eas build:configure`

* pass sdkVersion and reactNativeVersion in buildParams

* convert it to sync function

* update gql schema

* update CHANGELOG.md
2023-04-25 15:51:41 +02:00
Szymon Dziedzic c94a1e7d9c [eas-cli] allow users to select app to run if multiple apps are found in the build tarball (#1807)
* [eas-cli] allow users to select app to run if multiple apps are found in the build tarball

* rename variable

* improve error message

* apply suggested changes

* add dot

* update CHANGELOG.md
2023-04-25 15:42:50 +02:00
Szymon Dziedzic aefadf9376 [ENG-8074][eas-json] add info about new image (#1808)
* [eas-json] add info about new image

* update eas.schema.json

* update images descriptions

* update CHANGELOG.md
2023-04-25 15:28:10 +02:00
Expo CI 7acea869ff update CHANGELOG.md 2023-04-25 13:07:56 +00:00
Radoslaw Krzemien 02af3f587f v3.10.1 2023-04-25 14:59:42 +02:00
0d0da1a47a [eas-cli] [eas-json] [ENG-8120] Use cache.paths and deprecate cache.customPaths (#1794)
* Update dependencies

Bump version of `@expo/eas-build-job` to use `cache.paths` and deprecate `cache.customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Update default cache values

Updated the default cache values in `eas-cli` `prepareJobAsync` functions to use `paths` instead of `customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add tests

Added tests for `prepareJobAsync` functions both for Android and iOS to check if they handle both deprecated `customPaths` and new `paths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add `paths` and deprecate `customPaths`

Updated the schema in `eas-json` to add the new field `cache.paths` and deprecate the old `cache.customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add deprecation warning

Added a warning message to be returned when the eas.json contains deprecated field `cache.customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add tests

Added tests for `EasJsonUtils.getBuildProfileDeprecationWarnings()` checking if it correctly returns deprecation warnings for `cache.customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add tests

Added tests for printing the deprecation messages

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Uncomment and fix test

Had previously one test that was broken and commented out

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Update dependencies

Updated `@expo/eas-build-job` version after removing default value from `cache.customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Stop passing `customPaths` to `www`

Remove `cache.customPaths` from the job and add its values to `paths` if present

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Refactor getting cache settings

Moved getting the cache settings to `eas-json`, changed appending elements from `customPaths` to `paths` to overwriting them, added an error thrown when both `paths` and `customPaths` are present

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Fix deprecation message

Replaced hardcoded profile name with dynamic one

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Revert "Refactor getting cache settings"

This reverts commit 4727eba210.

* Revert changes and update Joi schema

Replaced the procedural code moving values of customPaths to paths into Joi schema validation

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Check for deprecation in raw EAS json

Changed the method checking for deprecated fields in eas.json to look into the raw EAS json instead of the validated build profile, because the validated build profile will always lack the deprecated field

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Update error message

Updated the message after user provides both `paths` and `customPaths` to be more understandable

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Fix broken test

One of the tests broke after the last adjustment of the error message

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* update CHANGELOG.md

* Fix import

Fixed incorrect import

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Pass accessor as argument

Passing eas json accessor as an argument instead of the results of its method. Also, removed a redundant check and made the accessor a mandatory parameter

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Check extended profile

Checking extended profile as well for deprecated `customPaths`

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Update packages/eas-json/src/utils.ts

Co-authored-by: Wojciech Kozyra <[email protected]>

* Add comment

Added a comment to a function made for testing only

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Update packages/eas-json/src/build/schema.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Fix errors

Fixed failing test and a syntax error after committing a review suggestion

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Make the `profileName` mandatory

Made the `profileName` param in `getBuildProfileDeprecationWarningsAsync` mandatory and let the cli decide what the default profile name is

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Create build profile in eas-json

Instead of passing the build profile into `getBuildProfileDeprecationWarningsAsync` it is created using the already passed accessor

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add type

Added variable type

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Move doc comment

Moved the comment outside of function

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Replace try/catch

Replaced try/catch blocks in test with expect().rejects.toThrow()

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Use toMatchSnapshot

Replaced checking the calls to warn() with expect().toMatchSnapshot()

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Update snapshots

Updated the test snapshots to be up to date

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Print base profile name

When checking for deprecation messages the profile name printed is the name of the base profile not the extending one

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Force chalk color in CI

Chalk behaves differently when env var CI is set to true. It can be forced to behave normally with `FORCE_COLOR` env and this should solve the problem with CI tests failing

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Force chalk color in CI

Chalk behaves differently when env var CI is set to true. It can be forced to behave normally with `FORCE_COLOR` env and this should solve the problem with CI tests failing

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Add comment

Added a comment to explain why the `FORCE_COLOR` env is necessary in CI

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

* Move env var

Moved the `FORCE_COLOR` env var from github actions workflow to jest config

See: https://linear.app/expo/issue/ENG-8120/rename-cachecustompaths-to-cachepaths-in-easjson-and-job-schema

---------

Co-authored-by: Wojciech Kozyra <[email protected]>
Co-authored-by: Dominik Sokal <[email protected]>
2023-04-25 14:21:19 +02:00
Expo CI eec0b88258 update CHANGELOG.md 2023-04-21 07:56:25 +00:00
Dominik Sokal 98be401942 v3.10.0 2023-04-21 09:49:21 +02:00
Keith Kurak 8c2689a27b [eas-cli] Device delete not disabling on Apple side (#1803)
* [device:delete] fix matching Expo device to Apple device

* Clarify when a registered device will show up on Apple side

* update CHANGELOG.md
2023-04-20 14:31:30 -04:00
Keith KurakandDominik Sokal 1b34118d85 Add eas device:rename command (#1787)
* [device] rename stubbed out with existing graphql fns

* added update device mutation

* Device rename working on Expo side

* restore codegen endpoint

* tweaked some text output

* Update graphql

* Remove debugging log

* get inputMethod.ts off the change graph

* update CHANGELOG.md

* Update packages/eas-cli/src/commands/device/rename.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Update packages/eas-cli/src/commands/device/rename.ts

Co-authored-by: Dominik Sokal <[email protected]>

* Warn when device not renamed (and fix device lookup)

* make warning slightly less wordy

---------

Co-authored-by: Dominik Sokal <[email protected]>
2023-04-20 14:24:02 -04:00
Keith Kurak 0f75fe407e [eas-cli] make ora recognize CI env variable (#1775) 2023-04-20 14:15:20 -04:00
Will Schurman efb7e534f4 [eas-cli] Fix message truncation for update message (#1801) 2023-04-20 09:25:01 -07:00
Szymon Dziedzic 53f89c0df1 [eas-cli] throw error if somebody tries to run iOS with large resource class selected using deprecated --resource-class flag (#1795)
* [eas-cli] throw error if somebody tries to run iOS with `large` resource class selected through deprecated `--resource-class` flag

* apply suggested changes

* improve error message

* update CHANGELOG.md
2023-04-20 13:52:05 +02:00
Dominik Sokal 1bbe7dfb72 [gha] add PR changelog check (#1798)
* [gha] add PR changelog check

* update PR template
2023-04-20 10:46:14 +02:00
Brent Vatne da4b56a58d Fix changelog for 3.9.3 2023-04-18 15:59:05 -07:00
Expo CI 1080600af6 update CHANGELOG.md 2023-04-18 22:47:47 +00:00
Will Schurman dcb6713f12 v3.9.3 2023-04-18 15:38:20 -07:00
Will Schurman 24a1d2d581 [eas-cli] Fix another prompt again in shared code (#1797) 2023-04-18 15:36:36 -07:00
Will Schurman 8af88c2d29 [eas-cli] Fix update message prompt (#1791) 2023-04-13 16:25:22 -07:00
Expo CI d416ac2162 update CHANGELOG.md 2023-04-13 10:11:07 +00:00
Dominik Sokal 7bf0493a4d v3.9.2 2023-04-13 12:01:04 +02:00
Dominik Sokal 91f32cc377 add missing changelog entry 2023-04-13 11:35:52 +02:00
Dominik Sokal cf54f62bac print meaningful error message if extra.eas.projectId is not a string (#1788) 2023-04-13 11:33:04 +02:00
Wojciech Kozyra 3ef3e8447c [eas-cli] always show logs from expo install (#1782)
* Always show logs from expo install

* update CHANGELOG.md
2023-04-12 12:39:03 +02:00
Jon Samp ad64e1afad [eas-cli] Fix rollout percents when running channel:rollout --end (#1781)
* fix rollout percents when running --end

* update CHANGELOG.md
2023-04-11 12:02:21 -05:00
Szymon Dziedzic a0800cf7ef [ENG-7968][eas-cli][eas-json] print deprecation warnings for deprecated eas.json fields (#1768)
* [eas-cli][eas-json] print deprecation warnings for deprecated `eas.json` fields

* fix tests

* update deprecation warning

* extract logic to separate function

* apply suggestions from code review

* fix tests

* update CHANGELOG.md
2023-04-11 13:40:00 +02:00
Expo CI 34436ee674 update CHANGELOG.md 2023-04-10 20:22:07 +00:00
Cedric van Putten 1072b75927 v3.9.1 2023-04-10 21:50:01 +02:00
Cedric van Putten c0c9d2e7df [eas-cli] Update apple utilities to avoid the maintenance error (#1780)
* [eas-cli] Update apple utilities to avoid the maintenance error

* [eas-cli] Add changelog entry

* [eas-cli] Dont change the eas-cli version

* [eas-cli] Fix changelog entry

* [eas-cli] fix it again
2023-04-10 21:48:24 +02:00
Dominik Sokal 19b831667f Update bug_report.yml (#1777) 2023-04-07 14:16:55 +02:00
Szymon Dziedzic 09db7da23b [ENG-7968][eas-cli][eas-json] don't use defaults for cacheDefaultPaths in the EAS CLI (#1769)
* [eas-cli][eas-json] don't use cacheDefaultPaths

* update deps

* update CHANGELOG.md

* update CHANGELOG.md

* fix changelog

* fix changelog again
2023-04-06 15:25:56 +02:00
Expo CI f08dde2469 update CHANGELOG.md 2023-04-06 12:49:28 +00:00
gabrieldonadel 213b5d240c v3.9.0 2023-04-06 09:28:23 -03:00
Gabriel Donadel Dall'AgnolandDominik Sokal 16ebdce913 [eas-cli] Fix expoCommandAsync to support the new cli bin path (#1772)
* [eas-cli] Fix expoCommandAsync to support the new cli bin path

* update CHANGELOG.md

* Apply suggestions from code review

Co-authored-by: Dominik Sokal <[email protected]>

---------

Co-authored-by: Dominik Sokal <[email protected]>
2023-04-06 09:22:04 -03:00
Dominik Sokal 121ff7a718 validate custom build config in CLI (#1770) 2023-04-06 10:34:25 +02:00
Wojciech Kozyra 360393f647 [eas-json] Add support for running on server (#1766)
* [eas-json] Add support for running on server

* review feedback

* update CHANGELOG.md
2023-04-06 09:55:04 +02:00
Wojciech Kozyra 82d66054dc [eas-cli] Do not require commits in eas build:internal (#1760)
* [eas-cli] Do not require commits in eas build:internal

* lint fix

* alternative approach

* update CHANGELOG.md
2023-03-30 12:25:10 +02:00
Will Schurman d8849dbf8c [eas-cli] Unhide channel:rollout command and modernize (#1758) 2023-03-28 09:54:45 -07:00
Dominik Sokal 4d6b5f435b [ENG-7981] use the eas-cli npm tag for eas-cli-local-build-plugin updates (#1759)
* use the eas-cli npm tag for eas-cli-local-build-plugin updates

* update CHANGELOG.md
2023-03-28 12:06:16 +02:00
Will Schurman 8fe7dfea29 [eas-cli] Add info about code signing and roll back to embedded in update info (#1755) 2023-03-24 13:42:59 -07:00
Will Schurman 8750155c25 [eas-cli] Add support for roll back to embedded updates (#1754) 2023-03-24 13:40:52 -07:00
Expo CI a0fce1c773 update CHANGELOG.md 2023-03-16 13:32:36 +00:00
Cedric van Putten cad3dca5df v3.8.1 2023-03-16 14:09:02 +01:00
Cedric van Putten 2ef6e79395 [eas-cli] Add missing changelog entry 2023-03-16 14:03:51 +01:00
Cedric van Putten 44270814a7 [eas-cli] Add test case to ensure public config is used as update (#1745)
* [eas-cli] Add test case to ensure public config is used as update

* [eas-cli] Use the public config when creating updates
2023-03-15 23:12:47 +01:00
Radosław Krzemień 95155681db [eas-cli] [ENG-6945] Don't use capitalised base error messages (#1744)
* Capitalize command id

In case the base error message is not provided and the command id starts with lower case character it will be capitalized to unify the behaviour with other cases

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Decapitalize command id

After some discussion it was agreed to decapitalize the base error messages instead, and unify the behaviour this way

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Remove redundant decapitalisation

Seems like `oclif` guarantees the command ids to be lower case in which case the explicit decapitalisation is redundant

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* update CHANGELOG.md
2023-03-14 15:42:41 +01:00
Radosław KrzemieńandWojciech Kozyra 53fd599d0a [eas-cli] [ENG-6945] Unify error display method (#1738)
* Unify error display method

Overriden the `Command`'s `catch` method to catch all the errors thrown by the commands, log the error message there the same way as it was previously handled in `src/build/build.ts` and re-throw generic `Error` instance with predefined base error message.
To avoid duplication, old `Log.error()` calls have been removed from `src/build/build.ts`.
Also added tests to see if the `catch()` method works as intended.

* Adjust base error messages

Base error messages have been adjusted after @dsokal suggested that the error may be caused by something different from a request and displaying message about a failed request is misleading

* update CHANGELOG.md

* Add test for unclean error message

Checks if the `[GraphQL] ` prefix gets removed when logging the error message

* Update handling GraphQL errors

Post-review update to how graphQL errors are handled

* Print different message for graphQL errors

When a graphQL error is caught a different base error message is printed

* Print original error to debug

When a graphQL error is caught and the message is cleaned, an original message is printed to debug (or just the same message otherwise)

* Add custom error classes

Created some custom error classes for known server side defined errors

* Log the error, not the message

Changed the debug log to log the error object, not the error message

* Fix

Fixed testing leftover

* Change phrasing

Edited the phrasing in default messages related to EAS Build

* Update if condition

Changed the if condition after review comment

* Create error base class

Created a new base class for the errors expected in build.ts, so they can be handled separately in `catch()`

* Move EasCommand properties

Moved the class properties above the class methods

* Remove helper method

Removed the helper method after review comment

* Change const type

Updated the type of SERVER_SIDE_DEFINED_ERRORS constant

* Change default base error message

Set the default base error message to `<command_name> command failed.`

* Remove comments

Removed leftover comments

* Change optional property

Changed empty string to optional property

* Post-review tweaks

Self-explanatory

* Fix failing test

One of tests started failing after changing the coalescing operators (|| -> ??). This fixes that test

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Remove error class

Removed `GenericGraphQLError` class after @wkozyra95 comment, since atm there's no need to handle it differently than a generic `Error`

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Move `EasCommandError` base class

Moved `EasCommandError` base class after @wkozyra95 comment. The class is not specific to `Build` command and so it has been moved to `commandUtils` directory

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Make message mandatory

Made `message` parameter in `EasCommandError`s mandatory, to remove need for providing a fallback string and making the server the source of truth for the errors messages

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Update packages/eas-cli/src/commandUtils/EasCommand.ts

Co-authored-by: Wojciech Kozyra <[email protected]>

* Move `baseGraphQLErrorMessage` property

Made the property into a constant in a global scope, since atm it is not changed in any of the subclasses

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Suppress ESLint warning

ESLint erroneously complains about the constructor of `EasCommandError` to be useless, which is not true is the constructor of `Error` parent class allows for optional `message` parameter and the constructor of `EasCommandError` does not

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Add error handling tests

Added a bunch of new tests related to the `handleBuildRequestError` function

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

* Directly export `handleBuildRequestError`

Dropped the `testExports` convention after team discussion

See: https://linear.app/expo/issue/ENG-6945/improve-eas-cli-error-logging

---------

Co-authored-by: Wojciech Kozyra <[email protected]>
2023-03-14 11:26:27 +01:00
dependabot[bot] b51201d010 Bump @sideway/formula from 3.0.0 to 3.0.1 (#1742)
Bumps [@sideway/formula](https://github.com/sideway/formula) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/sideway/formula/releases)
- [Commits](https://github.com/sideway/formula/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: "@sideway/formula"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-13 12:52:53 +01:00
Expo CI f86c079733 update CHANGELOG.md 2023-03-13 11:51:39 +00:00
116 changed files with 7466 additions and 1518 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ body:
required: true
attributes:
label: Environment
description: Run `npx expo-env-info` and `expo doctor`, and paste the output here.
description: Run `npx expo-env-info` and `npx expo-doctor`, and paste the output here.
- type: textarea
validations:
required: false
+2 -5
View File
@@ -1,8 +1,5 @@
<!-- Thanks for contributing to _EAS CLI_! Before you submit your pull request, please make sure to check the following boxes by putting an x in the [ ] (don't: [x ], [ x], do: [x]) -->
# Checklist
- [ ] I've added an entry to [CHANGELOG.md](https://github.com/expo/eas-cli/blob/main/CHANGELOG.md#main) if necessary. You can comment this pull request with `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]` and CHANGELOG.md will be updated automatically.
<!-- 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
+48
View File
@@ -0,0 +1,48 @@
name: Changelog bot
on:
issue_comment:
types: [created]
jobs:
pr-commented:
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
steps:
- uses: actions/checkout@v2
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
with:
node-version: 18
- name: Checkout Pull Request
run: hub pr checkout ${{ github.event.issue.number }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
- name: Add changelog entry
working-directory: ./scripts
run: |
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
if [[ ! "$PARAMS" =~ " " ]]; then
echo "Invalid /changelog-entry command"
exit 1
fi
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
yarn changelog-entry "$CATEGORY" "$MESSAGE"
env:
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
PR_COMMENT_BODY: ${{ github.event.comment.body }}
- name: Commit and push
run: |
# use the last commit author name and email
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git push
+29 -40
View File
@@ -1,48 +1,37 @@
on:
issue_comment:
types: [created]
name: CHANGELOG.md PR check
name: Changelog
on:
pull_request:
types:
- opened
- synchronize
- labeled
- unlabeled
jobs:
pr-commented:
pr-changelog-entry:
name: Check CHANGELOG.md updated
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login && startsWith(github.event.comment.body, '/changelog-entry ') }}
steps:
- uses: actions/checkout@v2
- name: Check if CHANGELOG.md was updated
id: changelog-updated
uses: tj-actions/changed-files@v35
with:
token: ${{ secrets.EXPO_BOT_PAT }}
- uses: actions/setup-node@v2
files: CHANGELOG.md
- name: Fail if CHANGELOG.md was not updated and the "no changelog" label is absent
if: steps.changelog-updated.outputs.any_changed == 'false' && !contains(github.event.pull_request.labels.*.name, 'no changelog')
uses: actions/github-script@v6
with:
node-version: 18
- name: Checkout Pull Request
run: hub pr checkout ${{ github.event.issue.number }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
working-directory: ./scripts
run: yarn install --frozen-lockfile --check-files
- name: Add changelog entry
working-directory: ./scripts
run: |
PARAMS=${PR_COMMENT_BODY#/changelog-entry }
if [[ ! "$PARAMS" =~ " " ]]; then
echo "Invalid /changelog-entry command"
exit 1
fi
CATEGORY=$( cut -d ' ' -f 1 <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
MESSAGE=$( cut -d ' ' -f 2- <<< "$PARAMS" | sed -e 's/^[[:space:]]*//' )
yarn changelog-entry "$CATEGORY" "$MESSAGE"
env:
GITHUB_PR_AUTHOR: ${{ github.event.issue.user.login }}
GITHUB_PR_NUMBER: ${{ github.event.issue.number }}
PR_COMMENT_BODY: ${{ github.event.comment.body }}
- name: Commit and push
run: |
# use the last commit author name and email
git config --global user.name "$(git --no-pager log --format=format:'%an' -n 1)"
git config --global user.email "$(git --no-pager log --format=format:'%ae' -n 1)"
git add CHANGELOG.md
git commit -m "update CHANGELOG.md"
git push
script: |
core.setFailed('Please add a changelog entry!')
- uses: mshick/[email protected]
if: always()
with:
message-id: changelog-entry-check
refresh-message-position: true
message: ${{ contains(github.event.pull_request.labels.*.name, 'no changelog') && '⏩ The changelog entry check has been skipped since the "no changelog" label is present.' || '✅ Thank you for adding the changelog entry!' }}
message-failure: |
❌ It looks like a changelog entry is missing for this PR. You have two options: you can add it manually, or you can use the changelog bot to do it for you.
🤖 To use the bot, simply comment on this PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`.
⏩ If this PR doesn't require a changelog entry, such as if it's an internal change that doesn't affect the user experience, you can add the "no changelog" label to the PR.
+6 -2
View File
@@ -1,4 +1,5 @@
name: Run tests
on:
push:
branches: [main]
@@ -25,20 +26,23 @@ jobs:
- run: yarn typecheck
- run: yarn test
if: ${{ !matrix.coverage }}
env:
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn test --coverage
if: ${{ matrix.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=4096
NODE_OPTIONS: --max-old-space-size=8192
- run: yarn lint --max-warnings=0
- run: yarn install --frozen-lockfile --check-files
working-directory: ./scripts
- run: yarn lint-changelog
- uses: codecov/codecov-action@v1
if: ${{ matrix.coverage }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
- run: yarn lint-changelog
notify-slack:
runs-on: ubuntu-latest
needs: test
+141 -2
View File
@@ -8,12 +8,151 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add new `m-medium` and `m-large` resource classes. ([#1739](https://github.com/expo/eas-cli/pull/1739) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
- Load iOS entitlements from prebuild config when `./ios` is gitignored. ([#1852](https://github.com/expo/eas-cli/pull/1852) by [@byCedric](https://github.com/byCedric))
### 🧹 Chores
- Short format for selecting devices prompt. ([#1840](https://github.com/expo/eas-cli/pull/1840) by [@khamilowicz](https://github.com/khamilowicz))
## [3.13.0](https://github.com/expo/eas-cli/releases/tag/v3.13.0) - 2023-05-17
### 🎉 New features
- Add clear cache flag to eas update. ([#1839](https://github.com/expo/eas-cli/pull/1839) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Fix building iOS projects with framework targets. ([#1835](https://github.com/expo/eas-cli/pull/1835) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Ignore dirty workingdir when building from GitHub. ([#1842](https://github.com/expo/eas-cli/pull/1842) by [@wkozyra95](https://github.com/wkozyra95))
- Remove App Specific Password prompt. ([#1843](https://github.com/expo/eas-cli/pull/1843) by [@quinlanj](https://github.com/quinlanj))
- Validate `CFBundleShortVersionString`. ([#1846](https://github.com/expo/eas-cli/pull/1846) by [@khamilowicz](https://github.com/khamilowicz))
## [3.12.1](https://github.com/expo/eas-cli/releases/tag/v3.12.1) - 2023-05-15
### 🐛 Bug fixes
- Don't submit expired builds. ([#1837](https://github.com/expo/eas-cli/pull/1837) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Change red apple icon to green apple icon on successful build. ([#1828](https://github.com/expo/eas-cli/pull/1828) by [@dchhetri](https://github.com/dchhetri))
## [3.12.0](https://github.com/expo/eas-cli/releases/tag/v3.12.0) - 2023-05-08
### 🎉 New features
- Add `build:version:get` command. ([#1815](https://github.com/expo/eas-cli/pull/1815) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
- Upgrade dependencies. ([#1824](https://github.com/expo/eas-cli/pull/1824) by [@dsokal](https://github.com/dsokal))
## [3.11.0](https://github.com/expo/eas-cli/releases/tag/v3.11.0) - 2023-05-05
### 🎉 New features
- Add account list to `eas whoami`/`eas account:view`. ([#1814](https://github.com/expo/eas-cli/pull/1814) by [@wschurman](https://github.com/wschurman))
- Add `large` resource class for iOS as allowed value in `eas.json`. ([#1817](https://github.com/expo/eas-cli/pull/1817) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Print request ID on unforeseen GraphQL error for easier tracking and follow up. ([#1813](https://github.com/expo/eas-cli/pull/1813) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
## [3.10.2](https://github.com/expo/eas-cli/releases/tag/v3.10.2) - 2023-04-25
### 🧹 Chores
- Add info about Xcode 14.3 image to `eas.schema.json`. ([#1808](https://github.com/expo/eas-cli/pull/1808) by [@szdziedzic](https://github.com/szdziedzic))
- Allow users to select an app to run using the `build:run` command if multiple apps are found in the tarball. ([#1807](https://github.com/expo/eas-cli/pull/1807) by [@szdziedzic](https://github.com/szdziedzic))
- Don't resolve `default` resource class for iOS in CLI. ([#1734](https://github.com/expo/eas-cli/pull/1734) by [@szdziedzic](https://github.com/szdziedzic))
## [3.10.1](https://github.com/expo/eas-cli/releases/tag/v3.10.1) - 2023-04-25
### 🧹 Chores
- Add `cache.paths` field and deprecate `cache.customPaths`. ([#1794](https://github.com/expo/eas-cli/pull/1794) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
## [3.10.0](https://github.com/expo/eas-cli/releases/tag/v3.10.0) - 2023-04-21
### 🎉 New features
- Added eas device:rename command. ([#1787](https://github.com/expo/eas-cli/pull/1787) by [@keith-kurak](https://github.com/keith-kurak))
### 🐛 Bug fixes
- Fix `device:delete` not disabling on Apple. ([#1803](https://github.com/expo/eas-cli/pull/1803) by [@keith-kurak](https://github.com/keith-kurak))
- Fix message truncation for updates. ([#1801](https://github.com/expo/eas-cli/pull/1801) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Throw an error if somebody tries to start iOS build with the `large` resource class selected using the deprecated `--resource-class` flag. ([#1795](https://github.com/expo/eas-cli/pull/1795) by [@szdziedzic](https://github.com/szdziedzic))
## [3.9.3](https://github.com/expo/eas-cli/releases/tag/v3.9.3) - 2023-04-18
### 🐛 Bug fixes
- Fix prompts in `eas update`. ([#1797](https://github.com/expo/eas-cli/pull/1797) by [@wschurman](https://github.com/wschurman))
## [3.9.2](https://github.com/expo/eas-cli/releases/tag/v3.9.2) - 2023-04-13
### 🐛 Bug fixes
- Fixes the rollout percentages when ending a rollout. ([#1781](https://github.com/expo/eas-cli/pull/1781) by [@jonsamp](https://github.com/jonsamp))
- Print meaningful error message if `extra.eas.projectId` is not a string. ([#1788](https://github.com/expo/eas-cli/pull/1788) by [@dsokal](https://github.com/dsokal))
### 🧹 Chores
- Show output of `expo install expo-updates`. ([#1782](https://github.com/expo/eas-cli/pull/1782) by [@wkozyra95](https://github.com/wkozyra95))
- Print deprecation warnings for deprecated `eas.json` fields. ([#1768](https://github.com/expo/eas-cli/pull/1768) by [@szdziedzic](https://github.com/szdziedzic))
## [3.9.1](https://github.com/expo/eas-cli/releases/tag/v3.9.1) - 2023-04-10
### 🐛 Bug fixes
- Update apple utilities to work around the maintenance error. ([#1780](https://github.com/expo/eas-cli/pull/1780) by [@byCedric](https://github.com/byCedric))
### 🧹 Chores
- Don't use defaults for the `cache.cacheDefaultPaths` build profile field. ([#1769](https://github.com/expo/eas-cli/pull/1769) by [@szdziedzic](https://github.com/szdziedzic))
## [3.9.0](https://github.com/expo/eas-cli/releases/tag/v3.9.0) - 2023-04-06
### 🎉 New features
- Add support for roll back to embedded updates. ([#1754](https://github.com/expo/eas-cli/pull/1754), [#1755](https://github.com/expo/eas-cli/pull/1755) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- Update `expoCommandAsync` to support the new Expo CLI bin path. ([#1772](https://github.com/expo/eas-cli/pull/1772) by [@gabrieldonadel](https://github.com/gabrieldonadel))
### 🧹 Chores
- Use the `eas-cli` npm tag for checking for the local build plugin updates. ([#1759](https://github.com/expo/eas-cli/pull/1759) by [@dsokal](https://github.com/dsokal))
- Ignore `requireCommit` option in `eas build:internal`. ([#1760](https://github.com/expo/eas-cli/pull/1760) by [@wkozyra95](https://github.com/wkozyra95))
- Support parsing eas.json from a string. ([#1766](https://github.com/expo/eas-cli/pull/1766) by [@wkozyra95](https://github.com/wkozyra95))
## [3.8.1](https://github.com/expo/eas-cli/releases/tag/v3.8.1) - 2023-03-16
### 🐛 Bug fixes
- Ensure public config is used as update. ([#1745](https://github.com/expo/eas-cli/pull/1745) by [@byCedric](https://github.com/byCedric))
### 🧹 Chores
- Unify how the command errors are displayed. ([#1738](https://github.com/expo/eas-cli/pull/1738) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Unify the case of base error messages for command failures. ([#1744](https://github.com/expo/eas-cli/pull/1744) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
## [3.8.0](https://github.com/expo/eas-cli/releases/tag/v3.8.0) - 2023-03-13
### 🎉 New features
- Add new `m-medium` and `m-large` resource classes. ([#1739](https://github.com/expo/eas-cli/pull/1739) by [@szdziedzic](https://github.com/szdziedzic))
## [3.7.2](https://github.com/expo/eas-cli/releases/tag/v3.7.2) - 2023-02-24
### 🐛 Bug fixes
@@ -30,7 +169,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Support variant detection for package*UniversalApk Gradle Commands. ([#1708](https://github.com/expo/eas-cli/pull/1708) by [@frw](https://github.com/frw))
- Support variant detection for package\*UniversalApk Gradle Commands. ([#1708](https://github.com/expo/eas-cli/pull/1708) by [@frw](https://github.com/frw))
## [3.6.1](https://github.com/expo/eas-cli/releases/tag/v3.6.1) - 2023-02-20
+1 -1
View File
@@ -1,6 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "3.8.0",
"version": "3.13.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+130 -60
View File
@@ -73,18 +73,21 @@ eas --help COMMAND
* [`eas build:resign`](#eas-buildresign)
* [`eas build:run`](#eas-buildrun)
* [`eas build:submit`](#eas-buildsubmit)
* [`eas build:version:get`](#eas-buildversionget)
* [`eas build:version:set`](#eas-buildversionset)
* [`eas build:version:sync`](#eas-buildversionsync)
* [`eas build:view [BUILD_ID]`](#eas-buildview-build_id)
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
* [`eas channel:list`](#eas-channellist)
* [`eas channel:rollout [CHANNEL]`](#eas-channelrollout-channel)
* [`eas channel:view [NAME]`](#eas-channelview-name)
* [`eas config`](#eas-config)
* [`eas credentials`](#eas-credentials)
* [`eas device:create`](#eas-devicecreate)
* [`eas device:delete`](#eas-devicedelete)
* [`eas device:list`](#eas-devicelist)
* [`eas device:rename`](#eas-devicerename)
* [`eas device:view [UDID]`](#eas-deviceview-udid)
* [`eas diagnostics`](#eas-diagnostics)
* [`eas help [COMMAND]`](#eas-help-command)
@@ -130,7 +133,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -147,7 +150,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -164,7 +167,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -178,7 +181,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -228,7 +231,7 @@ DESCRIPTION
create a branch
```
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -249,7 +252,7 @@ DESCRIPTION
delete a branch
```
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -269,7 +272,7 @@ DESCRIPTION
list all branches
```
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -289,7 +292,7 @@ DESCRIPTION
rename a branch
```
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -312,7 +315,7 @@ DESCRIPTION
view a branch
```
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -342,7 +345,7 @@ DESCRIPTION
start a build
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -359,7 +362,7 @@ DESCRIPTION
cancel a build
```
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -376,7 +379,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:inspect`
@@ -411,7 +414,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -448,7 +451,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -474,7 +477,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -498,7 +501,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -528,9 +531,30 @@ ALIASES
$ eas build:submit
```
## `eas build:version:get`
get the latest version from EAS servers
```
USAGE
$ eas build:version:get [-p android|ios|all] [-e <value>] [--json --non-interactive]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. Defaults to "production" if defined in
eas.json.
-p, --platform=(android|ios|all)
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
get the latest version from EAS servers
```
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
Update version of an app.
update version of an app
```
USAGE
@@ -542,14 +566,14 @@ FLAGS
-p, --platform=(android|ios)
DESCRIPTION
Update version of an app.
update version of an app
```
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
Update a version in native code with a value stored on EAS servers
update a version in native code with a value stored on EAS servers
```
USAGE
@@ -561,10 +585,10 @@ FLAGS
-p, --platform=(android|ios|all)
DESCRIPTION
Update a version in native code with a value stored on EAS servers
update a version in native code with a value stored on EAS servers
```
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -581,7 +605,7 @@ DESCRIPTION
view a build for your project
```
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -602,7 +626,7 @@ DESCRIPTION
create a channel
```
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -624,7 +648,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -644,7 +668,31 @@ DESCRIPTION
list all channels
```
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:rollout [CHANNEL]`
Roll a new branch out on a channel incrementally.
```
USAGE
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
ARGUMENTS
CHANNEL channel on which the rollout should be done
FLAGS
--branch=<value> branch to rollout
--end end the rollout
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--percent=<value> percent of users to send to the new branch
DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -667,7 +715,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -688,7 +736,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -705,7 +753,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -719,7 +767,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -739,7 +787,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -760,7 +808,28 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
rename a registered device
```
USAGE
$ eas device:rename [--apple-team-id <value>] [--udid <value>] [--name <value>] [--json --non-interactive]
FLAGS
--apple-team-id=<value> The Apple team ID on which to find the device
--json Enable JSON output, non-JSON messages will be printed to stderr.
--name=<value> The new name for the device
--non-interactive Run the command in non-interactive mode.
--udid=<value> The Apple device ID to rename
DESCRIPTION
rename a registered device
```
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -774,7 +843,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -788,7 +857,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -876,7 +945,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -893,7 +962,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -910,7 +979,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas open`
@@ -924,7 +993,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/open.ts)_
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -938,7 +1007,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -960,7 +1029,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -983,7 +1052,7 @@ DESCRIPTION
create an environment secret on the current project or owner account
```
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -1001,7 +1070,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -1015,7 +1084,7 @@ DESCRIPTION
list environment secrets available for your current app
```
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/secret/list.ts)_
## `eas secret:push`
@@ -1035,7 +1104,7 @@ DESCRIPTION
read environment secrets from env file and store on the server
```
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/secret/push.ts)_
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/secret/push.ts)_
## `eas submit`
@@ -1065,7 +1134,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1074,14 +1143,15 @@ publish an update group
```
USAGE
$ eas update [--branch <value>] [--channel <value>] [--message <value>] [--republish | --input-dir <value>
| --skip-bundler] [--group <value> | | ] [-p android|ios|all] [--auto] [--private-key-path <value>] [--json
--non-interactive]
| --skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--auto] [--private-key-path <value>]
[--json --non-interactive]
FLAGS
-p, --platform=(android|ios|all) [default: all]
--auto Use the current git branch and commit message for the EAS branch and update message
--branch=<value> Branch to publish the update group on
--channel=<value> Channel that the published update should affect
--clear-cache Clear the bundler cache before publishing
--group=<value> Update group to republish (deprecated, see republish command)
--input-dir=<value> [default: dist] Location of the bundle
--json Enable JSON output, non-JSON messages will be printed to stderr.
@@ -1097,7 +1167,7 @@ DESCRIPTION
publish an update group
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1115,7 +1185,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1136,7 +1206,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:list`
@@ -1158,11 +1228,11 @@ DESCRIPTION
view the recent updates
```
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
rollback to an existing update
roll back to an existing update
```
USAGE
@@ -1179,10 +1249,10 @@ FLAGS
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
rollback to an existing update
roll back to an existing update
```
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:view GROUPID`
@@ -1202,7 +1272,7 @@ DESCRIPTION
update group details
```
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas webhook:create`
@@ -1223,7 +1293,7 @@ DESCRIPTION
create a webhook
```
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1243,7 +1313,7 @@ DESCRIPTION
delete a webhook
```
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -1261,7 +1331,7 @@ DESCRIPTION
list webhooks
```
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -1283,7 +1353,7 @@ DESCRIPTION
update a webhook
```
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -1300,7 +1370,7 @@ DESCRIPTION
view a webhook
```
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.8.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.13.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
+2328 -374
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -2,4 +2,5 @@ module.exports = async () => {
process.env.TZ = 'UTC';
process.env.EAS_NO_VCS = '1';
process.env.EAS_PROJECT_ROOT = '/app';
process.env.FORCE_COLOR = '1';
};
+6 -5
View File
@@ -1,20 +1,20 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "3.8.0",
"version": "3.13.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "1.1.0",
"@expo/apple-utils": "1.2.0",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "7.0.3",
"@expo/config-plugins": "5.0.4",
"@expo/config-plugins": "6.0.2",
"@expo/config-types": "47.0.0",
"@expo/eas-build-job": "0.2.106",
"@expo/eas-json": "3.8.0",
"@expo/eas-build-job": "1.0.13",
"@expo/eas-json": "3.13.0",
"@expo/json-file": "8.2.37",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
@@ -27,6 +27,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.13",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "1.3.10",
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BuildVersionGetView reading version when appVersionSource is set to local 1`] = `"This project is not configured for using remote version source. Add {"cli": { "appVersionSource": "remote" }} in eas.json or re-run this command without "--non-interactive" flag."`;
@@ -0,0 +1,161 @@
import { AppVersionSource, EasJson } from '@expo/eas-json';
import chalk from 'chalk';
import BuildVersionGetView from '../../commands/build/version/get';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getMockEasJson, mockCommandContext, mockProjectId, mockTestCommand } from './utils';
jest.mock('../../project/applicationIdentifier');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../utils/json');
jest.mock('../../graphql/queries/AppVersionQuery');
function withRemoteVersionSource(easJson: EasJson): EasJson {
return {
...easJson,
cli: {
...easJson.cli,
appVersionSource: AppVersionSource.REMOTE,
},
};
}
describe(BuildVersionGetView, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('reading version for platform android', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(Log.log).toHaveBeenCalledWith(`Android versionCode - ${chalk.bold('100')}`);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('reading version for platform android when no remote version is set', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(BuildVersionGetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(Log.log).toHaveBeenCalledWith(`No remote versions are configured for this project.`);
expect(enableJsonOutput).not.toHaveBeenCalled();
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
test('reading version with --json flag for platform android', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
versionCode: '100',
});
});
test('reading version with --json flag for platform ios', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=ios'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'IOS',
'eas.test.com'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
buildNumber: '100',
});
});
test('reading version with --json flag when no remote version is set', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(enableJsonOutput).toHaveBeenCalled();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({});
});
test('reading version when appVersionSource is set to local ', async () => {
const ctx = mockCommandContext(BuildVersionGetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
const cmd = mockTestCommand(
BuildVersionGetView,
['--non-interactive', '--json', '--platform=android'],
ctx
);
await expect(cmd.run()).rejects.toThrowErrorMatchingSnapshot();
});
});
@@ -0,0 +1,140 @@
import { AppVersionSource, EasJson } from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import BuildVersionSetView from '../../commands/build/version/set';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import Log from '../../log';
import * as prompts from '../../prompts';
import {
getMockAppFragment,
getMockEasJson,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
function withRemoteVersionSource(easJson: EasJson): EasJson {
return {
...easJson,
cli: {
...easJson.cli,
appVersionSource: AppVersionSource.REMOTE,
},
};
}
describe(BuildVersionSetView, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('setting version for platform android', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(AppVersionMutation.createAppVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
expect.objectContaining({
buildVersion: '1000',
storeVersion: '1.0.0',
})
);
});
test('printing current remote version before prompting for a new one', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '100',
storeVersion: '1.0.0',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(Log.log).toHaveBeenCalledWith(expect.stringMatching('configured with versionCode 100'));
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
});
test('printing info that there is no remote version configured', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
expect(Log.log).toHaveBeenCalledWith(
expect.stringMatching('does not have any versionCode configured')
);
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
});
test('setting version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborting...');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
test('setting version when appVersionSource is set to local and user allows auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSetView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
const cmd = mockTestCommand(BuildVersionSetView, ['--platform=android'], ctx);
await cmd.run();
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
});
});
@@ -0,0 +1,204 @@
import { Workflow } from '@expo/eas-build-job';
import { AppVersionSource, EasJson } from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import { updateNativeVersionsAsync as updateAndroidNativeVersionsAsync } from '../../build/android/version';
import { updateNativeVersionsAsync as updateIosNativeVersionsAsync } from '../../build/ios/version';
import BuildVersionSyncView from '../../commands/build/version/sync';
import { Target } from '../../credentials/ios/types';
import { AppVersionMutation } from '../../graphql/mutations/AppVersionMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { AppVersionQuery } from '../../graphql/queries/AppVersionQuery';
import { getAppBuildGradleAsync } from '../../project/android/gradleUtils';
import { resolveTargetsAsync } from '../../project/ios/target';
import { resolveWorkflowAsync } from '../../project/workflow';
import * as prompts from '../../prompts';
import {
getMockAppFragment,
getMockEasJson,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
jest.mock('../../build/android/version');
jest.mock('../../build/ios/version');
jest.mock('../../project/applicationIdentifier');
jest.mock('../../graphql/queries/AppVersionQuery');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/AppVersionMutation');
jest.mock('../../project/workflow');
jest.mock('../../project/android/gradleUtils');
jest.mock('../../project/ios/target');
jest.mock('../../project/ios/scheme');
jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
function withRemoteVersionSource(easJson: EasJson): EasJson {
return {
...easJson,
cli: {
...easJson.cli,
appVersionSource: AppVersionSource.REMOTE,
},
};
}
describe(BuildVersionSyncView, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('syncing version for managed project on platform android', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(syncAndroidAsync).not.toHaveBeenCalled();
});
test('syncing version for bare project on platform android', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
jest.mocked(getAppBuildGradleAsync).mockImplementation(async () => ({
android: {},
}));
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
const syncAndroidAsync = jest.spyOn(cmd, 'syncAndroidAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'ANDROID',
'eas.test.com'
);
expect(syncAndroidAsync).toHaveBeenCalled();
expect(updateAndroidNativeVersionsAsync).toHaveBeenCalledWith({
projectDir: ctx.projectDir,
versionCode: 1000,
});
});
test('syncing version for managed project on platform ios', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.MANAGED);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'IOS',
'eas.test.com'
);
expect(syncIosAsync).not.toHaveBeenCalled();
});
test('syncing version for bare project on platform ios', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {
easJson: withRemoteVersionSource(getMockEasJson()),
});
const fakeTarget: Target = {
targetName: 'testapp',
bundleIdentifier: 'eas.test.com',
entitlements: {},
};
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => ({
buildVersion: '1000',
storeVersion: '0.0.1',
}));
jest.mocked(prompts.promptAsync).mockImplementation(async () => ({
version: '1000',
}));
jest.mocked(resolveWorkflowAsync).mockImplementation(async () => Workflow.GENERIC);
jest.mocked(resolveTargetsAsync).mockImplementation(async () => [fakeTarget]);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=ios'], ctx);
const syncIosAsync = jest.spyOn(cmd, 'syncIosAsync' as any);
await cmd.run();
expect(AppVersionQuery.latestVersionAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
'IOS',
'eas.test.com'
);
expect(syncIosAsync).toHaveBeenCalled();
expect(updateIosNativeVersionsAsync).toHaveBeenCalledWith({
buildNumber: '1000',
projectDir: ctx.projectDir,
targets: [fakeTarget],
});
});
test('syncing version aborts when appVersionSource is set to local and users refuse auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => false);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await expect(cmd.run()).rejects.toThrowError('Aborting...');
expect(AppVersionMutation.createAppVersionAsync).not.toHaveBeenCalledWith();
});
test('syncing version when appVersionSource is set to local and user allows auto configuration', async () => {
const ctx = mockCommandContext(BuildVersionSyncView, {});
jest.mocked(AppVersionQuery.latestVersionAsync).mockImplementation(async () => null);
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
jest.mocked(prompts.confirmAsync).mockImplementation(async () => true);
const cmd = mockTestCommand(BuildVersionSyncView, ['--platform=android'], ctx);
await cmd.run();
const easJsonAfterCmd = await fs.readJson(path.join(ctx.projectDir, 'eas.json'));
expect(easJsonAfterCmd.cli.appVersionSource).toBe('remote');
});
});
@@ -0,0 +1,118 @@
import { ExpoConfig } from '@expo/config-types';
import { EasJson } from '@expo/eas-json';
import { Command, Config } from '@oclif/core';
import { vol } from 'memfs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { ContextInput, ContextOutput } from '../../commandUtils/EasCommand';
import { DynamicConfigContextFn } from '../../commandUtils/context/DynamicProjectConfigContextField';
import { AppFragment, Role } from '../../graphql/generated';
export function getMockEasJson(): EasJson {
return {
cli: {},
build: {
production: {},
},
};
}
export const mockProjectId = 'fake-project-id';
export function getMockAppFragment(): AppFragment {
return {
id: mockProjectId,
slug: 'testapp',
fullName: '@testuser/testpp',
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
}
export function getMockExpoConfig(): ExpoConfig {
return {
name: 'testapp',
slug: 'tetsapp',
extra: {
projectId: mockProjectId,
},
};
}
export interface LocalProjectContext {
projectDir: string;
exp?: ExpoConfig;
appId?: string;
}
export function mockCommandContext<
C extends {
[name: string]: any;
} = object
>(
commandClass: { contextDefinition: ContextInput<C> },
overrides: {
easJson?: EasJson;
exp?: ExpoConfig;
projectId?: string;
getDynamicProjectConfigAsync?: DynamicConfigContextFn;
}
): ContextOutput<C> {
const projectDir = path.join('/test', uuidv4());
vol.reset();
vol.fromJSON({ 'eas.json': JSON.stringify(overrides.easJson ?? getMockEasJson()) }, projectDir);
const contextDefinition = commandClass.contextDefinition;
const result: any = {};
for (const [contextKey] of Object.entries(contextDefinition)) {
if (contextKey === 'loggedIn') {
result.loggedIn = {};
}
if (contextKey === 'projectDir') {
result.projectDir = projectDir;
}
if (contextKey === 'maybeLoggedIn') {
result.maybeLoggedIn = {};
}
if (contextKey === 'sessionManager') {
result.sessionManager = {};
}
if (contextKey === 'projectConfig') {
result.projectConfig = {
exp: overrides.exp ?? getMockExpoConfig(),
projectId: overrides.projectId ?? mockProjectId,
projectDir,
};
}
if (contextKey === 'getDynamicProjectConfigAsync') {
result.getDynamicProjectConfigAsync = () => ({
exp: overrides.exp ?? getMockExpoConfig(),
projectId: overrides.projectId ?? mockProjectId,
projectDir,
});
}
}
return result;
}
export function mockTestCommand<T extends Command>(
commandConstructor: new (argv: string[], config: Config) => T,
argv: string[],
ctx: any
): T {
const cmd = new commandConstructor(argv, new Config({ root: __dirname }));
(cmd as any).getContextAsync = jest.fn(() => ctx);
return cmd;
}
@@ -0,0 +1,416 @@
import { Platform } from '@expo/eas-build-job';
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { EasCommandError } from '../../commandUtils/errors';
import Build from '../../commands/build';
import Log, { link } from '../../log';
import { handleBuildRequestError } from '../build';
import {
EasBuildDownForMaintenanceError,
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildTooManyPendingBuildsError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from '../errors';
beforeEach(() => {
jest.resetAllMocks();
});
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
{
"message": "Error 1"
},
{
"message": "Error 2"
},
{
"message": "Error 3"
}
]`;
const EXPECTED_EAS_BUILD_DOWN_MESSAGE = `EAS Build is down for maintenance. Try again later. Check ${link(
'https://status.expo.dev/'
)} for updates.`;
const EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID = `You have already reached the maximum number of pending Android builds for your account. Try again later.`;
const EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS = `You have already reached the maximum number of pending iOS builds for your account. Try again later.`;
const EXPECTED_GENERIC_MESSAGE =
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.';
const mockRequestId = uuidv4();
const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
return new GraphQLError(message, null, null, null, null, null, {
errorCode,
requestId: mockRequestId,
});
};
const assertReThrownError = (
caughtError: CombinedError | Error,
expectedType: typeof EasCommandError,
expectedMessage: string = 'Error 1'
): void => {
expect(caughtError).toBeInstanceOf(expectedType);
expect((caughtError as Error).message).toEqual(expectedMessage);
};
describe(Build.name, () => {
describe('handle build request errors', () => {
describe('logs graphQL errors to debug if present', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const logDebugSpy = jest.spyOn(Log, 'debug');
const graphQLErrors = ['Error 1', 'Error 2', 'Error 3'];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const logDebugSpy = jest.spyOn(Log, 'debug');
const graphQLErrors = ['Error 1', 'Error 2', 'Error 3'];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON);
});
});
describe('logs nothing to debug if graphQLErrors not present', () => {
it('does it with Android', async () => {
const platform = Platform.ANDROID;
const logDebugSpy = jest.spyOn(Log, 'debug');
const error = new Error('Generic error');
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(undefined);
});
it('does it with iOS', async () => {
const platform = Platform.IOS;
const logDebugSpy = jest.spyOn(Log, 'debug');
const error = new Error('Generic error');
try {
handleBuildRequestError(error, platform);
} catch {}
expect(logDebugSpy).toBeCalledWith(undefined);
});
});
describe('error is server-side defined error', () => {
describe('for TURTLE_DEPRECATED_JOB_FORMAT', () => {
describe('with Android', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, TurtleDeprecatedJobFormatError, 'Error 1');
}
});
});
describe('with iOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'TURTLE_DEPRECATED_JOB_FORMAT');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, TurtleDeprecatedJobFormatError, 'Error 1');
}
});
});
});
describe('for EAS_BUILD_FREE_TIER_DISABLED', () => {
describe('with Android', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledError, 'Error 1');
}
});
});
describe('with iOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledError, 'Error 1');
}
});
});
});
describe('for EAS_BUILD_FREE_TIER_DISABLED_IOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_IOS');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, EasBuildFreeTierDisabledIOSError, 'Error 1');
}
});
});
describe('for EAS_BUILD_FREE_TIER_DISABLED_ANDROID', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED_ANDROID');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildFreeTierDisabledAndroidError,
'Error 1'
);
}
});
});
describe('for VALIDATION_ERROR', () => {
describe('with Android', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, RequestValidationError, 'Error 1');
}
});
});
describe('with iOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'VALIDATION_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, RequestValidationError, 'Error 1');
}
});
});
});
});
describe('error is other expected graphQL-related error', () => {
describe('for EAS_BUILD_DOWN_FOR_MAINTENANCE', () => {
describe('with Android', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildDownForMaintenanceError,
EXPECTED_EAS_BUILD_DOWN_MESSAGE
);
}
});
});
describe('with iOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_DOWN_FOR_MAINTENANCE');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildDownForMaintenanceError,
EXPECTED_EAS_BUILD_DOWN_MESSAGE
);
}
});
});
});
describe('for EAS_BUILD_TOO_MANY_PENDING_BUILDS', () => {
describe('with Android', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildTooManyPendingBuildsError,
EXPECTED_MAX_BUILD_COUNT_MESSAGE_ANDROID
);
}
});
});
describe('with iOS', () => {
it('throws correct EasCommandError subclass', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_TOO_MANY_PENDING_BUILDS');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(
caughtError as Error,
EasBuildTooManyPendingBuildsError,
EXPECTED_MAX_BUILD_COUNT_MESSAGE_IOS
);
}
});
});
});
});
describe('error is unexpected graphQL-related error', () => {
describe('with Android', () => {
it('throws base Error class with custom message', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const expectedMessage = EXPECTED_GENERIC_MESSAGE + `\nRequest ID: ${mockRequestId}`;
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, Error, expectedMessage);
}
});
describe('without request ID', () => {
it('throws base Error class with custom message without request ID line', async () => {
const platform = Platform.ANDROID;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
delete graphQLError.extensions.requestId;
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, Error, EXPECTED_GENERIC_MESSAGE);
}
});
});
});
describe('with iOS', () => {
it('throws base Error class with custom message', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
const expectedMessage = EXPECTED_GENERIC_MESSAGE + `\nRequest ID: ${mockRequestId}`;
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, Error, expectedMessage);
}
});
describe('without request ID', () => {
it('throws base Error class with custom message without request ID line', async () => {
const platform = Platform.IOS;
const graphQLError = getGraphQLError('Error 1', 'UNKNOWN_GRAPHQL_ERROR');
delete graphQLError.extensions.requestId;
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
assertReThrownError(caughtError as Error, Error, EXPECTED_GENERIC_MESSAGE);
}
});
});
});
});
describe('error is unexpected non-graphQL-related error', () => {
describe('with Android', () => {
it('throws base Error class with propagated message', async () => {
const platform = Platform.ANDROID;
const error = new Error('Non-graphQL-related error');
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
expect(caughtError).toStrictEqual(error);
}
});
});
describe('with iOS', () => {
it('throws base Error class with propagated message', async () => {
const platform = Platform.IOS;
const error = new Error('Non-graphQL-related error');
try {
handleBuildRequestError(error, platform);
} catch (caughtError) {
expect(caughtError).toStrictEqual(error);
}
});
});
});
});
});
@@ -24,8 +24,7 @@ interface JobData {
const cacheDefaults = {
disabled: false,
customPaths: [],
cacheDefaultPaths: true,
paths: [],
};
export async function prepareJobAsync(
+40 -26
View File
@@ -10,6 +10,7 @@ import { BuildEvent } from '../analytics/AnalyticsManager';
import { withAnalyticsAsync } from '../analytics/common';
import { getExpoWebsiteBaseUrl } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import {
AppPlatform,
BuildFragment,
@@ -36,6 +37,15 @@ import { createProgressTracker } from '../utils/progress';
import { sleepAsync } from '../utils/promise';
import { getVcsClient } from '../vcs';
import { BuildContext } from './context';
import {
EasBuildDownForMaintenanceError,
EasBuildFreeTierDisabledAndroidError,
EasBuildFreeTierDisabledError,
EasBuildFreeTierDisabledIOSError,
EasBuildTooManyPendingBuildsError,
RequestValidationError,
TurtleDeprecatedJobFormatError,
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import { collectMetadataAsync } from './metadata';
@@ -70,9 +80,14 @@ interface Builder<TPlatform extends Platform, Credentials, TJob extends Job> {
export type BuildRequestSender = () => Promise<BuildFragment | undefined>;
function resolveBuildParamsInput<T extends Platform>(ctx: BuildContext<T>): BuildParamsInput {
function resolveBuildParamsInput<T extends Platform>(
ctx: BuildContext<T>,
metadata: Metadata
): BuildParamsInput {
return {
resourceClass: ctx.resourceClass,
sdkVersion: metadata.sdkVersion,
reactNativeVersion: metadata.reactNativeVersion,
};
}
@@ -132,7 +147,7 @@ export async function prepareBuildRequestForPlatformAsync<
assert(projectArchive);
const metadata = await collectMetadataAsync(ctx);
const buildParams = resolveBuildParamsInput(ctx);
const buildParams = resolveBuildParamsInput(ctx, metadata);
const job = await builder.prepareJobAsync(ctx, {
projectArchive,
credentials: credentialsResult?.credentials,
@@ -161,39 +176,38 @@ export async function prepareBuildRequestForPlatformAsync<
};
}
const SERVER_SIDE_DEFINED_ERRORS = [
'TURTLE_DEPRECATED_JOB_FORMAT',
'EAS_BUILD_FREE_TIER_DISABLED',
'EAS_BUILD_FREE_TIER_DISABLED_IOS',
'EAS_BUILD_FREE_TIER_DISABLED_ANDROID',
'VALIDATION_ERROR',
];
const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
TURTLE_DEPRECATED_JOB_FORMAT: TurtleDeprecatedJobFormatError,
EAS_BUILD_FREE_TIER_DISABLED: EasBuildFreeTierDisabledError,
EAS_BUILD_FREE_TIER_DISABLED_IOS: EasBuildFreeTierDisabledIOSError,
EAS_BUILD_FREE_TIER_DISABLED_ANDROID: EasBuildFreeTierDisabledAndroidError,
VALIDATION_ERROR: RequestValidationError,
};
function handleBuildRequestError(error: any, platform: Platform): never {
export function handleBuildRequestError(error: any, platform: Platform): never {
Log.debug(JSON.stringify(error.graphQLErrors, null, 2));
if (SERVER_SIDE_DEFINED_ERRORS.includes(error?.graphQLErrors?.[0]?.extensions?.errorCode)) {
Log.error(error?.graphQLErrors?.[0]?.message);
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE'
) {
Log.error(
const graphQLErrorCode: string = error?.graphQLErrors?.[0]?.extensions?.errorCode;
if (graphQLErrorCode in SERVER_SIDE_DEFINED_ERRORS) {
const ErrorClass: typeof EasCommandError = SERVER_SIDE_DEFINED_ERRORS[graphQLErrorCode];
throw new ErrorClass(error?.graphQLErrors?.[0]?.message);
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
throw new EasBuildDownForMaintenanceError(
`EAS Build is down for maintenance. Try again later. Check ${link(
'https://status.expo.dev/'
)} for updates.`
);
throw new Error('Build request failed.');
} else if (
error?.graphQLErrors?.[0]?.extensions?.errorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS'
) {
Log.error(
} else if (graphQLErrorCode === 'EAS_BUILD_TOO_MANY_PENDING_BUILDS') {
throw new EasBuildTooManyPendingBuildsError(
`You have already reached the maximum number of pending ${requestedPlatformDisplayNames[platform]} builds for your account. Try again later.`
);
throw new Error('Build request failed.');
} else if (error?.graphQLErrors) {
Log.error(
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.'
const requestIdLine = error?.graphQLErrors?.[0]?.extensions?.requestId
? `\nRequest ID: ${error.graphQLErrors[0].extensions.requestId}`
: '';
throw new Error(
'Build request failed. Make sure you are using the latest eas-cli version. If the problem persists, report the issue.' +
requestIdLine
);
}
throw error;
@@ -607,7 +621,7 @@ function formatAccountSubscriptionsUrl(accountName: string): string {
}
async function updateIosBuildProfilesToUseM1WorkersAsync(projectDir: string): Promise<void> {
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
+3 -21
View File
@@ -1,5 +1,5 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJson, EasJsonAccessor, ResourceClass } from '@expo/eas-json';
import { EasJson, EasJsonAccessor } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
@@ -53,21 +53,11 @@ const EAS_JSON_MANAGED_DEFAULT: EasJson = {
development: {
developmentClient: true,
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
preview: {
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {},
},
submit: {
production: {},
@@ -86,20 +76,12 @@ const EAS_JSON_BARE_DEFAULT: EasJson = {
},
ios: {
buildConfiguration: 'Debug',
resourceClass: ResourceClass.M_MEDIUM,
},
},
preview: {
distribution: 'internal',
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {
ios: {
resourceClass: ResourceClass.M_MEDIUM,
},
},
production: {},
},
submit: {
production: {},
+2
View File
@@ -8,6 +8,7 @@ import { CredentialsContext } from '../credentials/context';
import { Target } from '../credentials/ios/types';
import { BuildResourceClass } from '../graphql/generated';
import { GradleBuildContext } from '../project/android/gradle';
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { XcodeBuildContext } from '../project/ios/scheme';
import { Actor } from '../user/User';
import { LocalBuildOptions } from './local';
@@ -51,6 +52,7 @@ export interface BuildContext<T extends Platform> {
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
workflow: Workflow;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
ios: T extends Platform.IOS ? IosBuildContext : undefined;
}
+4 -2
View File
@@ -9,6 +9,7 @@ import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/An
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { Actor } from '../user/User';
@@ -34,6 +35,7 @@ export async function createBuildContextAsync<T extends Platform>({
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
customBuildConfigMetadata,
}: {
buildProfileName: string;
buildProfile: BuildProfile<T>;
@@ -50,6 +52,7 @@ export async function createBuildContextAsync<T extends Platform>({
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicProjectConfigAsync({ env: buildProfile.env });
const projectName = exp.slug;
@@ -90,8 +93,6 @@ export async function createBuildContextAsync<T extends Platform>({
const resourceClass = await resolveBuildResourceClassAsync(
buildProfile,
platform,
projectDir,
exp,
resourceClassFlag
);
@@ -118,6 +119,7 @@ export async function createBuildContextAsync<T extends Platform>({
workflow,
message,
runFromCI,
customBuildConfigMetadata,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
+15
View File
@@ -0,0 +1,15 @@
import { EasCommandError } from '../commandUtils/errors';
export class TurtleDeprecatedJobFormatError extends EasCommandError {}
export class EasBuildFreeTierDisabledError extends EasCommandError {}
export class EasBuildFreeTierDisabledIOSError extends EasCommandError {}
export class EasBuildFreeTierDisabledAndroidError extends EasCommandError {}
export class RequestValidationError extends EasCommandError {}
export class EasBuildDownForMaintenanceError extends EasCommandError {}
export class EasBuildTooManyPendingBuildsError extends EasCommandError {}
@@ -77,7 +77,7 @@ describe(bumpVersionAsync, () => {
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
targets: [
{
@@ -88,9 +88,9 @@ describe(bumpVersionAsync, () => {
],
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
'/app/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
@@ -113,7 +113,7 @@ describe(bumpVersionAsync, () => {
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
targets: [
{
@@ -124,9 +124,9 @@ describe(bumpVersionAsync, () => {
],
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info2.plist'
'/app/ios/myproject/Info2.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
@@ -148,7 +148,7 @@ describe(bumpVersionAsync, () => {
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
targets: [
{
@@ -159,9 +159,9 @@ describe(bumpVersionAsync, () => {
],
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
'/app/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1');
@@ -183,7 +183,7 @@ describe(bumpVersionAsync, () => {
);
await bumpVersionAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
targets: [
{
@@ -194,9 +194,9 @@ describe(bumpVersionAsync, () => {
],
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
const infoPlist = (await readPlistAsync(
'/repo/ios/myproject/Info.plist'
'/app/ios/myproject/Info.plist'
)) as IOSConfig.InfoPlist;
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1');
@@ -214,11 +214,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.BUILD_NUMBER,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('2');
expect(appJSON.expo.version).toBe('1.0.0');
@@ -230,11 +230,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.APP_VERSION,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
expect(fakeExp.version).toBe('1.0.1');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.1');
@@ -246,11 +246,11 @@ describe(bumpVersionInAppJsonAsync, () => {
await bumpVersionInAppJsonAsync({
bumpStrategy: BumpStrategy.NOOP,
projectDir: '/repo',
projectDir: '/app',
exp: fakeExp,
});
const appJSON = await fs.readJSON('/repo/app.json');
const appJSON = await fs.readJSON('/app/app.json');
expect(fakeExp.version).toBe('1.0.0');
expect(fakeExp.ios?.buildNumber).toBe('1');
expect(appJSON.expo.version).toBe('1.0.0');
@@ -262,7 +262,7 @@ describe(readBuildNumberAsync, () => {
describe('bare project', () => {
it('reads the build number from native code', async () => {
const exp = initBareWorkflowProject();
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
const buildNumber = await readBuildNumberAsync('/app', exp, {});
expect(buildNumber).toBe('1');
});
});
@@ -270,7 +270,7 @@ describe(readBuildNumberAsync, () => {
describe('managed project', () => {
it('reads the build number from expo config', async () => {
const exp = initManagedProject();
const buildNumber = await readBuildNumberAsync('/repo', exp, {});
const buildNumber = await readBuildNumberAsync('/app', exp, {});
expect(buildNumber).toBe('1');
});
});
@@ -280,24 +280,38 @@ describe(readShortVersionAsync, () => {
describe('bare project', () => {
it('reads the short version from native code', async () => {
const exp = initBareWorkflowProject();
const appVersion = await readShortVersionAsync('/repo', exp, {});
const appVersion = await readShortVersionAsync('/app', exp, {});
expect(appVersion).toBe('1.0.0');
});
it('evaluates interpolated build number', async () => {
const exp = initBareWorkflowProject({
appVersion: '$(CURRENT_PROJECT_VERSION)',
});
const buildNumber = await readShortVersionAsync('/repo', exp, {
const buildNumber = await readShortVersionAsync('/app', exp, {
CURRENT_PROJECT_VERSION: '1.0.0',
});
expect(buildNumber).toBe('1.0.0');
});
it('fails when build number is invalid', async () => {
const exp = initBareWorkflowProject({
appVersion: '$(CURRENT_PROJECT_VERSION)',
});
await expect(
readShortVersionAsync('/app', exp, {
CURRENT_PROJECT_VERSION: '0.0.7.1.028',
})
).rejects.toThrowError(
'CFBundleShortVersionString (version field in app.json/app.config.js) must be a period-separated list of three non-negative integers. Current value: 0.0.7.1.028'
);
});
});
describe('managed project', () => {
it('reads the version from app config', async () => {
const exp = initBareWorkflowProject();
const appVersion = await readShortVersionAsync('/repo', exp, {});
const appVersion = await readShortVersionAsync('/app', exp, {});
expect(appVersion).toBe('1.0.0');
});
});
@@ -309,66 +323,75 @@ describe(getInfoPlistPath, () => {
{
'./ios/testapp/Info.plist': '',
},
'/repo'
'/app'
);
const plistPath = getInfoPlistPath('/repo', {});
expect(plistPath).toBe('/repo/ios/testapp/Info.plist');
const plistPath = getInfoPlistPath('/app', {});
expect(plistPath).toBe('/app/ios/testapp/Info.plist');
});
it('returns INFOPLIST_FILE if specified', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
'/app'
);
const plistPath = getInfoPlistPath('/repo', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
const plistPath = getInfoPlistPath('/app', { INFOPLIST_FILE: './qwert/NotInfo.plist' });
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
});
it('evaluates SRCROOT in Info.plist', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
'/app'
);
const plistPath = getInfoPlistPath('/repo', {
const plistPath = getInfoPlistPath('/app', {
INFOPLIST_FILE: '$(SRCROOT)/qwert/NotInfo.plist',
});
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
});
it('evaluates BuildSettings in Info.plist', () => {
vol.fromJSON(
{
'./ios/testapp/Info.plist': '',
},
'/repo'
'/app'
);
const plistPath = getInfoPlistPath('/repo', {
const plistPath = getInfoPlistPath('/app', {
INFOPLIST_FILE: '$(SRCROOT)/qwert/$(TARGET_NAME).plist',
TARGET_NAME: 'NotInfo',
});
expect(plistPath).toBe('/repo/ios/qwert/NotInfo.plist');
expect(plistPath).toBe('/app/ios/qwert/NotInfo.plist');
});
});
function initBareWorkflowProject({
appVersion = '1.0.0',
buildNumber = '1',
expoConfig = {},
infoPlistName = 'Info.plist',
}: { appVersion?: string; buildNumber?: string; infoPlistName?: string } = {}): ExpoConfig {
}: {
appVersion?: string;
buildNumber?: string;
infoPlistName?: string;
expoConfig?: { version?: string; ios?: object };
} = {}): ExpoConfig {
const fakeExp: ExpoConfig = {
name: 'myproject',
slug: 'myproject',
version: '1.0.0',
ios: {
buildNumber: '1',
...expoConfig.ios,
},
...expoConfig,
};
vol.fromJSON(
{
'./app.json': JSON.stringify({
expo: fakeExp,
}),
'./ios/myproject.xcodeproj/project.pbxproj': '',
[`./ios/myproject/${infoPlistName}`]: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -380,7 +403,7 @@ function initBareWorkflowProject({
</dict>
</plist>`,
},
'/repo'
'/app'
);
return fakeExp;
@@ -402,7 +425,7 @@ function initManagedProject(): ExpoConfig {
expo: fakeExp,
}),
},
'/repo'
'/app'
);
return fakeExp;
+1 -2
View File
@@ -27,8 +27,7 @@ interface JobData {
const cacheDefaults = {
disabled: false,
customPaths: [],
cacheDefaultPaths: true,
paths: [],
};
export async function prepareJobAsync(
+17 -3
View File
@@ -22,6 +22,8 @@ import { readPlistAsync, writePlistAsync } from '../../utils/plist';
import { updateAppJsonConfigAsync } from '../utils/appJson';
import { bumpAppVersionAsync, ensureStaticConfigExists } from '../utils/version';
const SHORT_VERSION_REGEX = /^\d+.\d+.\d+$/;
export enum BumpStrategy {
APP_VERSION,
BUILD_NUMBER,
@@ -97,6 +99,14 @@ export async function bumpVersionInAppJsonAsync({
}
}
function validateShortVersion(shortVersion: string | undefined): void {
if (shortVersion && !SHORT_VERSION_REGEX.test(shortVersion)) {
throw new Error(
`CFBundleShortVersionString (version field in app.json/app.config.js) must be a period-separated list of three non-negative integers. Current value: ${shortVersion}`
);
}
}
export async function readShortVersionAsync(
projectDir: string,
exp: ExpoConfig,
@@ -105,11 +115,15 @@ export async function readShortVersionAsync(
const workflow = await resolveWorkflowAsync(projectDir, Platform.IOS);
if (workflow === Workflow.GENERIC) {
const infoPlist = await readInfoPlistAsync(projectDir, buildSettings);
return (
const shortVersion =
infoPlist.CFBundleShortVersionString &&
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings)
);
evaluateTemplateString(infoPlist.CFBundleShortVersionString, buildSettings);
validateShortVersion(shortVersion);
return shortVersion;
} else {
validateShortVersion(exp.version);
return exp.version;
}
}
+6 -1
View File
@@ -19,6 +19,7 @@ import { getVcsClient } from '../vcs';
import { maybeResolveVersionsAsync as maybeResolveAndroidVersionsAsync } from './android/version';
import { BuildContext } from './context';
import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios/version';
import { LocalBuildMode } from './local';
export async function collectMetadataAsync<T extends Platform>(
ctx: BuildContext<T>
@@ -47,7 +48,10 @@ export async function collectMetadataAsync<T extends Platform>(
gitCommitMessage: truncateGitCommitMessage(
(await vcsClient.getLastCommitMessageAsync()) ?? undefined
),
isGitWorkingTreeDirty: await vcsClient.hasUncommittedChangesAsync(),
isGitWorkingTreeDirty:
ctx.localBuildOptions.localBuildMode === LocalBuildMode.INTERNAL
? false
: await vcsClient.hasUncommittedChangesAsync(),
username: getUsername(ctx.exp, ctx.user),
message: ctx.message,
...(ctx.platform === Platform.IOS && {
@@ -58,6 +62,7 @@ export async function collectMetadataAsync<T extends Platform>(
runWithNoWaitFlag: ctx.noWait,
runFromCI: ctx.runFromCI,
buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
};
return sanitizeMetadata(metadata);
}
@@ -31,7 +31,10 @@ import {
appPlatformEmojis,
toPlatforms,
} from '../platform';
import { validateCustomBuildConfigAsync } from '../project/customBuildConfig';
import {
CustomBuildConfigMetadata,
validateCustomBuildConfigAsync,
} from '../project/customBuildConfig';
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
import { validateAppVersionRuntimePolicySupportAsync } from '../project/projectUtils';
@@ -99,7 +102,7 @@ export async function runBuildAndSubmitAsync(
projectDir,
nonInteractive: flags.nonInteractive,
});
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
@@ -117,9 +120,14 @@ export async function runBuildAndSubmitAsync(
buildProfiles,
});
const customBuildConfigMetadataByPlatform: { [p in AppPlatform]?: CustomBuildConfigMetadata } =
{};
for (const buildProfile of buildProfiles) {
validateBuildProfileVersionSettings(buildProfile, easJsonCliConfig);
await validateCustomBuildConfigAsync(projectDir, buildProfile.profile);
const maybeMetadata = await validateCustomBuildConfigAsync(projectDir, buildProfile.profile);
if (maybeMetadata) {
customBuildConfigMetadataByPlatform[toAppPlatform(buildProfile.platform)] = maybeMetadata;
}
}
const startedBuilds: {
@@ -129,6 +137,7 @@ export async function runBuildAndSubmitAsync(
const buildCtxByPlatform: { [p in AppPlatform]?: BuildContext<Platform> } = {};
for (const buildProfile of buildProfiles) {
const platform = toAppPlatform(buildProfile.platform);
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
projectDir,
flags,
@@ -139,11 +148,12 @@ export async function runBuildAndSubmitAsync(
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
});
if (maybeBuild) {
startedBuilds.push({ build: maybeBuild, buildProfile });
}
buildCtxByPlatform[toAppPlatform(buildProfile.platform)] = buildCtx;
buildCtxByPlatform[platform] = buildCtx;
}
if (flags.localBuildOptions.localBuildMode) {
@@ -246,6 +256,7 @@ async function prepareAndStartBuildAsync({
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
customBuildConfigMetadata,
}: {
projectDir: string;
flags: BuildFlags;
@@ -256,6 +267,7 @@ async function prepareAndStartBuildAsync({
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
getDynamicProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
const buildCtx = await createBuildContextAsync({
buildProfileName: buildProfile.profileName,
@@ -273,6 +285,7 @@ async function prepareAndStartBuildAsync({
graphqlClient,
analytics,
getDynamicProjectConfigAsync,
customBuildConfigMetadata,
});
if (moreBuilds) {
@@ -1,12 +1,9 @@
import { ExpoConfig } from '@expo/config-types';
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, ResourceClass } from '@expo/eas-json';
import chalk from 'chalk';
import semver from 'semver';
import { BuildResourceClass } from '../../graphql/generated';
import Log from '../../log';
import { getReactNativeVersionAsync } from '../metadata';
import Log, { link } from '../../log';
type AndroidResourceClass = Exclude<
ResourceClass,
@@ -42,22 +39,20 @@ const androidResourceClassToBuildResourceClassMapping: Record<
export async function resolveBuildResourceClassAsync<T extends Platform>(
profile: BuildProfile<T>,
platform: Platform,
projectDir: string,
exp: ExpoConfig,
resourceClassFlag?: ResourceClass
): Promise<BuildResourceClass> {
const profileResourceClass = profile.resourceClass;
if (profileResourceClass && resourceClassFlag && resourceClassFlag !== profileResourceClass) {
Log.warn(
`Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing the "${resourceClassFlag}" as the override.`
`Build profile specifies the "${profileResourceClass}" resource class but you passed "${resourceClassFlag}" to --resource-class.\nUsing "${resourceClassFlag}" as the override.`
);
}
const selectedResourceClass = resourceClassFlag ?? profileResourceClass;
return platform === Platform.IOS
? await resolveIosResourceClassAsync(exp, projectDir, resourceClassFlag ?? profileResourceClass)
? resolveIosResourceClass(resourceClassFlag, profileResourceClass)
: resolveAndroidResourceClass(selectedResourceClass);
}
@@ -79,13 +74,19 @@ function resolveAndroidResourceClass(selectedResourceClass?: ResourceClass): Bui
return androidResourceClassToBuildResourceClassMapping[resourceClass as AndroidResourceClass];
}
async function resolveIosResourceClassAsync(
exp: ExpoConfig,
projectDir: string,
selectedResourceClass?: ResourceClass
): Promise<BuildResourceClass> {
const resourceClass =
selectedResourceClass ?? (await resolveIosDefaultRequestedResourceClassAsync(exp, projectDir));
function resolveIosResourceClass(
resourceClassFlag?: ResourceClass,
profileResourceClass?: ResourceClass
): BuildResourceClass {
const resourceClass = resourceClassFlag ?? profileResourceClass ?? ResourceClass.DEFAULT;
if (resourceClassFlag === ResourceClass.LARGE) {
throw new Error(
`Experimental "large" resource class for Intel iOS workers is no longer available. Remove the specified resource class to use the default, or learn more about all available resource classes: ${link(
'https://docs.expo.dev/build-reference/eas-json/'
)}`
);
}
if ([ResourceClass.M1_EXPERIMENTAL, ResourceClass.M1_MEDIUM].includes(resourceClass)) {
Log.warn(
@@ -95,29 +96,13 @@ async function resolveIosResourceClassAsync(
);
}
if (resourceClass === ResourceClass.M1_LARGE) {
if ([ResourceClass.M_LARGE, ResourceClass.M1_LARGE].includes(resourceClass)) {
Log.warn(
`Resource class ${chalk.bold('m1-large')} is deprecated. Use ${chalk.bold(
'm-large'
`Resource class ${chalk.bold(resourceClass)} is deprecated. Use ${chalk.bold(
'large'
)} instead.`
);
}
return iosResourceClassToBuildResourceClassMapping[resourceClass];
}
async function resolveIosDefaultRequestedResourceClassAsync(
exp: ExpoConfig,
projectDir: string
): Promise<ResourceClass> {
const { sdkVersion } = exp;
const reactNativeVersion = await getReactNativeVersionAsync(projectDir);
if (
(sdkVersion && semver.satisfies(sdkVersion, '>=48')) ||
(reactNativeVersion && semver.satisfies(reactNativeVersion, '>=0.71.0'))
) {
return ResourceClass.M_MEDIUM;
} else {
return ResourceClass.DEFAULT;
}
}
+37 -12
View File
@@ -1,4 +1,5 @@
import { Command } from '@oclif/core';
import { CombinedError } from '@urql/core';
import nullthrows from 'nullthrows';
import {
@@ -6,6 +7,7 @@ import {
CommandEvent,
createAnalyticsAsync,
} from '../analytics/AnalyticsManager';
import Log from '../log';
import SessionManager from '../user/SessionManager';
import AnalyticsContextField from './context/AnalyticsContextField';
import ContextField from './context/ContextField';
@@ -16,8 +18,9 @@ import { OptionalProjectConfigContextField } from './context/OptionalProjectConf
import ProjectConfigContextField from './context/ProjectConfigContextField';
import ProjectDirContextField from './context/ProjectDirContextField';
import SessionManagementContextField from './context/SessionManagementContextField';
import { EasCommandError } from './errors';
type ContextInput<
export type ContextInput<
T extends {
[name: string]: any;
} = object
@@ -25,7 +28,7 @@ type ContextInput<
[P in keyof T]: ContextField<T[P]>;
};
type ContextOutput<
export type ContextOutput<
T extends {
[name: string]: any;
} = object
@@ -33,6 +36,8 @@ type ContextOutput<
[P in keyof T]: T[P];
};
const BASE_GRAPHQL_ERROR_MESSAGE: string = 'GraphQL request failed.';
export default abstract class EasCommand extends Command {
protected static readonly ContextOptions = {
/**
@@ -103,6 +108,18 @@ export default abstract class EasCommand extends Command {
*/
static contextDefinition: ContextInput = {};
/**
* The user session manager. Responsible for coordinating all user session related state.
* If needed in a subclass, use the SessionManager ContextOption.
*/
private sessionManagerInternal?: SessionManager;
/**
* The analytics manager. Used for logging analytics.
* It is set up here to ensure a consistent setup.
*/
private analyticsInternal?: AnalyticsWithOrchestration;
/**
* Execute the context in the contextDefinition to satisfy command prerequisites.
*/
@@ -132,20 +149,10 @@ export default abstract class EasCommand extends Command {
return Object.fromEntries(contextValuePairs);
}
/**
* The user session manager. Responsible for coordinating all user session related state.
* If needed in a subclass, use the SessionManager ContextOption.
*/
private sessionManagerInternal?: SessionManager;
private get sessionManager(): SessionManager {
return nullthrows(this.sessionManagerInternal);
}
/**
* The analytics manager. Used for logging analytics.
* It is set up here to ensure a consistent set up.
*/
private analyticsInternal?: AnalyticsWithOrchestration;
private get analytics(): AnalyticsWithOrchestration {
return nullthrows(this.analyticsInternal);
}
@@ -175,4 +182,22 @@ export default abstract class EasCommand extends Command {
await this.analytics.flushAsync();
return super.finally(err);
}
protected override catch(err: Error): Promise<any> {
let baseMessage = `${this.id} command failed.`;
if (err instanceof EasCommandError) {
Log.error(err.message);
} else if (err instanceof CombinedError && err?.graphQLErrors) {
let cleanMessage = err.message.replace('[GraphQL] ', '');
cleanMessage += err?.graphQLErrors?.[0]?.extensions?.requestId
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
: '';
Log.error(cleanMessage);
baseMessage = BASE_GRAPHQL_ERROR_MESSAGE;
} else {
Log.error(err.message);
}
Log.debug(err);
throw new Error(baseMessage);
}
}
@@ -1,4 +1,9 @@
import { CombinedError } from '@urql/core';
import { GraphQLError } from 'graphql/error';
import { v4 as uuidv4 } from 'uuid';
import { AnalyticsWithOrchestration, createAnalyticsAsync } from '../../analytics/AnalyticsManager';
import Log from '../../log';
import SessionManager from '../../user/SessionManager';
import EasCommand from '../EasCommand';
@@ -11,8 +16,10 @@ jest.mock('../../analytics/AnalyticsManager', () => {
createAnalyticsAsync: jest.fn(),
};
});
jest.mock('../../log');
let originalProcessArgv: string[];
const mockRequestId = uuidv4();
beforeAll(() => {
originalProcessArgv = process.argv;
@@ -40,7 +47,7 @@ const createTestEasCommand = (): typeof EasCommand => {
async runAsync(): Promise<void> {}
}
TestEasCommand.id = 'TestEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
TestEasCommand.id = 'testEasCommand'; // normally oclif will assign ids, but b/c this is located outside the commands folder it will not
return TestEasCommand;
};
@@ -96,5 +103,103 @@ describe(EasCommand.name, () => {
expect(analytics.flushAsync).toHaveBeenCalled();
});
describe('catch', () => {
it('logs the message', async () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const error = new Error('Unexpected, internal error message');
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(logErrorSpy).toBeCalledWith('Unexpected, internal error message');
expect(logDebugSpy).toBeCalledWith(error);
});
it('logs the cleaned message if needed', async () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLErrors = ['Unexpected GraphQL error message'];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(logErrorSpy).toBeCalledWith('Unexpected GraphQL error message');
expect(logDebugSpy).toBeCalledWith(error);
});
it('logs the cleaned message with request ID if present', async () => {
const TestEasCommand = createTestEasCommand();
const logErrorSpy = jest.spyOn(Log, 'error');
const logDebugSpy = jest.spyOn(Log, 'debug');
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
const graphQLError = new GraphQLError(
'Unexpected GraphQL error message',
null,
null,
null,
null,
null,
{
errorCode: 'UNKNOWN_GRAPHQL_ERROR',
requestId: mockRequestId,
}
);
const graphQLErrors = [graphQLError];
const error = new CombinedError({ graphQLErrors });
runAsyncMock.mockImplementation(() => {
throw error;
});
try {
await TestEasCommand.run();
} catch {}
expect(logErrorSpy).toBeCalledWith(
`Unexpected GraphQL error message\nRequest ID: ${mockRequestId}`
);
expect(logDebugSpy).toBeCalledWith(error);
});
it('re-throws the error with default base message', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
throw new Error('Error message');
});
try {
await TestEasCommand.run();
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('testEasCommand command failed.');
}
});
it('re-throws the error with a different default base message in case of graphQLError', async () => {
const TestEasCommand = createTestEasCommand();
const runAsyncMock = jest.spyOn(TestEasCommand.prototype as any, 'runAsync');
runAsyncMock.mockImplementation(() => {
const graphQLErrors = ['Unexpected GraphQL error message'];
throw new CombinedError({ graphQLErrors });
});
try {
await TestEasCommand.run();
} catch (caughtError) {
expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toEqual('GraphQL request failed.');
}
});
});
});
});
@@ -16,8 +16,8 @@ export class DynamicProjectConfigContextField extends ContextField<DynamicConfig
nonInteractive,
sessionManager,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
const expBefore = getExpoConfig(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
@@ -252,4 +252,26 @@ describe(getProjectIdAsync, () => {
getProjectIdAsync(sessionManager, { name: 'test', slug: 'test' }, { nonInteractive: false })
).rejects.toThrow();
});
it('throws if extra.eas.projectId is not a string', async () => {
jest.mocked(getConfig).mockReturnValue({
exp: { name: 'test', slug: 'test', extra: { eas: { projectId: 1234 } } },
} as any);
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
fullName: '@notnotbrent/test',
slug: 'test',
ownerAccount: { name: 'notnotbrent' } as any,
});
await expect(
getProjectIdAsync(
sessionManager,
{ name: 'test', slug: 'wat', extra: { eas: { projectId: 1234 } } },
{ nonInteractive: false }
)
).rejects.toThrow(
`Project config: "extra.eas.projectId" must be a string, found number. If you're not sure how to set it up on your own, remove the property entirely and it will be automatically configured on the next EAS CLI run.`
);
});
});
@@ -12,7 +12,7 @@ import { getVcsClient, setVcsClient } from '../../../vcs';
import GitClient from '../../../vcs/clients/git';
async function applyCliConfigAsync(projectDir: string): Promise<void> {
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const config = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
if (config?.version && !semver.satisfies(easCliVersion, config.version)) {
throw new Error(
@@ -89,6 +89,12 @@ export async function getProjectIdAsync(
const localProjectId = exp.extra?.eas?.projectId;
if (localProjectId) {
if (typeof localProjectId !== 'string') {
throw new Error(
`Project config: "extra.eas.projectId" must be a string, found ${typeof localProjectId}. If you're not sure how to set it up on your own, remove the property entirely and it will be automatically configured on the next EAS CLI run.`
);
}
// check that the local project ID matches account and slug
const appForProjectId = await AppQuery.byIdAsync(graphqlClient, localProjectId);
if (exp.owner && exp.owner !== appForProjectId.ownerAccount.name) {
@@ -0,0 +1,7 @@
export class EasCommandError extends Error {
// constructor is not useless, since the constructor for Error allows for optional `message`
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
constructor(message: string) {
super(message);
}
}
+42 -1
View File
@@ -1,8 +1,10 @@
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import EasCommand from '../../commandUtils/EasCommand';
import { Role } from '../../graphql/generated';
import Log from '../../log';
import { getActorDisplayName } from '../../user/User';
import { Actor, getActorDisplayName } from '../../user/User';
export default class AccountView extends EasCommand {
static override description = 'show the username you are logged in as';
@@ -18,9 +20,48 @@ export default class AccountView extends EasCommand {
} = await this.getContextAsync(AccountView, { nonInteractive: true });
if (actor) {
Log.log(chalk.green(getActorDisplayName(actor)));
// personal account is included, only show if more accounts that personal account
// but do show personal account in list if there are more
const accountExcludingPersonalAccount = actor.accounts.filter(
account => !('username' in actor) || account.name !== actor.username
);
if (accountExcludingPersonalAccount.length > 0) {
Log.newLine();
Log.log('Accounts:');
actor.accounts.forEach(account => {
const roleOnAccount = AccountView.getRoleOnAccount(actor, account);
Log.log(`• ${account.name} (Role: ${AccountView.getLabelForRole(roleOnAccount)})`);
});
}
} else {
Log.warn('Not logged in');
process.exit(1);
}
}
private static getRoleOnAccount(actor: Actor, account: Actor['accounts'][0]): Role {
if ('username' in actor && account.name === actor.username) {
return Role.Owner;
}
return nullthrows(account.users.find(user => user.actor.id === actor.id)?.role);
}
private static getLabelForRole(role: Role): string {
switch (role) {
case Role.Owner:
return 'Owner';
case Role.Admin:
return 'Admin';
case Role.Developer:
return 'Developer';
case Role.ViewOnly:
return 'Viewer';
case Role.Custom:
case Role.HasAdmin:
case Role.NotAdmin:
return 'Custom';
}
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ export async function handleDeprecatedEasJsonAsync(
if (!(await fs.pathExists(EasJsonAccessor.formatEasJsonPath(projectDir)))) {
return;
}
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const profileNames = await EasJsonUtils.getBuildProfileNamesAsync(easJsonAccessor);
const platformAndProfileNames: [Platform, string][] = profileNames.flatMap(profileName => [
[Platform.ANDROID, profileName],
@@ -6,6 +6,8 @@ import { runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
import EasCommand from '../../commandUtils/EasCommand';
import { RequestedPlatform } from '../../platform';
import { enableJsonOutput } from '../../utils/json';
import { setVcsClient } from '../../vcs';
import GitNoCommitClient from '../../vcs/clients/gitNoCommit';
/**
* This command will be run on the EAS Build workers, when building
@@ -51,6 +53,8 @@ export default class BuildInternal extends EasCommand {
nonInteractive: true,
});
setVcsClient(new GitNoCommitClient());
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
await runBuildAndSubmitAsync(
@@ -121,7 +121,7 @@ export default class BuildResign extends EasCommand {
await handleDeprecatedEasJsonAsync(projectDir, flags.nonInteractive);
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasBuild]);
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
const easJsonCliConfig: EasJson['cli'] =
(await EasJsonUtils.getCliConfigAsync(easJsonAccessor)) ?? {};
@@ -0,0 +1,120 @@
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
import { AppVersionQuery } from '../../../graphql/queries/AppVersionQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { selectRequestedPlatformAsync, toPlatforms } from '../../../platform';
import { getApplicationIdentifierAsync } from '../../../project/applicationIdentifier';
import {
ensureVersionSourceIsRemoteAsync,
validateAppConfigForRemoteVersionSource,
} from '../../../project/remoteVersionSource';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import { getProfilesAsync } from '../../../utils/profiles';
export default class BuildVersionGetView extends EasCommand {
public static override description = 'get the latest version from EAS servers';
public static override flags = {
platform: Flags.enum({
char: 'p',
options: ['android', 'ios', 'all'],
}),
profile: Flags.string({
char: 'e',
description:
'Name of the build profile from eas.json. Defaults to "production" if defined in eas.json.',
helpValue: 'PROFILE_NAME',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.ProjectDir,
};
public async runAsync(): Promise<void> {
const { flags } = await this.parse(BuildVersionGetView);
if (flags.json) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient },
getDynamicProjectConfigAsync,
projectDir,
} = await this.getContextAsync(BuildVersionGetView, {
nonInteractive: true,
});
if (!flags.platform && flags['non-interactive']) {
throw new Error('"--platform" flag is required in non-interactive mode.');
}
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, {
nonInteractive: flags['non-interactive'],
});
const platforms = toPlatforms(requestedPlatform);
const buildProfiles = await getProfilesAsync({
type: 'build',
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
});
const results: { [key in Platform]?: string } = {};
for (const { profile, platform } of buildProfiles) {
const { exp, projectId } = await getDynamicProjectConfigAsync({
env: profile.env,
});
validateAppConfigForRemoteVersionSource(exp, platform);
const applicationIdentifier = await getApplicationIdentifierAsync({
graphqlClient,
projectDir,
projectId,
exp,
buildProfile: profile,
platform,
});
const remoteVersions = await AppVersionQuery.latestVersionAsync(
graphqlClient,
projectId,
toAppPlatform(platform),
applicationIdentifier
);
if (remoteVersions?.buildVersion) {
results[platform] = remoteVersions?.buildVersion;
}
}
if (flags.json) {
const jsonResults: { versionCode?: string; buildNumber?: string } = {};
if (results.android) {
jsonResults.versionCode = results.android;
}
if (results.ios) {
jsonResults.buildNumber = results.ios;
}
printJsonOnlyOutput(jsonResults);
} else {
if (Object.keys(results).length === 0) {
Log.log('No remote versions are configured for this project.');
return;
}
if (results.android) {
Log.log(`Android versionCode - ${chalk.bold(results.android)}`);
}
if (results.ios) {
Log.log(`iOS buildNumber - ${chalk.bold(results.ios)}`);
}
}
}
}
@@ -22,7 +22,7 @@ import {
import { promptAsync } from '../../../prompts';
export default class BuildVersionSetView extends EasCommand {
static override description = 'Update version of an app.';
static override description = 'update version of an app';
static override flags = {
platform: Flags.enum({
@@ -54,8 +54,8 @@ export default class BuildVersionSetView extends EasCommand {
});
const platform = await selectPlatformAsync(flags.platform);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, { nonInteractive: false });
const profile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
@@ -39,7 +39,7 @@ interface SyncContext<T extends Platform> {
export default class BuildVersionSyncView extends EasCommand {
public static override description =
'Update a version in native code with a value stored on EAS servers';
'update a version in native code with a value stored on EAS servers';
public static override flags = {
platform: Flags.enum({
@@ -71,8 +71,8 @@ export default class BuildVersionSyncView extends EasCommand {
});
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
const easJsonAccessor = new EasJsonAccessor(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await ensureVersionSourceIsRemoteAsync(easJsonAccessor, { nonInteractive: false });
const platforms = toPlatforms(requestedPlatform);
const buildProfiles = await getProfilesAsync({
@@ -1,6 +1,8 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } from '../../channel/queries';
import { BranchMapping, getBranchMapping } from '../../channel/utils';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
@@ -132,13 +134,11 @@ async function startRolloutAsync(
);
}
const logMessage = `️Started a rollout of branch ${chalk.bold(
const logMessage = `Started a rollout of branch ${chalk.bold(branchName)} on channel ${chalk.bold(
channelName!
)}! ${chalk.bold(percent)}% of users will be directed to branch ${chalk.bold(
branchName
)} onto channel ${chalk.bold(channelName!)}! ${chalk.bold(
percent
)}% of users will be directed to branch ${chalk.bold(branchName)}, ${chalk.bold(
100 - percent
)}% to branch ${chalk.bold(oldBranch.name)}.`;
)}, ${chalk.bold(100 - percent)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
@@ -190,7 +190,7 @@ async function editRolloutAsync(
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `️Rollout of branch ${chalk.bold(newBranch.name)} onto channel ${chalk.bold(
const logMessage = `Rollout of branch ${chalk.bold(newBranch.name)} on channel ${chalk.bold(
channelName!
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
percent
@@ -256,11 +256,11 @@ async function endRolloutAsync(
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
[
{
title: `${newBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
title: `${newBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
value: true,
},
{
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
value: false,
},
]
@@ -284,7 +284,7 @@ async function endRolloutAsync(
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `️Rollout on channel ${chalk.bold(
const logMessage = `Rollout on channel ${chalk.bold(
channelName
)} ended. All traffic is now sent to branch ${chalk.bold(
endOnNewBranch ? newBranch.name : oldBranch.name
@@ -294,14 +294,12 @@ async function endRolloutAsync(
}
export default class ChannelRollout extends EasCommand {
static override hidden = true;
static override description = 'Rollout a new branch out to a channel incrementally.';
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = [
{
name: 'channel',
required: true,
description: 'rollout that the channel is on',
description: 'channel on which the rollout should be done',
},
];
@@ -311,7 +309,7 @@ export default class ChannelRollout extends EasCommand {
required: false,
}),
percent: Flags.integer({
description: 'percent of traffic to redirect to the new branch',
description: 'percent of users to send to the new branch',
required: false,
}),
end: Flags.boolean({
@@ -328,7 +326,7 @@ export default class ChannelRollout extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { channel: channelName },
args: { channel: channelNameArg },
flags: {
json: jsonFlag,
end: endFlag,
@@ -349,9 +347,23 @@ export default class ChannelRollout extends EasCommand {
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
let channelName: string = channelNameArg;
if (!channelName) {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel on which to perform a rollout',
paginatedQueryOptions: {
json: jsonFlag,
nonInteractive,
offset: 0,
},
});
channelName = name;
}
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: channelName!,
channelName,
});
if (!channel) {
throw new Error(
@@ -396,7 +408,15 @@ export default class ChannelRollout extends EasCommand {
if (!isRollout) {
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
channelName,
branchName: branchName ?? (await promptForBranchNameAsync(channelName, nonInteractive)),
branchName:
branchName ??
(await promptForBranchNameAsync({
graphqlClient,
projectId,
channelName,
nonInteractive,
json: jsonFlag,
})),
percent,
nonInteractive,
projectId,
@@ -433,18 +453,36 @@ export default class ChannelRollout extends EasCommand {
}
}
async function promptForBranchNameAsync(
channelName: string,
nonInteractive: boolean
): Promise<string> {
async function promptForBranchNameAsync({
graphqlClient,
projectId,
channelName,
nonInteractive,
json,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
channelName: string;
nonInteractive: boolean;
json: boolean;
}): Promise<string> {
if (nonInteractive) {
throw new Error('Must supply branch flag in non-interactive mode');
}
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: `Select a branch to rollout onto ${channelName}`,
validate: value => (value ? true : 'A branch must be specified.'),
const { name: branchName } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: `Which branch would you like roll out on ${channelName}?`,
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json,
nonInteractive,
offset: 0,
},
});
return name;
return branchName;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export default class Config extends EasCommand {
nonInteractive: false,
});
const accessor = new EasJsonAccessor(projectDir);
const accessor = EasJsonAccessor.fromProjectPath(projectDir);
const profileName =
maybeProfile ??
(await selectAsync(
@@ -112,11 +112,17 @@ export default class DeviceDelete extends EasCommand {
const removeAppleSpinner = ora('Disabling device on Apple').start();
try {
const appleValidatedDevices = await Device.getAsync(context);
const appleValidatedDevice = appleValidatedDevices.find(d => d.id === device.id);
const appleValidatedDevice = appleValidatedDevices.find(
d => d.attributes.udid === device.identifier
);
if (appleValidatedDevice) {
await appleValidatedDevice.updateAsync({ status: DeviceStatus.DISABLED });
removeAppleSpinner.succeed('Disabled device on Apple');
} else {
removeAppleSpinner.warn(
'Device not found on Apple Developer Portal. Expo-registered devices will not appear there until they are chosen for an internal distribution build.'
);
}
removeAppleSpinner.succeed('Disabled device on Apple');
} catch (err) {
removeAppleSpinner.fail();
throw err;
@@ -0,0 +1,168 @@
import { Device } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import assert from 'assert';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { PaginatedQueryOptions, getPaginatedQueryOptions } from '../../commandUtils/pagination';
import { AppleDeviceMutation } from '../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import {
AppleDeviceQuery,
AppleDeviceQueryResult,
} from '../../credentials/ios/api/graphql/queries/AppleDeviceQuery';
import { authenticateAsync, getRequestContext } from '../../credentials/ios/appstore/authenticate';
import {
selectAppleDeviceOnAppleTeamAsync,
selectAppleTeamOnAccountAsync,
} from '../../devices/queries';
import formatDevice from '../../devices/utils/formatDevice';
import { AppleDevice, AppleTeamFragment, Maybe } from '../../graphql/generated';
import Log from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class DeviceRename extends EasCommand {
static override description = 'rename a registered device';
static override flags = {
'apple-team-id': Flags.string({ description: 'The Apple team ID on which to find the device' }),
udid: Flags.string({ description: 'The Apple device ID to rename' }),
name: Flags.string({ description: 'The new name for the device' }),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(DeviceRename);
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
let { 'apple-team-id': appleTeamIdentifier, udid, name } = flags;
const {
projectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(DeviceRename, {
nonInteractive: paginatedQueryOptions.nonInteractive,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let appleTeamName;
if (paginatedQueryOptions.json) {
enableJsonOutput();
}
let appleTeam: AppleTeamFragment;
if (!appleTeamIdentifier) {
appleTeam = await selectAppleTeamOnAccountAsync(graphqlClient, {
accountName: account.name,
selectionPromptTitle: `What Apple team would you like to list devices for?`,
paginatedQueryOptions,
});
appleTeamIdentifier = appleTeam.appleTeamIdentifier;
appleTeamName = appleTeam.appleTeamName;
}
assert(appleTeamIdentifier, 'No team identifier is specified');
const chosenDevice = udid
? await AppleDeviceQuery.getByDeviceIdentifierAsync(graphqlClient, account.name, udid)
: await selectAppleDeviceOnAppleTeamAsync(graphqlClient, {
accountName: account.name,
appleTeamIdentifier,
selectionPromptTitle: `Which device would you like to rename?`,
paginatedQueryOptions,
});
const newDeviceName = name ? name : await this.promptForNewDeviceNameAsync(chosenDevice.name);
this.logChosenDevice(chosenDevice, appleTeamName, appleTeamIdentifier, paginatedQueryOptions);
await this.renameDeviceOnExpoAsync(graphqlClient, chosenDevice, newDeviceName!);
await this.renameDeviceOnAppleAsync(chosenDevice, appleTeamIdentifier, newDeviceName!);
}
async promptForNewDeviceNameAsync(
initial: Maybe<string> | undefined
): Promise<string | undefined> {
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'New device name:',
initial: initial ?? undefined,
});
return name;
}
async renameDeviceOnExpoAsync(
graphqlClient: ExpoGraphqlClient,
chosenDevice: AppleDevice | AppleDeviceQueryResult,
newDeviceName: string
): Promise<void> {
const removalSpinner = ora(`Renaming Apple device on Expo`).start();
try {
await AppleDeviceMutation.updateAppleDeviceAsync(graphqlClient, chosenDevice.id, {
name: newDeviceName,
});
removalSpinner.succeed('Renamed Apple device on Expo');
} catch (err) {
removalSpinner.fail();
throw err;
}
}
async renameDeviceOnAppleAsync(
device: AppleDevice | AppleDeviceQueryResult,
appleTeamIdentifier: string,
newDeviceName: string
): Promise<void> {
const ctx = await authenticateAsync({ teamId: appleTeamIdentifier });
const context = getRequestContext(ctx);
Log.addNewLineIfNone();
const removeAppleSpinner = ora('Renaming device on Apple').start();
try {
const appleValidatedDevices = await Device.getAsync(context);
const appleValidatedDevice = appleValidatedDevices.find(
d => d.attributes.udid === device.identifier
);
if (appleValidatedDevice) {
await appleValidatedDevice.updateAsync({ name: newDeviceName });
removeAppleSpinner.succeed('Renamed device on Apple');
} else {
removeAppleSpinner.warn(
'Device not found on Apple Developer Portal. Expo-registered devices will not appear there until they are chosen for an internal distribution build.'
);
}
} catch (err) {
removeAppleSpinner.fail();
throw err;
}
}
logChosenDevice(
device: AppleDevice | AppleDeviceQueryResult,
appleTeamName: Maybe<string> | undefined,
appleTeamIdentifier: string,
{ json }: PaginatedQueryOptions
): void {
if (json) {
printJsonOnlyOutput(device);
} else {
Log.addNewLineIfNone();
Log.log(
formatDevice(device, {
appleTeamName,
appleTeamIdentifier: appleTeamIdentifier!,
})
);
Log.newLine();
}
}
}
@@ -44,7 +44,7 @@ export default class MetadataLint extends EasCommand {
const submitProfiles = await getProfilesAsync({
type: 'submit',
easJsonAccessor: new EasJsonAccessor(projectDir),
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
});
@@ -46,7 +46,7 @@ export default class MetadataPull extends EasCommand {
const submitProfiles = await getProfilesAsync({
type: 'submit',
easJsonAccessor: new EasJsonAccessor(projectDir),
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
});
@@ -44,7 +44,7 @@ export default class MetadataPush extends EasCommand {
const submitProfiles = await getProfilesAsync({
type: 'submit',
easJsonAccessor: new EasJsonAccessor(projectDir),
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
});
+1 -1
View File
@@ -115,7 +115,7 @@ export default class Submit extends EasCommand {
const platforms = toPlatforms(flagsWithPlatform.requestedPlatform);
const submissionProfiles = await getProfilesAsync({
type: 'submit',
easJsonAccessor: new EasJsonAccessor(projectDir),
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms,
profileName: flagsWithPlatform.profile,
});
@@ -1,6 +1,14 @@
import { AppJSONConfig, PackageJSONConfig, Platform, getConfig } from '@expo/config';
import {
AppJSONConfig,
ExpoConfig,
GetConfigOptions,
PackageJSONConfig,
Platform,
getConfig,
} from '@expo/config';
import { Updates } from '@expo/config-plugins';
import { vol } from 'memfs';
import nullthrows from 'nullthrows';
import path from 'path';
import { instance, mock } from 'ts-mockito';
@@ -29,6 +37,7 @@ const updateStub: UpdateFragment = {
platform: 'ios',
gitCommitHash: 'commit',
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
codeSigningInfo: null,
createdAt: '2022-01-01T12:00:00Z',
@@ -58,7 +67,10 @@ jest.mock('../../../project/publish', () => ({
}));
describe(UpdatePublish.name, () => {
afterEach(() => vol.reset());
afterEach(() => {
vol.reset();
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockClear();
});
// Deprecated and split to a new command: update:republish
it('errors with --republish', async () => {
@@ -80,7 +92,7 @@ describe(UpdatePublish.name, () => {
mockTestProject();
await expect(new UpdatePublish(flags, commandOptions).run()).rejects.toThrow(
'Cannot specify both --channel and --branch. Specify either --channel, --branch, or --auto'
'Cannot specify both --channel and --branch. Specify either --channel, --branch, or --auto.'
);
});
@@ -136,14 +148,59 @@ describe(UpdatePublish.name, () => {
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('creates a new update with the public expo config', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
// Add configuration to the project that should not be included in the update
const { appJson } = mockTestProject({
expoConfig: {
hooks: {
postPublish: [
{
file: 'custom-hook.js',
config: { some: 'config' },
},
],
},
},
});
const { platforms, runtimeVersion } = mockTestExport({ platforms: ['ios'] });
// Mock an existing branch, so we don't create a new one
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branchId: 'branch123',
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdatePublish(flags, commandOptions).run();
// Pull the publish data from the mocked publish function
const publishData = jest.mocked(PublishMutation.publishUpdateGroupAsync).mock.calls[0][1][0];
// Pull the Expo config from the publish data
const expoConfig = nullthrows(publishData.updateInfoGroup).ios!.extra.expoClient;
// Ensure the basic properties are present
expect(expoConfig).toHaveProperty('name', appJson.expo.name);
expect(expoConfig).toHaveProperty('slug', appJson.expo.slug);
// Ensure non-public config is not present
expect(expoConfig).not.toHaveProperty('hooks');
});
});
/** Create a new in-memory project, copied from src/commands/update/__tests__/republish.test.ts */
function mockTestProject({
configuredProjectId = '1234',
expoConfig = {},
}: {
configuredProjectId?: string;
} = {}): { projectId: string } {
expoConfig?: Partial<ExpoConfig>;
} = {}): { projectId: string; appJson: AppJSONConfig } {
const packageJSON: PackageJSONConfig = {
name: 'testing123',
version: '0.1.0',
@@ -163,6 +220,7 @@ function mockTestProject({
projectId: configuredProjectId,
},
},
...expoConfig,
},
};
@@ -180,11 +238,27 @@ function mockTestProject({
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
jest
.spyOn(DynamicProjectConfigContextField.prototype, 'getValueAsync')
.mockResolvedValue(async () => ({
exp: mockManifest.exp,
projectDir: projectRoot,
projectId: configuredProjectId,
}));
.mockResolvedValue(async (options?: GetConfigOptions) => {
let exp = { ...mockManifest.exp };
// Fake a limited public config
if (options?.isPublicConfig) {
exp = {
name: mockManifest.exp.name,
version: mockManifest.exp.version,
slug: mockManifest.exp.slug,
sdkVersion: mockManifest.exp.sdkVersion,
owner: mockManifest.exp.owner,
extra: mockManifest.exp.extra,
};
}
return {
exp,
projectDir: projectRoot,
projectId: configuredProjectId,
};
});
jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
actor: jester,
@@ -199,7 +273,7 @@ function mockTestProject({
ownerAccount: jester.accounts[0],
});
return { projectId: configuredProjectId };
return { projectId: configuredProjectId, appJson: appJSON };
}
/** Create a new in-memory export of the project */
@@ -27,6 +27,7 @@ const updateStub: UpdateFragment = {
platform: 'ios',
gitCommitHash: 'commit',
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
codeSigningInfo: null,
createdAt: '2022-01-01T12:00:00Z',
@@ -0,0 +1,257 @@
import {
AppJSONConfig,
ExpoConfig,
GetConfigOptions,
PackageJSONConfig,
getConfig,
} from '@expo/config';
import { Updates } from '@expo/config-plugins';
import { vol } from 'memfs';
import nullthrows from 'nullthrows';
import path from 'path';
import { instance, mock } from 'ts-mockito';
import { ensureBranchExistsAsync } from '../../../branch/queries';
import { DynamicProjectConfigContextField } from '../../../commandUtils/context/DynamicProjectConfigContextField';
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { UpdateFragment } from '../../../graphql/generated';
import { PublishMutation } from '../../../graphql/mutations/PublishMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { getBranchNameFromChannelNameAsync } from '../../../update/getBranchNameFromChannelNameAsync';
import UpdateRollBackToEmbedded from '../roll-back-to-embedded';
const projectRoot = '/test-project';
const commandOptions = { root: projectRoot } as any;
const updateStub: UpdateFragment = {
id: 'update-1234',
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
platform: 'ios',
gitCommitHash: 'commit',
manifestFragment: JSON.stringify({ fake: 'manifest' }),
isRollBackToEmbedded: false,
manifestPermalink: 'https://expo.dev/fake/manifest/link',
codeSigningInfo: null,
createdAt: '2022-01-01T12:00:00Z',
};
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('@expo/config-plugins');
jest.mock('../../../branch/queries');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../update/configure');
jest.mock('../../../update/getBranchNameFromChannelNameAsync');
jest.mock('../../../graphql/mutations/PublishMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../graphql/queries/UpdateQuery');
jest.mock('../../../ora', () => ({
ora: () => ({
start: () => ({ succeed: () => {}, fail: () => {}, stop: () => {} }),
}),
}));
jest.mock('../../../project/publish', () => ({
...jest.requireActual('../../../project/publish'),
buildBundlesAsync: jest.fn(),
collectAssetsAsync: jest.fn(),
resolveInputDirectoryAsync: jest.fn((inputDir = 'dist') => path.join(projectRoot, inputDir)),
uploadAssetsAsync: jest.fn(),
}));
describe(UpdateRollBackToEmbedded.name, () => {
afterEach(() => vol.reset());
it('errors with both --channel and --branch', async () => {
const flags = ['--channel=channel123', '--branch=branch123'];
mockTestProject();
await expect(new UpdateRollBackToEmbedded(flags, commandOptions).run()).rejects.toThrow(
'Cannot specify both --channel and --branch. Specify either --channel, --branch, or --auto.'
);
});
it('creates a roll back to embedded with --non-interactive, --branch, and --message', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
mockTestProject();
const platforms = ['android', 'ios'];
const runtimeVersion = 'exposdk:47.0.0';
jest.mocked(Updates.getRuntimeVersion).mockReturnValue(runtimeVersion);
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branchId: 'branch123',
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdateRollBackToEmbedded(flags, commandOptions).run();
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('creates a roll back to embedded with --non-interactive, --channel, and --message', async () => {
const flags = ['--non-interactive', '--channel=channel123', '--message=abc'];
const { projectId } = mockTestProject();
const platforms = ['android', 'ios'];
const runtimeVersion = 'exposdk:47.0.0';
jest.mocked(Updates.getRuntimeVersion).mockReturnValue(runtimeVersion);
jest.mocked(getBranchNameFromChannelNameAsync).mockResolvedValue('branchFromChannel');
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branchId: 'branch123',
createdBranch: false,
});
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockResolvedValue(
platforms.map(platform => ({
...updateStub,
runtimeVersion,
platform,
}))
);
await new UpdateRollBackToEmbedded(flags, commandOptions).run();
expect(ensureBranchExistsAsync).toHaveBeenCalledWith(
expect.any(Object), // graphql client
{
appId: projectId,
branchName: 'branchFromChannel',
}
);
expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled();
});
it('creates a roll back to embedded with the public expo config', async () => {
const flags = ['--non-interactive', '--branch=branch123', '--message=abc'];
// Add configuration to the project that should not be included in the update
mockTestProject({
expoConfig: {
hooks: {
postPublish: [
{
file: 'custom-hook.js',
config: { some: 'config' },
},
],
},
},
});
const platforms = ['ios'];
const runtimeVersion = 'exposdk:47.0.0';
jest.mocked(Updates.getRuntimeVersion).mockReturnValue(runtimeVersion);
// Mock an existing branch, so we don't create a new one
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
branchId: 'branch123',
createdBranch: false,
});
jest
.mocked(PublishMutation.publishUpdateGroupAsync)
.mockResolvedValue(platforms.map(platform => ({ ...updateStub, platform, runtimeVersion })));
await new UpdateRollBackToEmbedded(flags, commandOptions).run();
// Pull the publish data from the mocked publish function
const publishData = jest.mocked(PublishMutation.publishUpdateGroupAsync).mock.calls[0][1][0];
expect(nullthrows(publishData.rollBackToEmbeddedInfoGroup).ios).toBe(true);
});
});
/** Create a new in-memory project, based on src/commands/project/__tests__/init.test.ts */
function mockTestProject({
configuredProjectId = '1234',
expoConfig = {},
}: {
configuredProjectId?: string;
expoConfig?: Partial<ExpoConfig>;
} = {}): { projectId: string; appJson: AppJSONConfig } {
const packageJSON: PackageJSONConfig = {
name: 'testing123',
version: '0.1.0',
description: 'fake description',
main: 'index.js',
};
const appJSON: AppJSONConfig = {
expo: {
name: 'testing 123',
version: '0.1.0',
slug: 'testing-123',
sdkVersion: '33.0.0',
owner: jester.accounts[0].name,
extra: {
eas: {
projectId: configuredProjectId,
},
},
...expoConfig,
},
};
vol.fromJSON(
{
'package.json': JSON.stringify(packageJSON),
'app.json': JSON.stringify(appJSON),
},
projectRoot
);
const mockManifest = { exp: appJSON.expo };
const graphqlClient = instance(mock<ExpoGraphqlClient>({}));
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
jest
.spyOn(DynamicProjectConfigContextField.prototype, 'getValueAsync')
.mockResolvedValue(async (options?: GetConfigOptions) => {
let exp = { ...mockManifest.exp };
// Fake a limited public config
if (options?.isPublicConfig) {
exp = {
name: mockManifest.exp.name,
version: mockManifest.exp.version,
slug: mockManifest.exp.slug,
sdkVersion: mockManifest.exp.sdkVersion,
owner: mockManifest.exp.owner,
extra: mockManifest.exp.extra,
};
}
return {
exp,
projectDir: projectRoot,
projectId: configuredProjectId,
};
});
jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
actor: jester,
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
graphqlClient,
});
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
return { projectId: configuredProjectId, appJson: appJSON };
}
+56 -166
View File
@@ -1,13 +1,9 @@
import { ExpoConfig, Platform as PublishPlatform } from '@expo/config';
import { Updates } from '@expo/config-plugins';
import { Platform, Workflow } from '@expo/eas-build-job';
import { Platform as PublishPlatform } from '@expo/config';
import { Errors, Flags } from '@oclif/core';
import assert from 'assert';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { ensureBranchExistsAsync, selectBranchOnAppAsync } from '../../branch/queries';
import { getDefaultBranchNameAsync } from '../../branch/utils';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { getUpdateGroupUrl } from '../../build/utils/url';
import { ensureChannelExistsAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
@@ -23,32 +19,31 @@ import {
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import Log, { learnMore, link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform, requestedPlatformDisplayNames } from '../../platform';
import { RequestedPlatform } from '../../platform';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import {
ExpoCLIExportPlatformFlag,
buildBundlesAsync,
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
defaultPublishPlatforms,
filterExportedPlatformsByFlag,
getBranchNameForCommandAsync,
getRequestedPlatform,
getRuntimeToPlatformMappingFromRuntimeVersions,
getRuntimeVersionObjectAsync,
getUpdateMessageForCommandAsync,
isUploadedAssetCountAboveWarningThreshold,
resolveInputDirectoryAsync,
uploadAssetsAsync,
} from '../../project/publish';
import { resolveWorkflowAsync } from '../../project/workflow';
import { promptAsync } from '../../prompts';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { getBranchNameFromChannelNameAsync } from '../../update/getBranchNameFromChannelNameAsync';
import {
formatUpdateMessage,
getUpdateGroupJsonInfo,
truncateString as truncateUpdateMessage,
} from '../../update/utils';
import { getUpdateGroupJsonInfo } from '../../update/utils';
import {
checkManifestBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
getManifestBodyAsync,
signManifestBody,
signBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
@@ -56,8 +51,6 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
import { getVcsClient } from '../../vcs';
export const defaultPublishPlatforms: Partial<PublishPlatform>[] = ['android', 'ios'];
type RawUpdateFlags = {
auto: boolean;
branch?: string;
@@ -66,6 +59,7 @@ type RawUpdateFlags = {
platform: string;
'input-dir': string;
'skip-bundler': boolean;
'clear-cache': boolean;
'private-key-path'?: string;
'non-interactive': boolean;
json: boolean;
@@ -83,26 +77,12 @@ type UpdateFlags = {
updateMessage?: string;
inputDir: string;
skipBundler: boolean;
clearCache: boolean;
privateKeyPath?: string;
json: boolean;
nonInteractive: boolean;
};
function getRequestedPlatform(platform: ExpoCLIExportPlatformFlag): RequestedPlatform | null {
switch (platform) {
case 'android':
return RequestedPlatform.Android;
case 'ios':
return RequestedPlatform.Ios;
case 'web':
return null;
case 'all':
return RequestedPlatform.All;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
export default class UpdatePublish extends EasCommand {
static override description = 'publish an update group';
@@ -136,6 +116,10 @@ export default class UpdatePublish extends EasCommand {
description: `Skip running Expo CLI to bundle the app before publishing`,
default: false,
}),
'clear-cache': Flags.boolean({
description: `Clear the bundler cache before publishing`,
default: false,
}),
platform: Flags.enum({
char: 'p',
options: [
@@ -166,18 +150,19 @@ export default class UpdatePublish extends EasCommand {
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(UpdatePublish);
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
let {
const {
auto: autoFlag,
platform: platformFlag,
channelName,
updateMessage,
channelName: channelNameArg,
updateMessage: updateMessageArg,
inputDir,
skipBundler,
clearCache,
privateKeyPath,
json: jsonFlag,
nonInteractive,
branchName: branchNameArg,
} = this.sanitizeFlags(rawFlags);
let branchName = this.sanitizeFlags(rawFlags).branchName;
const {
getDynamicProjectConfigAsync,
@@ -198,10 +183,6 @@ export default class UpdatePublish extends EasCommand {
isPublicConfig: true,
});
const { exp: expPrivate } = await getDynamicProjectConfigAsync({
isPublicConfig: false,
});
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
await ensureEASUpdateIsConfiguredAsync(graphqlClient, {
@@ -211,81 +192,34 @@ export default class UpdatePublish extends EasCommand {
projectId,
});
const { exp } = await getDynamicProjectConfigAsync({});
const { exp } = await getDynamicProjectConfigAsync({ isPublicConfig: true });
const { exp: expPrivate } = await getDynamicProjectConfigAsync({
isPublicConfig: false,
});
const codeSigningInfo = await getCodeSigningInfoAsync(expPrivate, privateKeyPath);
let realizedPlatforms: PublishPlatform[] = [];
const branchName = await getBranchNameForCommandAsync({
graphqlClient,
projectId,
channelNameArg,
branchNameArg,
autoFlag,
nonInteractive,
paginatedQueryOptions,
});
if (channelName && branchName) {
throw new Error(
'Cannot specify both --channel and --branch. Specify either --channel, --branch, or --auto'
);
}
if (channelName) {
branchName = await getBranchNameFromChannelNameAsync(graphqlClient, projectId, channelName);
}
if (!branchName) {
if (autoFlag) {
branchName = await getDefaultBranchNameAsync();
} else if (nonInteractive) {
throw new Error('Must supply --channel, --branch or --auto when in non-interactive mode');
} else {
try {
const branch = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: `Which branch would you like to publish on?`,
displayTextForListItem: updateBranch => ({
title: `${updateBranch.name} ${chalk.grey(
`- current update: ${formatUpdateMessage(updateBranch.updates[0])}`
)}`,
}),
paginatedQueryOptions,
});
branchName = branch.name;
} catch {
// unable to select a branch (network error or no branches for project)
({ name: branchName } = await promptAsync({
type: 'text',
name: 'name',
message: 'No branches found. Provide a branch name:',
initial: await getDefaultBranchNameAsync(),
validate: value => (value ? true : 'Branch name may not be empty.'),
}));
}
assert(branchName, 'Branch name must be specified.');
}
}
if (!updateMessage && autoFlag) {
updateMessage = (await getVcsClient().getLastCommitMessageAsync())?.trim();
}
if (!updateMessage) {
if (nonInteractive) {
throw new Error('Must supply --message or use --auto when in non-interactive mode');
}
const validationMessage = 'publish message may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
({ updateMessage } = await promptAsync({
type: 'text',
name: 'updateMessage',
message: `Provide an update message.`,
initial: (await getVcsClient().getLastCommitMessageAsync())?.trim(),
validate: (value: any) => (value ? true : validationMessage),
}));
}
const updateMessage = await getUpdateMessageForCommandAsync({
updateMessageArg,
autoFlag,
nonInteractive,
jsonFlag,
});
// build bundle and upload assets for a new publish
if (!skipBundler) {
const bundleSpinner = ora().start('Exporting...');
try {
await buildBundlesAsync({ projectDir, inputDir, exp, platformFlag });
await buildBundlesAsync({ projectDir, inputDir, exp, platformFlag, clearCache });
bundleSpinner.succeed('Exported bundle(s)');
} catch (e) {
bundleSpinner.fail('Export failed');
@@ -300,6 +234,7 @@ export default class UpdatePublish extends EasCommand {
let unsortedUpdateInfoGroups: UpdateInfoGroup = {};
let uploadedAssetCount = 0;
let assetLimitPerUpdateGroup = 0;
let realizedPlatforms: PublishPlatform[] = [];
try {
const collectedAssets = await collectAssetsAsync(distRoot);
@@ -351,22 +286,9 @@ export default class UpdatePublish extends EasCommand {
throw e;
}
const truncatedMessage = truncateUpdateMessage(updateMessage!, 1024);
if (truncatedMessage !== updateMessage) {
Log.warn('Update message exceeds the allowed 1024 character limit. Truncating message...');
}
const runtimeVersions = await getRuntimeVersionObjectAsync(exp, realizedPlatforms, projectDir);
const runtimeToPlatformMapping: { runtimeVersion: string; platforms: string[] }[] = [];
for (const runtime of runtimeVersions) {
const platforms = runtimeVersions
.filter(({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion)
.map(({ platform }) => platform);
if (!runtimeToPlatformMapping.find(item => item.runtimeVersion === runtime.runtimeVersion)) {
runtimeToPlatformMapping.push({ runtimeVersion: runtime.runtimeVersion, platforms });
}
}
const runtimeToPlatformMapping =
getRuntimeToPlatformMappingFromRuntimeVersions(runtimeVersions);
const { branchId, createdBranch } = await ensureBranchExistsAsync(graphqlClient, {
appId: projectId,
@@ -401,7 +323,7 @@ export default class UpdatePublish extends EasCommand {
branchId,
updateInfoGroup: localUpdateInfoGroup,
runtimeVersion,
message: truncatedMessage,
message: updateMessage,
gitCommitHash,
isGitWorkingTreeDirty,
awaitingCodeSigningInfo: !!codeSigningInfo,
@@ -418,7 +340,10 @@ export default class UpdatePublish extends EasCommand {
const updatesTemp = [...newUpdates];
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
const newUpdates = updatesTemp.splice(0, Object.keys(updateGroup.updateInfoGroup).length);
const newUpdates = updatesTemp.splice(
0,
Object.keys(nullthrows(updateGroup.updateInfoGroup)).length
);
return {
updateGroup,
newUpdates,
@@ -438,11 +363,13 @@ export default class UpdatePublish extends EasCommand {
checkManifestBodyAgainstUpdateInfoGroup(
manifestBody,
nullthrows(
updateGroup.updateInfoGroup[newUpdate.platform as keyof UpdateInfoGroup]
nullthrows(updateGroup.updateInfoGroup)[
newUpdate.platform as keyof UpdateInfoGroup
]
)
);
const manifestSignature = signManifestBody(manifestBody, codeSigningInfo);
const manifestSignature = signBody(manifestBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
@@ -504,7 +431,7 @@ export default class UpdatePublish extends EasCommand {
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: truncatedMessage },
{ label: 'Message', value: updateMessage },
...(gitCommitHash
? [
{
@@ -568,6 +495,7 @@ export default class UpdatePublish extends EasCommand {
updateMessage,
inputDir: flags['input-dir'],
skipBundler: flags['skip-bundler'],
clearCache: flags['clear-cache'],
platform: flags.platform as RequestedPlatform,
privateKeyPath: flags['private-key-path'],
nonInteractive,
@@ -575,41 +503,3 @@ export default class UpdatePublish extends EasCommand {
};
}
}
/** Get runtime versions grouped by platform. Runtime version is always `null` on web where the platform is always backwards compatible. */
async function getRuntimeVersionObjectAsync(
exp: ExpoConfig,
platforms: PublishPlatform[],
projectDir: string
): Promise<{ platform: string; runtimeVersion: string }[]> {
for (const platform of platforms) {
if (platform === 'web') {
continue;
}
const isPolicy = typeof (exp[platform]?.runtimeVersion ?? exp.runtimeVersion) === 'object';
if (isPolicy) {
const isManaged =
(await resolveWorkflowAsync(projectDir, platform as Platform)) === Workflow.MANAGED;
if (!isManaged) {
throw new Error(
'Runtime version policies are only supported in the managed workflow. In the bare workflow, runtime version needs to be set manually.'
);
}
}
}
return [...new Set(platforms)].map(platform => {
if (platform === 'web') {
return { platform: 'web', runtimeVersion: 'UNVERSIONED' };
}
return {
platform,
runtimeVersion: nullthrows(
Updates.getRuntimeVersion(exp, platform),
`Unable to determine runtime version for ${
requestedPlatformDisplayNames[platform]
}. ${learnMore('https://docs.expo.dev/eas-update/runtime-versions/')}`
),
};
});
}
@@ -56,7 +56,7 @@ type UpdateToRepublish = {
>;
export default class UpdateRepublish extends EasCommand {
static override description = 'rollback to an existing update';
static override description = 'roll back to an existing update';
static override flags = {
channel: Flags.string({
@@ -0,0 +1,389 @@
import { Platform as PublishPlatform } from '@expo/config';
import { Errors, Flags } from '@oclif/core';
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { ensureBranchExistsAsync } from '../../branch/queries';
import { getUpdateGroupUrl } from '../../build/utils/url';
import { ensureChannelExistsAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
import fetch from '../../fetch';
import {
PublishUpdateGroupInput,
StatuspageServiceName,
UpdatePublishMutation,
} from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { RequestedPlatform } from '../../platform';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import {
ExpoCLIExportPlatformFlag,
defaultPublishPlatforms,
getBranchNameForCommandAsync,
getRequestedPlatform,
getRuntimeToPlatformMappingFromRuntimeVersions,
getRuntimeVersionObjectAsync,
getUpdateMessageForCommandAsync,
} from '../../project/publish';
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
import { getUpdateGroupJsonInfo } from '../../update/utils';
import {
CodeSigningInfo,
checkDirectiveBodyAgainstUpdateInfoGroup,
getCodeSigningInfoAsync,
getDirectiveBodyAsync,
signBody,
} from '../../utils/code-signing';
import uniqBy from '../../utils/expodash/uniqBy';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
import { getVcsClient } from '../../vcs';
type RawUpdateFlags = {
auto: boolean;
branch?: string;
channel?: string;
message?: string;
platform: string;
'private-key-path'?: string;
'non-interactive': boolean;
json: boolean;
};
type UpdateFlags = {
auto: boolean;
platform: ExpoCLIExportPlatformFlag;
branchName?: string;
channelName?: string;
updateMessage?: string;
privateKeyPath?: string;
json: boolean;
nonInteractive: boolean;
};
export default class UpdateRollBackToEmbedded extends EasCommand {
static override hidden = true; // until we launch
static override description = 'roll back to the embedded update';
static override flags = {
branch: Flags.string({
description: 'Branch to publish the rollback to embedded update group on',
required: false,
}),
channel: Flags.string({
description: 'Channel that the published rollback to embedded update should affect',
required: false,
}),
message: Flags.string({
description: 'A short message describing the rollback to embedded update',
required: false,
}),
platform: Flags.enum({
char: 'p',
options: [...defaultPublishPlatforms, 'all'],
default: 'all',
required: false,
}),
auto: Flags.boolean({
description:
'Use the current git branch and commit message for the EAS branch and update message',
default: false,
}),
'private-key-path': Flags.string({
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.DynamicProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags: rawFlags } = await this.parse(UpdateRollBackToEmbedded);
const paginatedQueryOptions = getPaginatedQueryOptions(rawFlags);
const {
auto: autoFlag,
platform: platformFlag,
channelName: channelNameArg,
updateMessage: updateMessageArg,
privateKeyPath,
json: jsonFlag,
nonInteractive,
branchName: branchNameArg,
} = this.sanitizeFlags(rawFlags);
const {
getDynamicProjectConfigAsync,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateRollBackToEmbedded, {
nonInteractive,
});
if (jsonFlag) {
enableJsonOutput();
}
const {
exp: expPossiblyWithoutEasUpdateConfigured,
projectId,
projectDir,
} = await getDynamicProjectConfigAsync({
isPublicConfig: true,
});
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
await ensureEASUpdateIsConfiguredAsync(graphqlClient, {
exp: expPossiblyWithoutEasUpdateConfigured,
platform: getRequestedPlatform(platformFlag),
projectDir,
projectId,
});
const { exp } = await getDynamicProjectConfigAsync({ isPublicConfig: true });
const { exp: expPrivate } = await getDynamicProjectConfigAsync({
isPublicConfig: false,
});
const codeSigningInfo = await getCodeSigningInfoAsync(expPrivate, privateKeyPath);
const branchName = await getBranchNameForCommandAsync({
graphqlClient,
projectId,
channelNameArg,
branchNameArg,
autoFlag,
nonInteractive,
paginatedQueryOptions,
});
const updateMessage = await getUpdateMessageForCommandAsync({
updateMessageArg,
autoFlag,
nonInteractive,
jsonFlag,
});
const realizedPlatforms: PublishPlatform[] =
platformFlag === 'all' ? defaultPublishPlatforms : [platformFlag];
const { branchId, createdBranch } = await ensureBranchExistsAsync(graphqlClient, {
appId: projectId,
branchName,
});
if (createdBranch) {
await ensureChannelExistsAsync(graphqlClient, {
appId: projectId,
branchId,
channelName: branchName,
});
}
Log.withTick(`Channel: ${chalk.bold(branchName)} pointed at branch: ${chalk.bold(branchName)}`);
const vcsClient = getVcsClient();
const gitCommitHash = await vcsClient.getCommitHashAsync();
const isGitWorkingTreeDirty = await vcsClient.hasUncommittedChangesAsync();
const runtimeVersions = await getRuntimeVersionObjectAsync(exp, realizedPlatforms, projectDir);
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
const publishSpinner = ora('Publishing...').start();
try {
newUpdates = await this.publishRollbacksAsync({
graphqlClient,
isGitWorkingTreeDirty,
gitCommitHash,
updateMessage,
branchId,
codeSigningInfo,
runtimeVersions,
realizedPlatforms,
});
publishSpinner.succeed('Published!');
} catch (e) {
publishSpinner.fail('Failed to publish updates');
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateGroupJsonInfo(newUpdates));
} else {
if (new Set(newUpdates.map(update => update.group)).size > 1) {
Log.addNewLineIfNone();
Log.log(
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
);
}
Log.addNewLineIfNone();
for (const runtime of uniqBy(runtimeVersions, version => version.runtimeVersion)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
);
}
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
update => update.platform === 'android'
);
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
const projectName = exp.slug;
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
const updateGroupLink = link(updateGroupUrl, { dim: false });
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: runtime.runtimeVersion },
{ label: 'Platform', value: platforms.join(', ') },
{ label: 'Update group ID', value: updateGroupId },
...(newAndroidUpdate
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
: []),
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
{ label: 'Message', value: updateMessage },
...(gitCommitHash
? [
{
label: 'Commit',
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
},
]
: []),
{ label: 'Website link', value: updateGroupLink },
])
);
Log.addNewLineIfNone();
}
}
}
private async publishRollbacksAsync({
graphqlClient,
isGitWorkingTreeDirty,
gitCommitHash,
updateMessage,
branchId,
codeSigningInfo,
runtimeVersions,
realizedPlatforms,
}: {
graphqlClient: ExpoGraphqlClient;
isGitWorkingTreeDirty: boolean | undefined;
gitCommitHash: string | undefined;
updateMessage: string;
branchId: string;
codeSigningInfo: CodeSigningInfo | undefined;
runtimeVersions: { platform: string; runtimeVersion: string }[];
realizedPlatforms: PublishPlatform[];
}): Promise<UpdatePublishMutation['updateBranch']['publishUpdateGroups']> {
const runtimeToPlatformMapping =
getRuntimeToPlatformMappingFromRuntimeVersions(runtimeVersions);
const rollbackInfoGroups = Object.fromEntries(
realizedPlatforms.map(platform => [platform, true])
);
// Sort the updates into different groups based on their platform specific runtime versions
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformMapping.map(
({ runtimeVersion, platforms }) => {
const localRollbackInfoGroup = Object.fromEntries(
platforms.map(platform => [platform, rollbackInfoGroups[platform]])
);
return {
branchId,
rollBackToEmbeddedInfoGroup: localRollbackInfoGroup,
runtimeVersion,
message: updateMessage,
gitCommitHash,
isGitWorkingTreeDirty,
awaitingCodeSigningInfo: !!codeSigningInfo,
};
}
);
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
if (codeSigningInfo) {
Log.log('🔒 Signing roll back');
const updatesTemp = [...newUpdates];
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
const newUpdates = updatesTemp.splice(
0,
Object.keys(nullthrows(updateGroup.rollBackToEmbeddedInfoGroup)).length
);
return {
updateGroup,
newUpdates,
};
});
await Promise.all(
updateGroupsAndTheirUpdates.map(async ({ newUpdates }) => {
await Promise.all(
newUpdates.map(async newUpdate => {
const response = await fetch(newUpdate.manifestPermalink, {
method: 'GET',
headers: { accept: 'multipart/mixed' },
});
const directiveBody = nullthrows(await getDirectiveBodyAsync(response));
checkDirectiveBodyAgainstUpdateInfoGroup(directiveBody);
const directiveSignature = signBody(directiveBody, codeSigningInfo);
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
alg: codeSigningInfo.codeSigningMetadata.alg,
keyid: codeSigningInfo.codeSigningMetadata.keyid,
sig: directiveSignature,
});
})
);
})
);
}
return newUpdates;
}
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
const nonInteractive = flags['non-interactive'] ?? false;
const { auto, branch: branchName, channel: channelName, message: updateMessage } = flags;
if (nonInteractive && !auto && !(updateMessage && (branchName || channelName))) {
Errors.error(
'--branch and --message, or --channel and --message are required when updating in non-interactive mode unless --auto is specified',
{ exit: 1 }
);
}
return {
auto,
branchName,
channelName,
updateMessage,
platform: flags.platform as RequestedPlatform,
privateKeyPath: flags['private-key-path'],
nonInteractive,
json: flags.json ?? false,
};
}
}
@@ -128,7 +128,7 @@ export default class IosCredentialsProvider {
private async disablePushNotificationsSetupInEasJsonAsync(
ctx: CredentialsContext
): Promise<void> {
const easJsonAccessor = new EasJsonAccessor(ctx.projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(ctx.projectDir);
await easJsonAccessor.readRawJsonAsync();
easJsonAccessor.patch(easJsonRawObject => {
easJsonRawObject.cli = {
@@ -13,6 +13,7 @@ export async function chooseDevicesAsync(
const { devices } = await promptAsync({
type: 'multiselect',
name: 'devices',
selectionFormat: '<num> devices selected',
message: 'Select devices for the ad hoc build:',
hint: '- Space to select. Return to submit',
choices: allDevices.map(device => ({
@@ -31,10 +31,10 @@ export enum SetupAscApiKeyChoice {
export class SetUpAscApiKey {
public choices: { title: string; value: string }[] = [
{
title: '[Choose an existing ASC API Key]',
title: '[Choose an existing key]',
value: SetupAscApiKeyChoice.USE_EXISTING,
},
{ title: '[Add a new ASC API Key]', value: SetupAscApiKeyChoice.GENERATE },
{ title: '[Add a new key]', value: SetupAscApiKeyChoice.GENERATE },
];
constructor(private app: AppLookupParams, private purpose: AppStoreApiKeyPurpose) {}
@@ -126,7 +126,7 @@ export class SetUpAscApiKey {
const result = await promptAsync({
type: 'select',
name: 'choice',
message: 'Select what to use for your project:',
message: 'Select the App Store Connect Api Key to use for your project:',
choices,
});
choice = result.choice;
@@ -1,70 +0,0 @@
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import wrapAnsi from 'wrap-ansi';
import { CommonIosAppCredentialsFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
import { UnsupportedCredentialsChoiceError } from '../../errors';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppStoreApiKeyPurpose } from './AscApiKeyUtils';
import { SetUpAscApiKey } from './SetUpAscApiKey';
export const PROMPT_FOR_APP_SPECIFIC_PASSWORD = 'PROMPT_FOR_APP_SPECIFIC_PASSWORD';
export class SetUpSubmissionCredentials {
private setupAscApiKeyAction;
constructor(app: AppLookupParams) {
this.setupAscApiKeyAction = new SetUpAscApiKey(app, AppStoreApiKeyPurpose.SUBMISSION_SERVICE);
// Add this unrelated choice to ASC API Key setup for legacy purposes -- we will deprecate it soon
this.setupAscApiKeyAction.choices = this.setupAscApiKeyAction.choices.concat({
title: '[Enter an App Specific Password]',
value: PROMPT_FOR_APP_SPECIFIC_PASSWORD,
});
}
public async runAsync(
ctx: CredentialsContext
): Promise<CommonIosAppCredentialsFragment | string> {
try {
const iosAppCredentials = await this.setupAscApiKeyAction.runAsync(ctx);
const { keyIdentifier, name } = nullthrows(
iosAppCredentials.appStoreConnectApiKeyForSubmissions,
'ASC API Key must be defined for EAS Submit'
);
Log.log(`Using API Key ID: ${keyIdentifier}${name ? ` (${name})` : ''}`);
return iosAppCredentials;
} catch (e) {
if (e instanceof UnsupportedCredentialsChoiceError) {
if (e.choice === PROMPT_FOR_APP_SPECIFIC_PASSWORD) {
return await this.promptForAppSpecificPasswordAsync();
}
}
throw e;
}
}
async promptForAppSpecificPasswordAsync(): Promise<string> {
Log.addNewLineIfNone();
Log.log(`Enter your Apple app-specific password.`);
Log.log(learnMore('https://expo.fyi/apple-app-specific-password'));
Log.warn(
wrapAnsi(
`This option will be deprecated soon. You will still be able to provide your password with the ${chalk.bold(
'EXPO_APPLE_APP_SPECIFIC_PASSWORD'
)} environment variable.`,
process.stdout.columns || 80
)
);
const { appSpecificPassword } = await promptAsync({
name: 'appSpecificPassword',
message: 'Your Apple app-specific password:',
type: 'password',
validate: (val: string) => val !== '' || 'Apple app-specific password cannot be empty!',
});
return appSpecificPassword;
}
}
@@ -1,70 +0,0 @@
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { findApplicationTarget } from '../../../../project/ios/target';
import { confirmAsync, promptAsync } from '../../../../prompts';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import {
getNewIosApiMock,
testCommonIosAppCredentialsFragment,
testTargets,
} from '../../../__tests__/fixtures-ios';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpAscApiKey } from '../SetUpAscApiKey';
import {
PROMPT_FOR_APP_SPECIFIC_PASSWORD,
SetUpSubmissionCredentials,
} from '../SetUpSubmissionCredentials';
jest.mock('../../../../prompts');
jest.mocked(confirmAsync).mockImplementation(async () => true);
jest.mock('../../../../graphql/queries/AppQuery');
describe(SetUpSubmissionCredentials, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('allows user to enter an App Specific Password', async () => {
jest
.mocked(promptAsync)
.mockImplementationOnce(async () => ({
choice: PROMPT_FOR_APP_SPECIFIC_PASSWORD,
}))
.mockImplementationOnce(async () => ({
appSpecificPassword: 'super secret',
}));
const ctx = createCtxMock({
nonInteractive: false,
ios: {
...getNewIosApiMock(),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupAscApiKeyAction = new SetUpSubmissionCredentials(appLookupParams);
const asp = await setupAscApiKeyAction.runAsync(ctx);
// prompt to choose ASP, then prompt to input ASP
expect(jest.mocked(promptAsync).mock.calls.length).toBe(2);
expect(asp).toBe('super secret');
});
it('returns an ASC API key', async () => {
try {
jest
.spyOn(SetUpAscApiKey.prototype, 'runAsync')
.mockImplementation(async _ctx => testCommonIosAppCredentialsFragment);
const ctx = createCtxMock();
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupAscApiKeyAction = new SetUpSubmissionCredentials(appLookupParams);
const asc = await setupAscApiKeyAction.runAsync(ctx);
expect(asc).toEqual(testCommonIosAppCredentialsFragment);
} finally {
jest.restoreAllMocks();
}
});
});
@@ -6,8 +6,10 @@ import { withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AppleDeviceFragment,
AppleDeviceInput,
AppleDeviceUpdateInput,
CreateAppleDeviceMutation,
DeleteAppleDeviceResult,
UpdateAppleDeviceMutation,
} from '../../../../../graphql/generated';
import { AppleDeviceFragmentNode } from '../../../../../graphql/types/credentials/AppleDevice';
@@ -67,4 +69,35 @@ export const AppleDeviceMutation = {
);
return data.id;
},
async updateAppleDeviceAsync(
graphqlClient: ExpoGraphqlClient,
id: string,
appleDeviceUpdateInput: AppleDeviceUpdateInput
): Promise<AppleDeviceFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateAppleDeviceMutation>(
gql`
mutation UpdateAppleDeviceMutation(
$id: ID!
$appleDeviceUpdateInput: AppleDeviceUpdateInput!
) {
appleDevice {
updateAppleDevice(id: $id, appleDeviceUpdateInput: $appleDeviceUpdateInput) {
id
...AppleDeviceFragment
}
}
}
${print(AppleDeviceFragmentNode)}
`,
{
id,
appleDeviceUpdateInput,
}
)
.toPromise()
);
return data.appleDevice.updateAppleDevice;
},
};
@@ -169,4 +169,8 @@ describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
})
).rejects.toThrow(/is not available. Enter a different string/);
});
it(`ignores capabilities from "/ios" folder when gitignored`, async () => {
//
});
});
@@ -8,7 +8,7 @@ export class SelectBuildProfileFromEasJson<T extends Platform> {
private easJsonAccessor: EasJsonAccessor;
constructor(projectDir: string, private platform: T) {
this.easJsonAccessor = new EasJsonAccessor(projectDir);
this.easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
}
async runAsync(): Promise<BuildProfile<T>> {
@@ -81,7 +81,9 @@ async function collectDeviceDataAsync(
Log.newLine();
Log.log(
`We are going to register the following device in our database.
This will ${chalk.bold('not')} register the device on the Apple Developer Portal yet.`
This device will ${chalk.bold(
'not'
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
);
Log.newLine();
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
+53
View File
@@ -0,0 +1,53 @@
import { Choice, PromptObject } from 'prompts';
import { MultiselectPrompt } from 'prompts/lib/elements';
const noop = (): void => {};
export type Question<T extends string = string> = PromptObject<T> & {
selectionFormat?: string;
};
/**
* Customized multiselect prompt.
*
* Additional parameters:
*
* @param selectionFormat
* String indicating number of selected options. Should contain `<num>` substring.
*
* Example:
* 'Selected <num> devices'
*
* Short format is used when more than one option is selected.
*
**/
export default class EasMultiselect extends MultiselectPrompt {
constructor(opts: Question) {
super(opts);
this.selectionFormat = opts.selectionFormat;
}
override renderDoneOrInstructions(): string {
if (this.done && this.selectionFormat && this.value) {
const selectedOptionsCount = this.value.filter(e => e.selected).length;
if (selectedOptionsCount > 1) {
return this.selectionFormat.replace('<num>', selectedOptionsCount.toString());
}
}
return super.renderDoneOrInstructions();
}
}
export const easMultiselect = (args: Question): Promise<Choice[]> => {
const toSelected = (items: Choice[]): Choice[] =>
items.filter(item => item.selected).map(item => item.value);
return new Promise((res, rej) => {
const p = new EasMultiselect(args);
const onAbort = toSelected || noop;
const onSubmit = toSelected || noop;
p.on('submit', x => res(onSubmit(x)));
p.on('abort', x => rej(onAbort(x)));
});
};
+388 -101
View File
File diff suppressed because it is too large. Load diff
@@ -55,6 +55,7 @@ export const BuildFragmentNode = gql`
message
completedAt
resourceClass
expirationDate
}
`;
@@ -9,6 +9,7 @@ export const UpdateFragmentNode = gql`
runtimeVersion
platform
manifestFragment
isRollBackToEmbedded
manifestPermalink
gitCommitHash
actor {
+4 -1
View File
@@ -1,3 +1,4 @@
import { boolish } from 'getenv';
// eslint-disable-next-line
import oraReal, { Options, Ora } from 'ora';
@@ -14,6 +15,8 @@ const warnReal = console.warn;
// eslint-disable-next-line no-console
const errorReal = console.error;
const isCi = boolish('CI', false);
/**
* A custom ora spinner that sends the stream to stdout in CI, or non-TTY, instead of stderr (the default).
*
@@ -22,7 +25,7 @@ const errorReal = console.error;
*/
export function ora(options?: Options | string): Ora {
const inputOptions = typeof options === 'string' ? { text: options } : options ?? {};
const disabled = Log.isDebug || !process.stdin.isTTY;
const disabled = Log.isDebug || !process.stdin.isTTY || isCi;
const spinner = oraReal({
// Ensure our non-interactive mode emulates CI mode.
isEnabled: !disabled,
+1 -1
View File
@@ -9,7 +9,7 @@ export const appPlatformDisplayNames: Record<AppPlatform, string> = {
};
export const appPlatformEmojis = {
[AppPlatform.Ios]: '🍎',
[AppPlatform.Ios]: '🍏',
[AppPlatform.Android]: '🤖',
};
@@ -0,0 +1,17 @@
import { ExpoConfig } from '@expo/config';
import { BuildProfile, Platform } from '@expo/eas-json';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
async function defaultGetApplicationIdentifierAsync(_opts: {
graphqlClient: ExpoGraphqlClient;
projectDir: string;
projectId: string;
exp: ExpoConfig;
buildProfile: BuildProfile;
platform: Platform;
}): Promise<string> {
return 'eas.test.com';
}
export const getApplicationIdentifierAsync = jest.fn(defaultGetApplicationIdentifierAsync);
@@ -5,7 +5,6 @@ import { instance, mock } from 'ts-mockito';
import { v4 as uuidv4 } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { defaultPublishPlatforms } from '../../commands/update';
import { AssetMetadataStatus } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
@@ -14,6 +13,7 @@ import {
buildUnsortedUpdateInfoGroupAsync,
collectAssetsAsync,
convertAssetToUpdateInfoGroupFormatAsync,
defaultPublishPlatforms,
filterExportedPlatformsByFlag,
filterOutAssetsThatAlreadyExistAsync,
getAssetHashFromPath,
@@ -1,24 +1,54 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile } from '@expo/eas-json';
import { errors, readAndValidateBuildConfigAsync } from '@expo/steps';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
export interface CustomBuildConfigMetadata {
workflowName?: string;
}
export async function validateCustomBuildConfigAsync(
projectDir: string,
profile: BuildProfile<Platform>
): Promise<void> {
): Promise<CustomBuildConfigMetadata | undefined> {
if (!profile.config) {
return;
return undefined;
}
const relativeBuildConfigPath = getCustomBuildConfigPath(profile.config);
const buildConfigPath = path.join(projectDir, relativeBuildConfigPath);
if (!(await fs.pathExists(buildConfigPath))) {
const relativeConfigPath = getCustomBuildConfigPath(profile.config);
const configPath = path.join(projectDir, relativeConfigPath);
if (!(await fs.pathExists(configPath))) {
throw new Error(
`Custom build configuration file ${chalk.bold(relativeBuildConfigPath)} does not exist.`
`Custom build configuration file ${chalk.bold(relativeConfigPath)} does not exist.`
);
}
try {
const config = await readAndValidateBuildConfigAsync(configPath, {
skipNamespacedFunctionsCheck: true,
});
return {
workflowName: config.build.name,
};
} catch (err) {
if (err instanceof errors.BuildConfigYAMLError) {
throw new Error(
`Custom build configuration file ${chalk.bold(
relativeConfigPath
)} contains invalid YAML.\n\n${err.message}`
);
} else if (err instanceof errors.BuildConfigError) {
throw new Error(
`Custom build configuration file ${chalk.bold(
relativeConfigPath
)} contains invalid configuration. Please check the docs!\n${err.message}`
);
} else {
throw err;
}
}
}
export function getCustomBuildConfigPath(configFilename: string): string {
@@ -2,12 +2,15 @@ import { IOSConfig, compileModsAsync } from '@expo/config-plugins';
import { JSONObject } from '@expo/json-file';
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
import Log from '../../log';
import { readPlistAsync } from '../../utils/plist';
import { getVcsClient } from '../../vcs';
interface Target {
buildConfiguration?: string;
targetName: string;
}
export async function getManagedApplicationTargetEntitlementsAsync(
projectDir: string,
env: Record<string, string>
@@ -43,3 +46,37 @@ export async function getNativeTargetEntitlementsAsync(
return null;
}
}
/**
* When users have the `./ios` folder ignored through gitignore,
* we should load the app entitlements as managed instead of native/bare workflow.
* Without this, the user's configuration through config plugins will be ignored.
* The gitignore check can be disabled by setting the `EAS_SYNC_NATIVE_CAPABILITIES=1` env variable.
*/
export async function getNativeTargetEntitlementsUnlessGitIgnoredAsync(
projectDir: string,
{
target,
env,
}: {
target: Target;
env: Record<string, string>;
}
): Promise<JSONObject | null> {
// Escape hatch to force syncing native capabilities from `/ios` folder
const useNativeCapabilities = process.env.EAS_SYNC_NATIVE_CAPABILITIES;
if (useNativeCapabilities) {
Log.log(
'Syncing native ios capabilities from `./ios` folder, EAS_SYNC_NATIVE_CAPABILITIES is defined.'
);
return getNativeTargetEntitlementsAsync(projectDir, target);
}
// Load the capabilities from the prebuild config, instead of the native project when gitignored
if (await getVcsClient().isFileIgnoredAsync('ios')) {
return getManagedApplicationTargetEntitlementsAsync(projectDir, env);
}
// Load the capabilities from the `./ios` native project
return getNativeTargetEntitlementsAsync(projectDir, target);
}
+20 -7
View File
@@ -11,7 +11,7 @@ import { resolveWorkflowAsync } from '../workflow';
import { getBundleIdentifierAsync } from './bundleIdentifier';
import {
getManagedApplicationTargetEntitlementsAsync,
getNativeTargetEntitlementsAsync,
getNativeTargetEntitlementsUnlessGitIgnoredAsync,
} from './entitlements';
import { XcodeBuildContext } from './scheme';
@@ -89,6 +89,7 @@ export async function resolveBareProjectTargetsAsync({
exp,
projectDir,
xcodeBuildContext,
env,
}: ResolveTargetOptions): Promise<Target[]> {
const { buildScheme, buildConfiguration } = xcodeBuildContext;
const result: Target[] = [];
@@ -102,9 +103,12 @@ export async function resolveBareProjectTargetsAsync({
targetName: applicationTarget.name,
buildConfiguration,
});
const entitlements = await getNativeTargetEntitlementsAsync(projectDir, {
targetName: applicationTarget.name,
buildConfiguration,
const entitlements = await getNativeTargetEntitlementsUnlessGitIgnoredAsync(projectDir, {
env: env ?? {},
target: {
targetName: applicationTarget.name,
buildConfiguration,
},
});
result.push({
targetName: applicationTarget.name,
@@ -151,6 +155,7 @@ async function resolveBareProjectDependenciesAsync({
target,
bundleIdentifier,
pbxProject,
env,
}: {
exp: ExpoConfig;
projectDir: string;
@@ -158,18 +163,25 @@ async function resolveBareProjectDependenciesAsync({
target: IOSConfig.Target.Target;
bundleIdentifier: string;
pbxProject: XcodeProject;
env?: ResolveTargetOptions['env'];
}): Promise<Target[]> {
const result: Target[] = [];
if (target.dependencies && target.dependencies.length > 0) {
for (const dependency of target.dependencies) {
if (!dependency.signable) {
continue;
}
const dependencyBundleIdentifier = await getBundleIdentifierAsync(projectDir, exp, {
targetName: dependency.name,
buildConfiguration,
});
const entitlements = await getNativeTargetEntitlementsAsync(projectDir, {
targetName: target.name,
buildConfiguration,
const entitlements = await getNativeTargetEntitlementsUnlessGitIgnoredAsync(projectDir, {
env: env ?? {},
target: {
targetName: target.name,
buildConfiguration,
},
});
result.push({
targetName: dependency.name,
@@ -190,6 +202,7 @@ async function resolveBareProjectDependenciesAsync({
target: dependency,
bundleIdentifier: dependencyBundleIdentifier,
pbxProject,
env,
});
if (dependencyDependencies.length > 0) {
result.push(...dependencyDependencies);
+204 -1
View File
@@ -1,22 +1,36 @@
import { ExpoConfig, Platform } from '@expo/config';
import { Updates } from '@expo/config-plugins';
import { Platform as EASBuildJobPlatform, Workflow } from '@expo/eas-build-job';
import JsonFile from '@expo/json-file';
import assert from 'assert';
import chalk from 'chalk';
import crypto from 'crypto';
import fs from 'fs-extra';
import Joi from 'joi';
import mime from 'mime';
import nullthrows from 'nullthrows';
import path from 'path';
import promiseLimit from 'promise-limit';
import { selectBranchOnAppAsync } from '../branch/queries';
import { getDefaultBranchNameAsync } from '../branch/utils';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import Log from '../log';
import Log, { learnMore } from '../log';
import { RequestedPlatform, requestedPlatformDisplayNames } from '../platform';
import { promptAsync } from '../prompts';
import { getBranchNameFromChannelNameAsync } from '../update/getBranchNameFromChannelNameAsync';
import { formatUpdateMessage, truncateString as truncateUpdateMessage } from '../update/utils';
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
import { expoCommandAsync, shouldUseVersionedExpoCLI } from '../utils/expoCli';
import chunk from '../utils/expodash/chunk';
import { truthy } from '../utils/expodash/filter';
import uniqBy from '../utils/expodash/uniqBy';
import { getVcsClient } from '../vcs';
import { resolveWorkflowAsync } from './workflow';
export type ExpoCLIExportPlatformFlag = Platform | 'all';
@@ -166,11 +180,13 @@ export async function buildBundlesAsync({
inputDir,
exp,
platformFlag,
clearCache,
}: {
projectDir: string;
inputDir: string;
exp: Pick<ExpoConfig, 'sdkVersion'>;
platformFlag: ExpoCLIExportPlatformFlag;
clearCache?: boolean;
}): Promise<void> {
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
if (!packageJSON) {
@@ -186,6 +202,7 @@ export async function buildBundlesAsync({
'--dump-assetmap',
'--platform',
platformFlag,
...(clearCache ? ['--clear'] : []),
]);
} else {
// Legacy global Expo CLI
@@ -199,6 +216,7 @@ export async function buildBundlesAsync({
'--dump-assetmap',
'--platform',
platformFlag,
...(clearCache ? ['--clear'] : []),
]);
}
}
@@ -453,3 +471,188 @@ export function isUploadedAssetCountAboveWarningThreshold(
const warningThreshold = Math.floor(assetLimitPerUpdateGroup * 0.75);
return uploadedAssetCount > warningThreshold;
}
export async function getBranchNameForCommandAsync({
graphqlClient,
projectId,
channelNameArg,
branchNameArg,
autoFlag,
nonInteractive,
paginatedQueryOptions,
}: {
graphqlClient: ExpoGraphqlClient;
projectId: string;
channelNameArg: string | undefined;
branchNameArg: string | undefined;
autoFlag: boolean;
nonInteractive: boolean;
paginatedQueryOptions: PaginatedQueryOptions;
}): Promise<string> {
if (channelNameArg && branchNameArg) {
throw new Error(
'Cannot specify both --channel and --branch. Specify either --channel, --branch, or --auto.'
);
}
if (channelNameArg) {
return await getBranchNameFromChannelNameAsync(graphqlClient, projectId, channelNameArg);
}
if (branchNameArg) {
return branchNameArg;
}
if (autoFlag) {
return await getDefaultBranchNameAsync();
} else if (nonInteractive) {
throw new Error('Must supply --channel, --branch or --auto when in non-interactive mode.');
} else {
let branchName: string;
try {
const branch = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: `Which branch would you like to use?`,
displayTextForListItem: updateBranch => ({
title: `${updateBranch.name} ${chalk.grey(
`- current update: ${formatUpdateMessage(updateBranch.updates[0])}`
)}`,
}),
paginatedQueryOptions,
});
branchName = branch.name;
} catch {
// unable to select a branch (network error or no branches for project)
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'No branches found. Provide a branch name:',
initial: await getDefaultBranchNameAsync(),
validate: value => (value ? true : 'Branch name may not be empty.'),
});
branchName = name;
}
assert(branchName, 'Branch name must be specified.');
return branchName;
}
}
export async function getUpdateMessageForCommandAsync({
updateMessageArg,
autoFlag,
nonInteractive,
jsonFlag,
}: {
updateMessageArg: string | undefined;
autoFlag: boolean;
nonInteractive: boolean;
jsonFlag: boolean;
}): Promise<string> {
let updateMessage = updateMessageArg;
if (!updateMessageArg && autoFlag) {
updateMessage = (await getVcsClient().getLastCommitMessageAsync())?.trim();
}
if (!updateMessage) {
if (nonInteractive) {
throw new Error('Must supply --message or use --auto when in non-interactive mode');
}
const validationMessage = 'publish message may not be empty.';
if (jsonFlag) {
throw new Error(validationMessage);
}
const { updateMessageLocal } = await promptAsync({
type: 'text',
name: 'updateMessageLocal',
message: `Provide an update message:`,
initial: (await getVcsClient().getLastCommitMessageAsync())?.trim(),
validate: (value: any) => (value ? true : validationMessage),
});
updateMessage = updateMessageLocal;
}
assert(updateMessage, 'Update message must be specified.');
const truncatedMessage = truncateUpdateMessage(updateMessage, 1024);
if (truncatedMessage !== updateMessage) {
Log.warn('Update message exceeds the allowed 1024 character limit. Truncating message...');
}
return truncatedMessage;
}
export const defaultPublishPlatforms: Platform[] = ['android', 'ios'];
export function getRequestedPlatform(
platform: ExpoCLIExportPlatformFlag
): RequestedPlatform | null {
switch (platform) {
case 'android':
return RequestedPlatform.Android;
case 'ios':
return RequestedPlatform.Ios;
case 'web':
return null;
case 'all':
return RequestedPlatform.All;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
/** Get runtime versions grouped by platform. Runtime version is always `null` on web where the platform is always backwards compatible. */
export async function getRuntimeVersionObjectAsync(
exp: ExpoConfig,
platforms: Platform[],
projectDir: string
): Promise<{ platform: string; runtimeVersion: string }[]> {
for (const platform of platforms) {
if (platform === 'web') {
continue;
}
const isPolicy = typeof (exp[platform]?.runtimeVersion ?? exp.runtimeVersion) === 'object';
if (isPolicy) {
const isManaged =
(await resolveWorkflowAsync(projectDir, platform as EASBuildJobPlatform)) ===
Workflow.MANAGED;
if (!isManaged) {
throw new Error(
'Runtime version policies are only supported in the managed workflow. In the bare workflow, runtime version needs to be set manually.'
);
}
}
}
return [...new Set(platforms)].map(platform => {
if (platform === 'web') {
return { platform: 'web', runtimeVersion: 'UNVERSIONED' };
}
return {
platform,
runtimeVersion: nullthrows(
Updates.getRuntimeVersion(exp, platform),
`Unable to determine runtime version for ${
requestedPlatformDisplayNames[platform]
}. ${learnMore('https://docs.expo.dev/eas-update/runtime-versions/')}`
),
};
});
}
export function getRuntimeToPlatformMappingFromRuntimeVersions(
runtimeVersions: { platform: string; runtimeVersion: string }[]
): { runtimeVersion: string; platforms: string[] }[] {
const runtimeToPlatformMapping: { runtimeVersion: string; platforms: string[] }[] = [];
for (const runtime of runtimeVersions) {
const platforms = runtimeVersions
.filter(({ runtimeVersion }) => runtimeVersion === runtime.runtimeVersion)
.map(({ platform }) => platform);
if (!runtimeToPlatformMapping.find(item => item.runtimeVersion === runtime.runtimeVersion)) {
runtimeToPlatformMapping.push({ runtimeVersion: runtime.runtimeVersion, platforms });
}
}
return runtimeToPlatformMapping;
}
@@ -8,12 +8,20 @@ import { confirmAsync } from '../prompts';
import { ProfileData } from '../utils/profiles';
export async function ensureVersionSourceIsRemoteAsync(
easJsonAccessor: EasJsonAccessor
easJsonAccessor: EasJsonAccessor,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<void> {
const easJsonCliConfig = await EasJsonUtils.getCliConfigAsync(easJsonAccessor);
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
return;
}
if (nonInteractive) {
throw new Error(
`This project is not configured for using remote version source. Add ${chalk.bold(
'{"cli": { "appVersionSource": "remote" }}'
)} in eas.json or re-run this command without "--non-interactive" flag.`
);
}
Log.log(
`The app version source defines whether the app version is stored locally in your project source (e.g. in app.json, Info.plist, or build.gradle) or remotely on EAS servers and only applied to the project at build time. To use this command, you will need to enable the remote version policy by adding ${chalk.bold(
+7 -1
View File
@@ -1,8 +1,13 @@
import { constants } from 'os';
import prompts, { Answers, Choice, Options, PromptType, PromptObject as Question } from 'prompts';
import prompts, { Answers, Choice, Options, prompts as PromptElements, PromptType } from 'prompts';
import { Question, easMultiselect } from './easMultiselect';
export { PromptType, Question, Choice };
if (PromptElements) {
PromptElements.multiselect = easMultiselect;
}
export interface ExpoChoice<T> extends Choice {
value: T;
}
@@ -42,6 +47,7 @@ export async function confirmAsync(
);
return value;
}
export async function selectAsync<T>(
message: string,
choices: ExpoChoice<T>[],
@@ -171,6 +171,19 @@ async function handleLatestSourceAsync(
});
}
if (new Date() >= new Date(latestBuild.expirationDate)) {
Log.error(
chalk.bold(
`The latest build is expired. Run ${chalk.bold(
'eas build --auto-submit'
)} or choose another build.`
)
);
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
return {
sourceType: ArchiveSourceType.build,
build: latestBuild,
@@ -225,6 +238,13 @@ async function handleBuildIdSourceAsync(
});
}
if (new Date() >= new Date(build.expirationDate)) {
Log.error(chalk.bold(`The build with ID ${build.id} is expired. Choose another build.`));
return getArchiveAsync(ctx, {
sourceType: ArchiveSourceType.prompt,
});
}
return {
sourceType: ArchiveSourceType.build,
build,
@@ -166,6 +166,27 @@ describe(getArchiveAsync, () => {
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('prompts again if build with provided ID is expired', async () => {
jest.mocked(BuildQuery.byIdAsync).mockResolvedValueOnce({
...MOCK_BUILD_FRAGMENT,
expirationDate: new Date(Date.now() - 1000),
} as BuildFragment);
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.buildId,
id: uuidv4(),
}
);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('handles latest build source', async () => {
const projectId = uuidv4();
jest
@@ -187,6 +208,27 @@ describe(getArchiveAsync, () => {
expect(archive.sourceType).toBe(ArchiveSourceType.build);
});
it('prompts again if the latest build is expired', async () => {
jest
.mocked(getRecentBuildsForSubmissionAsync)
.mockResolvedValueOnce([
{ ...MOCK_BUILD_FRAGMENT, expirationDate: new Date(Date.now() - 1000) } as BuildFragment,
]);
jest
.mocked(promptAsync)
.mockResolvedValueOnce({ sourceType: ArchiveSourceType.url })
.mockResolvedValueOnce({ url: ARCHIVE_SOURCE.url });
const archive = await getArchiveAsync(
{ ...SOURCE_STUB_INPUT, graphqlClient },
{
sourceType: ArchiveSourceType.latest,
}
);
expect(archive.sourceType).toBe(ArchiveSourceType.url);
});
it('prompts again if no builds exists when selected latest', async () => {
jest.mocked(getRecentBuildsForSubmissionAsync).mockResolvedValueOnce([]);
jest
@@ -1,21 +1,38 @@
import { Platform } from '@expo/eas-build-job';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import { promptForAscApiKeyPathAsync } from '../../credentials/ios/actions/AscApiKeyUtils';
import {
AppStoreApiKeyPurpose,
promptForAscApiKeyPathAsync,
} from '../../credentials/ios/actions/AscApiKeyUtils';
import { SetUpAscApiKey } from '../../credentials/ios/actions/SetUpAscApiKey';
import { AscApiKeyPath, MinimalAscApiKey } from '../../credentials/ios/credentials';
import Log from '../../log';
import {
AmbiguousBundleIdentifierError,
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
getBundleIdentifierAsync,
isBundleIdentifierValid,
} from '../../project/ios/bundleIdentifier';
import { promptAsync } from '../../prompts';
import { SubmissionContext } from '../context';
import { isExistingFileAsync } from '../utils/files';
export enum AscApiKeySourceType {
path,
prompt,
credentialsService,
}
interface AscApiKeySourceBase {
sourceType: AscApiKeySourceType;
}
interface AscApiKeyCredentialsServiceSource extends AscApiKeySourceBase {
sourceType: AscApiKeySourceType.credentialsService;
}
interface AscApiKeyPromptSource extends AscApiKeySourceBase {
sourceType: AscApiKeySourceType.prompt;
}
@@ -25,7 +42,10 @@ interface AscApiKeyEnvVarSource extends AscApiKeySourceBase {
path: AscApiKeyPath;
}
export type AscApiKeySource = AscApiKeyEnvVarSource | AscApiKeyPromptSource;
export type AscApiKeySource =
| AscApiKeyEnvVarSource
| AscApiKeyPromptSource
| AscApiKeyCredentialsServiceSource;
type AscApiKeySummary = {
source: 'local' | 'EAS servers';
@@ -43,6 +63,84 @@ export type AscApiKeyResult = {
summary: AscApiKeySummary;
};
export async function getAscApiKeyResultAsync(
ctx: SubmissionContext<Platform.IOS>,
source: AscApiKeySource
): Promise<AscApiKeyResult> {
if (source.sourceType === AscApiKeySourceType.credentialsService) {
return await getAscApiKeyFromCredentialsServiceAsync(ctx);
} else {
return await getAscApiKeyLocallyAsync(ctx, source);
}
}
async function maybeGetIosBundleIdentifierAsync(
ctx: SubmissionContext<Platform.IOS>
): Promise<string | null> {
try {
return await getBundleIdentifierAsync(ctx.projectDir, ctx.exp);
} catch (error: any) {
if (error instanceof AmbiguousBundleIdentifierError) {
Log.warn(
'bundleIdentifier in the Xcode project is ambiguous, specify it via "bundleIdentifier" field in the submit profile in the eas.json.'
);
return null;
}
throw new Error(`Failed to resolve bundleIdentifier in the Xcode project: ${error.message}.`);
}
}
async function promptForBundleIdentifierAsync(): Promise<string> {
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
message: 'Bundle identifier:',
type: 'text',
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
return bundleIdentifier;
}
async function getAscApiKeyFromCredentialsServiceAsync(
ctx: SubmissionContext<Platform.IOS>
): Promise<AscApiKeyResult> {
const bundleIdentifier =
ctx.applicationIdentifierOverride ??
ctx.profile.bundleIdentifier ??
(await maybeGetIosBundleIdentifierAsync(ctx)) ??
(await promptForBundleIdentifierAsync());
Log.log(`Looking up credentials configuration for ${bundleIdentifier}...`);
const appLookupParams = {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
bundleIdentifier,
};
const setupAscApiKeyAction = new SetUpAscApiKey(
appLookupParams,
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
);
const iosAppCredentials = await setupAscApiKeyAction.runAsync(ctx.credentialsCtx);
const ascKeyForSubmissions = nullthrows(
iosAppCredentials.appStoreConnectApiKeyForSubmissions,
`An EAS Submit ASC Api Key could not be found for ${iosAppCredentials.appleAppIdentifier.bundleIdentifier}`
);
const { id, keyIdentifier, name } = ascKeyForSubmissions;
Log.log(`Using Api Key ID: ${keyIdentifier}${name ? ` (${name})` : ''}`);
return {
result: {
ascApiKeyId: id,
},
summary: {
source: 'EAS servers',
keyId: keyIdentifier,
name: name ?? undefined,
},
};
}
export async function getAscApiKeyLocallyAsync(
ctx: SubmissionContext<Platform.IOS>,
source: AscApiKeySource
@@ -70,6 +168,8 @@ export async function getAscApiKeyPathAsync(
return await handlePathSourceAsync(ctx, source);
case AscApiKeySourceType.prompt:
return await handlePromptSourceAsync(ctx, source);
case AscApiKeySourceType.credentialsService:
throw new Error(`AscApiKeySourceType ${source} does not return a path.`);
}
}
@@ -1,84 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import { SetUpSubmissionCredentials } from '../../credentials/ios/actions/SetUpSubmissionCredentials';
import Log from '../../log';
import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../../project/ios/bundleIdentifier';
import { promptAsync } from '../../prompts';
import { SubmissionContext } from '../context';
import {
AppSpecificPasswordCredentials,
getAppleIdUsernameAsync,
} from './AppSpecificPasswordSource';
import { AscApiKeyResult } from './AscApiKeySource';
/**
* The Credentials Service will either return an ASC API Key or an App Specific Password
* When we no longer support the App Specific Password user prompt, refactor this into the AscApiKeySource
*/
export const CREDENTIALS_SERVICE_SOURCE = 'CREDENTIALS_SERVICE_SOURCE' as const;
export interface CredentialsServiceSource {
sourceType: typeof CREDENTIALS_SERVICE_SOURCE;
bundleIdentifier?: string;
}
async function promptForBundleIdentifierAsync(): Promise<string> {
const { bundleIdentifier } = await promptAsync({
name: 'bundleIdentifier',
message: 'Bundle identifier:',
type: 'text',
validate: value => (isBundleIdentifierValid(value) ? true : INVALID_BUNDLE_IDENTIFIER_MESSAGE),
});
return bundleIdentifier;
}
export async function getFromCredentialsServiceAsync(
ctx: SubmissionContext<Platform.IOS>,
source: CredentialsServiceSource
): Promise<
{ appSpecificPassword: AppSpecificPasswordCredentials } | { ascApiKeyResult: AscApiKeyResult }
> {
const bundleIdentifier = source.bundleIdentifier ?? (await promptForBundleIdentifierAsync());
Log.log(`Looking up credentials configuration for ${bundleIdentifier}...`);
const appLookupParams = {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
bundleIdentifier,
};
const setupSubmissionCredentialsAction = new SetUpSubmissionCredentials(appLookupParams);
const ascOrAsp = await setupSubmissionCredentialsAction.runAsync(ctx.credentialsCtx);
const isAppSpecificPassword = typeof ascOrAsp === 'string';
if (isAppSpecificPassword) {
return {
appSpecificPassword: {
password: ascOrAsp,
appleIdUsername: await getAppleIdUsernameAsync(ctx),
},
};
} else {
const ascKeyForSubmissions = nullthrows(
ascOrAsp.appStoreConnectApiKeyForSubmissions,
`An EAS Submit ASC API Key could not be found for ${ascOrAsp.appleAppIdentifier.bundleIdentifier}`
);
const { id, keyIdentifier, name } = ascKeyForSubmissions;
return {
ascApiKeyResult: {
result: {
ascApiKeyId: id,
},
summary: {
source: 'EAS servers',
keyId: keyIdentifier,
name: name ?? undefined,
},
},
};
}
}
@@ -7,10 +7,6 @@ import wrapAnsi from 'wrap-ansi';
import { MissingCredentialsError } from '../../credentials/errors';
import { SubmissionFragment } from '../../graphql/generated';
import Log, { learnMore } from '../../log';
import {
AmbiguousBundleIdentifierError,
getBundleIdentifierAsync,
} from '../../project/ios/bundleIdentifier';
import { ArchiveSource } from '../ArchiveSource';
import { resolveArchiveSource } from '../commons';
import { SubmissionContext } from '../context';
@@ -20,7 +16,6 @@ import {
AppSpecificPasswordSourceType,
} from './AppSpecificPasswordSource';
import { AscApiKeySource, AscApiKeySourceType } from './AscApiKeySource';
import { CREDENTIALS_SERVICE_SOURCE, CredentialsServiceSource } from './CredentialsServiceSource';
import IosSubmitter, { IosSubmissionOptions } from './IosSubmitter';
export default class IosSubmitCommand {
@@ -42,17 +37,12 @@ export default class IosSubmitCommand {
: null;
const maybeAscApiKeySource =
'ascApiKeySource' in credentialsSource ? credentialsSource.ascApiKeySource : null;
const maybeCredentialsServiceSource =
'credentialsServiceSource' in credentialsSource
? credentialsSource.credentialsServiceSource
: null;
const ascAppIdentifier = await this.resolveAscAppIdentifierAsync();
const errored = [
archiveSource,
...(maybeAppSpecificPasswordSource ? [maybeAppSpecificPasswordSource] : []),
...(maybeAscApiKeySource ? [maybeAscApiKeySource] : []),
...(maybeCredentialsServiceSource ? [maybeCredentialsServiceSource] : []),
ascAppIdentifier,
].filter(r => !r.ok);
if (errored.length > 0) {
@@ -75,42 +65,14 @@ export default class IosSubmitCommand {
ascApiKeySource: maybeAscApiKeySource.enforceValue(),
}
: null),
...(maybeCredentialsServiceSource
? {
credentialsServiceSource: maybeCredentialsServiceSource.enforceValue(),
}
: null),
};
}
private async maybeGetIosBundleIdentifierAsync(): Promise<Result<string | null>> {
try {
return result(await getBundleIdentifierAsync(this.ctx.projectDir, this.ctx.exp));
} catch (error: any) {
if (error instanceof AmbiguousBundleIdentifierError) {
Log.warn(
'bundleIdentifier in the Xcode project is ambiguous, specify it via "bundleIdentifier" field in the submit profile in the eas.json.'
);
return result(null);
}
return result(
new Error(`Failed to resolve bundleIdentifier in the Xcode project: ${error.message}.`)
);
}
}
private async resolveCredentialSubmissionOptionsAsync(): Promise<
| { appSpecificPasswordSource: Result<AppSpecificPasswordSource> }
| { ascApiKeySource: Result<AscApiKeySource> }
| { credentialsServiceSource: Result<CredentialsServiceSource> }
> {
const ascApiKeySource = this.resolveAscApiKeySource();
const shouldSkipAscApiKeySource =
!ascApiKeySource.ok && ascApiKeySource.enforceError() instanceof MissingCredentialsError;
if (!shouldSkipAscApiKeySource) {
return { ascApiKeySource };
}
// if an App Specific Password env var is not specified, use ASC Api Key
const appSpecificPasswordSource = this.resolveAppSpecificPasswordSource();
const shouldSkipAppSpecificPasswordSource =
!appSpecificPasswordSource.ok &&
@@ -119,26 +81,8 @@ export default class IosSubmitCommand {
return { appSpecificPasswordSource: this.resolveAppSpecificPasswordSource() };
}
let bundleIdentifier =
this.ctx.applicationIdentifierOverride ?? this.ctx.profile.bundleIdentifier;
if (!bundleIdentifier) {
const bundleIdentifierResult = await this.maybeGetIosBundleIdentifierAsync();
if (!bundleIdentifierResult.ok) {
return {
credentialsServiceSource: result(bundleIdentifierResult.reason),
};
}
const bundleIdentifierValue = bundleIdentifierResult.enforceValue();
if (bundleIdentifierValue) {
bundleIdentifier = bundleIdentifierValue;
}
}
return {
credentialsServiceSource: result({
sourceType: CREDENTIALS_SERVICE_SOURCE,
bundleIdentifier,
}),
};
const ascApiKeySource = this.resolveAscApiKeySource();
return { ascApiKeySource };
}
private resolveAppSpecificPasswordSource(): Result<AppSpecificPasswordSource> {
@@ -187,11 +131,7 @@ export default class IosSubmitCommand {
});
}
return result(
new MissingCredentialsError(
'Set the ascApiKeyPath, ascApiKeyIssuerId and ascApiKeyId fields in eas.json.'
)
);
return result({ sourceType: AscApiKeySourceType.credentialsService });
}
private resolveArchiveSource(): Result<ArchiveSource> {
@@ -23,12 +23,8 @@ import {
AscApiKeyFromExpoServers,
AscApiKeyResult,
AscApiKeySource,
getAscApiKeyLocallyAsync,
getAscApiKeyResultAsync,
} from './AscApiKeySource';
import {
CredentialsServiceSource,
getFromCredentialsServiceAsync,
} from './CredentialsServiceSource';
export interface IosSubmissionOptions
extends Pick<IosSubmissionConfigInput, 'appleIdUsername' | 'ascAppIdentifier'> {
@@ -36,7 +32,6 @@ export interface IosSubmissionOptions
archiveSource: ArchiveSource;
appSpecificPasswordSource?: AppSpecificPasswordSource;
ascApiKeySource?: AscApiKeySource;
credentialsServiceSource?: CredentialsServiceSource;
}
interface ResolvedSourceOptions {
@@ -74,15 +69,11 @@ export default class IosSubmitter extends BaseSubmitter<
)
: null;
const maybeAppStoreConnectApiKey = this.options.ascApiKeySource
? await getAscApiKeyLocallyAsync(this.ctx, this.options.ascApiKeySource)
: null;
const maybeAscOrAspFromCredentialsService = this.options.credentialsServiceSource
? await getFromCredentialsServiceAsync(this.ctx, this.options.credentialsServiceSource)
? await getAscApiKeyResultAsync(this.ctx, this.options.ascApiKeySource)
: null;
return {
...(maybeAppSpecificPassword ? { appSpecificPassword: maybeAppSpecificPassword } : null),
...(maybeAppStoreConnectApiKey ? { ascApiKeyResult: maybeAppStoreConnectApiKey } : null),
...(maybeAscOrAspFromCredentialsService ? maybeAscOrAspFromCredentialsService : null),
};
},
};
@@ -10,16 +10,19 @@ import {
testAppQueryByIdResponse,
testProjectId,
} from '../../../credentials/__tests__/fixtures-constants';
import { testCommonIosAppCredentialsFragment } from '../../../credentials/__tests__/fixtures-ios';
import { SetUpAscApiKey } from '../../../credentials/ios/actions/SetUpAscApiKey';
import { getCredentialsFromUserAsync } from '../../../credentials/utils/promptForCredentials';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { createTestProject } from '../../../project/__tests__/project-utils';
import { getBundleIdentifierAsync } from '../../../project/ios/bundleIdentifier';
import { promptAsync } from '../../../prompts';
import { SubmissionContext, createSubmissionContextAsync } from '../../context';
import {
AscApiKeySource,
AscApiKeySourceType,
getAscApiKeyLocallyAsync,
getAscApiKeyPathAsync,
getAscApiKeyResultAsync,
} from '../AscApiKeySource';
jest.mock('fs');
@@ -29,6 +32,7 @@ jest.mock('../../../user/User', () => ({
getUserAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../project/ios/bundleIdentifier');
const testProject = createTestProject(testProjectId, mockJester.accounts[0].name, {
android: {
@@ -179,14 +183,14 @@ describe(getAscApiKeyPathAsync, () => {
});
});
describe(getAscApiKeyLocallyAsync, () => {
describe(getAscApiKeyResultAsync, () => {
it('returns a local Asc API Key file with a AscApiKeySourceType.path source', async () => {
const ctx = await getIosSubmissionContextAsync();
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.path,
path: { keyP8Path: '/asc-api-key.p8', keyId: 'test-key-id', issuerId: 'test-issuer-id' },
};
const ascApiKeyResult = await getAscApiKeyLocallyAsync(ctx, source);
const ascApiKeyResult = await getAscApiKeyResultAsync(ctx, source);
expect(ascApiKeyResult).toMatchObject({
result: {
keyP8: 'super secret',
@@ -213,7 +217,7 @@ describe(getAscApiKeyLocallyAsync, () => {
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.prompt,
};
const serviceAccountResult = await getAscApiKeyLocallyAsync(ctx, source);
const serviceAccountResult = await getAscApiKeyResultAsync(ctx, source);
expect(serviceAccountResult).toMatchObject({
result: {
keyP8: 'super secret',
@@ -227,4 +231,45 @@ describe(getAscApiKeyLocallyAsync, () => {
},
});
});
it('returns an Asc Api Key from server with a AscApiKeySourceType.credentialService source', async () => {
const graphqlClient = {} as any as ExpoGraphqlClient;
const analytics = instance(mock<Analytics>());
const ctx = await createSubmissionContextAsync({
platform: Platform.IOS,
projectDir: testProject.projectRoot,
archiveFlags: {
url: 'http://expo.dev/fake.apk',
},
profile: {
language: 'en-US',
},
nonInteractive: true,
actor: mockJester,
graphqlClient,
analytics,
exp: testProject.appJSON.expo,
projectId,
});
const source: AscApiKeySource = {
sourceType: AscApiKeySourceType.credentialsService,
};
jest
.spyOn(SetUpAscApiKey.prototype, 'runAsync')
.mockImplementation(async _ctx => testCommonIosAppCredentialsFragment);
jest.mocked(getBundleIdentifierAsync).mockImplementation(async () => 'com.hello.world');
const result = await getAscApiKeyResultAsync(ctx, source);
expect(result).toEqual({
result: {
ascApiKeyId: testCommonIosAppCredentialsFragment.appStoreConnectApiKeyForSubmissions?.id,
},
summary: {
keyId:
testCommonIosAppCredentialsFragment.appStoreConnectApiKeyForSubmissions?.keyIdentifier,
name: testCommonIosAppCredentialsFragment.appStoreConnectApiKeyForSubmissions?.name,
source: 'EAS servers',
},
});
});
});
@@ -1,124 +0,0 @@
import { Platform } from '@expo/eas-build-job';
import { instance, mock } from 'ts-mockito';
import { v4 as uuidv4 } from 'uuid';
import { Analytics } from '../../../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
jester as mockJester,
testAppQueryByIdResponse,
testProjectId,
} from '../../../credentials/__tests__/fixtures-constants';
import { testCommonIosAppCredentialsFragment } from '../../../credentials/__tests__/fixtures-ios';
import { SetUpSubmissionCredentials } from '../../../credentials/ios/actions/SetUpSubmissionCredentials';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { createTestProject } from '../../../project/__tests__/project-utils';
import { getBundleIdentifierAsync } from '../../../project/ios/bundleIdentifier';
import { promptAsync } from '../../../prompts';
import { createSubmissionContextAsync } from '../../context';
import {
CREDENTIALS_SERVICE_SOURCE,
getFromCredentialsServiceAsync,
} from '../CredentialsServiceSource';
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../credentials/utils/promptForCredentials');
jest.mock('../../../user/User', () => ({
getUserAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../project/ios/bundleIdentifier');
jest.mock('../../../graphql/queries/AppQuery');
const testProject = createTestProject(testProjectId, mockJester.accounts[0].name, {
android: {
package: 'com.expo.test.project',
},
});
const projectId = uuidv4();
beforeEach(() => {
jest.restoreAllMocks();
});
describe(getFromCredentialsServiceAsync, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
it('returns an App Specific Password from the credentialService source', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const analytics = instance(mock<Analytics>());
const ctx = await createSubmissionContextAsync({
platform: Platform.IOS,
projectDir: testProject.projectRoot,
archiveFlags: {
url: 'http://expo.dev/fake.apk',
},
profile: {
language: 'en-US',
},
nonInteractive: false,
actor: mockJester,
graphqlClient,
analytics,
exp: testProject.appJSON.expo,
projectId,
});
jest
.spyOn(SetUpSubmissionCredentials.prototype, 'runAsync')
.mockImplementation(async _ctx => 'super secret');
jest.mocked(getBundleIdentifierAsync).mockImplementation(async () => 'com.hello.world');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({ appleId: '[email protected]' }));
const result = await getFromCredentialsServiceAsync(ctx, {
sourceType: CREDENTIALS_SERVICE_SOURCE,
bundleIdentifier: 'com.expo.test.project',
});
expect(result).toEqual({
appSpecificPassword: { password: 'super secret', appleIdUsername: '[email protected]' },
});
});
it('returns an ASC API Key from the credentialService source', async () => {
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const analytics = instance(mock<Analytics>());
const ctx = await createSubmissionContextAsync({
platform: Platform.IOS,
projectDir: testProject.projectRoot,
archiveFlags: {
url: 'http://expo.dev/fake.apk',
},
profile: {
language: 'en-US',
},
nonInteractive: true,
actor: mockJester,
graphqlClient,
analytics,
exp: testProject.appJSON.expo,
projectId,
});
jest
.spyOn(SetUpSubmissionCredentials.prototype, 'runAsync')
.mockImplementation(async _ctx => testCommonIosAppCredentialsFragment);
jest.mocked(getBundleIdentifierAsync).mockImplementation(async () => 'com.hello.world');
const result = await getFromCredentialsServiceAsync(ctx, {
sourceType: CREDENTIALS_SERVICE_SOURCE,
bundleIdentifier: 'com.expo.test.project',
});
expect(result).toEqual({
ascApiKeyResult: {
result: {
ascApiKeyId: testCommonIosAppCredentialsFragment.appStoreConnectApiKeyForSubmissions?.id,
},
summary: {
keyId:
testCommonIosAppCredentialsFragment.appStoreConnectApiKeyForSubmissions?.keyIdentifier,
name: testCommonIosAppCredentialsFragment.appStoreConnectApiKeyForSubmissions?.name,
source: 'EAS servers',
},
},
});
});
});
@@ -124,6 +124,7 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
runtime: {} as Runtime, // Temporary fix to resolve type errors
platform: 'ios',
manifestFragment: '...',
isRollBackToEmbedded: false,
activityTimestamp: '2022-12-07T02:24:43.487Z',
branchId: '',
awaitingCodeSigningInfo: false,
+2 -2
View File
@@ -273,7 +273,7 @@ export async function ensureEASUpdateIsConfiguredInEasJsonAsync(projectDir: stri
}
try {
const easJsonAccessor = new EasJsonAccessor(projectDir);
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
await easJsonAccessor.readRawJsonAsync();
easJsonAccessor.patch(easJsonRawObject => {
@@ -343,7 +343,7 @@ export async function ensureEASUpdateIsConfiguredAsync(
expWithoutUpdates.sdkVersion
);
if (!hasExpoUpdates) {
await installExpoUpdatesAsync(projectDir, { silent: !Log.isDebug });
await installExpoUpdatesAsync(projectDir, { silent: false });
Log.withTick('Installed expo updates');
}
@@ -53,7 +53,7 @@ export async function readChannelSafelyAsync(projectDir: string): Promise<string
try {
const expoPlist = await readExpoPlistAsync(projectDir);
const updatesRequestHeaders = expoPlist['EXUpdatesRequestHeaders'];
return updatesRequestHeaders['expo-channel-name'] ?? null;
return updatesRequestHeaders?.['expo-channel-name'] ?? null;
} catch {
return null;
}
+11
View File
@@ -48,6 +48,8 @@ export type FormattedUpdateGroupDescription = {
group: string;
platforms: string;
runtimeVersion: string;
codeSigningKey: string | undefined;
isRollBackToEmbedded: boolean;
};
export type FormattedBranchDescription = {
@@ -74,6 +76,8 @@ export function formatUpdateGroup(update: FormattedUpdateGroupDescription): stri
{ label: 'Platforms', value: update.platforms },
{ label: 'Runtime Version', value: update.runtimeVersion },
{ label: 'Message', value: update.message },
{ label: 'Code Signing Key', value: update.codeSigningKey ?? 'N/A' },
{ label: 'Is Roll Back to Embedded', value: update.isRollBackToEmbedded ? 'Yes' : 'No' },
{ label: 'Group ID', value: update.group },
]);
}
@@ -203,6 +207,7 @@ export function getUpdateGroupJsonInfo(updateGroups: UpdateFragment[]): UpdateJs
runtimeVersion: update.runtimeVersion,
platform: update.platform,
manifestPermalink: update.manifestPermalink,
isRollBackToEmbedded: update.isRollBackToEmbedded,
gitCommitHash: update.gitCommitHash,
}));
}
@@ -213,6 +218,8 @@ export function getUpdateGroupDescriptions(
return updateGroups.map(updateGroup => ({
message: formatUpdateMessage(updateGroup[0]),
runtimeVersion: updateGroup[0].runtimeVersion,
isRollBackToEmbedded: updateGroup[0].isRollBackToEmbedded,
codeSigningKey: updateGroup[0].codeSigningInfo?.keyid,
group: updateGroup[0].group,
platforms: formatPlatformForUpdateGroup(updateGroup),
}));
@@ -225,6 +232,8 @@ export function getUpdateGroupDescriptionsWithBranch(
branch: updateGroup[0].branch.name,
message: formatUpdateMessage(updateGroup[0]),
runtimeVersion: updateGroup[0].runtimeVersion,
isRollBackToEmbedded: updateGroup[0].isRollBackToEmbedded,
codeSigningKey: updateGroup[0].codeSigningInfo?.keyid,
group: updateGroup[0].group,
platforms: formatPlatformForUpdateGroup(updateGroup),
}));
@@ -241,6 +250,8 @@ export function getBranchDescription(branch: UpdateBranchFragment): FormattedBra
update: {
message: formatUpdateMessage(latestUpdate),
runtimeVersion: latestUpdate.runtimeVersion,
isRollBackToEmbedded: latestUpdate.isRollBackToEmbedded,
codeSigningKey: latestUpdate.codeSigningInfo?.keyid,
group: latestUpdate.group,
platforms: getPlatformsForGroup({
group: latestUpdate.group,
@@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`maybePrintBuildProfileDeprecationWarningsAsync multiple deprecation warnings prints the warnings 1`] = `
[
[
"Detected deprecated fields in eas.json:",
],
[
" The "build.production.cache.customPaths" field in eas.json is deprecated and will be removed in the future. Please use "build.production.cache.paths" instead.",
],
[
" Learn more: https://docs.expo.dev/build-reference/eas-json/#cache",
],
[
" The "build.production.cache.cacheDefaultPaths" field in eas.json is deprecated and will be removed in the future.",
],
[
" Learn more: https://docs.expo.dev/build-reference/caching/#ios-dependencies",
],
[
" Other message",
],
]
`;
exports[`maybePrintBuildProfileDeprecationWarningsAsync one deprecation warning prints the warning 1`] = `
[
[
"Detected deprecated fields in eas.json:",
],
[
" The "build.production.cache.customPaths" field in eas.json is deprecated and will be removed in the future. Please use "build.production.cache.paths" instead.",
],
[
" Learn more: https://docs.expo.dev/build-reference/eas-json/#cache",
],
]
`;
@@ -1,8 +1,13 @@
import { Platform } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils, errors } from '@expo/eas-json';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, errors } from '@expo/eas-json';
import Log from '../../log';
import { selectAsync } from '../../prompts';
import { getProfilesAsync } from '../profiles';
import {
clearHasPrintedDeprecationWarnings,
getProfilesAsync,
maybePrintBuildProfileDeprecationWarningsAsync,
} from '../profiles';
jest.mock('../../prompts');
jest.mock('@expo/eas-json', () => {
@@ -11,6 +16,7 @@ jest.mock('@expo/eas-json', () => {
const EasJsonUtilsMock = {
getBuildProfileAsync: jest.fn(),
getBuildProfileNamesAsync: jest.fn(),
getBuildProfileDeprecationWarningsAsync: jest.fn(() => []),
};
return {
...actual,
@@ -20,6 +26,12 @@ jest.mock('@expo/eas-json', () => {
const getBuildProfileAsync = jest.spyOn(EasJsonUtils, 'getBuildProfileAsync');
const getBuildProfileNamesAsync = jest.spyOn(EasJsonUtils, 'getBuildProfileNamesAsync');
const getBuildProfileDeprecationWarningsAsync = jest.spyOn(
EasJsonUtils,
'getBuildProfileDeprecationWarningsAsync'
);
const newLineSpy = jest.spyOn(Log, 'newLine');
const warnSpy = jest.spyOn(Log, 'warn');
describe(getProfilesAsync, () => {
afterEach(() => {
@@ -29,7 +41,11 @@ describe(getProfilesAsync, () => {
});
it('defaults to production profile', async () => {
const easJsonAccessor = new EasJsonAccessor('/fake');
const easJsonAccessor = EasJsonAccessor.fromProjectPath('/fake');
const readRawEasJsonMock = jest.spyOn(easJsonAccessor, 'readRawJsonAsync');
readRawEasJsonMock.mockImplementation(async () => {
return {};
});
const result = await getProfilesAsync({
easJsonAccessor,
platforms: [Platform.ANDROID, Platform.IOS],
@@ -51,7 +67,7 @@ describe(getProfilesAsync, () => {
await expect(
getProfilesAsync({
easJsonAccessor: new EasJsonAccessor('/fake'),
easJsonAccessor: EasJsonAccessor.fromProjectPath('/fake'),
platforms: [Platform.ANDROID],
profileName: undefined,
type: 'build',
@@ -60,7 +76,11 @@ describe(getProfilesAsync, () => {
});
it('gets a specific profile', async () => {
const easJsonAccessor = new EasJsonAccessor('/fake');
const easJsonAccessor = EasJsonAccessor.fromProjectPath('/fake');
const readRawEasJsonMock = jest.spyOn(easJsonAccessor, 'readRawJsonAsync');
readRawEasJsonMock.mockImplementation(async () => {
return {};
});
const result = await getProfilesAsync({
easJsonAccessor,
platforms: [Platform.ANDROID, Platform.IOS],
@@ -85,7 +105,7 @@ describe(getProfilesAsync, () => {
await expect(
getProfilesAsync({
easJsonAccessor: new EasJsonAccessor('/fake'),
easJsonAccessor: EasJsonAccessor.fromProjectPath('/fake'),
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
@@ -93,3 +113,82 @@ describe(getProfilesAsync, () => {
).rejects.toThrowError(/eas.json is not valid/);
});
});
describe(maybePrintBuildProfileDeprecationWarningsAsync, () => {
afterEach(() => {
clearHasPrintedDeprecationWarnings();
newLineSpy.mockClear();
warnSpy.mockClear();
});
const easJsonAccessor = EasJsonAccessor.fromProjectPath('/fake');
const readRawEasJsonMock = jest.spyOn(easJsonAccessor, 'readRawJsonAsync');
readRawEasJsonMock.mockImplementation(async () => {
return {};
});
getBuildProfileAsync.mockImplementation(async () => {
return {} as BuildProfile<Platform.ANDROID>;
});
describe('no deprecation warnings', () => {
it('does not print any warnings', async () => {
getBuildProfileDeprecationWarningsAsync.mockImplementation(async () => []);
await maybePrintBuildProfileDeprecationWarningsAsync(
easJsonAccessor,
Platform.ANDROID,
'production'
);
expect(newLineSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
});
});
describe('one deprecation warning', () => {
it('prints the warning', async () => {
getBuildProfileDeprecationWarningsAsync.mockImplementation(async () => [
{
message: [
'The "build.production.cache.customPaths" field in eas.json is deprecated and will be removed in the future. Please use "build.production.cache.paths" instead.',
],
docsUrl: 'https://docs.expo.dev/build-reference/eas-json/#cache',
},
]);
await maybePrintBuildProfileDeprecationWarningsAsync(
easJsonAccessor,
Platform.ANDROID,
'production'
);
expect(newLineSpy).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledTimes(3);
const warnCalls = warnSpy.mock.calls;
expect(warnCalls).toMatchSnapshot();
});
});
describe('multiple deprecation warnings', () => {
it('prints the warnings', async () => {
getBuildProfileDeprecationWarningsAsync.mockImplementation(async () => [
{
message: [
'The "build.production.cache.customPaths" field in eas.json is deprecated and will be removed in the future. Please use "build.production.cache.paths" instead.',
],
docsUrl: 'https://docs.expo.dev/build-reference/eas-json/#cache',
},
{
message: [
'The "build.production.cache.cacheDefaultPaths" field in eas.json is deprecated and will be removed in the future.',
],
docsUrl: 'https://docs.expo.dev/build-reference/caching/#ios-dependencies',
},
{
message: ['Other message'],
},
]);
await maybePrintBuildProfileDeprecationWarningsAsync(
easJsonAccessor,
Platform.ANDROID,
'production'
);
expect(newLineSpy).toHaveBeenCalledTimes(4);
expect(warnSpy).toHaveBeenCalledTimes(6);
const warnCalls = warnSpy.mock.calls;
expect(warnCalls).toMatchSnapshot();
});
});
});
+30 -4
View File
@@ -16,8 +16,9 @@ import nullthrows from 'nullthrows';
import { Response } from '../fetch';
import { PartialManifest, PartialManifestAsset } from '../graphql/generated';
import areSetsEqual from './expodash/areSetsEqual';
type CodeSigningInfo = {
export type CodeSigningInfo = {
privateKey: PKI.rsa.PrivateKey;
certificate: PKI.Certificate;
codeSigningMetadata: { alg: string; keyid: string };
@@ -101,7 +102,7 @@ export async function getKeyAndCertificateFromPathsAsync({
};
}
export async function getManifestBodyAsync(res: Response): Promise<string | null> {
async function getMultipartBodyPartAsync(res: Response, partName: string): Promise<string | null> {
const contentType = res.headers.get('content-type');
if (!contentType) {
throw new Error('The multipart manifest response is missing the content-type header');
@@ -111,11 +112,19 @@ export async function getManifestBodyAsync(res: Response): Promise<string | null
contentType,
Buffer.from(bodyBuffer)
);
const manifestPart = multipartParts.find(part => isMultipartPartWithName(part, 'manifest'));
const manifestPart = multipartParts.find(part => isMultipartPartWithName(part, partName));
return manifestPart?.body ?? null;
}
export function signManifestBody(body: string, codeSigningInfo: CodeSigningInfo): string {
export async function getManifestBodyAsync(res: Response): Promise<string | null> {
return await getMultipartBodyPartAsync(res, 'manifest');
}
export async function getDirectiveBodyAsync(res: Response): Promise<string | null> {
return await getMultipartBodyPartAsync(res, 'directive');
}
export function signBody(body: string, codeSigningInfo: CodeSigningInfo): string {
return signBufferRSASHA256AndVerify(
codeSigningInfo.privateKey,
codeSigningInfo.certificate,
@@ -181,3 +190,20 @@ export function checkManifestBodyAgainstUpdateInfoGroup(
assertAssetParity(correspondingManifestResponseBodyAssetJSON, nullthrows(partialManifestAsset));
}
}
export function checkDirectiveBodyAgainstUpdateInfoGroup(directiveResponseBody: string): void {
const directiveResponseBodyJSON = JSON.parse(directiveResponseBody);
if (
!areSetsEqual(
new Set(Object.keys(directiveResponseBodyJSON)),
new Set(['extra', 'type', 'parameters'])
)
) {
throw new Error('Code signing directive integrity error: Unexpected keys');
}
if (directiveResponseBodyJSON.type !== 'rollBackToEmbedded') {
throw new Error('Code signing directive integrity error: Incorrect directive type');
}
}
+24 -4
View File
@@ -10,6 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
import fetch, { RequestInit, Response } from '../fetch';
import { AppPlatform } from '../graphql/generated';
import Log from '../log';
import { promptAsync } from '../prompts';
import { formatBytes } from './files';
import { getTmpDirectory } from './paths';
import { ProgressHandler, createProgressTracker } from './progress';
@@ -162,16 +163,35 @@ export async function extractAppFromLocalArchiveAsync(
}
async function getAppPathAsync(outputDir: string, applicationExtension: string): Promise<string> {
const appFileName = await glob(`*.${applicationExtension}`, {
const appFilePaths = await glob(`./**/*.${applicationExtension}`, {
cwd: outputDir,
onlyFiles: false,
});
if (appFileName.length === 0) {
throw Error('Something went wrong while extracting the app from app archive');
if (appFilePaths.length === 0) {
throw Error('Did not find any installable apps inside tarball.');
}
return path.join(outputDir, appFileName[0]);
if (appFilePaths.length === 1) {
return path.join(outputDir, appFilePaths[0]);
}
Log.newLine();
Log.log('Detected multiple apps in the tarball:');
Log.newLine();
const { selectedFile } = await promptAsync({
type: 'select',
message: 'Select the app to run:',
name: 'selectedFile',
choices: [
...appFilePaths.map(filePath => ({
title: filePath,
value: filePath,
})),
],
});
return path.join(outputDir, selectedFile);
}
async function tarExtractAsync(input: string, output: string): Promise<void> {
+3 -2
View File
@@ -2,7 +2,7 @@ import { ExpoConfig } from '@expo/config-types';
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import { boolish } from 'getenv';
import resolveFrom from 'resolve-from';
import resolveFrom, { silent as silentResolveFrom } from 'resolve-from';
import semver from 'semver';
import Log from '../log';
@@ -63,7 +63,8 @@ export async function expoCommandAsync(
args: string[],
{ silent = false }: { silent?: boolean } = {}
): Promise<void> {
const expoCliPath = resolveFrom(projectDir, 'expo/bin/cli.js');
const expoCliPath =
silentResolveFrom(projectDir, 'expo/bin/cli') ?? resolveFrom(projectDir, 'expo/bin/cli.js');
const spawnPromise = spawnAsync(expoCliPath, args, {
stdio: ['inherit', 'pipe', 'pipe'], // inherit stdin so user can install a missing expo-cli from inside this command
});
@@ -0,0 +1,17 @@
import areSetsEqual from '../areSetsEqual';
describe(areSetsEqual, () => {
it.each([
[new Set([1, 2]), new Set([1, 2])],
[new Set([1, 2]), new Set([2, 1])],
])('equal cases: %p', (a, b) => {
expect(areSetsEqual(a, b)).toBe(true);
});
it.each([
[new Set([1, 2, 3]), new Set([1, 2])],
[new Set([1, 2]), new Set([1, 2, 3])],
])('non-equal cases: %p', (a, b) => {
expect(areSetsEqual(a, b)).toBe(false);
});
});
@@ -0,0 +1,3 @@
export default function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
return a.size === b.size && [...a].every(value => b.has(value));
}
+46 -2
View File
@@ -7,6 +7,8 @@ import {
SubmitProfile,
} from '@expo/eas-json';
import Log, { learnMore } from '../log';
type EasProfile<T extends ProfileType> = T extends 'build'
? BuildProfile<Platform>
: SubmitProfile<Platform>;
@@ -57,11 +59,15 @@ async function readProfileAsync<T extends ProfileType>({
profileName?: string;
}): Promise<EasProfile<T>> {
if (type === 'build') {
return (await EasJsonUtils.getBuildProfileAsync(
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
easJsonAccessor,
platform,
profileName
)) as EasProfile<T>;
);
await maybePrintBuildProfileDeprecationWarningsAsync(easJsonAccessor, platform, profileName);
return buildProfile as EasProfile<T>;
} else {
return (await EasJsonUtils.getSubmitProfileAsync(
easJsonAccessor,
@@ -70,3 +76,41 @@ async function readProfileAsync<T extends ProfileType>({
)) as EasProfile<T>;
}
}
let hasPrintedDeprecationWarnings = false;
/**
* Only for testing purposes
*/
export function clearHasPrintedDeprecationWarnings(): void {
hasPrintedDeprecationWarnings = false;
}
export async function maybePrintBuildProfileDeprecationWarningsAsync(
easJsonAccessor: EasJsonAccessor,
platform: Platform,
profileName?: string
): Promise<void> {
if (hasPrintedDeprecationWarnings) {
return;
}
const deprecationWarnings = await EasJsonUtils.getBuildProfileDeprecationWarningsAsync(
easJsonAccessor,
platform,
profileName ?? 'production'
);
if (deprecationWarnings.length === 0) {
return;
}
Log.newLine();
Log.warn('Detected deprecated fields in eas.json:');
for (const warning of deprecationWarnings) {
const warnlog: string = warning.message.map(line => `\t${line}`).join('\n');
Log.warn(warnlog);
if (warning.docsUrl) {
Log.warn(`\t${learnMore(warning.docsUrl)}`);
}
Log.newLine();
}
hasPrintedDeprecationWarnings = true;
}
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more