Compare commits

...
Author SHA1 Message Date
Douglas Lowder a7f002fb47 Show both tvOS and iOS simulators in build:run 2023-09-26 19:00:01 -07:00
Expo CI 04049a8d1f update CHANGELOG.md 2023-09-25 16:41:24 +00:00
Szymon Dziedzic 5bdd46a2a9 v5.3.0 2023-09-25 18:27:59 +02:00
Szymon Dziedzic a7e88bd7cb upgrade eas-cli-local-build-plugin to 1.0.44 2023-09-25 18:27:06 +02:00
Kadi KramanandSzymon Dziedzic 78b75c6224 [eas-cli] [eas-json] [ENG-10069] add support for bun (#2055)
* Update eas-build-job dependency

* Add bun to eas-cli

* Add bun to eas-json

* Add changelog

* Use correct PR in changelog

* Update packages/eas-json/schema/eas.schema.json

Co-authored-by: Szymon Dziedzic <[email protected]>

* Update gql schema

---------

Co-authored-by: Szymon Dziedzic <[email protected]>
2023-09-25 16:54:23 +01:00
Szymon Dziedzic 52f54fc7ae fix tests 2023-09-18 10:26:36 +02:00
Piotr Szeremeta 7ddd630cfd [FIX] Remove semver validation from node version read from .nvmrc (#2052)
* [FIX] Remove semver validation from node version read from .nvmrc

Node version can be specified as an alias (such as `iojs`, `lts/hydrogen` etc), so validating it as semver may cause errors for some users.

* update CHANGELOG.md
2023-09-18 08:28:39 +02:00
_nderscore 8751a2835f feat(eas-cli): expose expo export dev flag as an option in eas update (#2050) 2023-09-13 13:00:01 -07:00
Piotr Szeremeta da9829a853 [ENG-5266] Suggest correct bundleId when account name not available (#2027) 2023-09-11 14:56:53 +02:00
Quinlan Jung cc822b18f3 [eas-cli] rollouts: more robust printing function (#2047)
* [eas-cli] rollouts: more robust printing function

* update CHANGELOG.md
2023-09-06 11:10:44 -07:00
Expo CI bcab3bcb09 update CHANGELOG.md 2023-09-05 20:58:51 +00:00
Quinlan Jung a5dc0bd27c v5.2.0 2023-09-05 13:50:41 -07:00
Quinlan Jung 54127c4e37 [ENG-9828][eas-cli] rollouts: view action for CI (#2040)
* [eas-cli] rollouts: non-interactive view

* update CHANGELOG.md

* rebase
2023-09-05 13:49:12 -07:00
Quinlan Jung 9097ab2537 [ENG-9602][eas-cli] rollouts: json output for ci (#2037)
* [eas-cli] rollouts: json output for ci

* update CHANGELOG.md

* fix tests
2023-09-05 13:01:20 -07:00
Quinlan Jung 4e2029a5c5 [eas-cli] rollout tests (#2042)
* [eas-cli] rollout tests

* update CHANGELOG.md
2023-09-05 12:30:31 -07:00
Quinlan Jung ad02a056d7 [eas-cli] remove unreachable codesigning option (#2041)
* [eas-cli] remove unreachable option

* update CHANGELOG.md
2023-09-05 12:29:23 -07:00
Quinlan Jung ea9f734b11 [eas-cli] fix graphql (#2039)
* [eas-cli] fix graphql

* update CHANGELOG.md
2023-09-05 12:27:54 -07:00
Piotr Szeremeta 7db9d79c55 [Fix] Remove doubled definition of pnpm field from eas.schema.json (#2038) 2023-09-04 07:44:39 +02:00
Quinlan Jung 2e31b42b76 [eas-cli] only print channel-branch pairing if we created a channel (#2036)
* [eas-cli] only print channel-branch pairing if we created a channel

* update CHANGELOG.md
2023-09-01 12:38:42 -07:00
Expo CI 66d02d9724 update CHANGELOG.md 2023-09-01 14:14:32 +00:00
Piotr Szeremeta 7c96cc10cf v5.1.0 2023-09-01 16:05:53 +02:00
Piotr Szeremeta 523a781ba5 upgrade eas-cli-local-build-plugin to 1.0.42 2023-09-01 16:05:39 +02:00
Piotr Szeremeta 6cf8bd5c5a [ENG-9473] Support installing selected pnpm version (#1988)
* [wip][ENG-9473]

* [ENG-9473] Add `pnpm` option to `builderEnvironment`.

* update CHANGELOG.md

* Fix changelog
2023-09-01 15:26:28 +02:00
dependabot[bot] 67e9ea706b Bump word-wrap from 1.2.3 to 1.2.5 (#2033)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.5.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.5)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-30 17:04:07 +02:00
Szymon Dziedzic c22f410889 [eas-cli][eas-json] bump semver version (#2030)
* [eas-cli][eas-json] bump `semver` version

* update scripts yarn.lock
2023-08-30 16:00:49 +02:00
Radosław Krzemień 9ff47af4fa [eas-cli] [ENG-7536] Stop app config error repeating (#2020)
* [eas-cli] Stop app config error repeating

If app config error is thrown it is re-thrown with modified message instead of printing it to the as a "cryptic error" and repeating with 0% chances of success (since the config is incorrect)

See: https://linear.app/expo/issue/ENG-7536/make-app-config-error-on-credentials-command-not-repeat-indefinitely

* [eas-cli] Create custom error

Replaced generic error with specific message with a custom error class, which allows for not relying on a message to be exactly as expected

See: https://linear.app/expo/issue/ENG-7536/make-app-config-error-on-credentials-command-not-repeat-indefinitely

* update CHANGELOG.md

* [eas-cli] Remove blank line

Removed blank line from changelog

See: https://linear.app/expo/issue/ENG-7536/make-app-config-error-on-credentials-command-not-repeat-indefinitely
2023-08-30 09:11:00 +02:00
Quinlan Jung 324dce8fc0 [eas-cli] fix printing bug: branch with no update (#2023)
* [eas-cli] fix branch with no update printing bug

* tests

* update CHANGELOG.md
2023-08-29 14:07:35 -07:00
Expo CI b30c86f0c0 update CHANGELOG.md 2023-08-29 11:51:19 +00:00
Szymon Dziedzic 008279e1dd v5.0.2 2023-08-29 13:15:39 +02:00
Cedric van Putten d00814f84b fix(eas-cli): implement variadic --platform flag when creating EAS updates (#1939)
* fix(eas-cli): implement variadic `--platform` flag when creating EAS updates

* fix(eas-cli): finish todo and set minimum `@expo/[email protected]` version for multiple `--platform` flags during export

* update CHANGELOG.md

* fix(eas-cli): be verbose about `--platform=<flag>` in bundle script
2023-08-29 13:11:49 +02:00
Expo CI 81aa127e28 update CHANGELOG.md 2023-08-28 19:05:12 +00:00
Szymon Dziedzic d321a0bccb v5.0.1 2023-08-28 20:54:10 +02:00
Szymon Dziedzic a1d86549ed [eas-cli] fix eas update passing platform argument incorrectly to expo-cli (#2028)
* [eas-cli] fix `eas update` passing `platform` argument incorrectly to `expo-cli`

* update CHANGELOG.md
2023-08-28 20:51:18 +02:00
Expo CI f7e9af913c update CHANGELOG.md 2023-08-28 14:00:12 +00:00
Szymon Dziedzic 137f9d4ac7 v5.0.0 2023-08-28 14:20:36 +02:00
Szymon Dziedzic 2ac863a9e3 upgrade eas-cli-local-build-plugin to 1.0.39 2023-08-28 14:19:20 +02:00
Quinlan Jung 11be9eba49 [ENG-9685][eas-cli] create a linked channel on build if not exists (#2017)
* [eas-cli] create linked channel on build

* refactor

* update CHANGELOG.md

* amend doc block
2023-08-25 12:07:59 -07:00
Will Schurman b36db15601 [eas-cli][updates] configure updates as well when somebody tries to run a build with channel set (#2016) 2023-08-25 09:14:29 -07:00
Piotr Szeremeta 93c5dc1a32 [ENG-9422] nvmrc support for setting node version (#1998)
* [ENG-9422][eas-cli] Use .nvmrc to set node version

* update CHANGELOG.md
2023-08-25 15:46:55 +02:00
Szymon Dziedzic c86550c0c8 [eas-cli][eas-json] add developmentClient to metadata (#2015)
* [eas-cli][eas-json] add `developmentClient` to metadata

* update CHANGELOG.md
2023-08-25 13:26:33 +02:00
Brent Vatne 36b4296d3c [.github] Add issue template config 2023-08-24 17:09:47 -07:00
Brent Vatne 8c08541ca3 Create SECURITY.md 2023-08-24 17:06:03 -07:00
Will Schurman 5a1406d39c [eas-cli] Add info to EAS Update asset upload process about asset counts and limits (#2013) 2023-08-24 09:22:30 -07:00
Radosław Krzemień 78ea88cc5f [eas-cli] [ENG-7591] Detect not provisioned devices (#2011)
* [eas-cli] Detect not provisioned devices

After selecting devices to be provisioned and making a call to App Store Connect API the result is compared with the list of chosen devices. If any are missing, a message is displayed to the user

See: https://linear.app/expo/issue/ENG-7591/detect-processing-devices-on-apple-developer-portal-and-dont-allow-the

* [eas-cli] Adjust message

Updated the message with the link to the devices page in the Apple Developer portal

See: https://linear.app/expo/issue/ENG-7591/detect-processing-devices-on-apple-developer-portal-and-dont-allow-the

* update CHANGELOG.md
2023-08-24 15:35:48 +02:00
Will Schurman 6cb02d0ccd [eas-cli][eas-update] Support republishing roll back to embedded updates (#2006) 2023-08-15 11:39:24 -04:00
Will Schurman d6c7623718 [eas-cli][eas-update] Add rollback disambiguation command (#2004) 2023-08-15 11:36:18 -04:00
Quinlan Jung 32142b4fe8 [eas-cli] more branch mapping utils (#2003)
* [eas-cli] more branch mapping utils

* update CHANGELOG.md
2023-08-14 14:51:29 -07:00
Will Schurman 6bdee0f395 [eas-cli][update] Only export at most ios and android dist (#2002) 2023-08-14 17:28:06 -04:00
Quinlan Jung baf94a21c3 [eas-cli] more branch map utility functions (#2001)
* [eas-cli] more branch map utility functions

* update CHANGELOG.md

* feedback
2023-08-11 11:35:47 -07:00
Radoslaw Krzemien 162d64bce6 Revert "[eas-cli] Update oclif version"
This reverts commit d16c1fc757.
2023-08-11 12:22:56 +02:00
Radoslaw Krzemien d16c1fc757 [eas-cli] Update oclif version
Updated oclif version

See: https://linear.app/expo/issue/ENG-9175/update-oclif-in-eas-cli
2023-08-11 12:22:17 +02:00
Expo CI e42ea7e2ec update CHANGELOG.md 2023-08-10 10:26:10 +00:00
Szymon Dziedzic 5fea1ad16e v4.1.2 2023-08-10 12:12:49 +02:00
Szymon Dziedzic 2a57a2eb3d upgrade eas-cli-local-build-plugin to 1.0.38 2023-08-10 12:12:41 +02:00
Quinlan Jung 5628b78523 [eas-cli] make branch mapping utility files have no dependencies (#1993)
* [eas-cli] generalize branch mapping utils

* make standalone

* update CHANGELOG.md
2023-08-09 15:32:19 -07:00
Expo CI 19d1e6bffd update CHANGELOG.md 2023-08-08 18:36:49 +00:00
Quinlan Jung 88b068511c v4.1.1 2023-08-08 11:28:16 -07:00
Quinlan Jung 842efcf3ab upgrade eas-cli-local-build-plugin to 1.0.35 2023-08-08 11:28:02 -07:00
Quinlan Jung f5f08aa506 [eas-cli] rollouts websupport (#1997)
* [eas-cli] rollouts websupport

* update CHANGELOG.md

* fix changelog
2023-08-08 11:26:18 -07:00
Expo CI 9f7f412cfb update CHANGELOG.md 2023-08-08 13:19:03 +00:00
Szymon Dziedzic e369ec1e25 v4.1.0 2023-08-08 15:10:39 +02:00
Szymon Dziedzic b353441f31 upgrade eas-cli-local-build-plugin to 1.0.34 2023-08-08 15:10:24 +02:00
Szymon Dziedzic 4db35e73c6 [eas-cli] bump @expo/steps version (#1995) 2023-08-08 15:02:33 +02:00
Szymon Dziedzic 10bc872dc9 [eas-json][eas-cli] add global withoutCredentials property to eas.json (#1994)
* [eas-json][eas-cli] add global `withoutCredentials` property to `eas.json`

* update CHANGELOG.md

* fix tests
2023-08-08 14:13:25 +02:00
Szymon Dziedzic fedc39ea48 [eas-cli] pass iOS credentials to custom builds (#1989)
* [eas-cli] pass iOS credentials to custom builds

* update CHANGELOG.md
2023-08-08 13:55:18 +02:00
Expo CI 6046723769 update CHANGELOG.md 2023-08-08 08:39:19 +00:00
Quinlan Jung afaee9df38 v4.0.1 2023-08-07 16:28:14 -07:00
Quinlan Jung de39d6f00d [eas-cli] account for empty branch mapping (#1992)
* [eas-cli] account for empty branch mapping

* update CHANGELOG.md
2023-08-07 16:24:32 -07:00
Expo CI 401751fffa update CHANGELOG.md 2023-08-07 20:40:01 +00:00
Quinlan Jung 7f7f1c6a9a v4.0.0 2023-08-07 13:31:43 -07:00
Quinlan Jung 279d6716d2 [ENG-9617][eas-cli] release rollout into developer preview (#1986)
* [eas-cli] release rollout into developer preview

* update CHANGELOG.md
2023-08-07 13:14:52 -07:00
Quinlan Jung 92977d9a53 [ENG-9666][eas-cli] rollouts: link fyi (#1991)
* [eas-cli] rollouts: link fyi:

* update CHANGELOG.md
2023-08-07 13:14:25 -07:00
Quinlan Jung 31871a9eda [ENG-9614][eas-cli] rollout ux improvements (#1984)
* [eas-cli] rollout ux improvements

* more efficient 'only one' detection

* update CHANGELOG.md

* fix tsc

* amend tests to account for new behaviour
2023-08-07 12:59:16 -07:00
Quinlan Jung f122f42149 [eas-ci] rollouts: fix fp precision (#1985)
* [eas-ci] rollouts: fix fp precision

* update CHANGELOG.md
2023-08-07 10:51:26 -07:00
Quinlan Jung a9a49d6b97 [eas-cli] more grammar fixes (#1980)
* replace 'route', no more plate emoji

replace plate emoji

more grammar nits

* update CHANGELOG.md

* szdziedzic feedback

* wschurman feedback

* send -> put

* put -> remain
2023-08-04 12:59:58 -07:00
Quinlan Jung 1d94414334 [ENG-9603][eas-cli] rollout edit edge case (#1981)
* [eas-cli] rollout edit edge case

* update CHANGELOG.md

* warn only

* warn only

* lint
2023-08-04 12:27:01 -07:00
Quinlan Jung 0b73dc32f6 [eas-cli] use 'roll out' instead of rollout for verbs (#1979)
* [eas-cli] use 'roll out' instead of rollout for verbs

* update CHANGELOG.md
2023-08-04 11:30:03 -07:00
Radosław Krzemień ef8ad24639 [eas-cli] [ENG-8475] Add option to add current device automatically (#1943)
* [eas-cli] Add option to add current device automatically

Added a new option to `device:create` command to add a current device by automatically grabbing the provisioning UDID of the current Apple Silicon machine. If the current machine is not Apple Silicon, the command fails with proper message. If the user does not proceed when prompted it also fails since trying again with the same UDID does not make much sense

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Adjust log messages

Improved the log message from testing phase

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Fix test

The `currentMachineMethod` tests used to be platform dependent, since the function checks for the architecture of the machine. This check is now mocked to make the tests platform agnostic

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Adjust message

On failed arch check the message was displayed twice, now it is displayed once

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Fix tests

The tests were still platform dependent since the system profiler is only viable for Macs. This should fix it

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Remove timeout

Jest.timeout was leftover from testing

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Re-generate gql schema

Generated schema again from production

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Re-generate gql schema

Generated schema again from production

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Fix test

Fix test that was failing after re-generating gql schema

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Add mac device class

Added the mac apple device class and made it default for current machine method

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Generate gql schema

Generated the schema again after merge from main

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Remove `arch` check

More systems than Apple Silicon can have `arch` of `arm64`

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* update CHANGELOG.md

* [eas-cli] Set mac device class automatically

It doesn't make sense to give the user choice of device class if we previously check if the current machine is an Apple Silicon Mac

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Remove unused import

Import became redundant after last changes

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Check for platform

Updated the apple silicon check to check the platform as well

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Update command description

The command description had a typo and mentioned only iPhones and iPads

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli

* [eas-cli] Remove unused import

Remove unused import

See: https://linear.app/expo/issue/ENG-8475/investigate-grabbing-registering-macos-provisioning-udid-from-eas-cli
2023-08-04 14:28:13 +02:00
Will Schurman 24dbe6e7cc [eas-cli] Fix rollout-preview ending with republish with code signing (#1978) 2023-08-03 15:30:04 -07:00
Expo CI aad026febb update CHANGELOG.md 2023-08-03 18:07:08 +00:00
Quinlan Jung 6ae8125c0e v3.18.3 2023-08-03 10:58:19 -07:00
Quinlan Jung 87d143b310 [ENG-8715][eas-cli] get channel:{view,list,edit} to play nice with rollouts (#1974)
* [eas-cli] get channel:{view,list,edit} to play nice with rollouts

* tests

* update CHANGELOG.md
2023-08-03 10:55:20 -07:00
Will Schurman 346dee9ea5 [eas-cli] Fix republishing with code signing (#1973) 2023-08-03 10:48:47 -07:00
Quinlan Jung 4308d21927 [eas-cli] rollout relationships (#1972)
* [eas-cli] rollout relationships

* update CHANGELOG.md
2023-08-03 10:18:18 -07:00
Expo CI 207f623338 update CHANGELOG.md 2023-08-03 12:53:11 +00:00
Szymon Dziedzic 601101844c v3.18.2 2023-08-03 14:43:28 +02:00
Szymon Dziedzic 7027eaa974 upgrade eas-cli-local-build-plugin to 1.0.33 2023-08-03 14:43:17 +02:00
Szymon Dziedzic b3203345ea Revert "[ENG-9422][eas-cli] Use .nvmrc to set node version (#1954)" (#1976)
* Revert "[ENG-9422][eas-cli] Use .nvmrc to set node version (#1954)"

This reverts commit 0b5194b1a5.

* update CHANGELOG.md
2023-08-03 14:35:28 +02:00
Szymon Dziedzic 5ed2d01146 [eas-cli][metadata] use just a comma to concatenate keywords (#1967)
* [eas-cli][metadata] use just a comma to concatenate keywords

* update CHANGELOG.md
2023-08-03 14:30:24 +02:00
Szymon Dziedzic 6fc64d337f fix changelog once again 2023-08-03 14:22:49 +02:00
Szymon Dziedzic f7ed4cfa20 fix changelog 2023-08-03 13:22:09 +02:00
Expo CI 47968a1b8b update CHANGELOG.md 2023-08-03 02:14:06 +00:00
Quinlan Jung 7c779b3dc0 v3.18.1 2023-08-02 19:04:22 -07:00
Quinlan Jung 50474ec5e9 [eas-cli] fix update:republish group id (#1971)
* [eas-cli] fix update:republish group id

* update CHANGELOG.md
2023-08-02 15:20:33 -07:00
Quinlan Jung 46ff97f173 [ENG-8616][eas-cli] change update default runtime policy (#1968)
* [eas-cli] change update default runtime policy

* update CHANGELOG.md
2023-08-02 14:59:09 -07:00
Szymon Dziedzic b263dffbde update and fix changelog 2023-08-02 23:53:24 +02:00
Quinlan Jung 8d57828f7e [ENG-9441][eas-cli] channel:rollout-unstable (#1966)
* [eas-cli] eas channel:rollout-unstable

* json unsupport

* update CHANGELOG.md

* fix tsc

* fix test

* fix lint

* new selectors using filter pagination

* replace selectors

* remove unused stuff

* branch mapping tests

* add tests for relay utilities and selectors

* at is not shipped in node14

* pr feedback
2023-08-02 14:40:40 -07:00
Szymon Dziedzic fd9561cfc3 v3.18.0 2023-08-02 23:33:31 +02:00
Szymon Dziedzic 93ce451bc7 upgrade eas-cli-local-build-plugin to 1.0.31 2023-08-02 23:33:14 +02:00
Szymon Dziedzic 554e433548 update @expo/steps and @expo/eas-build-job (#1970) 2023-08-02 23:24:35 +02:00
Szymon Dziedzic 12a1cb7011 [ENG-9482][eas-cli] provide credentials for custom Android builds (#1969)
* [eas-cli] provide credentials for custom Android builds

* update CHANGELOG.md
2023-08-02 18:06:41 +02:00
Patryk FrydaandPatryk Fryda 360de90eea [eas-cli] fix 'manged' typo in ios target (#1961)
Co-authored-by: Patryk Fryda <[email protected]>
2023-07-31 17:08:12 +02:00
Quinlan Jung ea02fcebda [eas-cli] select existing rollouts and channels (#1958)
* [eas-cli] select existing rollouts and channels

* update CHANGELOG.md
2023-07-28 09:27:17 -07:00
Piotr Szeremeta 0b5194b1a5 [ENG-9422][eas-cli] Use .nvmrc to set node version (#1954)
* [ENG-9422][eas-cli] Use .nvmrc to set node version

* update CHANGELOG.md
2023-07-28 12:26:03 +02:00
Quinlan Jung 05799aba9c [eas-cli] more branch mapping utility (#1957)
* [eas-cli] more branch mapping utility

* update CHANGELOG.md
2023-07-27 17:05:00 -07:00
Expo CI 2cece65dbf update CHANGELOG.md 2023-07-27 14:32:42 +00:00
Szymon Dziedzic 17b6b4ec45 v3.17.1 2023-07-27 15:37:26 +02:00
Szymon Dziedzic 4f892c66e7 [eas-cli] update @expo/steps version (#1956) 2023-07-27 15:15:09 +02:00
Quinlan Jung fd99365655 [eas-cli] fetch entire relay compliant dataset (#1955)
* [eas-cli] fetch entire relay compliant dataset

* update CHANGELOG.md
2023-07-26 20:48:21 -07:00
Quinlan Jung 7dca2c8021 [eas-cli] revert UpdateBranchWithCurrentGroupFragment (#1952)
* [eas-ci] revert UpdateBranchWithCurrentGroupFragment

* update CHANGELOG.md
2023-07-26 10:32:11 -07:00
Quinlan Jung 55bce8d82a [eas-cli] update channel basic tests and fixtures (#1950) 2023-07-25 09:37:47 -07:00
Quinlan Jung 1f5533e5e3 [eas-cli][1/2] unify channel graphql query types (#1949)
* [eas-cli] unify channel graphql query types

* update CHANGELOG.md
2023-07-25 09:34:12 -07:00
Expo CI 51d0555baa update CHANGELOG.md 2023-07-24 17:24:12 +00:00
Szymon Dziedzic 2137176a65 v3.17.0 2023-07-24 19:16:40 +02:00
Szymon Dziedzic 3fda52c8d7 [eas-cli] handle server side defined error for situation when intel-medium is not available and remove intel-medium for eas.schema.json (#1947)
* [eas-cli] handle server side defined error when `intel-medium` resource class is not available

* don't suggest using `intel-medium` in `eas.schema.json`

* update CHANGELOG.md

* update CHANGELOG.md
2023-07-24 19:08:10 +02:00
Quinlan Jung b252b5b582 [eas-cli] utils: amend rollouts definition (#1945)
* [eas-cli] utils: amend rollouts definition

* update CHANGELOG.md
2023-07-24 09:10:07 -07:00
Szymon Dziedzic 2befe410cf [eas-cli] allow for rollout option when configuring submission (#1938)
* [eas-cli] allow for `rollout` option when configuring submission

* update CHANGELOG.md

* add entry to `eas.schema.json`

* improve eas.schema.json

* make rollout default to 1 if not set

* Revert "make rollout default to 1 if not set"

This reverts commit 2ab689b02c.

* generate schema against staging

* update fragment
2023-07-24 14:05:10 +02:00
Quinlan Jung 1463e0de74 [eas-cli] branch mapping utility functions (#1944)
* [eas-cli] branch mapping primitives

* refactor

* extra test

* update CHANGELOG.md
2023-07-20 12:45:40 -07:00
Piotr Szeremeta e963558ef6 [ENG-8956][eas-build] Improve EAS CLI tsconfig.json for local development (#1933)
Changed noUnusedLocals and noUnusedParameters to false for easier local development and experimenting.
These checks do not run during local development. Instead, `yarn build` catches these cases on github.
2023-07-20 16:35:52 +02:00
Quinlan Jung fe57c055b6 [eas-cli] bump urql graphql client (#1936)
* [eas-cli] bump urql graphql client

* update CHANGELOG.md

* fix test
2023-07-19 10:34:04 -07:00
Expo CI 045840111e update CHANGELOG.md 2023-07-18 16:13:46 +00:00
Szymon Dziedzic 9a6068c1f2 v3.16.0 2023-07-18 18:04:52 +02:00
Szymon Dziedzic d06a77bd6d [eas-cli] fix incorrect handling of valid inProgress release status (#1934)
* [eas-cli] fix incorrect handling of valid `inProgress` release status

* update CHANGELOG.md

* add another test
2023-07-18 15:47:45 +02:00
Szymon Dziedzic 7bcf83d141 Revert "add another test"
This reverts commit cd9323e115.
2023-07-18 15:34:09 +02:00
Szymon Dziedzic cd9323e115 add another test 2023-07-18 15:33:15 +02:00
Firas Regaieg 34145c38ca [Build] Print friendly error msg in case account doesn't have required permission (#1867)
* Print friendly error msg (reviewed)

Comment: This is useful when credentials fail, if user doesn't have permissions.

* Print friendly error msg - 2nd review

* Print friendly error msg - 2nd review (reviewed)

* Replace changelog event under main section
2023-07-18 15:28:33 +02:00
Piotr SzeremetaandSzymon Dziedzic 57dd372957 [ENG-9283][eas-cli] Throw error when project tarball exceeds 1GB (#1928)
* [ENG-9283][eas-cli] Throw error when project tarball exceeds 1GB

* update CHANGELOG.md

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

Co-authored-by: Szymon Dziedzic <[email protected]>

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

Co-authored-by: Szymon Dziedzic <[email protected]>

* Update CHANGELOG.md

Co-authored-by: Szymon Dziedzic <[email protected]>

---------

Co-authored-by: Szymon Dziedzic <[email protected]>
2023-07-17 14:12:17 +02:00
Will Schurman 5232641217 [eas-cli] Use node server default port selection for SSO login server (#1930) 2023-07-13 09:29:51 -07:00
Cedric van Putten 3c3d3150c9 [eas-cli] Ignore the existing entitlements when ios is gitignored (#1906)
* [eas-cli] Ignore the existing entitlements when ios is gitignored

* chore: add changelog entry
2023-07-13 16:51:39 +02:00
Will Schurman fd322d4189 [eas-cli] Add styling to SSO auth redirect completion page (#1929) 2023-07-12 09:11:03 -07:00
Expo CI 77254f6be1 update CHANGELOG.md 2023-07-11 06:59:32 +00:00
140 changed files with 11708 additions and 1581 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Expo Developers Discord
url: https://chat.expo.dev/
about: Join other developers in discussions regarding Expo and EAS.
+4 -4
View File
@@ -29,7 +29,7 @@ jobs:
with:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- run: yarn build-ci
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
@@ -70,7 +70,7 @@ jobs:
with:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: yarn build
- run: yarn build-ci
- name: Build tarballs
working-directory: ${{ github.workspace }}/packages/eas-cli
run: |
@@ -99,7 +99,7 @@ jobs:
node-version: 18
- run: yarn install --frozen-lockfile --check-files
- run: sudo apt-get install nsis
- run: yarn build
- run: yarn build-ci
- run: |
yarn pretarball-ci
yarn oclif pack:win
@@ -142,7 +142,7 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile --check-files
- name: Build
run: yarn build
run: yarn build-ci
- name: Publish packages to npm
run: yarn lerna publish from-package --yes --no-verify-access
env:
+217
View File
@@ -10,6 +10,223 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
### 🧹 Chores
## [5.3.0](https://github.com/expo/eas-cli/releases/tag/v5.3.0) - 2023-09-25
### 🎉 New features
- Update: expose expo cli `--dev` flag as an argument. ([#2050](https://github.com/expo/eas-cli/pull/2050) by [@nderscore](https://github.com/nderscore))
- Support `bun` option in eas.json. ([#2055](https://github.com/expo/eas-cli/pull/2055) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- Support node aliases in .nvmrc. ([#2052](https://github.com/expo/eas-cli/pull/2052) by [@khamilowicz](https://github.com/khamilowicz))
### 🧹 Chores
- Rollouts: more robust printing function. ([#2047](https://github.com/expo/eas-cli/pull/2047) by [@quinlanj](https://github.com/quinlanj))
## [5.2.0](https://github.com/expo/eas-cli/releases/tag/v5.2.0) - 2023-09-05
### 🎉 New features
- Rollouts: json output for ci. ([#2037](https://github.com/expo/eas-cli/pull/2037) by [@quinlanj](https://github.com/quinlanj))
### 🐛 Bug fixes
- Update: only print channel-branch pairing if we created a channel. ([#2036](https://github.com/expo/eas-cli/pull/2036) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Added rollout tests. ([#2042](https://github.com/expo/eas-cli/pull/2042) by [@quinlanj](https://github.com/quinlanj))
- Remove unreachable codesigning option. ([#2041](https://github.com/expo/eas-cli/pull/2041) by [@quinlanj](https://github.com/quinlanj))
- Fix generated graphql tsc errors. ([#2039](https://github.com/expo/eas-cli/pull/2039) by [@quinlanj](https://github.com/quinlanj))
- Rollouts: view action for CI. ([#2040](https://github.com/expo/eas-cli/pull/2040) by [@quinlanj](https://github.com/quinlanj))
## [5.1.0](https://github.com/expo/eas-cli/releases/tag/v5.1.0) - 2023-09-01
### 🎉 New features
- Support `pnpm` option in eas.json. ([#1988](https://github.com/expo/eas-cli/pull/1988) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
- Make app config error not repeat indefinitely. ([#2020](https://github.com/expo/eas-cli/pull/2020) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
## [5.0.2](https://github.com/expo/eas-cli/releases/tag/v5.0.2) - 2023-08-29
### 🐛 Bug fixes
- Add proper expo cli `--platform` flag handling when exporting updates. ([#1939](https://github.com/expo/eas-cli/pull/1939) by [@byCedric](https://github.com/byCedric))
## [5.0.1](https://github.com/expo/eas-cli/releases/tag/v5.0.1) - 2023-08-28
### 🐛 Bug fixes
- Pass `platform` argument to expo-cli correctly when using the `eas update` command. ([#2028](https://github.com/expo/eas-cli/pull/2028) by [@szdziedzic](https://github.com/szdziedzic))
## [5.0.0](https://github.com/expo/eas-cli/releases/tag/v5.0.0) - 2023-08-28
### 🛠 Breaking changes
- Only export at most ios and android dist for EAS updates. ([#2002](https://github.com/expo/eas-cli/pull/2002) by [@wschurman](https://github.com/wschurman))
### 🎉 New features
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
### 🐛 Bug fixes
- Support republishing roll back to embedded updates. ([#2006](https://github.com/expo/eas-cli/pull/2006) by [@wschurman](https://github.com/wschurman))
- Configure updates as well when somebody tries to run a build with channel set. ([#2016](https://github.com/expo/eas-cli/pull/2016) by [@wschurman](https://github.com/wschurman))
- Fix printing bug: branch with no update. ([#2023](https://github.com/expo/eas-cli/pull/2023) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- More branch map utility functions. ([#2001](https://github.com/expo/eas-cli/pull/2001) by [@quinlanj](https://github.com/quinlanj))
- More branch mapping utils. ([#2003](https://github.com/expo/eas-cli/pull/2003) by [@quinlanj](https://github.com/quinlanj))
- Create a linked channel on build if not exists. ([#2017](https://github.com/expo/eas-cli/pull/2017) by [@quinlanj](https://github.com/quinlanj))
- Add `developmentClient` to metadata. ([#2015](https://github.com/expo/eas-cli/pull/2015) by [@szdziedzic](https://github.com/szdziedzic))
## [4.1.2](https://github.com/expo/eas-cli/releases/tag/v4.1.2) - 2023-08-10
### 🧹 Chores
- Make branch mapping utility files have no dependencies. ([#1993](https://github.com/expo/eas-cli/pull/1993) by [@quinlanj](https://github.com/quinlanj))
## [4.1.1](https://github.com/expo/eas-cli/releases/tag/v4.1.1) - 2023-08-08
### 🧹 Chores
- Logger to say website support is coming soon for rollouts. ([#1997](https://github.com/expo/eas-cli/pull/1997) by [@quinlanj](https://github.com/quinlanj))
## [4.1.0](https://github.com/expo/eas-cli/releases/tag/v4.1.0) - 2023-08-08
### 🎉 New features
- Pass credentials to custom iOS builds. ([#1989](https://github.com/expo/eas-cli/pull/1989) by [@szdziedzic](https://github.com/szdziedzic))
- Add the `withoutCredentials` option as a common build profile field in `eas.json`. ([#1994](https://github.com/expo/eas-cli/pull/1994) by [@szdziedzic](https://github.com/szdziedzic))
## [4.0.1](https://github.com/expo/eas-cli/releases/tag/v4.0.1) - 2023-08-08
### 🐛 Bug fixes
- Fix `eas channel:*` to work with empty branch mappings. ([#1992](https://github.com/expo/eas-cli/pull/1992) by [@quinlanj](https://github.com/quinlanj))
## [4.0.0](https://github.com/expo/eas-cli/releases/tag/v4.0.0) - 2023-08-07
### 🛠 Breaking changes
- Release redesigned `eas channel:rollout` into developer preview. The set of flag arguments are different, in addition to the workflow. ([#1986](https://github.com/expo/eas-cli/pull/1986) by [@quinlanj](https://github.com/quinlanj))
### 🎉 New features
- Option to add current Apple Silicon device without the need to manually provide the provisioning UDID. ([#1943](https://github.com/expo/eas-cli/pull/1943) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
### 🐛 Bug fixes
- Fix rollout-preview ending with republish with code signing. ([#1978](https://github.com/expo/eas-cli/pull/1978) by [@wschurman](https://github.com/wschurman))
- Rollouts: fix fp precision. ([#1985](https://github.com/expo/eas-cli/pull/1985) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Fixing more grammar errors. ([#1980](https://github.com/expo/eas-cli/pull/1980) by [@quinlanj](https://github.com/quinlanj))
- Handle rollout edit edge case. ([#1981](https://github.com/expo/eas-cli/pull/1981) by [@quinlanj](https://github.com/quinlanj))
- Linked expo.fyi rollout article. ([#1991](https://github.com/expo/eas-cli/pull/1991) by [@quinlanj](https://github.com/quinlanj))
- Rollout ux improvements. ([#1984](https://github.com/expo/eas-cli/pull/1984) by [@quinlanj](https://github.com/quinlanj))
## [3.18.3](https://github.com/expo/eas-cli/releases/tag/v3.18.3) - 2023-08-03
### 🐛 Bug fixes
- Fix republishing with code signing. ([#1973](https://github.com/expo/eas-cli/pull/1973) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Add relationships flag to rollouts-preview. ([#1972](https://github.com/expo/eas-cli/pull/1972) by [@quinlanj](https://github.com/quinlanj))
- Get channel:{view,list,edit} to play nice with rollouts. ([#1974](https://github.com/expo/eas-cli/pull/1974) by [@quinlanj](https://github.com/quinlanj))
- Use 'roll out' instead of rollout for verbs. ([#1979](https://github.com/expo/eas-cli/pull/1979) by [@quinlanj](https://github.com/quinlanj))
## [3.18.2](https://github.com/expo/eas-cli/releases/tag/v3.18.2) - 2023-08-03
### 🐛 Bug fixes
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Use just a comma instead of `, ` when concatenating `keywords` in `eas metadata`. ([#1967](https://github.com/expo/eas-cli/pull/1967) by [@szdziedzic](https://github.com/szdziedzic))
## [3.18.1](https://github.com/expo/eas-cli/releases/tag/v3.18.1) - 2023-08-03
### 🐛 Bug fixes
- Pass correct group into `update:republish`. ([#1971](https://github.com/expo/eas-cli/pull/1971) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Make new rollouts version available for internal dogfooding. ([#1966](https://github.com/expo/eas-cli/pull/1966) by [@quinlanj](https://github.com/quinlanj))
- Change default runtime version policy for EAS Update to appVersion. ([#1968](https://github.com/expo/eas-cli/pull/1968) by [@quinlanj](https://github.com/quinlanj))
## [3.18.0](https://github.com/expo/eas-cli/releases/tag/v3.18.0) - 2023-08-02
### 🎉 New features
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
- Provide credentials for custom Android builds. ([#1969](https://github.com/expo/eas-cli/pull/1969) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- More branch mapping utility. ([#1957](https://github.com/expo/eas-cli/pull/1957) by [@quinlanj](https://github.com/quinlanj))
- Utility classes to select existing rollouts and channels. ([#1958](https://github.com/expo/eas-cli/pull/1958) by [@quinlanj](https://github.com/quinlanj))
## [3.17.1](https://github.com/expo/eas-cli/releases/tag/v3.17.1) - 2023-07-27
### 🧹 Chores
- Unify channel graphql query types. ([#1949](https://github.com/expo/eas-cli/pull/1949) by [@quinlanj](https://github.com/quinlanj))
- Revert UpdateBranchWithCurrentGroupFragment. ([#1952](https://github.com/expo/eas-cli/pull/1952) by [@quinlanj](https://github.com/quinlanj))
- Fetch entire relay compliant dataset. ([#1953](https://github.com/expo/eas-cli/pull/1953) by [@quinlanj](https://github.com/quinlanj))
## [3.17.0](https://github.com/expo/eas-cli/releases/tag/v3.17.0) - 2023-07-24
### 🎉 New features
- Add `rollout` option for configuring Android submissions. ([#1938](https://github.com/expo/eas-cli/pull/1938) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Added branch mapping utility functions. ([#1944](https://github.com/expo/eas-cli/pull/1944) by [@quinlanj](https://github.com/quinlanj))
- Amend branch mapping utility functions. ([#1945](https://github.com/expo/eas-cli/pull/1945) by [@quinlanj](https://github.com/quinlanj))
- Handle error thrown when `intel-medium` resource class is not available as server-side defined error. ([#1947](https://github.com/expo/eas-cli/pull/1947) by [@szdziedzic](https://github.com/szdziedzic))
- Remove `intel-medium` from `eas.schema.json`, so it's not suggested as a valid value by our VSCode plugin. ([#1947](https://github.com/expo/eas-cli/pull/1947) by [@szdziedzic](https://github.com/szdziedzic))
## [3.16.0](https://github.com/expo/eas-cli/releases/tag/v3.16.0) - 2023-07-18
### 🎉 New features
- Add styling to SSO auth redirect completion page. ([#1929](https://github.com/expo/eas-cli/pull/1929) by [@wschurman](https://github.com/wschurman))
- Ignore entitlements from native template when `/ios` is gitignored. ([#1906](https://github.com/expo/eas-cli/pull/1906) by [@byCedric](https://github.com/byCedric))
- Use node server default port selection for SSO login server. ([#1930](https://github.com/expo/eas-cli/pull/1930) by [@wschurman](https://github.com/wschurman))
### 🐛 Bug fixes
- Fix incorrect handling of valid `inProgress` Android submission release status. ([#1934](https://github.com/expo/eas-cli/pull/1934) by [@szdziedzic](https://github.com/szdziedzic))
### 🧹 Chores
- Print friendly error msg in case account doesn't have required permission. ([#1867](https://github.com/expo/eas-cli/pull/1867) by [@firasrg](https://github.com/firasrg))
- Limit project file upload size to 2GB. ([#1928](https://github.com/expo/eas-cli/pull/1928) by [@khamilowicz](https://github.com/khamilowicz))
- Bump urql graphql client major version. ([#1936](https://github.com/expo/eas-cli/pull/1936) by [@quinlanj](https://github.com/quinlanj))
## [3.15.1](https://github.com/expo/eas-cli/releases/tag/v3.15.1) - 2023-07-11
### 🐛 Bug fixes
- Ensure useClassicUpdates is not set when using EAS Update commands. ([#1915](https://github.com/expo/eas-cli/pull/1915) by [@ide](https://github.com/ide))
- Handle multiple GraphQLErrors when receiving a CombinedError. ([#1924](https://github.com/expo/eas-cli/pull/1924) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
+5
View File
@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Instead of opening a GitHub issue for security vulnerabilities, send an email to our secure inbox: secure@expo.io.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "3.15.1",
"version": "5.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1
View File
@@ -6,6 +6,7 @@
],
"scripts": {
"build": "lerna run build",
"build-ci": "lerna run build-ci",
"build:tarballs:linux": "yarn build-for-size-check",
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
"typecheck": "lerna run typecheck",
+79 -67
View File
@@ -133,7 +133,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -150,7 +150,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -167,7 +167,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -181,7 +181,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -231,7 +231,7 @@ DESCRIPTION
create a branch
```
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -252,7 +252,7 @@ DESCRIPTION
delete a branch
```
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -272,7 +272,7 @@ DESCRIPTION
list all branches
```
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -292,7 +292,7 @@ DESCRIPTION
rename a branch
```
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -315,7 +315,7 @@ DESCRIPTION
view a branch
```
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -345,7 +345,7 @@ DESCRIPTION
start a build
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -362,7 +362,7 @@ DESCRIPTION
cancel a build
```
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -379,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.15.1/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:inspect`
@@ -414,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.15.1/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -452,7 +452,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -478,7 +478,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -502,7 +502,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/run.ts)_
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -551,7 +551,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -570,7 +570,7 @@ DESCRIPTION
update version of an app
```
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -589,7 +589,7 @@ DESCRIPTION
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.15.1/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -606,7 +606,7 @@ DESCRIPTION
view a build for your project
```
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -627,7 +627,7 @@ DESCRIPTION
create a channel
```
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -649,7 +649,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -669,7 +669,7 @@ DESCRIPTION
list all channels
```
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -677,23 +677,31 @@ Roll a new branch out on a channel incrementally.
```
USAGE
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
$ eas channel:rollout [CHANNEL] [--action create|edit|end|view] [--percent <value>] [--outcome
republish-and-revert|revert] [--branch <value>] [--runtime-version <value>] [--private-key-path <value>] [--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
--action=(create|edit|end|view) Rollout action to perform
--branch=<value> Branch to roll out. Use with --action=create
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--outcome=(republish-and-revert|revert) End outcome of rollout. Use with --action=end
--percent=<value> Percent of users to send to the new branch. Use with --action=edit or
--action=create
--private-key-path=<value> 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.
--runtime-version=<value> Runtime version to target. Use with --action=create
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.15.1/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -716,7 +724,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -737,7 +745,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -754,7 +762,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -768,7 +776,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.15.1/packages/eas-cli/src/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -788,7 +796,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -809,7 +817,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -830,7 +838,7 @@ DESCRIPTION
rename a registered device
```
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -844,7 +852,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -858,7 +866,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -946,7 +954,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -963,7 +971,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.15.1/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -980,7 +988,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.15.1/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas open`
@@ -994,7 +1002,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/open.ts)_
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1008,7 +1016,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/project/info.ts)_
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1030,7 +1038,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/project/init.ts)_
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -1053,7 +1061,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.15.1/packages/eas-cli/src/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -1071,7 +1079,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -1085,7 +1093,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.15.1/packages/eas-cli/src/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/list.ts)_
## `eas secret:push`
@@ -1105,7 +1113,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.15.1/packages/eas-cli/src/commands/secret/push.ts)_
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/secret/push.ts)_
## `eas submit`
@@ -1135,7 +1143,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1144,8 +1152,8 @@ publish an update group
```
USAGE
$ eas update [--branch <value>] [--channel <value>] [-m <value>] [--republish | --input-dir <value> |
--skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--auto] [--private-key-path <value>]
[--json --non-interactive]
--skip-bundler] [--group <value> | | ] [--clear-cache] [-p android|ios|all] [--dev] [--auto] [--private-key-path
<value>] [--json --non-interactive]
FLAGS
-m, --message=<value> A short message describing the update
@@ -1154,6 +1162,7 @@ FLAGS
--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
--dev Publish a development bundle
--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.
@@ -1168,7 +1177,7 @@ DESCRIPTION
publish an update group
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/update/index.ts)_
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1186,7 +1195,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1207,7 +1216,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.15.1/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:list`
@@ -1229,7 +1238,7 @@ DESCRIPTION
view the recent updates
```
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/update/list.ts)_
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1238,22 +1247,25 @@ roll back to an existing update
```
USAGE
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [-m <value>] [-p android|ios|all]
[--json --non-interactive]
[--private-key-path <value>] [--json --non-interactive]
FLAGS
-m, --message=<value> Short message describing the republished update
-m, --message=<value> Short message describing the republished update group
-p, --platform=(android|ios|all) [default: all]
--branch=<value> Branch name to select an update to republish from
--channel=<value> Channel name to select an update to republish from
--branch=<value> Branch name to select an update group to republish from
--channel=<value> Channel name to select an update group to republish from
--group=<value> Update group ID to republish
--json Enable JSON output, non-JSON messages will be printed to stderr.
--non-interactive Run the command in non-interactive mode.
--private-key-path=<value> 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.
DESCRIPTION
roll back to an existing update
```
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:view GROUPID`
@@ -1273,7 +1285,7 @@ DESCRIPTION
update group details
```
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/update/view.ts)_
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas webhook:create`
@@ -1294,7 +1306,7 @@ DESCRIPTION
create a webhook
```
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1314,7 +1326,7 @@ DESCRIPTION
delete a webhook
```
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -1332,7 +1344,7 @@ DESCRIPTION
list webhooks
```
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -1354,7 +1366,7 @@ DESCRIPTION
update a webhook
```
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -1371,7 +1383,7 @@ DESCRIPTION
view a webhook
```
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.15.1/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v5.3.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
+3442 -347
View File
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "3.15.1",
"version": "5.3.0",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -13,8 +13,8 @@
"@expo/config": "8.1.2",
"@expo/config-plugins": "7.2.4",
"@expo/config-types": "49.0.0",
"@expo/eas-build-job": "1.0.13",
"@expo/eas-json": "3.15.1",
"@expo/eas-build-job": "1.0.43",
"@expo/eas-json": "5.3.0",
"@expo/json-file": "8.2.37",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
@@ -27,13 +27,13 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.25",
"@expo/steps": "1.0.34",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "1.3.10",
"@segment/ajv-human-errors": "^2.1.2",
"@urql/core": "3.1.1",
"@urql/exchange-retry": "1.0.0",
"@urql/core": "4.0.11",
"@urql/exchange-retry": "1.2.0",
"ajv": "8.11.0",
"ajv-formats": "2.1.1",
"better-opn": "3.0.2",
@@ -47,7 +47,6 @@
"fast-glob": "3.2.12",
"figures": "3.2.0",
"form-data": "4.0.0",
"freeport-async": "2.0.0",
"fs-extra": "10.1.0",
"getenv": "1.0.0",
"gradle-to-js": "2.0.1",
@@ -74,7 +73,7 @@
"prompts": "2.4.2",
"qrcode-terminal": "0.12.0",
"resolve-from": "5.0.0",
"semver": "7.3.8",
"semver": "7.5.2",
"slash": "3.0.0",
"tar": "6.1.13",
"terminal-link": "2.1.1",
@@ -205,11 +204,12 @@
"scripts": {
"postpack": "rimraf oclif.manifest.json",
"prepack": "yarn rebuild && node ./scripts/prepack.js",
"rebuild": "rimraf build && yarn build",
"rebuild": "rimraf build && yarn build-ci",
"pretarball-ci": "./scripts/pretarball-ci.sh",
"build": "tsc --project tsconfig.build.json",
"build-ci": "tsc --project tsconfig.build-ci.json",
"watch": "yarn build --watch --preserveWatchOutput",
"typecheck": "tsc",
"typecheck": "tsc --project tsconfig.build-ci.json",
"test": "jest",
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
-12
View File
@@ -2,7 +2,6 @@ import { JSONValue } from '@expo/json-file';
import { ApiV2Error } from './ApiV2Error';
import fetch, { RequestError, RequestInit } from './fetch';
import { getFreePortAsync } from './utils/port';
interface RequestOptions {
body: JSONValue;
@@ -105,14 +104,3 @@ export function getEASUpdateURL(projectId: string): string {
return new URL(projectId, `https://u.expo.dev`).href;
}
}
export async function getSsoLocalServerPortAsync(): Promise<number> {
let startPort: number;
if (process.env.SSO_LOCAL_SERVER_PORT) {
startPort = Number(process.env.SSO_LOCAL_SERVER_PORT);
} else {
startPort = 19300;
}
const port = await getFreePortAsync(startPort);
return port;
}
@@ -0,0 +1,12 @@
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
export const testBasicBranchInfo: UpdateBranchBasicInfoFragment = {
id: 'c4fec95d-7ab1-4d35-bfe6-fe7ff05beda9',
name: 'development',
__typename: 'UpdateBranch',
};
export const testBasicBranchInfo2: UpdateBranchBasicInfoFragment = {
id: '43ccb8a4-8aaa-47f0-9790-dcc80933441b',
name: 'preview',
__typename: 'UpdateBranch',
};
@@ -0,0 +1,107 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a branch for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the branches that are shown to the user. It takes a branchInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of branch printed to the user. Defaults to 'branch'.
* @param {number} options.pageSize - The number of branches to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectBranch implements EASUpdateAction<UpdateBranchBasicInfoFragment | null> {
constructor(
private options: {
filterPredicate?: (branchInfo: UpdateBranchBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateBranchBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) => boolean
): Promise<Connection<UpdateBranchBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateBranchBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'branch';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateBranchBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
const getTitleAsync = async (branchInfo: UpdateBranchBasicInfoFragment): Promise<string> =>
branchInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -0,0 +1,92 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateBranchBasicInfoFragment } from '../../../graphql/generated';
import { BranchQuery } from '../../../graphql/queries/BranchQuery';
import { promptAsync } from '../../../prompts';
import { testBasicBranchInfo, testBasicBranchInfo2 } from '../../__tests__/fixtures';
import { SelectBranch } from '../SelectBranch';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/BranchQuery');
describe(SelectBranch, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no branchs', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBeNull();
});
it('still prompts if project has only one branch', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected branch', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicBranchInfo,
}));
const ctx = createCtxMock();
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('returns the only available branch with a filter', async () => {
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicBranchInfo, cursor: 'cursor' },
{ node: testBasicBranchInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectBranch = new SelectBranch({
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo === testBasicBranchInfo,
});
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectBranch = new SelectBranch();
// dont fail if users are running in non-interactive mode
await expect(selectBranch.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -27,12 +27,18 @@ beforeEach(() => {
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `[
{
"name": "GraphQLError",
"extensions": {},
"message": "Error 1"
},
{
"name": "GraphQLError",
"extensions": {},
"message": "Error 2"
},
{
"name": "GraphQLError",
"extensions": {},
"message": "Error 3"
}
]`;
+1 -2
View File
@@ -124,8 +124,7 @@ export async function prepareAndroidBuildAsync(
}
function shouldProvideCredentials(ctx: BuildContext<Platform.ANDROID>): boolean {
const isCustomBuild = ctx.buildProfile.config !== undefined;
return !ctx.buildProfile.withoutCredentials && !isCustomBuild;
return !ctx.buildProfile.withoutCredentials;
}
async function ensureAndroidCredentialsAsync(
@@ -66,6 +66,8 @@ export async function prepareJobAsync(
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn,
ndk: buildProfile.ndk,
expoCli: buildProfile.expoCli,
+6
View File
@@ -45,6 +45,7 @@ import {
EasBuildFreeTierDisabledIOSError,
EasBuildFreeTierIosLimitExceededError,
EasBuildFreeTierLimitExceededError,
EasBuildLegacyResourceClassNotAvailableError,
EasBuildProjectArchiveUploadError,
EasBuildResourceClassNotAvailableInFreeTierError,
EasBuildTooManyPendingBuildsError,
@@ -190,6 +191,7 @@ const SERVER_SIDE_DEFINED_ERRORS: Record<string, typeof EasCommandError> = {
EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED: EasBuildFreeTierIosLimitExceededError,
EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER:
EasBuildResourceClassNotAvailableInFreeTierError,
EAS_BUILD_LEGACY_RESOURCE_CLASS_NOT_AVAILABLE: EasBuildLegacyResourceClassNotAvailableError,
VALIDATION_ERROR: RequestValidationError,
};
@@ -255,6 +257,10 @@ async function uploadProjectAsync<TPlatform extends Platform>(
);
}
if (projectTarball.size > 2 * 1024 * 1024 * 1024) {
throw new Error('Project archive is too big. Maximum allowed size is 2GB.');
}
projectTarballPath = projectTarball.path;
const bucketKey = await uploadFileAtPathToGCSAsync(
+1
View File
@@ -55,4 +55,5 @@ export interface BuildContext<T extends Platform> {
customBuildConfigMetadata?: CustomBuildConfigMetadata;
android: T extends Platform.ANDROID ? AndroidBuildContext : undefined;
ios: T extends Platform.IOS ? IosBuildContext : undefined;
developmentClient: boolean;
}
@@ -60,6 +60,12 @@ export async function createBuildContextAsync<T extends Platform>({
const workflow = await resolveWorkflowAsync(projectDir, platform);
const accountId = account.id;
const runFromCI = getenv.boolish('CI', false);
const developmentClient =
buildProfile.developmentClient ??
(platform === Platform.ANDROID
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
false;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
@@ -120,6 +126,7 @@ export async function createBuildContextAsync<T extends Platform>({
message,
runFromCI,
customBuildConfigMetadata,
developmentClient,
};
if (platform === Platform.ANDROID) {
const common = commonContext as CommonContext<Platform.ANDROID>;
+2
View File
@@ -14,6 +14,8 @@ export class EasBuildFreeTierIosLimitExceededError extends EasCommandError {}
export class EasBuildResourceClassNotAvailableInFreeTierError extends EasCommandError {}
export class EasBuildLegacyResourceClassNotAvailableError extends EasCommandError {}
export class RequestValidationError extends EasCommandError {}
export class EasBuildDownForMaintenanceError extends EasCommandError {}
@@ -55,6 +55,5 @@ export async function ensureIosCredentialsForBuildResignAsync(
}
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
return !buildCtx.buildProfile.simulator && !isCustomBuild;
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials;
}
@@ -58,6 +58,8 @@ export async function prepareJobAsync(
builderEnvironment: {
image: buildProfile.image,
node: buildProfile.node,
pnpm: buildProfile.pnpm,
bun: buildProfile.bun,
yarn: buildProfile.yarn,
bundler: buildProfile.bundler,
cocoapods: buildProfile.cocoapods,
+1 -1
View File
@@ -6,7 +6,7 @@ import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.27';
const PLUGIN_PACKAGE_VERSION = '1.0.44';
export enum LocalBuildMode {
/**
+1
View File
@@ -63,6 +63,7 @@ export async function collectMetadataAsync<T extends Platform>(
runFromCI: ctx.runFromCI,
buildMode: ctx.buildProfile.config ? BuildMode.CUSTOM : BuildMode.BUILD,
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
developmentClient: ctx.developmentClient,
};
return sanitizeMetadata(metadata);
}
@@ -1,3 +1,4 @@
import { ExpoConfig } from '@expo/config-types';
import { Platform, Workflow } from '@expo/eas-build-job';
import {
AppVersionSource,
@@ -13,6 +14,7 @@ import chalk from 'chalk';
import nullthrows from 'nullthrows';
import { Analytics } from '../analytics/AnalyticsManager';
import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/queries';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import {
@@ -38,9 +40,9 @@ import {
import { checkExpoSdkIsSupportedAsync } from '../project/expoSdk';
import { validateMetroConfigForManagedWorkflowAsync } from '../project/metroConfig';
import {
installExpoUpdatesAsync,
isExpoUpdatesInstalledAsDevDependency,
isExpoUpdatesInstalledOrAvailable,
isUsingEASUpdate,
validateAppVersionRuntimePolicySupportAsync,
} from '../project/projectUtils';
import {
@@ -57,6 +59,7 @@ import {
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../submit/submit';
import { printSubmissionDetailsUrls } from '../submit/utils/urls';
import { ensureEASUpdateIsConfiguredAsync } from '../update/configure';
import { validateBuildProfileConfigMatchesProjectConfigAsync } from '../update/utils';
import { Actor } from '../user/User';
import { downloadAndMaybeExtractAppAsync } from '../utils/download';
@@ -117,6 +120,7 @@ export async function runBuildAndSubmitAsync(
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
Log.log(
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
@@ -183,6 +187,7 @@ export async function runBuildAndSubmitAsync(
platforms,
profileName: flags.submitProfile,
type: 'submit',
projectDir,
});
for (const startedBuild of startedBuilds) {
const submitProfile = nullthrows(
@@ -320,11 +325,26 @@ async function prepareAndStartBuildAsync({
);
if (buildProfile.profile.channel) {
await validateExpoUpdatesInstalledAsProjectDependencyAsync({
graphqlClient,
exp: buildCtx.exp,
projectId: buildCtx.projectId,
projectDir,
sdkVersion: buildCtx.exp.sdkVersion,
nonInteractive: flags.nonInteractive,
buildProfile,
});
if (isUsingEASUpdate(buildCtx.exp, buildCtx.projectId)) {
const doesChannelExist = await doesChannelExistAsync(graphqlClient, {
appId: buildCtx.projectId,
channelName: buildProfile.profile.channel,
});
if (!doesChannelExist) {
await createAndLinkChannelAsync(graphqlClient, {
appId: buildCtx.projectId,
channelName: buildProfile.profile.channel,
});
}
}
}
await validateAppVersionRuntimePolicySupportAsync(buildCtx.projectDir, buildCtx.exp);
@@ -447,11 +467,17 @@ async function maybeDownloadAndRunSimulatorBuildsAsync(
}
async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
exp,
graphqlClient,
projectId,
projectDir,
buildProfile,
nonInteractive,
sdkVersion,
}: {
graphqlClient: ExpoGraphqlClient;
exp: ExpoConfig;
projectId: string;
projectDir: string;
buildProfile: ProfileData<'build'>;
nonInteractive: boolean;
@@ -467,18 +493,24 @@ async function validateExpoUpdatesInstalledAsProjectDependencyAsync({
);
} else if (nonInteractive) {
Log.warn(
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates".`
`The build profile "${buildProfile.profileName}" has specified the channel "${buildProfile.profile.channel}", but the "expo-updates" package hasn't been installed. To use channels for your builds, install the "expo-updates" package by running "npx expo install expo-updates" followed by "eas update:configure".`
);
} else {
Log.warn(
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package.`
`The build profile "${buildProfile.profileName}" specifies the channel "${buildProfile.profile.channel}", but the "expo-updates" package is missing. To use channels in your builds, install the "expo-updates" package and run "eas update:configure".`
);
const installExpoUpdates = await confirmAsync({
message: `Would you like to install the "expo-updates" package?`,
message: `Would you like to install the "expo-updates" package and configure EAS Update now?`,
});
if (installExpoUpdates) {
await installExpoUpdatesAsync(projectDir, { silent: false });
Log.withTick('Installed expo-updates');
await ensureEASUpdateIsConfiguredAsync(graphqlClient, {
exp,
projectId,
projectDir,
platform: RequestedPlatform.All,
});
Log.withTick('Installed expo-updates and configured EAS Update.');
throw new Error('Command must be re-run to pick up new updates configuration.');
}
}
}
@@ -0,0 +1,22 @@
import { BranchBasicInfo, ChannelBasicInfo, UpdateChannelInfoWithBranches } from '../utils';
export const testChannelBasicInfo: ChannelBasicInfo = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
};
export const testBasicBranchInfo1: BranchBasicInfo = {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
};
export const testBasicBranchInfo2: BranchBasicInfo = {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
};
export const channelInfoWithBranches: UpdateChannelInfoWithBranches<BranchBasicInfo> = {
...testChannelBasicInfo,
updateBranches: [testBasicBranchInfo1, testBasicBranchInfo2],
};
@@ -0,0 +1,70 @@
import {
emptyBranchMapping,
rolloutBranchMapping,
standardBranchMapping,
} from '../../rollout/__tests__/fixtures';
import {
BranchMappingValidationError,
assertVersion,
getAlwaysTrueBranchMapping,
getBranchMapping,
getEmptyBranchMapping,
getStandardBranchId,
isAlwaysTrueBranchMapping,
isEmptyBranchMapping,
} from '../branch-mapping';
import { testChannelBasicInfo } from './branch-mapping-fixtures';
describe(assertVersion, () => {
it('throws if the branch mapping is not the correct version', () => {
expect(() => assertVersion(testChannelBasicInfo, 5)).toThrowError(BranchMappingValidationError);
});
it('asserts the correct version', () => {
assertVersion(testChannelBasicInfo, 0);
expect(getBranchMapping(testChannelBasicInfo.branchMapping).version).toBe(0);
});
});
describe(getEmptyBranchMapping, () => {
it('gets an empty branch mapping', () => {
expect(isEmptyBranchMapping(getEmptyBranchMapping())).toBe(true);
});
});
describe(isEmptyBranchMapping, () => {
it('detects empty branch mappings', () => {
expect(isEmptyBranchMapping(emptyBranchMapping)).toBe(true);
expect(isEmptyBranchMapping(standardBranchMapping)).toBe(false);
expect(isEmptyBranchMapping(rolloutBranchMapping)).toBe(false);
});
});
describe(isAlwaysTrueBranchMapping, () => {
it('detects always true branch mappings', () => {
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
expect(isAlwaysTrueBranchMapping(emptyBranchMapping)).toBe(false);
});
});
describe(getAlwaysTrueBranchMapping, () => {
it('gets an always true branch mapping', () => {
const alwaysTrueBranchMapping = getAlwaysTrueBranchMapping('test-id');
expect(isAlwaysTrueBranchMapping(alwaysTrueBranchMapping)).toBe(true);
});
});
describe(getStandardBranchId, () => {
it('throws if the branch mapping is not a standard mapping', () => {
expect(() => getStandardBranchId(testChannelBasicInfo)).toThrowError(
BranchMappingValidationError
);
});
it('gets a standard branch id', () => {
const channelObjectWithStandardMapping = { ...testChannelBasicInfo };
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
standardBranchMapping.data[0].branchId
);
});
});
@@ -0,0 +1,148 @@
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
export const testUpdateBranch1: UpdateBranchObject = {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
updateGroups: [
[
{
id: 'bbe3e63a-f620-4603-8c4d-258f3e51590d',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/bbe3e63a-f620-4603-8c4d-258f3e51590d',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'db22401b-d321-4629-a068-21d6d1f19c02',
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/db22401b-d321-4629-a068-21d6d1f19c02',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '754bf17f-efc0-46ab-8a59-a03f20e53e9b',
name: 'wrong-channel',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testUpdateBranch2: UpdateBranchObject = {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
updateGroups: [
[
{
id: 'f04aafc1-0bf5-4548-8c35-726b467e7c40',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/f04aafc1-0bf5-4548-8c35-726b467e7c40',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
{
id: 'd8d15786-2727-407f-bf3e-f4bda7e397d9',
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
isRollBackToEmbedded: false,
manifestPermalink: 'https://u.expo.dev/update/d8d15786-2727-407f-bf3e-f4bda7e397d9',
gitCommitHash: 'e91c5bb733eff675dd8a3722ed5d4f885a4b10ce',
actor: {
__typename: 'User',
id: '026ace2b-c1f8-4a02-818d-6b52c100bdf2',
username: 'quintest113',
},
branch: {
id: '6941a8dd-5c0a-48bc-8876-f49c88ed419f',
name: 'production',
__typename: 'UpdateBranch',
},
codeSigningInfo: null,
__typename: 'Update',
},
],
],
__typename: 'UpdateBranch',
};
export const testChannelObject: UpdateChannelObject = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
createdAt: '2023-05-16T21:26:16.946Z',
updatedAt: '2023-05-16T21:26:16.946Z',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
updateBranches: [testUpdateBranch1, testUpdateBranch2],
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = {
id: '9309afc2-9752-40db-8ef7-4abc10744c61',
name: 'production',
branchMapping:
'{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
id: '9d5c7bf0-d52d-474c-9140-c1bad7f0de9d',
name: 'staging',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
};
@@ -0,0 +1,57 @@
import { format } from '@expo/timeago.js';
import { getAlwaysTrueBranchMapping, getEmptyBranchMapping } from '../branch-mapping';
import { getDescriptionByBranchId } from '../print-utils';
import { testChannelObject, testUpdateBranch1, testUpdateBranch2 } from './fixtures';
describe(getDescriptionByBranchId, () => {
it('should get descriptions for multiple branches with multiple update groups', () => {
const descriptionByBranchId = getDescriptionByBranchId(testChannelObject);
expect(Object.values(descriptionByBranchId)).toHaveLength(2);
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({
branch: 'wrong-channel',
branchRolloutPercentage: 15,
update: {
codeSigningKey: undefined,
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
isRollBackToEmbedded: false,
message: `"fix bug" (${format('2023-07-17T22:48:59.278Z', 'en_US')} by quintest113)`,
platforms: 'android, ios',
runtimeVersion: 'exposdk:48.0.0',
},
});
expect(descriptionByBranchId[testUpdateBranch2.id]).toEqual({
branch: 'production',
branchRolloutPercentage: 85,
update: {
codeSigningKey: undefined,
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
isRollBackToEmbedded: false,
message: `"fix bug" (${format('2023-06-23T23:37:10.004Z', 'en_US')} by quintest113)`,
platforms: 'android, ios',
runtimeVersion: 'exposdk:48.0.0',
},
});
});
it('should get descriptions for branches with no updates', () => {
const noUpdatesBranch = { ...testUpdateBranch1, updateGroups: [] };
const oneBranchChannel = {
...testChannelObject,
updateBranches: [noUpdatesBranch],
branchMapping: JSON.stringify(getAlwaysTrueBranchMapping(noUpdatesBranch.id)),
};
const descriptionByBranchId = getDescriptionByBranchId(oneBranchChannel);
expect(Object.values(descriptionByBranchId)).toHaveLength(1);
expect(descriptionByBranchId[testUpdateBranch1.id]).toEqual({ branch: 'wrong-channel' });
});
it('should get descriptions for no branches', () => {
const noBranchChannel = {
...testChannelObject,
updateBranches: [],
branchMapping: JSON.stringify(getEmptyBranchMapping()),
};
const descriptionByBranchId = getDescriptionByBranchId(noBranchChannel);
expect(Object.values(descriptionByBranchId)).toHaveLength(0);
});
});
@@ -0,0 +1,11 @@
import { getUpdateBranch } from '../utils';
import { channelInfoWithBranches, testBasicBranchInfo1 } from './branch-mapping-fixtures';
describe(getUpdateBranch, () => {
it('gets the update branch', () => {
expect(getUpdateBranch(channelInfoWithBranches, '754bf17f-efc0-46ab-8a59-a03f20e53e9b')).toBe(
testBasicBranchInfo1
);
expect(() => getUpdateBranch(channelInfoWithBranches, 'not-a-branch-ID')).toThrow();
});
});
@@ -0,0 +1,107 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a channel for the project.
*
* @constructor
* @param {function} options.filterPredicate - A predicate to filter the channels that are shown to the user. It takes a channelInfo object as a parameter and returns a boolean.
* @param {string} options.printedType - The type of channel printed to the user. Defaults to 'channel'.
* @param {number} options.pageSize - The number of channels to show per page. Defaults to 100.
* @param {function} options.beforeEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called before each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @param {function} options.afterEachFilterQuery Optional. If a filter predicate was specified, this callback function will be called after each query.
* @args externalQueryParams The query params for the pagination.
* @args totalNodesFetched The total number of nodes fetched so far.
* @args dataset The dataset so far.
* @args willFetchAgain If the query will fetch again to get a complete page.
*/
export class SelectChannel implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
constructor(
private options: {
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
printedType?: string;
pageSize?: number;
beforeEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[]
) => void;
afterEachFilterQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
) => void;
} = {}
) {}
async queryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId: projectId,
...queryParams,
});
}
async filterQueryAsync(
ctx: EASUpdateContext,
queryParams: QueryParams,
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
): Promise<Connection<UpdateChannelBasicInfoFragment>> {
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
await this.queryAsync(ctx, queryParams);
return await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery: this.options.beforeEachFilterQuery,
afterEachQuery: this.options.afterEachFilterQuery,
});
}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const { nonInteractive } = ctx;
const { filterPredicate } = this.options;
const printedType = this.options.printedType ?? 'channel';
const pageSize = this.options.pageSize ?? 100;
if (nonInteractive) {
throw new NonInteractiveError(
`${printedType} selection cannot be run in non-interactive mode.`
);
}
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
channelInfo.name;
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
}
}
@@ -0,0 +1,92 @@
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../__tests__/fixtures';
import { SelectChannel } from '../SelectChannel';
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectChannel, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no channels', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBeNull();
});
it('still prompts if project has only one channel', async () => {
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
expect(promptAsync).toHaveBeenCalledTimes(1);
});
it('returns the selected channel', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('returns the only available channel with a filter', async () => {
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const ctx = createCtxMock();
const selectChannel = new SelectChannel({
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) =>
channelInfo === testBasicChannelInfo,
});
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('throws an error in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
nonInteractive: true,
});
const selectChannel = new SelectChannel();
// dont fail if users are running in non-interactive mode
await expect(selectChannel.runAsync(ctx)).rejects.toThrowError(NonInteractiveError);
});
});
@@ -0,0 +1,214 @@
import { ChannelBasicInfo } from './utils';
// TODO(quin): move this into a common package with www
export type BranchMappingOperator =
| '=='
| '!='
| '<'
| '>'
| '<='
| '>='
| 'in'
| 'regex'
| 'hash_lt'
| 'hash_lte'
| 'hash_gt'
| 'hash_gte';
export type BranchMappingObject = {
clientKey: string;
branchMappingOperator: BranchMappingOperator;
operand: number | string | string[];
};
export type BranchMappingAlwaysTrue = 'true';
export type BranchMappingStatement =
| ['and' | 'or', ...BranchMappingNode[]]
| ['not', BranchMappingNode];
export type BranchMappingNode =
| BranchMappingAlwaysTrue
| BranchMappingObject
| BranchMappingStatement;
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: BranchMappingNode;
}[];
};
export type AlwaysTrueBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export type EmptyBranchMapping = {
version: number;
data: [];
};
export function getEmptyBranchMapping(): EmptyBranchMapping {
return {
version: 0,
data: [],
};
}
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
return {
version: 0,
data: [
{
branchId,
branchMappingLogic: 'true',
},
],
};
}
export function hasEmptyBranchMap(channelInfo: ChannelBasicInfo): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isEmptyBranchMapping(branchMapping);
}
export function hasStandardBranchMap(channelInfo: ChannelBasicInfo): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isAlwaysTrueBranchMapping(branchMapping);
}
export function getStandardBranchId(channelInfo: ChannelBasicInfo): string {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
assertAlwaysTrueBranchMapping(branchMapping);
return getBranchIdFromStandardMapping(branchMapping);
}
export function isEmptyBranchMapping(
branchMapping: BranchMapping
): branchMapping is EmptyBranchMapping {
return branchMapping.data.length === 0;
}
export function isAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): branchMapping is AlwaysTrueBranchMapping {
const numBranches = branchMapping.data.length;
if (numBranches !== 1) {
return false;
}
const branchMappingLogic = branchMapping.data[0].branchMappingLogic;
return isAlwaysTrue(branchMappingLogic);
}
function getBranchIdFromStandardMapping(branchMapping: AlwaysTrueBranchMapping): string {
return branchMapping.data[0].branchId;
}
export function getBranchIds(branchMapping: BranchMapping): string[] {
return branchMapping.data.map(data => data.branchId);
}
export function getBranchMapping(branchMappingString: string): BranchMapping {
try {
return JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
}
export function getNodesFromStatement(statement: BranchMappingStatement): BranchMappingNode[] {
return statement.slice(1) as BranchMappingNode[];
}
export function isAndStatement(
statement: BranchMappingStatement
): statement is BranchMappingStatement {
return statement[0] === 'and';
}
export function isStatement(node: BranchMappingNode): node is BranchMappingStatement {
return Array.isArray(node);
}
export function isNodeObject(node: BranchMappingNode): node is BranchMappingObject {
return typeof node === 'object' && !isStatement(node);
}
export function andStatement(nodes: BranchMappingNode[]): ['and', ...BranchMappingNode[]] {
return ['and', ...nodes];
}
export function isAlwaysTrue(node: BranchMappingNode): boolean {
return node === 'true';
}
export function alwaysTrue(): BranchMappingAlwaysTrue {
return 'true';
}
export function equalsOperator(): BranchMappingOperator {
return '==';
}
export function hashLtOperator(): BranchMappingOperator {
return 'hash_lt';
}
function isVersion(branchMapping: BranchMapping, version: number): boolean {
return branchMapping.version === version;
}
export function assertVersion(channelInfo: ChannelBasicInfo, version: number): void {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
if (!isVersion(branchMapping, version)) {
throw new BranchMappingValidationError(
`Expected branch mapping version ${version}. Received: ${JSON.stringify(branchMapping)}`
);
}
}
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
if (!isStatement(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
);
}
}
export function assertNodeObject(node: BranchMappingNode): asserts node is BranchMappingObject {
if (!isNodeObject(node)) {
throw new BranchMappingValidationError(
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
);
}
}
export function assertNumber(operand: string | number | string[]): asserts operand is number {
if (typeof operand !== 'number') {
throw new BranchMappingValidationError(
'Expected a number. Received: ' + JSON.stringify(operand)
);
}
}
export function assertString(operand: string | number | string[]): asserts operand is string {
if (typeof operand !== 'string') {
throw new BranchMappingValidationError(
'Expected a string. Received: ' + JSON.stringify(operand)
);
}
}
function assertAlwaysTrueBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is AlwaysTrueBranchMapping {
if (!isAlwaysTrueBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Expected standard branch mapping. Received: ' + JSON.stringify(branchMapping)
);
}
}
export class BranchMappingValidationError extends Error {}
@@ -0,0 +1,68 @@
import chalk from 'chalk';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { getRollout, isRollout } from '../rollout/branch-mapping';
import {
FormattedBranchDescription,
formatBranch,
getUpdateGroupDescriptionsWithBranch,
} from '../update/utils';
import { assertVersion, hasEmptyBranchMap, hasStandardBranchMap } from './branch-mapping';
/**
* Log all the branches associated with the channel and their most recent update group
*/
export function logChannelDetails(channel: UpdateChannelObject): void {
assertVersion(channel, 0);
const doesSupportMapping =
hasEmptyBranchMap(channel) || hasStandardBranchMap(channel) || isRollout(channel);
if (!doesSupportMapping) {
Log.log(chalk.dim('Custom branch mapping detected.'));
return;
}
const branchDescriptionByBranchId = getDescriptionByBranchId(channel);
const entries = Object.entries(branchDescriptionByBranchId);
if (entries.length === 0) {
Log.log(chalk.dim('No branches are pointed to this channel.'));
} else {
Log.log(
entries
.map(([_branchId, description]) => formatBranch(description))
.join(`\n\n${chalk.dim('———')}\n\n`)
);
}
}
export function getDescriptionByBranchId(
channel: UpdateChannelObject
): Record<string, FormattedBranchDescription> {
return channel.updateBranches.reduce((acc, branch) => {
const maybeRollout = isRollout(channel) ? getRollout(channel) : null;
let maybePercentOnBranch: number | undefined = undefined;
if (maybeRollout) {
maybePercentOnBranch =
maybeRollout.rolledOutBranchId === branch.id
? maybeRollout.percentRolledOut
: 100 - maybeRollout.percentRolledOut;
}
if (branch.updateGroups.length === 0) {
acc[branch.id] = { branch: branch.name, branchRolloutPercentage: maybePercentOnBranch };
return acc;
}
const updateGroupsWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
branch.updateGroups
);
// display the most recent update group
const updateGroupWithBranchDescription = updateGroupsWithBranchDescriptions[0];
const { branch: branchName, ...updateGroup } = updateGroupWithBranchDescription;
acc[branch.id] = {
branch: branchName,
branchRolloutPercentage: maybePercentOnBranch,
update: updateGroup,
};
return acc;
}, {} as Record<string, FormattedBranchDescription>);
}
+108 -3
View File
@@ -1,6 +1,9 @@
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { createUpdateBranchOnAppAsync } from '../branch/queries';
import { BranchNotFoundError } from '../branch/utils';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { PaginatedQueryOptions } from '../commandUtils/pagination';
import { withErrorHandlingAsync } from '../graphql/client';
@@ -12,14 +15,18 @@ import {
} from '../graphql/generated';
import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
import Log from '../log';
import { getDisplayNameForProjectIdAsync } from '../project/projectUtils';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { logChannelDetails } from './utils';
import { ChannelNotFoundError } from './errors';
import { logChannelDetails } from './print-utils';
import { ChannelBasicInfo } from './utils';
export const CHANNELS_LIMIT = 25;
@@ -227,11 +234,11 @@ export async function createChannelOnAppAsync(
updateChannel {
createUpdateChannelForApp(appId: $appId, name: $name, branchMapping: $branchMapping) {
id
name
branchMapping
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{
appId,
@@ -262,3 +269,101 @@ export async function ensureChannelExistsAsync(
}
}
}
export async function doesChannelExistAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName }: { appId: string; channelName: string }
): Promise<boolean> {
try {
await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId,
channelName,
});
} catch (err) {
if (err instanceof ChannelNotFoundError) {
return false;
}
throw err;
}
return true;
}
/**
*
* Creates a channel and links it to a branch with the same name.
*
* @param appId the app ID, also known as the project ID
* @param channelName the name of the channel to create
* @param shouldPrintJson print only the JSON output
*/
export async function createAndLinkChannelAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
channelName,
shouldPrintJson,
}: { appId: string; channelName: string; shouldPrintJson?: boolean }
): Promise<ChannelBasicInfo> {
let branchId: string;
let branchMessage: string;
try {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId,
name: channelName,
});
branchId = branch.id;
branchMessage = `We found a branch with the same name`;
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
} else {
throw error;
}
}
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createChannelOnAppAsync(graphqlClient, {
appId,
channelName,
branchId,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on project with id ${appId}`
);
}
if (shouldPrintJson) {
printJsonOnlyOutput(newChannel);
} else {
Log.addNewLineIfNone();
Log.withTick(
`Created a new channel on project ${chalk.bold(
await getDisplayNameForProjectIdAsync(graphqlClient, appId)
)}`
);
Log.log(
formatFields([
{ label: 'Name', value: newChannel.name },
{ label: 'ID', value: newChannel.id },
])
);
Log.addNewLineIfNone();
Log.withTick(`${branchMessage} and have pointed the channel at it.`);
Log.log(
formatFields([
{ label: 'Name', value: newChannel.name },
{ label: 'ID', value: branchId },
])
);
}
return newChannel;
}
+27 -109
View File
@@ -1,117 +1,35 @@
import assert from 'assert';
import chalk from 'chalk';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import {
FormattedBranchDescription,
formatBranch,
getUpdateGroupDescriptionsWithBranch,
} from '../update/utils';
export type BranchMapping = {
version: number;
data: {
branchId: string;
branchMappingLogic: {
operand: number;
clientKey: string;
branchMappingOperator: string;
} & string;
}[];
export type ChannelBasicInfo = {
id: string;
name: string;
branchMapping: string;
};
export type BranchBasicInfo = {
id: string;
name: string;
};
/**
* Get the branch mapping and determine whether it is a rollout.
* Ensure that the branch mapping is properly formatted.
*/
export function getBranchMapping(branchMappingString?: string): {
branchMapping: BranchMapping;
isRollout: boolean;
rolloutPercent?: number;
} {
if (!branchMappingString) {
throw new Error('Missing branch mapping.');
}
let branchMapping: BranchMapping;
try {
branchMapping = JSON.parse(branchMappingString);
} catch {
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
}
assert(branchMapping, 'Branch Mapping must be defined.');
export type UpdateChannelInfoWithBranches<Branch extends BranchBasicInfo> = ChannelBasicInfo & {
updateBranches: Branch[];
};
if (branchMapping.version !== 0) {
throw new Error('Branch mapping must be version 0.');
}
const isRollout = branchMapping.data.length === 2;
const rolloutPercent = branchMapping.data[0]?.branchMappingLogic.operand;
switch (branchMapping.data.length) {
case 0:
break;
case 1:
if (branchMapping.data[0].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a single branch must be "true"');
}
break;
case 2:
if (branchMapping.data[0].branchMappingLogic.clientKey !== 'rolloutToken') {
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
}
if (branchMapping.data[0].branchMappingLogic.branchMappingOperator !== 'hash_lt') {
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
}
if (rolloutPercent == null) {
throw new Error('Branch mapping is missing a "rolloutPercent"');
}
if (branchMapping.data[1].branchMappingLogic !== 'true') {
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
}
break;
default:
throw new Error('Branch mapping data must have length less than or equal to 2.');
}
return { branchMapping, isRollout, rolloutPercent };
function getUpdateBranchNullable<Branch extends BranchBasicInfo>(
channel: UpdateChannelInfoWithBranches<Branch>,
branchId: string
): Branch | null {
const updateBranches = channel.updateBranches;
const updateBranch = updateBranches.find(branch => branch.id === branchId);
return updateBranch ?? null;
}
export function logChannelDetails(channel: UpdateChannelObject): void {
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
if (branchMapping.data.length > 2) {
throw new Error('Branch Mapping data must have length less than or equal to 2.');
}
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
const branchDescription = channel.updateBranches.flatMap(branch => {
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
branch.updateGroups
);
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
let rolloutPercentNumber: number | undefined = undefined;
if (isRolloutBranch) {
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
}
return updateGroupWithBranchDescriptions.map(
({ branch, ...updateGroup }): FormattedBranchDescription => ({
branch,
branchRolloutPercentage: rolloutPercentNumber,
update: updateGroup,
})
);
});
if (branchDescription.length === 0) {
Log.log(chalk.dim('No branches are pointed to this channel.'));
} else {
Log.log(
branchDescription
.map(description => formatBranch(description))
.join(`\n\n${chalk.dim('———')}\n\n`)
export function getUpdateBranch<Branch extends BranchBasicInfo>(
channel: UpdateChannelInfoWithBranches<Branch>,
branchId: string
): Branch {
const updateBranch = getUpdateBranchNullable(channel, branchId);
if (!updateBranch) {
throw new Error(
`Could not find branch with id "${branchId}" in branch-mapping of channel "${channel.name}"`
);
}
return updateBranch;
}
@@ -200,7 +200,14 @@ export default abstract class EasCommand extends Command {
const requestIdLine = graphQLError.extensions?.requestId
? `\nRequest ID: ${err.graphQLErrors[0].extensions.requestId}`
: '';
return `${messageLine}${requestIdLine}`;
const defaultMsg = `${messageLine}${requestIdLine}`;
if (graphQLError.extensions?.errorCode === 'UNAUTHORIZED_ERROR') {
return `You don't have the required permissions to perform this operation.\n\n${defaultMsg}`;
}
return defaultMsg;
})
.join('\n');
Log.error(cleanMessage);
@@ -3,11 +3,10 @@ import {
Client,
OperationContext,
OperationResult,
PromisifiedSource,
OperationResultSource,
TypedDocumentNode,
cacheExchange,
createClient as createUrqlClient,
dedupExchange,
fetchExchange,
} from '@urql/core';
import { retryExchange } from '@urql/exchange-retry';
@@ -22,7 +21,7 @@ export interface ExpoGraphqlClient extends Client {
query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
variables: Variables,
context: Partial<OperationContext> & { additionalTypenames: string[] }
): PromisifiedSource<OperationResult<Data, Variables>>;
): OperationResultSource<OperationResult<Data, Variables>>;
}
export function createGraphqlClient(authInfo: {
@@ -32,7 +31,6 @@ export function createGraphqlClient(authInfo: {
return createUrqlClient({
url: getExpoApiBaseUrl() + '/graphql',
exchanges: [
dedupExchange,
cacheExchange,
retryExchange({
maxDelayMs: 4000,
@@ -68,6 +68,7 @@ export default class BuildVersionGetView extends EasCommand {
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
const results: { [key in Platform]?: string } = {};
for (const { profile, platform } of buildProfiles) {
@@ -80,6 +80,7 @@ export default class BuildVersionSyncView extends EasCommand {
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
for (const profileInfo of buildProfiles) {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
@@ -1,16 +1,11 @@
import chalk from 'chalk';
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
import { BranchNotFoundError } from '../../branch/utils';
import { createChannelOnAppAsync } from '../../channel/queries';
import { createAndLinkChannelAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { enableJsonOutput } from '../../utils/json';
export default class ChannelCreate extends EasCommand {
static override description = 'create a channel';
@@ -60,69 +55,13 @@ export default class ChannelCreate extends EasCommand {
}));
}
let branchId: string;
let branchMessage: string;
try {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: projectId,
name: channelName,
});
branchId = branch.id;
branchMessage = `We found a branch with the same name`;
} catch (error) {
if (error instanceof BranchNotFoundError) {
const newBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
appId: projectId,
name: channelName,
});
branchId = newBranch.id;
branchMessage = `We also went ahead and made a branch with the same name`;
} else {
throw error;
}
}
const {
updateChannel: { createUpdateChannelForApp: newChannel },
} = await createChannelOnAppAsync(graphqlClient, {
await createAndLinkChannelAsync(graphqlClient, {
appId: projectId,
channelName,
branchId,
shouldPrintJson: jsonFlag,
});
if (!newChannel) {
throw new Error(
`Could not create channel with name ${channelName} on project with id ${projectId}`
);
}
if (jsonFlag) {
printJsonOnlyOutput(newChannel);
} else {
Log.addNewLineIfNone();
Log.withTick(
`Created a new channel on project ${chalk.bold(
await getDisplayNameForProjectIdAsync(graphqlClient, projectId)
)}`
);
Log.log(
formatFields([
{ label: 'Name', value: newChannel.name },
{ label: 'ID', value: newChannel.id },
])
);
Log.addNewLineIfNone();
Log.withTick(`${branchMessage} and have pointed the channel at it.`);
Log.log(
formatFields([
{ label: 'Name', value: newChannel.name },
{ label: 'ID', value: branchId },
])
);
Log.addNewLineIfNone();
Log.log(chalk.bold('You can now update your app by publishing!'));
}
Log.addNewLineIfNone();
Log.log(chalk.bold('You can now update your app by publishing!'));
}
}
+11 -4
View File
@@ -1,26 +1,31 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { print } from 'graphql';
import gql from 'graphql-tag';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { hasEmptyBranchMap, hasStandardBranchMap } from '../../channel/branch-mapping';
import { selectChannelOnAppAsync } from '../../channel/queries';
import EasCommand from '../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { withErrorHandlingAsync } from '../../graphql/client';
import {
UpdateChannelBasicInfoFragment,
UpdateChannelBranchMappingMutation,
UpdateChannelBranchMappingMutationVariables,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
import Log from '../../log';
import { isRollout } from '../../rollout/branch-mapping';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export async function updateChannelBranchMappingAsync(
graphqlClient: ExpoGraphqlClient,
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
): Promise<UpdateChannelBranchMappingMutation['updateChannel']['editUpdateChannel']> {
): Promise<UpdateChannelBasicInfoFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
@@ -29,11 +34,11 @@ export async function updateChannelBranchMappingAsync(
updateChannel {
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
id
name
branchMapping
...UpdateChannelBasicInfoFragment
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ channelId, branchMapping }
)
@@ -95,8 +100,10 @@ export default class ChannelEdit extends EasCommand {
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
});
if (existingChannel.updateBranches.length > 1) {
if (isRollout(existingChannel)) {
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
} else if (!hasStandardBranchMap(existingChannel) && !hasEmptyBranchMap(existingChannel)) {
throw new Error('Only standard branch mappings can be edited with this command.');
}
const branch = branchFlag
+143 -438
View File
@@ -1,297 +1,51 @@
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';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import { UpdateBranch } from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import { ChannelQuery, UpdateChannelByNameObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
import { promptAsync, selectAsync } from '../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { updateChannelBranchMappingAsync } from './edit';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
EndOutcome,
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from '../../rollout/actions/EndRollout';
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
import {
MainMenuActions,
RolloutActions,
RolloutMainMenu,
} from '../../rollout/actions/RolloutMainMenu';
import { enableJsonOutput } from '../../utils/json';
async function promptForRolloutPercentAsync({
promptMessage,
}: {
promptMessage: string;
}): Promise<number> {
const { name: rolloutPercent } = await promptAsync({
type: 'text',
name: 'name',
format: value => {
return parseInt(value, 10);
},
message: promptMessage,
initial: 0,
validate: (rolloutPercent: string): true | string => {
const floatValue = parseFloat(rolloutPercent);
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
? true
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
},
});
return rolloutPercent;
enum ActionRawFlagValue {
CREATE = 'create',
EDIT = 'edit',
END = 'end',
VIEW = 'view',
}
type ChannelRolloutRawArgsAndFlags = {
channel?: string;
action?: ActionRawFlagValue;
percent?: number;
outcome?: EndOutcome;
'non-interactive': boolean;
branch?: string;
'runtime-version'?: string;
'private-key-path'?: string;
json?: boolean;
};
function getRolloutInfo(channel: UpdateChannelByNameObject): {
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
currentPercent: number;
} {
const { branchMapping } = getBranchMapping(channel.branchMapping);
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
const newBranch = channel.updateBranches.filter(branch => branch.id === newBranchId)[0];
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
if (!newBranch || !oldBranch) {
throw new Error(`Branch mapping rollout is missing a branch for channel "${channel.name}".`);
}
const currentPercent = 100 * branchMapping.data[0].branchMappingLogic.operand;
return { newBranch, oldBranch, currentPercent };
}
async function startRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
branchName,
percent,
projectId,
displayName,
currentBranchMapping,
channel,
nonInteractive,
}: {
channelName?: string;
branchName: string;
percent?: number;
projectId: string;
displayName: string;
currentBranchMapping: BranchMapping;
channel: UpdateChannelByNameObject;
nonInteractive: boolean;
}
): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: projectId,
name: branchName,
});
const oldBranchId = currentBranchMapping.data[0].branchId;
if (branch.id === oldBranchId) {
throw new Error(
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
);
}
if (percent == null) {
if (nonInteractive) {
throw new Error(
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive flag.'
);
}
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
percent = await promptForRolloutPercentAsync({ promptMessage });
}
const newBranchMapping = {
version: 0,
data: [
{
branchId: branch.id,
branchMappingLogic: {
operand: percent / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
},
currentBranchMapping.data[0],
],
};
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
if (!oldBranch) {
throw new Error(
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
);
}
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
)}, ${chalk.bold(100 - percent)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
async function editRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
percent,
nonInteractive,
currentBranchMapping,
channel,
}: {
channelName?: string;
percent?: number;
nonInteractive: boolean;
currentBranchMapping: BranchMapping;
channel: UpdateChannelByNameObject;
}
): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
if (percent == null) {
if (nonInteractive) {
throw new Error(
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
);
}
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
newBranch.name
} and ${100 - currentPercent}% of all users are routed to branch ${
oldBranch.name
}. What percent of users should be directed to the branch ${newBranch.name}?`;
percent = await promptForRolloutPercentAsync({ promptMessage });
}
const newBranchMapping = { ...currentBranchMapping };
newBranchMapping.data[0].branchMappingLogic.operand = percent / 100;
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
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
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
100 - percent
)}% to branch ${chalk.bold(oldBranch.name)}.`;
return { newChannelInfo, logMessage };
}
async function endRolloutAsync(
graphqlClient: ExpoGraphqlClient,
{
channelName,
branchName,
nonInteractive,
projectId,
channel,
}: {
channelName?: string;
branchName?: string;
nonInteractive: boolean;
projectId: string;
channel: UpdateChannelByNameObject;
}
): Promise<{
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
}> {
// end rollout
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
let endOnNewBranch;
if (branchName) {
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: projectId,
name: branchName,
});
switch (branch.id) {
case newBranch.id:
endOnNewBranch = true;
break;
case oldBranch.id:
endOnNewBranch = false;
break;
default:
throw new Error(
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
);
}
} else {
if (nonInteractive) {
throw new Error(
'Branch name must be specified with the --branch flag when both the --end and --non-interactive flag are true.'
);
}
endOnNewBranch = await selectAsync<boolean>(
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
[
{
title: `${newBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
value: true,
},
{
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
value: false,
},
]
);
}
if (endOnNewBranch == null) {
throw new Error('Branch to end on is undefined.');
}
const newBranchMapping = {
version: 0,
data: [
{
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
branchMappingLogic: 'true',
},
],
};
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channel.id,
branchMapping: JSON.stringify(newBranchMapping),
});
const logMessage = `Rollout on channel ${chalk.bold(
channelName
)} ended. All traffic is now sent to branch ${chalk.bold(
endOnNewBranch ? newBranch.name : oldBranch.name
)}`;
return { newChannelInfo, logMessage };
}
type ChannelRolloutArgsAndFlags = {
channelName?: string;
action?: RolloutActions;
nonInteractive: boolean;
json?: boolean;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>;
export default class ChannelRollout extends EasCommand {
static override description = 'Roll a new branch out on a channel incrementally.';
@@ -304,17 +58,68 @@ export default class ChannelRollout extends EasCommand {
];
static override flags = {
branch: Flags.string({
description: 'branch to rollout',
action: Flags.enum({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
relationships: [
{
type: 'all',
flags: [
{
name: 'percent',
// eslint-disable-next-line async-protect/async-suffix
when: async flags => {
return (
!!flags['non-interactive'] &&
(flags['action'] === ActionRawFlagValue.CREATE ||
flags['action'] === ActionRawFlagValue.EDIT)
);
},
},
{
name: 'outcome',
// eslint-disable-next-line async-protect/async-suffix
when: async flags =>
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.END,
},
{
name: 'branch',
// eslint-disable-next-line async-protect/async-suffix
when: async flags =>
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
},
{
name: 'runtime-version',
// eslint-disable-next-line async-protect/async-suffix
when: async flags =>
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
},
],
},
],
}),
percent: Flags.integer({
description: 'percent of users to send to the new branch',
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: false,
}),
end: Flags.boolean({
description: 'end the rollout',
default: false,
outcome: Flags.enum({
description: 'End outcome of rollout. Use with --action=end',
options: Object.values(EndOutcome),
required: false,
}),
branch: Flags.string({
description: 'Branch to roll out. Use with --action=create',
required: false,
}),
'runtime-version': Flags.string({
description: 'Runtime version to target. Use with --action=create',
required: 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,
};
@@ -325,164 +130,64 @@ export default class ChannelRollout extends EasCommand {
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelRollout);
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
const {
args: { channel: channelNameArg },
flags: {
json: jsonFlag,
end: endFlag,
branch: branchName,
percent,
'non-interactive': nonInteractive,
},
} = await this.parse(ChannelRollout);
const {
privateProjectConfig: { projectId },
privateProjectConfig: { exp, projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelRollout, {
nonInteractive,
nonInteractive: argsAndFlags.nonInteractive,
});
if (jsonFlag) {
if (argsAndFlags.json) {
enableJsonOutput();
}
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,
});
if (!channel) {
throw new Error(
`Could not find a channel named "${channelName}". Check which channels exist on this project with ${chalk.bold(
'eas channel:list'
)}.`
);
}
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
channel.branchMapping
);
if (currentBranchMapping.data.length === 0) {
throw new Error('The channel is not pointing at any branches.');
}
if (currentBranchMapping.data.length > 2) {
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
}
// This combination doesn't make sense. Throw an error explaining the options.
if (isRollout && branchName && !endFlag) {
throw new Error(
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
);
}
/**
* This if/else block has three branches:
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
* 2. The branch mapping is a rollout.
* a. increase/decrease the rollout percentage.
* b. end the rollout.
*/
let rolloutMutationResult: {
newChannelInfo: {
id: string;
name: string;
branchMapping: string;
};
logMessage: string;
const app = { projectId, exp };
const ctx = {
projectId,
nonInteractive: argsAndFlags.nonInteractive,
graphqlClient,
app,
};
if (!isRollout) {
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
channelName,
branchName:
branchName ??
(await promptForBranchNameAsync({
graphqlClient,
projectId,
channelName,
nonInteractive,
json: jsonFlag,
})),
percent,
nonInteractive,
projectId,
displayName: projectDisplayName,
currentBranchMapping,
channel,
});
} else if (endFlag) {
rolloutMutationResult = await endRolloutAsync(graphqlClient, {
channelName,
branchName,
nonInteractive,
projectId,
channel,
});
if (argsAndFlags.nonInteractive) {
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
} else {
rolloutMutationResult = await editRolloutAsync(graphqlClient, {
channelName,
percent,
nonInteractive,
currentBranchMapping,
channel,
});
}
if (!rolloutMutationResult) {
throw new Error('rollout result is empty');
}
const { newChannelInfo, logMessage } = rolloutMutationResult;
if (jsonFlag) {
printJsonOnlyOutput(newChannelInfo);
} else {
Log.withTick(logMessage);
Log.addNewLineIfNone();
Log.warn(
`✨ This command is in Developer Preview and has not been released to production yet. Website support is coming soon.`
);
Log.addNewLineIfNone();
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
}
}
}
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');
private getAction(action: ActionRawFlagValue): RolloutActions {
switch (action) {
case ActionRawFlagValue.CREATE:
return MainMenuActions.CREATE_NEW;
case ActionRawFlagValue.EDIT:
return ManageRolloutActions.EDIT;
case ActionRawFlagValue.END:
return ManageRolloutActions.END;
case ActionRawFlagValue.VIEW:
return ManageRolloutActions.VIEW;
}
}
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 branchName;
private sanitizeArgsAndFlags(
rawFlags: ChannelRolloutRawArgsAndFlags
): ChannelRolloutArgsAndFlags {
const action = rawFlags.action;
return {
channelName: rawFlags.channel,
percent: rawFlags.percent,
outcome: rawFlags.outcome,
branchNameToRollout: rawFlags.branch,
runtimeVersion: rawFlags['runtime-version'],
privateKeyPath: rawFlags['private-key-path'] ?? null,
action: action ? this.getAction(action) : undefined,
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
};
}
}
@@ -47,6 +47,7 @@ export default class MetadataLint extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
@@ -49,6 +49,7 @@ export default class MetadataPull extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
@@ -47,6 +47,7 @@ export default class MetadataPush extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
+1
View File
@@ -118,6 +118,7 @@ export default class Submit extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms,
profileName: flagsWithPlatform.profile,
projectDir,
});
const submissions: SubmissionFragment[] = [];
@@ -1,5 +1,5 @@
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
import { vol } from 'memfs';
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
import { DirectoryJSON, vol } from 'memfs';
import { instance, mock } from 'ts-mockito';
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
@@ -8,12 +8,17 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/cr
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { CodeSigningInfo, UpdateFragment } from '../../../graphql/generated';
import { UpdateFragment } from '../../../graphql/generated';
import { PublishMutation } from '../../../graphql/mutations/PublishMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import { getBranchNameFromChannelNameAsync } from '../../../update/getBranchNameFromChannelNameAsync';
import { selectUpdateGroupOnBranchAsync } from '../../../update/queries';
import {
getCodeSigningInfoAsync,
getManifestBodyAsync,
signBody,
} from '../../../utils/code-signing';
import UpdateRepublish from '../republish';
const projectRoot = '/test-project';
@@ -33,12 +38,6 @@ const updateStub: UpdateFragment = {
createdAt: '2022-01-01T12:00:00Z',
};
const codeSigningStub: CodeSigningInfo = {
keyid: 'keyid',
alg: 'alg',
sig: 'sig',
};
jest.mock('fs');
jest.mock('@expo/config');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
@@ -52,16 +51,12 @@ jest.mock('../../../ora', () => ({
start: () => ({ succeed: () => {}, fail: () => {} }),
}),
}));
jest.mock('../../../utils/code-signing');
jest.mock('../../../fetch');
describe(UpdateRepublish.name, () => {
afterEach(() => vol.reset());
it('errors without --channel, --branch, or --group', async () => {
await expect(new UpdateRepublish([], commandOptions).run()).rejects.toThrow(
'--channel, --branch, or --group must be specified'
);
});
it('errors when providing both --group and --branch', async () => {
const flags = ['--group=1234', '--branch=main'];
@@ -156,18 +151,45 @@ describe(UpdateRepublish.name, () => {
});
it('re-creates update using codesigning with --group and --message', async () => {
const flags = ['--group=1234', '--message=test-republish'];
const flags = [
'--group=1234',
'--message=test-republish',
`--private-key-path=./keys/test-private-key.pem`,
];
const codeSigning = {
alg: 'alg',
alg: 'rsa-v1_5-sha256',
keyid: 'keyid',
sig: 'sig',
};
mockTestProject();
mockTestProject({
extraManifest: {
updates: {
codeSigningCertificate: './keys/test-certificate.pem',
codeSigningMetadata: {
alg: 'rsa-v1_5-sha256',
keyid: 'keyid',
},
},
},
extraVol: {
'./keys/test-private-key.pem': 'testpemprivate',
'./keys/test-certificate.pem': 'testpemcertificate',
},
});
jest.mocked(getCodeSigningInfoAsync).mockResolvedValue({
privateKey: '' as any,
certificate: '' as any,
codeSigningMetadata: { alg: 'rsa-v1_5-sha256', keyid: 'keyid' },
});
jest.mocked(getManifestBodyAsync).mockResolvedValue('test');
jest.mocked(signBody).mockReturnValue('sig');
// Mock queries to retrieve the update and code signing info
jest
.mocked(UpdateQuery.viewUpdateGroupAsync)
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigningStub }]);
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigning }]);
// Mock mutations to store the new update
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockResolvedValue([
{
@@ -287,8 +309,12 @@ describe(UpdateRepublish.name, () => {
/** Create a new in-memory project, based on src/commands/project/__tests__/init.test.ts */
function mockTestProject({
configuredProjectId = '1234',
extraManifest,
extraVol,
}: {
configuredProjectId?: string;
extraManifest?: Partial<ExpoConfig>;
extraVol?: DirectoryJSON;
} = {}): void {
const packageJSON: PackageJSONConfig = {
name: 'testing123',
@@ -309,6 +335,7 @@ function mockTestProject({
projectId: configuredProjectId,
},
},
...extraManifest,
},
};
@@ -316,6 +343,7 @@ function mockTestProject({
{
'package.json': JSON.stringify(packageJSON),
'app.json': JSON.stringify(appJSON),
...extraVol,
},
projectRoot
);
+29 -4
View File
@@ -35,6 +35,7 @@ import {
getRuntimeVersionObjectAsync,
getUpdateMessageForCommandAsync,
isUploadedAssetCountAboveWarningThreshold,
platformDisplayNames,
resolveInputDirectoryAsync,
uploadAssetsAsync,
} from '../../project/publish';
@@ -55,6 +56,7 @@ import { getVcsClient } from '../../vcs';
type RawUpdateFlags = {
auto: boolean;
dev: boolean;
branch?: string;
channel?: string;
message?: string;
@@ -73,6 +75,7 @@ type RawUpdateFlags = {
type UpdateFlags = {
auto: boolean;
dev: boolean;
platform: ExpoCLIExportPlatformFlag;
branchName?: string;
channelName?: string;
@@ -133,6 +136,10 @@ export default class UpdatePublish extends EasCommand {
default: 'all',
required: false,
}),
dev: Flags.boolean({
description: 'Publish a development bundle',
default: false,
}),
auto: Flags.boolean({
description:
'Use the current git branch and commit message for the EAS branch and update message',
@@ -157,6 +164,7 @@ export default class UpdatePublish extends EasCommand {
auto: autoFlag,
platform: platformFlag,
channelName: channelNameArg,
dev,
updateMessage: updateMessageArg,
inputDir,
skipBundler,
@@ -219,7 +227,7 @@ export default class UpdatePublish extends EasCommand {
if (!skipBundler) {
const bundleSpinner = ora().start('Exporting...');
try {
await buildBundlesAsync({ projectDir, inputDir, exp, platformFlag, clearCache });
await buildBundlesAsync({ projectDir, inputDir, dev, exp, platformFlag, clearCache });
bundleSpinner.succeed('Exported bundle(s)');
} catch (e) {
bundleSpinner.fail('Export failed');
@@ -325,6 +333,21 @@ export default class UpdatePublish extends EasCommand {
for (const uploadedAssetPath of uploadResults.uniqueUploadedAssetPaths) {
Log.debug(chalk.dim(`- ${uploadedAssetPath}`));
}
const platformString = (Object.keys(assets) as PublishPlatform[])
.map(platform => {
const collectedAssetForPlatform = nullthrows(assets[platform]);
const totalAssetsForPlatform = collectedAssetForPlatform.assets.length + 1; // launch asset
const assetString = totalAssetsForPlatform === 1 ? 'asset' : 'assets';
return `${totalAssetsForPlatform} ${platformDisplayNames[platform]} ${assetString}`;
})
.join(', ');
Log.withInfo(
`${platformString} (maximum: ${assetLimitPerUpdateGroup} total per update). ${learnMore(
'https://expo.fyi/eas-update-asset-limits',
{ learnMoreMessage: 'Learn more about asset limits.' }
)}`
);
} catch (e) {
assetSpinner.fail('Failed to upload');
throw e;
@@ -344,10 +367,11 @@ export default class UpdatePublish extends EasCommand {
branchId,
channelName: branchName,
});
Log.withTick(
`Channel: ${chalk.bold(branchName)} pointed at branch: ${chalk.bold(branchName)}`
);
}
Log.withTick(`Channel: ${chalk.bold(branchName)} pointed at branch: ${chalk.bold(branchName)}`);
const vcsClient = getVcsClient();
const gitCommitHash = await vcsClient.getCommitHashAsync();
@@ -507,7 +531,7 @@ export default class UpdatePublish extends EasCommand {
private sanitizeFlags(flags: RawUpdateFlags): UpdateFlags {
const nonInteractive = flags['non-interactive'] ?? false;
const { auto, branch: branchName, channel: channelName, message: updateMessage } = flags;
const { auto, branch: branchName, channel: channelName, dev, 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',
@@ -536,6 +560,7 @@ export default class UpdatePublish extends EasCommand {
auto,
branchName,
channelName,
dev,
updateMessage,
inputDir: flags['input-dir'],
skipBundler: flags['skip-bundler'],
+98 -135
View File
@@ -1,23 +1,21 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { getUpdateGroupUrl } from '../../build/utils/url';
import { selectBranchOnAppAsync } from '../../branch/queries';
import { selectChannelOnAppAsync } 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 { Update } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { getBranchNameFromChannelNameAsync } from '../../update/getBranchNameFromChannelNameAsync';
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
import { UpdateToRepublish, republishAsync } from '../../update/republish';
import { truncateString as truncateUpdateMessage } from '../../update/utils';
import formatFields from '../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import { enableJsonOutput } from '../../utils/json';
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
@@ -27,6 +25,7 @@ type UpdateRepublishRawFlags = {
group?: string;
message?: string;
platform: string;
'private-key-path'?: string;
'non-interactive': boolean;
json?: boolean;
};
@@ -37,34 +36,21 @@ type UpdateRepublishFlags = {
groupId?: string;
updateMessage?: string;
platform: Platform[];
privateKeyPath?: string;
nonInteractive: boolean;
json: boolean;
};
type UpdateToRepublish = {
groupId: string;
branchId: string;
branchName: string;
} & Pick<
Update,
| 'message'
| 'runtimeVersion'
| 'manifestFragment'
| 'platform'
| 'gitCommitHash'
| 'codeSigningInfo'
>;
export default class UpdateRepublish extends EasCommand {
static override description = 'roll back to an existing update';
static override flags = {
channel: Flags.string({
description: 'Channel name to select an update to republish from',
description: 'Channel name to select an update group to republish from',
exclusive: ['branch', 'group'],
}),
branch: Flags.string({
description: 'Branch name to select an update to republish from',
description: 'Branch name to select an update group to republish from',
exclusive: ['channel', 'group'],
}),
group: Flags.string({
@@ -73,7 +59,7 @@ export default class UpdateRepublish extends EasCommand {
}),
message: Flags.string({
char: 'm',
description: 'Short message describing the republished update',
description: 'Short message describing the republished update group',
required: false,
}),
platform: Flags.enum({
@@ -82,6 +68,10 @@ export default class UpdateRepublish extends EasCommand {
default: 'all',
required: 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,
};
@@ -105,6 +95,8 @@ export default class UpdateRepublish extends EasCommand {
enableJsonOutput();
}
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
const updatesToPublish = existingUpdates.filter(update =>
flags.platform.includes(update.platform as Platform)
@@ -124,117 +116,43 @@ export default class UpdateRepublish extends EasCommand {
}
if (rawFlags.platform === 'all') {
Log.withTick(`The republished update will appear only on: ${rawFlags.platform}`);
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
} else {
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
if (platformsFromUpdates.length < defaultRepublishPlatforms.length) {
Log.warn(`You are republishing an update that wasn't published for all platforms.`);
Log.warn(`You are republishing an update group that wasn't published for all platforms.`);
}
Log.withTick(
`The republished update will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
`The republished update group will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
', '
)}`
);
}
// This command only republishes a single update group
// The update group properties are the same for all updates
const { branchId, branchName, runtimeVersion } = updatesToPublish[0];
const updateMessage = await getOrAskUpdateMessageAsync(updatesToPublish, flags);
// If codesigning was created for the original update, we need to add it to the republish
const shouldRepublishWithCodesigning = updatesToPublish.some(update => update.codeSigningInfo);
if (shouldRepublishWithCodesigning) {
Log.withTick(
`The republished update will be signed with the same codesigning as the original update.`
);
}
const publishIndicator = ora('Republishing...').start();
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
try {
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
{
branchId,
runtimeVersion,
message: updateMessage,
updateInfoGroup: Object.fromEntries(
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
),
gitCommitHash: updatesToPublish[0].gitCommitHash,
awaitingCodeSigningInfo: shouldRepublishWithCodesigning,
},
]);
if (shouldRepublishWithCodesigning) {
const codeSigningByPlatform = Object.fromEntries(
updatesToPublish.map(update => [update.platform, update.codeSigningInfo])
);
await Promise.all(
updatesRepublished.map(async update => {
const codeSigning = codeSigningByPlatform[update.platform];
if (codeSigning) {
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, update.id, codeSigning);
}
})
);
}
publishIndicator.succeed('Republished update');
} catch (error: any) {
publishIndicator.fail('Failed to republish update');
throw error;
}
if (flags.json) {
return printJsonOnlyOutput(updatesRepublished);
}
const updatesRepublishedByPlatform = Object.fromEntries(
updatesRepublished.map(update => [update.platform, update])
);
const updateGroupUrl = getUpdateGroupUrl(
(await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name,
exp.slug,
updatesRepublished[0].group
);
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Branch', value: branchName },
{ label: 'Runtime version', value: updatesRepublished[0].runtimeVersion },
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
{ label: 'Update Group ID', value: updatesRepublished[0].id },
...(updatesRepublishedByPlatform.android
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
: []),
...(updatesRepublishedByPlatform.ios
? [{ label: 'iOS update ID', value: updatesRepublishedByPlatform.ios.id }]
: []),
{ label: 'Message', value: updateMessage },
{ label: 'Website link', value: link(updateGroupUrl, { dim: false }) },
])
);
const arbitraryUpdate = updatesToPublish[0];
await republishAsync({
graphqlClient,
app: { exp, projectId },
updatesToPublish,
targetBranch: { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName },
updateMessage,
codeSigningInfo,
json: flags.json,
});
}
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
private sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
const branchName = rawFlags.branch;
const channelName = rawFlags.channel;
const groupId = rawFlags.group;
const nonInteractive = rawFlags['non-interactive'];
const privateKeyPath = rawFlags['private-key-path'];
if (nonInteractive && !groupId) {
throw new Error('Only --group can be used in non-interactive mode');
}
if (!groupId && !(branchName || channelName)) {
throw new Error(`--channel, --branch, or --group must be specified`);
}
const platform =
rawFlags.platform === 'all' ? defaultRepublishPlatforms : ([rawFlags.platform] as Platform[]);
@@ -245,6 +163,7 @@ export default class UpdateRepublish extends EasCommand {
groupId,
platform,
updateMessage: rawFlags.message,
privateKeyPath,
json: rawFlags.json ?? false,
nonInteractive,
};
@@ -258,18 +177,22 @@ async function getOrAskUpdatesAsync(
flags: UpdateRepublishFlags
): Promise<UpdateToRepublish[]> {
if (flags.groupId) {
const updateGroups = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
groupId: flags.groupId,
});
return updateGroups.map(group => ({
...group,
groupId: group.group,
branchId: group.branch.id,
branchName: group.branch.name,
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
if (flags.nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
if (flags.branchName) {
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
@@ -286,7 +209,55 @@ async function getOrAskUpdatesAsync(
});
}
throw new Error('--channel, --branch, or --group is required');
const { choice } = await promptAsync({
type: 'select',
message: 'Find update by branch or channel?',
name: 'choice',
choices: [
{ title: 'Branch', value: 'branch' },
{ title: 'Channel', value: 'channel' },
],
});
if (choice === 'channel') {
const { name } = await selectChannelOnAppAsync(graphqlClient, {
projectId,
selectionPromptTitle: 'Select a channel to view',
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromChannelNameAsync(graphqlClient, {
...flags,
channelName: name,
projectId,
});
} else if (choice === 'branch') {
const { name } = await selectBranchOnAppAsync(graphqlClient, {
projectId,
promptTitle: 'Select branch from which to choose update',
displayTextForListItem: updateBranch => ({
title: updateBranch.name,
}),
// discard limit and offset because this query is not their intended target
paginatedQueryOptions: {
json: flags.json,
nonInteractive: flags.nonInteractive,
offset: 0,
},
});
return await askUpdatesFromBranchNameAsync(graphqlClient, {
...flags,
branchName: name,
projectId,
});
} else {
throw new Error('Must choose update via channel or branch');
}
}
/** Ask the user which update needs to be republished by branch name, this requires interactive mode */
@@ -299,21 +270,17 @@ async function askUpdatesFromBranchNameAsync(
nonInteractive,
}: { projectId: string; branchName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
if (nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
const updateGroups = await selectUpdateGroupOnBranchAsync(graphqlClient, {
const updateGroup = await selectUpdateGroupOnBranchAsync(graphqlClient, {
projectId,
branchName,
paginatedQueryOptions: getPaginatedQueryOptions({ json, 'non-interactive': nonInteractive }),
});
return updateGroups.map(group => ({
...group,
groupId: group.id,
branchId: group.branch.id,
branchName: group.branch.name,
return updateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
}));
}
/** Ask the user which update needs to be republished by channel name, this requires interactive mode */
@@ -326,10 +293,6 @@ async function askUpdatesFromChannelNameAsync(
nonInteractive,
}: { projectId: string; channelName: string; json: boolean; nonInteractive: boolean }
): Promise<UpdateToRepublish[]> {
if (nonInteractive) {
throw new Error('Must supply --group when in non-interactive mode');
}
const branchName = await getBranchNameFromChannelNameAsync(graphqlClient, projectId, channelName);
return await askUpdatesFromBranchNameAsync(graphqlClient, {
@@ -86,7 +86,11 @@ export default class UpdateRollBackToEmbedded extends EasCommand {
}),
platform: Flags.enum({
char: 'p',
options: [...defaultPublishPlatforms, 'all'],
options: [
// TODO: Add web when it's fully supported
...defaultPublishPlatforms,
'all',
],
default: 'all',
required: false,
}),
@@ -0,0 +1,42 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { promptAsync } from '../../prompts';
import UpdateRepublish from './republish';
import UpdateRollBackToEmbedded from './roll-back-to-embedded';
export default class UpdateRollback extends EasCommand {
static override description = 'roll back to an embedded update or an existing update';
static override hidden = true;
static override flags = {
'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,
}),
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateRollback);
const { choice } = await promptAsync({
type: 'select',
message: 'Which type of update would you like to roll back to?',
name: 'choice',
choices: [
{ title: 'Published Update', value: 'published' },
{ title: 'Embedded Update', value: 'embedded' },
],
});
const privateKeyPathArg = flags['private-key-path']
? ['--private-key-path', flags['private-key-path']]
: [];
if (choice === 'published') {
await UpdateRepublish.run(privateKeyPathArg);
} else {
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
}
}
}
@@ -11,6 +11,7 @@ import {
} from '../../../project/projectUtils';
import { promptAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
import { AndroidPackageNotDefinedError } from '../../errors';
import { AppLookupParams } from '../api/GraphqlClient';
/**
@@ -111,7 +112,7 @@ export async function getAppLookupParamsFromContextAsync(
gradleContext
);
if (!androidApplicationIdentifier) {
throw new Error(
throw new AndroidPackageNotDefinedError(
`android.package needs to be defined in your ${getProjectConfigDescription(
ctx.projectDir
)} file`
@@ -5,10 +5,10 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AndroidAppBuildCredentials,
AndroidAppBuildCredentialsFragment,
AndroidAppBuildCredentialsInput,
CreateAndroidAppBuildCredentialsMutation,
SetDefaultAndroidAppBuildCredentialsMutation,
SetKeystoreMutation,
} from '../../../../../graphql/generated';
import { AndroidAppBuildCredentialsFragmentNode } from '../../../../../graphql/types/credentials/AndroidAppBuildCredentials';
@@ -62,9 +62,9 @@ export const AndroidAppBuildCredentialsMutation = {
): Promise<AndroidAppBuildCredentialsFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<AndroidAppBuildCredentials>(
.mutation<SetDefaultAndroidAppBuildCredentialsMutation>(
gql`
mutation AndroidAppBuildCredentialsMutation(
mutation SetDefaultAndroidAppBuildCredentialsMutation(
$androidAppBuildCredentialsId: ID!
$isDefault: Boolean!
) {
@@ -85,7 +85,7 @@ export const AndroidAppBuildCredentialsMutation = {
.toPromise()
);
assert(data, `GraphQL: 'setDefault' not defined in server response ${JSON.stringify(data)}}`);
return data;
return data.androidAppBuildCredentials.setDefault;
},
async setKeystoreAsync(
graphqlClient: ExpoGraphqlClient,
@@ -15,3 +15,9 @@ export class UnsupportedCredentialsChoiceError extends Error {
super(message);
}
}
export class AndroidPackageNotDefinedError extends Error {
constructor(message?: string) {
super(message ?? 'android.package needs to be defined in your app.json/app.config.js file');
}
}
@@ -177,7 +177,23 @@ export class SetUpAdhocProvisioningProfile {
);
}
// 6. Create (or update) app build credentials
// 6. Compare selected devices with the ones actually provisioned
const diffList = differenceBy(
chosenDevices,
appleProvisioningProfile.appleDevices,
'identifier'
);
if (diffList && diffList.length > 0) {
Log.warn(`Failed to provision ${diffList.length} of the selected devices:`);
for (const missingDevice of diffList) {
Log.warn(`- ${formatDeviceLabel(missingDevice)}`);
}
Log.log(
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
);
}
// 7. Create (or update) app build credentials
assert(appleProvisioningProfile);
return await assignBuildCredentialsAsync(
ctx,
@@ -1,4 +1,38 @@
import { doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
import { Env } from '@expo/eas-build-job';
import { EasJson } from '@expo/eas-json';
import { Analytics } from '../../../../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
Account,
AppleAppIdentifierFragment,
AppleDevice,
AppleDeviceClass,
AppleDeviceFragment,
AppleDistributionCertificateFragment,
IosAppBuildCredentialsFragment,
} from '../../../../graphql/generated';
import Log from '../../../../log';
import { getApplePlatformFromTarget } from '../../../../project/ios/target';
import { Actor } from '../../../../user/User';
import { CredentialsContext, CredentialsContextProjectInfo } from '../../../context';
import { ProvisioningProfile } from '../../appstore/Credentials.types';
import { ApplePlatform } from '../../appstore/constants';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils';
import { chooseDevicesAsync } from '../DeviceUtils';
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
jest.mock('../BuildCredentialsUtils');
jest.mock('../../../context');
jest.mock('../../../ios/api/GraphqlClient');
jest.mock('../DeviceUtils', () => {
return {
__esModule: true,
chooseDevicesAsync: jest.fn(),
formatDeviceLabel: jest.requireActual('../DeviceUtils').formatDeviceLabel,
};
});
jest.mock('../../../../project/ios/target');
describe(doUDIDsMatch, () => {
it('return false if UDIDs do not match', () => {
@@ -12,3 +46,104 @@ describe(doUDIDsMatch, () => {
expect(doUDIDsMatch(udidsA, udidsB)).toBe(true);
});
});
describe('runWithDistributionCertificateAsync', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
});
describe('compare chosen and provisioned devices', () => {
describe('not all devices provisioned', () => {
it('displays warning to the user and lists the missing devices', async () => {
const { ctx, distCert } = setUpTest();
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
provisioningProfile: {
appleTeam: {},
appleDevices: [{ identifier: 'id1' }],
developerPortalIdentifier: 'provisioningProfileId',
},
} as IosAppBuildCredentialsFragment);
const LogWarnSpy = jest.spyOn(Log, 'warn');
const LogLogSpy = jest.spyOn(Log, 'log');
const result = await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(
ctx,
distCert
);
expect(result).toEqual({} as IosAppBuildCredentialsFragment);
expect(LogWarnSpy).toHaveBeenCalledTimes(3);
expect(LogWarnSpy).toHaveBeenCalledWith('Failed to provision 2 of the selected devices:');
expect(LogWarnSpy).toHaveBeenCalledWith('- id2 (iPhone) (Device 2)');
expect(LogWarnSpy).toHaveBeenCalledWith('- id3 (Mac) (Device 3)');
expect(LogLogSpy).toHaveBeenCalledTimes(1);
expect(LogLogSpy).toHaveBeenCalledWith(
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
);
});
});
describe('all devices provisioned', () => {
it('does not display warning', async () => {
const { ctx, distCert } = setUpTest();
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
provisioningProfile: {
appleTeam: {},
appleDevices: [{ identifier: 'id1' }, { identifier: 'id2' }, { identifier: 'id3' }],
developerPortalIdentifier: 'provisioningProfileId',
},
} as IosAppBuildCredentialsFragment);
const LogWarnSpy = jest.spyOn(Log, 'warn');
const LogLogSpy = jest.spyOn(Log, 'log');
const result = await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(
ctx,
distCert
);
expect(result).toEqual({} as IosAppBuildCredentialsFragment);
expect(LogWarnSpy).not.toHaveBeenCalled();
expect(LogLogSpy).not.toHaveBeenCalled();
});
});
});
});
function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCertificateFragment } {
const ctx = jest.mocked(
new CredentialsContext(
{} as {
projectInfo: CredentialsContextProjectInfo | null;
easJsonCliConfig?: EasJson['cli'];
nonInteractive: boolean;
projectDir: string;
user: Actor;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
env?: Env;
}
)
);
Object.defineProperty(ctx, 'ios', { value: jest.mock('../../../ios/api/GraphqlClient') });
ctx.ios.getDevicesForAppleTeamAsync = jest
.fn()
.mockResolvedValue([
{ identifier: 'id1' },
{ identifier: 'id2' },
{ identifier: 'id3' },
] as AppleDeviceFragment[]);
jest.mocked(chooseDevicesAsync).mockResolvedValue([
{ identifier: 'id1', name: 'Device 1', deviceClass: AppleDeviceClass.Ipad },
{ identifier: 'id2', name: 'Device 2', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id3', name: 'Device 3', deviceClass: AppleDeviceClass.Mac },
] as AppleDevice[]);
// @ts-ignore
jest.mocked(getApplePlatformFromTarget).mockResolvedValue(ApplePlatform.IOS);
Object.defineProperty(ctx, 'appStore', { value: jest.mock('../../appstore/AppStoreApi') });
ctx.appStore.createOrReuseAdhocProvisioningProfileAsync = jest.fn().mockResolvedValue({
provisioningProfileId: 'provisioningProfileId',
} as ProvisioningProfile);
ctx.ios.createOrGetExistingAppleAppIdentifierAsync = jest
.fn()
.mockResolvedValue({} as AppleAppIdentifierFragment);
jest.mocked(assignBuildCredentialsAsync).mockResolvedValue({} as IosAppBuildCredentialsFragment);
const distCert = {} as AppleDistributionCertificateFragment;
return { ctx, distCert };
}
@@ -27,6 +27,7 @@ import {
displayEmptyAndroidCredentials,
} from '../android/utils/printCredentials';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import { AndroidPackageNotDefinedError } from '../errors';
import { ActionInfo, AndroidActionType, Scope } from './Actions';
import {
buildCredentialsActions,
@@ -131,6 +132,12 @@ export class ManageAndroid {
await this.runProjectSpecificActionAsync(ctx, chosenAction, gradleContext);
} catch (err) {
if (err instanceof AndroidPackageNotDefinedError) {
err.message += `\n${learnMore(
'https://docs.expo.dev/workflow/configuration/'
)} about configuration with app.json/app.config.js`;
throw err; // breaks out of the loop to avoid failing again after continuation
}
Log.error(err);
if (process.env.DEBUG) {
throw err; // breaks out of the loop so we can get stack trace
@@ -0,0 +1,69 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJsonUtils } from '@expo/eas-json';
import { Analytics } from '../../../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { learnMore } from '../../../log';
import { getProjectConfigDescription } from '../../../project/projectUtils';
import { pressAnyKeyToContinueAsync } from '../../../prompts';
import { Actor } from '../../../user/User';
import { getAppLookupParamsFromContextAsync } from '../../android/actions/BuildCredentialsUtils';
import { CredentialsContextProjectInfo } from '../../context';
import { AndroidPackageNotDefinedError } from '../../errors';
import { Action } from '../HelperActions';
import { ManageAndroid } from '../ManageAndroid';
jest.mock('../../android/actions/BuildCredentialsUtils');
jest.mock('../../../prompts', () => {
return {
...jest.requireActual('../../../prompts'),
pressAnyKeyToContinueAsync: jest.fn(),
};
});
jest.mock('../../../project/projectUtils');
describe('runAsync', () => {
describe('"android.package" missing in app.json', () => {
const manageAndroid = new ManageAndroid(
{
projectInfo: {} as CredentialsContextProjectInfo,
actor: {} as Actor,
graphqlClient: {} as ExpoGraphqlClient,
analytics: {} as Analytics,
getDynamicPrivateProjectConfigAsync: jest
.fn()
.mockResolvedValue({ exp: {}, projectId: '' }),
runAsync: jest.fn(),
} as Action,
''
);
it('does not repeat the error indefinitely and prints useful error', async () => {
jest.spyOn(EasJsonUtils, 'getBuildProfileNamesAsync').mockResolvedValue(['testProfile']);
jest
.spyOn(EasJsonUtils, 'getBuildProfileAsync')
.mockResolvedValue({} as BuildProfile<Platform.ANDROID>);
jest.mocked(getProjectConfigDescription).mockReturnValue('app.json');
jest.mocked(getAppLookupParamsFromContextAsync).mockImplementation(() => {
throw new AndroidPackageNotDefinedError(
'Specify "android.package" in app.json and run this command again.'
);
});
const pressAnyKeyToContinueAsyncMock = jest.mocked(pressAnyKeyToContinueAsync);
Array.from(Array(100)).map((_, i) => {
// continue 101 times if error is rethrown indefinitely
pressAnyKeyToContinueAsyncMock.mockResolvedValueOnce();
});
pressAnyKeyToContinueAsyncMock.mockImplementationOnce(async () => {
// fail after 102nd time
fail('test should not reach this place - if it does, the error repeats indefinitely');
});
const reThrownError = new AndroidPackageNotDefinedError(
'Specify "android.package" in app.json and run this command again.\n' +
`${learnMore(
'https://docs.expo.dev/workflow/configuration/'
)} about configuration with app.json/app.config.js`
);
await expect(manageAndroid.runAsync()).rejects.toThrow(reThrownError);
});
});
});
@@ -0,0 +1 @@
export const runCurrentMachineMethodAsync = jest.fn();
@@ -5,6 +5,7 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
import AppStoreApi from '../../../../credentials/ios/appstore/AppStoreApi';
import { AccountFragment, Role } from '../../../../graphql/generated';
import DeviceCreateAction, { RegistrationMethod } from '../action';
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
import { runDeveloperPortalMethodAsync } from '../developerPortalMethod';
import { runInputMethodAsync } from '../inputMethod';
import { runRegistrationUrlMethodAsync } from '../registrationUrlMethod';
@@ -13,6 +14,7 @@ jest.mock('prompts');
jest.mock('../developerPortalMethod');
jest.mock('../inputMethod');
jest.mock('../registrationUrlMethod');
jest.mock('../currentMachineMethod');
beforeEach(() => {
const promptsMock = jest.mocked(prompts);
@@ -22,6 +24,7 @@ beforeEach(() => {
});
jest.mocked(runRegistrationUrlMethodAsync).mockClear();
jest.mocked(runInputMethodAsync).mockClear();
jest.mocked(runCurrentMachineMethodAsync).mockClear();
});
describe(DeviceCreateAction, () => {
@@ -118,5 +121,36 @@ describe(DeviceCreateAction, () => {
expect(runDeveloperPortalMethodAsync).toBeCalled();
});
it('calls runCurrentMachineMethodAsync if user chooses the current machine method', async () => {
jest.mocked(prompts).mockImplementationOnce(async () => ({
method: RegistrationMethod.CURRENT_MACHINE,
}));
const appStoreApiMock = mock<AppStoreApi>();
const appStoreApi = instance(appStoreApiMock);
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const account: AccountFragment = {
id: 'account_id',
name: 'foobar',
users: [
{
role: Role.Owner,
actor: {
id: 'user_id',
},
},
],
};
const appleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123Y',
appleTeamName: 'John Doe (Individual)',
};
const action = new DeviceCreateAction(graphqlClient, appStoreApi, account, appleTeam);
await action.runAsync();
expect(runCurrentMachineMethodAsync).toBeCalled();
});
});
});
@@ -0,0 +1,183 @@
import os from 'os';
import prompts from 'prompts';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleDeviceMutation } from '../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { AppleTeam } from '../../../../graphql/generated';
import { DeviceCreateError } from '../../../utils/errors';
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
jest.mock('../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation');
jest.mock('../../../../ora');
jest.mock('os', () => {
return {
...jest.requireActual('os'),
cpus: jest.fn(),
};
});
jest.mock('@expo/spawn-async', () => {
return {
__esModule: true,
...jest.requireActual('@expo/spawn-async'),
default: jest.fn(async () => {
return {
stdout: JSON.stringify({
SPHardwareDataType: [
{
provisioning_UDID: 'fake_udid',
machine_name: 'fake_machine_name',
},
],
}),
};
}),
};
});
let actualPlatform: string;
beforeAll(() => {
actualPlatform = process.platform;
});
beforeEach(() => {
jest.mocked(prompts).mockReset();
jest.mocked(prompts).mockImplementation(() => {
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
});
jest.mocked(AppleDeviceMutation.createAppleDeviceAsync).mockClear();
});
afterAll(() => {
Object.defineProperty(process, 'platform', { value: actualPlatform });
});
describe(runCurrentMachineMethodAsync, () => {
it('allows registering the default Mac device class', async () => {
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
});
it('proceeds with registration if user approves, only once', async () => {
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
});
it('exits registration if user cancels', async () => {
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
});
it('exits registration if current Mac machine is not of Apple Silicon type', async () => {
jest
.mocked(os.cpus)
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'darwin' });
mockDeviceData('my old Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await expect(
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
).rejects.toThrowError(
new DeviceCreateError(
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
)
);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
});
it('exits registration if current machine is not Mac', async () => {
jest
.mocked(os.cpus)
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
Object.defineProperty(process, 'platform', { value: 'win32' });
mockDeviceData('my Windows PC');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('my other Mac');
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
const graphqlClient = instance(mock<ExpoGraphqlClient>());
const accountId = 'account-id';
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
const appleTeam: AppleTeam = {
id: 'apple-team-id',
appleTeamIdentifier: 'ABC123XY',
appleTeamName: 'John Doe (Individual)',
};
await expect(
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
).rejects.toThrowError(
new DeviceCreateError(
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
)
);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
});
});
function mockDeviceData(name: string): void {
jest.mocked(prompts).mockImplementationOnce(async () => ({ name }));
}
@@ -21,6 +21,8 @@ describe(runInputMethodAsync, () => {
it('should allow for multiple device registration', async () => {
mockDeviceData('00001111-001122334455662E', 'my iPhone', AppleDeviceClass.Iphone);
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('00001111-001122334455662F', 'my Mac', AppleDeviceClass.Mac);
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
mockDeviceData('b12cba9856d89c932ab7a4b813c4d932534e1679', 'my iPad', AppleDeviceClass.Ipad);
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
@@ -35,7 +37,7 @@ describe(runInputMethodAsync, () => {
await runInputMethodAsync(graphqlClient, accountId, appleTeam);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(2);
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(3);
});
});
@@ -5,6 +5,7 @@ import AppStoreApi from '../../../credentials/ios/appstore/AppStoreApi';
import { AccountFragment, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { runCurrentMachineMethodAsync } from './currentMachineMethod';
import { runDeveloperPortalMethodAsync } from './developerPortalMethod';
import { runInputMethodAsync } from './inputMethod';
import { runRegistrationUrlMethodAsync } from './registrationUrlMethod';
@@ -13,6 +14,7 @@ export enum RegistrationMethod {
WEBSITE,
INPUT,
DEVELOPER_PORTAL,
CURRENT_MACHINE,
EXIT,
}
@@ -37,6 +39,8 @@ export default class DeviceCreateAction {
);
} else if (method === RegistrationMethod.INPUT) {
await runInputMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.CURRENT_MACHINE) {
await runCurrentMachineMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
} else if (method === RegistrationMethod.EXIT) {
Log.log('Bye!');
process.exit(0);
@@ -66,6 +70,12 @@ export default class DeviceCreateAction {
title: `${chalk.bold('Input')} - allows you to type in UDIDs (advanced option)`,
value: RegistrationMethod.INPUT,
},
{
title: `${chalk.bold(
'Current Machine'
)} - automatically sets the provisioning UDID of the current Apple Silicon machine`,
value: RegistrationMethod.CURRENT_MACHINE,
},
{ title: chalk.bold('Exit'), value: RegistrationMethod.EXIT },
],
validate: (val: RegistrationMethod) => Object.values(RegistrationMethod).includes(val),
@@ -0,0 +1,127 @@
import spawnAsync from '@expo/spawn-async';
import chalk from 'chalk';
import os from 'os';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';
import { DeviceCreateError } from '../../utils/errors';
import { DeviceData, printDeviceData, promptForNameAsync } from './utils';
export async function runCurrentMachineMethodAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
): Promise<void> {
Log.newLine();
Log.log(chalk.white('Checking if current machine is an Apple Silicon one.'));
if (!isMachineAppleSilicon()) {
throw new DeviceCreateError(
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
);
}
Log.log(chalk.green('Check successful.'));
await collectDataAndRegisterDeviceAsync(graphqlClient, { accountId, appleTeam });
}
function isMachineAppleSilicon(): boolean {
return os.cpus()[0].model.includes('Apple M') && process.platform === 'darwin';
}
async function collectDataAndRegisterDeviceAsync(
graphqlClient: ExpoGraphqlClient,
{
accountId,
appleTeam,
}: {
accountId: string;
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>;
}
): Promise<void> {
const deviceData = await collectDeviceDataAsync(appleTeam);
if (!deviceData) {
return;
}
const { udid, deviceClass, name } = deviceData;
const spinner = ora(`Registering Apple device on Expo`).start();
try {
await AppleDeviceMutation.createAppleDeviceAsync(
graphqlClient,
{
appleTeamId: appleTeam.id,
identifier: udid,
name,
deviceClass: deviceClass ?? undefined,
},
accountId
);
} catch (err) {
spinner.fail();
throw err;
}
spinner.succeed();
}
async function collectDeviceDataAsync(
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>,
initialValues: Partial<DeviceData> = {}
): Promise<DeviceData | null> {
Log.log(chalk.white('Fetching the provisioning UDID.'));
const [udid, defaultMachineName] = await fetchCurrentMachineUDIDAsync();
Log.log(chalk.green(`Fetched the provisioning UDID - ${udid}`));
const name = await promptForNameAsync(defaultMachineName ?? initialValues.name);
const deviceClass = AppleDeviceClass.Mac;
const deviceData: DeviceData = {
udid,
name,
deviceClass,
};
printDeviceData(deviceData, appleTeam);
const registrationConfirmed = await confirmAsync({
message: 'Is this what you want to register?',
});
if (!registrationConfirmed) {
Log.log('No worries, just try again.');
Log.newLine();
return null;
} else {
return deviceData;
}
}
async function fetchCurrentMachineUDIDAsync(): Promise<[string, string?]> {
try {
const profilerData = (
await spawnAsync('system_profiler', ['-json', 'SPHardwareDataType'])
).stdout.trim();
if (!profilerData) {
const message = 'Failed to fetch the provisioning UDID from system profiler';
Log.error(message);
throw new DeviceCreateError(message);
}
const profilerDataJSON = JSON.parse(profilerData);
const provisioningUDID = profilerDataJSON?.SPHardwareDataType[0]?.provisioning_UDID;
if (!provisioningUDID) {
const message = 'System profiler data did not contain the provisioning UDID';
Log.error(message);
throw new DeviceCreateError(message);
}
const defaultMachineName = profilerDataJSON?.SPHardwareDataType[0]?.machine_name;
return [provisioningUDID, defaultMachineName];
} catch (err: any) {
if (!(err instanceof DeviceCreateError)) {
const message = `Failed to fetch the provisioning UDID of the current machine - ${err.message}`;
Log.error(message);
throw new DeviceCreateError(message);
}
throw err;
}
}
@@ -19,6 +19,7 @@ const DEVICE_IMPORT_CHUNK_SIZE = 10;
const DEVICE_CLASS_TO_GRAPHQL_TYPE: Partial<Record<DeviceClass, AppleDeviceClass>> = {
[DeviceClass.IPAD]: AppleDeviceClass.Ipad,
[DeviceClass.IPHONE]: AppleDeviceClass.Iphone,
[DeviceClass.MAC]: AppleDeviceClass.Mac,
};
export async function runDeveloperPortalMethodAsync(
@@ -105,7 +106,7 @@ async function findUnregisteredPortalDevicesAsync(
return portalDevices.filter(
portalDevice =>
!(portalDevice.attributes.udid in expoRegisteredDevicesByUdid) &&
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV].includes(
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV, DeviceClass.MAC].includes(
portalDevice.attributes.deviceClass
)
);
@@ -2,18 +2,17 @@ import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import { AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync, promptAsync } from '../../../prompts';
import { isValidUDID, normalizeUDID } from '../../udids';
import { formatNewDevice } from '../../utils/formatDevice';
interface DeviceData {
udid: string;
name?: string;
deviceClass: AppleDeviceClass | null;
}
import { confirmAsync } from '../../../prompts';
import {
DeviceData,
printDeviceData,
promptForDeviceClassAsync,
promptForNameAsync,
promptForUDIDAsync,
} from './utils';
export async function runInputMethodAsync(
graphqlClient: ExpoGraphqlClient,
@@ -78,16 +77,7 @@ async function collectDeviceDataAsync(
deviceClass,
};
Log.newLine();
Log.log(
`We are going to register the following device in our database.
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));
Log.newLine();
printDeviceData(deviceData, appleTeam);
const registrationConfirmed = await confirmAsync({
message: 'Is this what you want to register?',
@@ -100,56 +90,3 @@ This device will ${chalk.bold(
return deviceData;
}
}
export async function promptForUDIDAsync(initial?: string): Promise<string> {
const { udid } = await promptAsync({
type: 'text',
name: 'udid',
message: 'UDID:',
initial,
validate: (rawVal: string) => {
const val = normalizeUDID(rawVal);
if (!val || val === '') {
return 'UDID cannot be empty';
} else if (val.length !== 25 && val.length !== 40) {
return 'UDID should be a 25 or 40-character string';
} else if (!isValidUDID(val)) {
return 'UDID is invalid';
} else {
return true;
}
},
format: (val: string) => normalizeUDID(val),
});
return udid;
}
async function promptForNameAsync(initial?: string): Promise<string | undefined> {
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Device name (optional):',
initial,
});
return name;
}
async function promptForDeviceClassAsync(
initial?: AppleDeviceClass | null
): Promise<AppleDeviceClass | null> {
const choices = [
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
{ title: 'iPad', value: AppleDeviceClass.Ipad },
{ title: 'Not sure (leave empty)', value: null },
];
const values = choices.map(({ value }) => value);
const { deviceClass } = await promptAsync({
type: 'select',
name: 'deviceClass',
message: 'Device class (optional):',
choices,
initial: initial !== undefined && values.indexOf(initial),
});
return deviceClass;
}
@@ -0,0 +1,83 @@
import chalk from 'chalk';
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { isValidUDID, normalizeUDID } from '../../udids';
import { formatNewDevice } from '../../utils/formatDevice';
export interface DeviceData {
udid: string;
name?: string;
deviceClass: AppleDeviceClass | null;
}
export async function promptForDeviceClassAsync(
initial?: AppleDeviceClass | null
): Promise<AppleDeviceClass | null> {
const choices = [
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
{ title: 'iPad', value: AppleDeviceClass.Ipad },
{ title: 'Mac', value: AppleDeviceClass.Mac },
{ title: 'Not sure (leave empty)', value: null },
];
const values = choices.map(({ value }) => value);
const { deviceClass } = await promptAsync({
type: 'select',
name: 'deviceClass',
message: 'Device class (optional):',
choices,
initial: initial !== undefined && values.indexOf(initial),
});
return deviceClass;
}
export async function promptForNameAsync(initial?: string): Promise<string | undefined> {
const { name } = await promptAsync({
type: 'text',
name: 'name',
message: 'Device name (optional):',
initial,
});
return name;
}
export async function promptForUDIDAsync(initial?: string): Promise<string> {
const { udid } = await promptAsync({
type: 'text',
name: 'udid',
message: 'UDID:',
initial,
validate: (rawVal: string) => {
const val = normalizeUDID(rawVal);
if (!val || val === '') {
return 'UDID cannot be empty';
} else if (val.length !== 25 && val.length !== 40) {
return 'UDID should be a 25 or 40-character string';
} else if (!isValidUDID(val)) {
return 'UDID is invalid';
} else {
return true;
}
},
format: (val: string) => normalizeUDID(val),
});
return udid;
}
export function printDeviceData(
deviceData: DeviceData,
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>
): void {
Log.newLine();
Log.log(
`We are going to register the following device in our database.
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));
Log.newLine();
}
+3 -3
View File
@@ -12,10 +12,10 @@ import { Actor } from '../user/User';
import DeviceCreateAction from './actions/create/action';
import { DeviceManagerContext } from './context';
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones and iPads) for internal distribution of your app.
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones, iPads and Macs) for internal distribution of your app.
Internal distribution means that you won't need to upload your app archive to App Store / Testflight.
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisiong profile.
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones and iPads.
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisioning profile.
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones, iPads and Macs.
First of all, choose the Expo account under which you want to register your devices.
Later, authenticate with Apple and choose your desired Apple Team (if your Apple ID has access to multiple teams).`;
@@ -3,3 +3,9 @@ export class DeviceNotFoundError extends Error {
super(message ?? 'Device not found.');
}
}
export class DeviceCreateError extends Error {
constructor(message?: string) {
super(message ?? 'Failed to create a device.');
}
}
@@ -9,6 +9,7 @@ export type AppleTeamIdAndName = Pick<AppleTeam, 'appleTeamIdentifier' | 'appleT
const DEVICE_CLASS_DISPLAY_NAMES: Record<AppleDeviceClass, string> = {
[AppleDeviceClass.Iphone]: 'iPhone',
[AppleDeviceClass.Ipad]: 'iPad',
[AppleDeviceClass.Mac]: 'Mac',
};
function formatDeviceClass(device: Device | NewDevice): string {
@@ -0,0 +1,10 @@
import { testAppJson } from '../../credentials/__tests__/fixtures-constants';
import { EASUpdateContext } from '../utils';
export function createCtxMock(options: { nonInteractive?: boolean } = {}): EASUpdateContext {
return {
graphqlClient: jest.fn() as any,
nonInteractive: options.nonInteractive ?? false,
app: { exp: testAppJson, projectId: 'test-project-id' },
};
}
+15
View File
@@ -0,0 +1,15 @@
import { ExpoConfig } from '@expo/config-types';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
export type EASUpdateContext = {
graphqlClient: ExpoGraphqlClient;
nonInteractive: boolean;
app: { exp: ExpoConfig; projectId: string };
};
export interface EASUpdateAction<T = any> {
runAsync(ctx: EASUpdateContext): Promise<T>;
}
export class NonInteractiveError extends Error {}
File diff suppressed because it is too large. Load diff
@@ -5,8 +5,11 @@ import { BranchNotFoundError } from '../../branch/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
BranchesBasicPaginatedOnAppQuery,
BranchesBasicPaginatedOnAppQueryVariables,
BranchesByAppQuery,
BranchesByAppQueryVariables,
UpdateBranchBasicInfoFragment,
UpdateBranchFragment,
ViewBranchQuery,
ViewBranchQueryVariables,
@@ -15,6 +18,7 @@ import {
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateBranchFragmentNode } from '../types/UpdateBranch';
import { UpdateBranchBasicInfoFragmentNode } from '../types/UpdateBranchBasicInfo';
export type UpdateBranchOnChannelObject = NonNullable<
ViewBranchesOnUpdateChannelQuery['app']['byId']['updateChannelByName']
@@ -24,7 +28,7 @@ export const BranchQuery = {
async getBranchByNameAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name }: ViewBranchQueryVariables
): Promise<NonNullable<ViewBranchQuery['app']['byId']['updateBranchByName']>> {
): Promise<UpdateBranchBasicInfoFragment> {
const response = await withErrorHandlingAsync<ViewBranchQuery>(
graphqlClient
.query<ViewBranchQuery, ViewBranchQueryVariables>(
@@ -35,11 +39,12 @@ export const BranchQuery = {
id
updateBranchByName(name: $name) {
id
name
...UpdateBranchBasicInfoFragment
}
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{
appId,
@@ -88,6 +93,56 @@ export const BranchQuery = {
return data?.app?.byId.updateBranches ?? [];
},
async listBranchesBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after, last, before }: BranchesBasicPaginatedOnAppQueryVariables
): Promise<BranchesBasicPaginatedOnAppQuery['app']['byId']['branchesPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<BranchesBasicPaginatedOnAppQuery, BranchesBasicPaginatedOnAppQueryVariables>(
gql`
query BranchesBasicPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
branchesPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
...UpdateBranchBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(UpdateBranchBasicInfoFragmentNode)}
`,
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateBranch'] }
)
.toPromise()
);
const { branchesPaginated } = response.app.byId;
if (!branchesPaginated) {
throw new Error(`Could not find channels on project with id ${appId}`);
}
return branchesPaginated;
},
async listBranchesOnChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName, offset, limit }: ViewBranchesOnUpdateChannelQueryVariables
@@ -5,43 +5,68 @@ import { ChannelNotFoundError } from '../../channel/errors';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
UpdateBranchBasicInfoFragment,
UpdateFragment,
ViewUpdateChannelOnAppQuery,
ViewUpdateChannelOnAppQueryVariables,
ViewUpdateChannelsOnAppQuery,
ViewUpdateChannelsOnAppQueryVariables,
ViewUpdateChannelsPaginatedOnAppQuery,
ViewUpdateChannelsPaginatedOnAppQueryVariables,
} from '../generated';
import { UpdateFragmentNode } from '../types/Update';
import { UpdateChannelBasicInfoFragmentNode } from '../types/UpdateChannelBasicInfo';
export type UpdateChannelObject = NonNullable<
type ViewUpdateChannelsOnAppObject = NonNullable<
ViewUpdateChannelsOnAppQuery['app']['byId']['updateChannels']
>[number];
export type UpdateChannelByNameObject = NonNullable<
type UpdateChannelByNameObject = NonNullable<
ViewUpdateChannelOnAppQuery['app']['byId']['updateChannelByName']
>;
// these types should have the same fields
export type UpdateChannelObject = ViewUpdateChannelsOnAppObject & UpdateChannelByNameObject;
export type UpdateBranchObject = UpdateChannelObject['updateBranches'][number];
export function composeUpdateBranchObject(
branchInfo: UpdateBranchBasicInfoFragment,
updateGroups: UpdateFragment[][]
): UpdateBranchObject {
return {
...branchInfo,
updateGroups,
};
}
export const ChannelQuery = {
async viewUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, channelName }: ViewUpdateChannelOnAppQueryVariables
{ appId, channelName, filter }: ViewUpdateChannelOnAppQueryVariables
): Promise<UpdateChannelByNameObject> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<ViewUpdateChannelOnAppQuery, ViewUpdateChannelOnAppQueryVariables>(
gql`
query ViewUpdateChannelOnApp($appId: String!, $channelName: String!) {
query ViewUpdateChannelOnApp(
$appId: String!
$channelName: String!
$filter: UpdatesFilter
) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
updatedAt
createdAt
branchMapping
updateBranches(offset: 0, limit: 5) {
id
name
updateGroups(offset: 0, limit: 1) {
updateGroups(offset: 0, limit: 1, filter: $filter) {
id
...UpdateFragment
}
@@ -52,7 +77,7 @@ export const ChannelQuery = {
}
${print(UpdateFragmentNode)}
`,
{ appId, channelName },
{ appId, channelName, filter },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -80,6 +105,8 @@ export const ChannelQuery = {
updateChannels(offset: $offset, limit: $limit) {
id
name
updatedAt
createdAt
branchMapping
updateBranches(offset: 0, limit: 5) {
id
@@ -108,4 +135,57 @@ export const ChannelQuery = {
return updateChannels;
},
async viewUpdateChannelsBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after, last, before }: ViewUpdateChannelsPaginatedOnAppQueryVariables
): Promise<ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
.query<
ViewUpdateChannelsPaginatedOnAppQuery,
ViewUpdateChannelsPaginatedOnAppQueryVariables
>(
gql`
query ViewUpdateChannelsPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
channelsPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
...UpdateChannelBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
);
const { channelsPaginated } = response.app.byId;
if (!channelsPaginated) {
throw new Error(`Could not find channels on project with id ${appId}`);
}
return channelsPaginated;
},
};
@@ -0,0 +1,85 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { BranchNotFoundError } from '../../branch/utils';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection } from '../../utils/relay';
import { withErrorHandlingAsync } from '../client';
import {
RuntimeFragment,
ViewRuntimesOnBranchQuery,
ViewRuntimesOnBranchQueryVariables,
} from '../generated';
import { RuntimeFragmentNode } from '../types/Runtime';
export const RuntimeQuery = {
async getRuntimesOnBranchAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, name, first, after, last, before, filter }: ViewRuntimesOnBranchQueryVariables
): Promise<Connection<RuntimeFragment>> {
const response = await withErrorHandlingAsync<ViewRuntimesOnBranchQuery>(
graphqlClient
.query<ViewRuntimesOnBranchQuery, ViewRuntimesOnBranchQueryVariables>(
gql`
query ViewRuntimesOnBranch(
$appId: String!
$name: String!
$first: Int
$after: String
$last: Int
$before: String
$filter: RuntimeFilterInput
) {
app {
byId(appId: $appId) {
id
updateBranchByName(name: $name) {
id
runtimes(
first: $first
after: $after
before: $before
last: $last
filter: $filter
) {
edges {
node {
id
...RuntimeFragment
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
}
${print(RuntimeFragmentNode)}
`,
{
appId,
name,
first,
after,
last,
before,
filter,
},
{ additionalTypenames: ['UpdateBranch', 'Runtime'] }
)
.toPromise()
);
const { updateBranchByName } = response.app.byId;
if (!updateBranchByName) {
throw new BranchNotFoundError(`Could not find a branch named "${name}".`);
}
return updateBranchByName.runtimes;
},
};
@@ -0,0 +1,8 @@
import gql from 'graphql-tag';
export const RuntimeFragmentNode = gql`
fragment RuntimeFragment on Runtime {
id
version
}
`;
@@ -18,6 +18,7 @@ export const SubmissionFragmentNode = gql`
applicationIdentifier
track
releaseStatus
rollout
}
iosConfig {
ascAppIdentifier
@@ -2,15 +2,17 @@ import { print } from 'graphql';
import gql from 'graphql-tag';
import { UpdateFragmentNode } from './Update';
import { UpdateBranchBasicInfoFragmentNode } from './UpdateBranchBasicInfo';
export const UpdateBranchFragmentNode = gql`
fragment UpdateBranchFragment on UpdateBranch {
id
name
...UpdateBranchBasicInfoFragment
updates(offset: 0, limit: 10) {
id
...UpdateFragment
}
}
${print(UpdateFragmentNode)}
${print(UpdateBranchBasicInfoFragmentNode)}
`;
@@ -0,0 +1,8 @@
import gql from 'graphql-tag';
export const UpdateBranchBasicInfoFragmentNode = gql`
fragment UpdateBranchBasicInfoFragment on UpdateBranch {
id
name
}
`;
@@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export const UpdateChannelBasicInfoFragmentNode = gql`
fragment UpdateChannelBasicInfoFragment on UpdateChannel {
id
name
branchMapping
}
`;
@@ -5,6 +5,7 @@ import { AppleDeviceClass } from '../../generated';
export const APPLE_DEVICE_CLASS_LABELS: Record<AppleDeviceClass, string> = {
[AppleDeviceClass.Ipad]: 'iPad',
[AppleDeviceClass.Iphone]: 'iPhone',
[AppleDeviceClass.Mac]: 'Mac',
};
export const AppleDeviceFragmentNode = gql`
+4
View File
@@ -57,6 +57,10 @@ export default class Log {
Log.consoleLog(chalk.green(figures.tick), ...args);
}
public static withInfo(...args: any[]): void {
Log.consoleLog(chalk.green(figures.info), ...args);
}
private static consoleLog(...args: any[]): void {
Log.updateIsLastLineNewLine(args);
// eslint-disable-next-line no-console
@@ -180,7 +180,7 @@ export class AppleConfigReader {
return {
locale,
description: info.description,
keywords: info.keywords?.join(', '),
keywords: info.keywords?.join(','),
// TODO: maybe move this to task logic, it's more an exception than data handling
whatsNew: context.versionIsFirst ? undefined : info.releaseNotes || DEFAULT_WHATSNEW,
marketingUrl: info.marketingUrl,
@@ -201,8 +201,14 @@ async function getSuggestedBundleIdentifierAsync(
} else {
// the only callsite is heavily interactive
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
let possibleId: string;
// It's common to use dashes in your node project name, strip them from the suggested package name.
const possibleId = `com.${account.name}.${exp.slug}`.split('-').join('');
if (account.name) {
possibleId = `com.${account.name}.${exp.slug}`.split('-').join('');
} else {
possibleId = `com.${exp.slug}`.split('-').join('');
}
if (isBundleIdentifierValid(possibleId)) {
return possibleId;
}
@@ -3,6 +3,7 @@ import { JSONObject } from '@expo/json-file';
import { getPrebuildConfigAsync } from '@expo/prebuild-config';
import { readPlistAsync } from '../../utils/plist';
import { hasIgnoredIosProjectAsync } from '../workflow';
interface Target {
buildConfiguration?: string;
@@ -13,6 +14,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
env: Record<string, string>
): Promise<JSONObject> {
const originalProcessEnv: NodeJS.ProcessEnv = process.env;
try {
process.env = {
...process.env,
@@ -24,6 +26,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
projectRoot: projectDir,
platforms: ['ios'],
introspect: true,
ignoreExistingNativeFiles: await hasIgnoredIosProjectAsync(projectDir),
});
return expWithMods.ios?.entitlements || {};
} finally {
+2 -2
View File
@@ -38,7 +38,7 @@ const AppExtensionsConfigSchema = Joi.array().items(
})
);
export async function resolveMangedProjectTargetsAsync({
export async function resolveManagedProjectTargetsAsync({
exp,
projectDir,
xcodeBuildContext,
@@ -138,7 +138,7 @@ export async function resolveTargetsAsync(opts: ResolveTargetOptions): Promise<T
if (workflow === Workflow.GENERIC) {
return await resolveBareProjectTargetsAsync(opts);
} else if (workflow === Workflow.MANAGED) {
return await resolveMangedProjectTargetsAsync(opts);
return await resolveManagedProjectTargetsAsync(opts);
} else {
throw new Error(`Unknown workflow: ${workflow}`);
}
+60 -18
View File
@@ -25,7 +25,11 @@ 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 {
expoCommandAsync,
shouldUseVersionedExpoCLI,
shouldUseVersionedExpoCLIWithExplicitPlatforms,
} from '../utils/expoCli';
import chunk from '../utils/expodash/chunk';
import { truthy } from '../utils/expodash/filter';
import uniqBy from '../utils/expodash/uniqBy';
@@ -181,32 +185,23 @@ export async function buildBundlesAsync({
exp,
platformFlag,
clearCache,
dev = false,
}: {
projectDir: string;
inputDir: string;
exp: Pick<ExpoConfig, 'sdkVersion'>;
exp: Pick<ExpoConfig, 'sdkVersion' | 'web'>;
platformFlag: ExpoCLIExportPlatformFlag;
clearCache?: boolean;
dev?: boolean;
}): Promise<void> {
const packageJSON = JsonFile.read(path.resolve(projectDir, 'package.json'));
if (!packageJSON) {
throw new Error('Could not locate package.json');
}
if (shouldUseVersionedExpoCLI(projectDir, exp)) {
await expoCommandAsync(projectDir, [
'export',
'--output-dir',
inputDir,
'--dump-sourcemap',
'--dump-assetmap',
'--platform',
platformFlag,
...(clearCache ? ['--clear'] : []),
]);
} else {
// Legacy global Expo CLI
await expoCommandAsync(projectDir, [
// Legacy global Expo CLI
if (!shouldUseVersionedExpoCLI(projectDir, exp)) {
return await expoCommandAsync(projectDir, [
'export',
'--output-dir',
inputDir,
@@ -214,11 +209,52 @@ export async function buildBundlesAsync({
'--non-interactive',
'--dump-sourcemap',
'--dump-assetmap',
'--platform',
platformFlag,
`--platform=${platformFlag}`,
...(dev ? ['--dev'] : []),
...(clearCache ? ['--clear'] : []),
]);
}
// Versioned Expo CLI, with multiple platform flag support
if (shouldUseVersionedExpoCLIWithExplicitPlatforms(projectDir)) {
// When creating EAS updates, we don't want to build a web bundle
const platformArgs =
platformFlag === 'all'
? ['--platform', 'ios', '--platform', 'android']
: ['--platform', platformFlag];
return await expoCommandAsync(projectDir, [
'export',
'--output-dir',
inputDir,
'--dump-sourcemap',
'--dump-assetmap',
...platformArgs,
...(dev ? ['--dev'] : []),
...(clearCache ? ['--clear'] : []),
]);
}
// Versioned Expo CLI, without multiple platform flag support
// Warn users about potential export issues when using Metro web
// See: https://github.com/expo/expo/pull/23621
if (exp.web?.bundler === 'metro') {
Log.warn('Exporting bundle for all platforms, including Metro web.');
Log.warn(
'If your app is incompatible with web, remove the "expo.web.bundler" property from your app manifest, or upgrade to the latest Expo SDK.'
);
}
return await expoCommandAsync(projectDir, [
'export',
'--output-dir',
inputDir,
'--dump-sourcemap',
'--dump-assetmap',
`--platform=${platformFlag}`,
...(dev ? ['--dev'] : []),
...(clearCache ? ['--clear'] : []),
]);
}
export async function resolveInputDirectoryAsync(
@@ -682,3 +718,9 @@ export function getRuntimeToPlatformMappingFromRuntimeVersions(
}
return runtimeToPlatformMapping;
}
export const platformDisplayNames: Record<Platform, string> = {
android: 'Android',
ios: 'iOS',
web: 'Web',
};
+12
View File
@@ -44,3 +44,15 @@ export async function resolveWorkflowPerPlatformAsync(
]);
return { android, ios };
}
export async function hasIgnoredIosProjectAsync(projectDir: string): Promise<boolean> {
const vcsClient = getVcsClient();
const vcsRootPath = path.normalize(await vcsClient.getRootPathAsync());
try {
const pbxProjectPath = IOSConfig.Paths.getPBXProjectPath(projectDir);
return await vcsClient.isFileIgnoredAsync(path.relative(vcsRootPath, pbxProjectPath));
} finally {
return false;
}
}
@@ -0,0 +1,352 @@
import { v4 as uuidv4 } from 'uuid';
import {
channelInfoWithBranches,
testBasicBranchInfo1,
testBasicBranchInfo2,
testChannelBasicInfo,
} from '../../channel/__tests__/branch-mapping-fixtures';
import {
BranchMappingOperator,
BranchMappingValidationError,
alwaysTrue,
andStatement,
equalsOperator,
hashLtOperator,
} from '../../channel/branch-mapping';
import {
assertRolloutBranchMapping,
composeRollout,
createRolloutBranchMapping,
doesTargetRollout,
editRolloutBranchMapping,
getRollout,
getRolloutInfo,
getRolloutInfoFromBranchMapping,
isConstrainedRollout,
isConstrainedRolloutInfo,
isLegacyRolloutInfo,
isRolloutBranchMapping,
} from '../branch-mapping';
import {
rolloutBranchMapping,
rolloutBranchMappingLegacy,
standardBranchMapping,
} from './fixtures';
describe(doesTargetRollout, () => {
it('detects whether a runtime targets a constrained rollout', () => {
expect(doesTargetRollout(rolloutBranchMapping, '1.0.0')).toBe(true);
expect(doesTargetRollout(rolloutBranchMapping, '2.0.0')).toBe(false);
});
it('should always return true if the rollout is unconstrained', () => {
expect(doesTargetRollout(rolloutBranchMappingLegacy, '1.0.0')).toBe(true);
expect(doesTargetRollout(rolloutBranchMappingLegacy, '2.0.0')).toBe(true);
});
});
describe(composeRollout, () => {
it('composes a rollout', () => {
const rollout = getRollout(channelInfoWithBranches);
const rolloutInfo = getRolloutInfo(testChannelBasicInfo);
const composedRollout = composeRollout(rolloutInfo, testBasicBranchInfo2, testBasicBranchInfo1);
expect(composedRollout).toEqual(rollout);
});
it('throws if the branches to not match the rollout info', () => {
const rolloutInfo = getRolloutInfo(testChannelBasicInfo);
expect(() =>
composeRollout(rolloutInfo, testBasicBranchInfo1, testBasicBranchInfo2)
).toThrowError(BranchMappingValidationError);
});
});
describe(createRolloutBranchMapping, () => {
it('creates a rollout branch mapping', () => {
const branchMapping = createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 10,
runtimeVersion: '1.0.0',
});
expect(isRolloutBranchMapping(branchMapping)).toBe(true);
});
it('throws if an invalid number is passed in', () => {
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 0.1,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: -1,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
expect(() =>
createRolloutBranchMapping({
defaultBranchId: 'default-branch-id',
rolloutBranchId: 'rollout-branch-id',
percent: 1000,
runtimeVersion: '1.0.0',
})
).toThrowError(BranchMappingValidationError);
});
});
describe(assertRolloutBranchMapping, () => {
it('asserts a rollout branch mapping', () => {
expect(() => assertRolloutBranchMapping(standardBranchMapping)).toThrowError(
BranchMappingValidationError
);
expect(() => assertRolloutBranchMapping(rolloutBranchMapping)).not.toThrowError(
BranchMappingValidationError
);
expect(() => assertRolloutBranchMapping(rolloutBranchMappingLegacy)).not.toThrowError(
BranchMappingValidationError
);
});
});
describe(isLegacyRolloutInfo, () => {
it('classifies rollouts properly', () => {
const constrainedRollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(isLegacyRolloutInfo(constrainedRollout)).toBe(false);
const legacyRollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(isLegacyRolloutInfo(legacyRollout)).toBe(true);
});
});
describe(isConstrainedRollout, () => {
it('classifies rollouts properly', () => {
const constrainedRollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(isConstrainedRolloutInfo(constrainedRollout)).toBe(true);
const legacyRollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(isConstrainedRolloutInfo(legacyRollout)).toBe(false);
});
});
describe(getRollout, () => {
it('doesnt get the mapping if it isnt a rollout', () => {
const notRollout = { ...channelInfoWithBranches };
notRollout.branchMapping = JSON.stringify(standardBranchMapping);
expect(() => getRollout(notRollout)).toThrowError();
});
it('gets a constrained rollout', () => {
const constrainedRollout = { ...channelInfoWithBranches };
const rolloutBranchMappingWithCorrectBranch = { ...rolloutBranchMapping };
rolloutBranchMappingWithCorrectBranch.data[0].branchId = testBasicBranchInfo1.id;
rolloutBranchMappingWithCorrectBranch.data[1].branchId = testBasicBranchInfo2.id;
constrainedRollout.branchMapping = JSON.stringify(rolloutBranchMapping);
const rollout = getRollout(constrainedRollout);
expect(rollout.percentRolledOut).toEqual(10);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranch).toEqual(testBasicBranchInfo2);
expect(rollout.rolledOutBranch).toEqual(testBasicBranchInfo1);
expect(rollout.defaultBranchId).toEqual(testBasicBranchInfo2.id);
expect(rollout.rolledOutBranchId).toEqual(testBasicBranchInfo1.id);
});
it('gets a legacy rollout', () => {
const rollout = getRollout(channelInfoWithBranches);
expect(rollout.percentRolledOut).toEqual(15);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranch).toEqual(testBasicBranchInfo2);
expect(rollout.rolledOutBranch).toEqual(testBasicBranchInfo1);
expect(rollout.defaultBranchId).toEqual(testBasicBranchInfo2.id);
expect(rollout.rolledOutBranchId).toEqual(testBasicBranchInfo1.id);
});
});
describe(getRolloutInfoFromBranchMapping, () => {
it('gets a constrained rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(rollout.percentRolledOut).toEqual(10);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranchId).toEqual(rolloutBranchMapping.data[1].branchId);
expect(rollout.rolledOutBranchId).toEqual(rolloutBranchMapping.data[0].branchId);
});
it('gets a legacy rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMappingLegacy);
expect(rollout.percentRolledOut).toEqual(10);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
expect(rollout.defaultBranchId).toEqual(rolloutBranchMappingLegacy.data[1].branchId);
expect(rollout.rolledOutBranchId).toEqual(rolloutBranchMappingLegacy.data[0].branchId);
});
});
describe(editRolloutBranchMapping, () => {
it('doesnt edit the branch mapping if the input is out of range', () => {
expect(() => editRolloutBranchMapping(rolloutBranchMapping, -1)).toThrowError();
expect(() => editRolloutBranchMapping(rolloutBranchMapping, 101)).toThrowError();
expect(() => editRolloutBranchMapping(rolloutBranchMapping, 0.1)).toThrowError();
});
it('returns a new instance of a branch mapping', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMapping, 50);
expect(rolloutBranchMapping).not.toBe(editedBranchMapping);
});
it('edits the branch mapping for a constrained rollout', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMapping, 50);
const rollout = getRolloutInfoFromBranchMapping(editedBranchMapping);
expect(rollout.percentRolledOut).toEqual(50);
expect(isConstrainedRolloutInfo(rollout)).toBe(true);
});
it('edits the branch mapping for a legacy rollout', () => {
const editedBranchMapping = editRolloutBranchMapping(rolloutBranchMappingLegacy, 50);
const rollout = getRolloutInfoFromBranchMapping(editedBranchMapping);
expect(rollout.percentRolledOut).toEqual(50);
expect(isLegacyRolloutInfo(rollout)).toBe(true);
});
});
describe(isRolloutBranchMapping, () => {
it('detects a rollout made by the cli', () => {
expect(isRolloutBranchMapping(rolloutBranchMappingLegacy)).toBe(true);
expect(isRolloutBranchMapping(rolloutBranchMapping)).toBe(true);
});
it('detects custom mappings equivalent to rollouts made by the cli', () => {
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping1)).toBe(true);
});
it('correctly classifies branchMappings that arent rollouts', () => {
expect(isRolloutBranchMapping(standardBranchMapping)).toBe(false);
const customMapping1 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_gt' as BranchMappingOperator,
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping1)).toBe(false);
const customMapping2 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping2)).toBe(false);
const customMapping3 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping3)).toBe(false);
const customMapping4 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'FOOBAR',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRolloutBranchMapping(customMapping4)).toBe(false);
const customMapping5 = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
{
operand: '2.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
},
]),
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRolloutBranchMapping(customMapping5)).toBe(false);
const customMapping6 = {
version: 0,
data: [
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
},
},
],
};
expect(isRolloutBranchMapping(customMapping6)).toBe(false);
});
});
@@ -0,0 +1,75 @@
import { v4 as uuidv4 } from 'uuid';
import { BranchMapping, alwaysTrue } from '../../channel/branch-mapping';
import { ConstrainedRolloutBranchMapping, LegacyRolloutBranchMapping } from '../branch-mapping';
export const rolloutBranchMapping: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: [
'and',
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMapping2: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: [
'and',
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMappingLegacy: LegacyRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const standardBranchMapping: BranchMapping = {
version: 0,
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
};
export const emptyBranchMapping: BranchMapping = {
version: 0,
data: [],
};
@@ -0,0 +1,332 @@
import { Updates } from '@expo/config-plugins';
import assert from 'assert';
import { SelectBranch } from '../../branch/actions/SelectBranch';
import {
getStandardBranchId,
hasEmptyBranchMap,
hasStandardBranchMap,
} from '../../channel/branch-mapping';
import { getUpdateBranch } from '../../channel/utils';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import {
UpdateBranchBasicInfoFragment,
UpdateChannelBasicInfoFragment,
UpdateFragment,
} from '../../graphql/generated';
import { BranchQuery } from '../../graphql/queries/BranchQuery';
import {
ChannelQuery,
UpdateChannelObject,
composeUpdateBranchObject,
} from '../../graphql/queries/ChannelQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { truthy } from '../../utils/expodash/filter';
import {
composeRollout,
createRolloutBranchMapping,
getRolloutInfoFromBranchMapping,
isRollout,
} from '../branch-mapping';
import {
displayRolloutDetails,
formatBranchWithUpdateGroup,
promptForRolloutPercentAsync,
} from '../utils';
import { SelectRuntime } from './SelectRuntime';
export type NonInteractiveOptions = {
branchNameToRollout: string;
percent: number;
runtimeVersion: string;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.branchNameToRollout && !!options.percent && !!options.runtimeVersion;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--branch, --percent and --runtime-version are required for creating a rollout in non-interactive mode.'
);
}
/**
* Create a rollout for the project.
*/
export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { branchNameToRollout } = this.options;
const { nonInteractive, graphqlClient } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
if (isRollout(this.channelInfo)) {
throw new Error(`A rollout is already in progress for channel ${this.channelInfo.name}`);
}
if (hasEmptyBranchMap(this.channelInfo)) {
throw new Error(
`Your channel needs to be linked to a branch before a rollout can be created. Do this by running 'eas channel:edit'`
);
}
if (!hasStandardBranchMap(this.channelInfo)) {
throw new Error(
`You have a custom branch mapping. Map your channel to a single branch before creating a rollout. Received: ${this.channelInfo.branchMapping}`
);
}
const defaultBranchId = getStandardBranchId(this.channelInfo);
const branchInfoToRollout = branchNameToRollout
? await this.resolveBranchNameAsync(ctx, branchNameToRollout)
: await this.selectBranchAsync(ctx, defaultBranchId);
if (branchInfoToRollout.id === defaultBranchId) {
throw new Error(
`Channel ${this.channelInfo.name} is already mapped to branch ${branchInfoToRollout.name}.`
);
}
const runtimeVersion =
this.options.runtimeVersion ??
(await this.selectRuntimeVersionAsync(ctx, branchInfoToRollout, defaultBranchId));
Log.newLine();
const promptMessage = `What percent of users should be rolled out to branch ${branchInfoToRollout.name}?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
const rolloutBranchMapping = createRolloutBranchMapping({
defaultBranchId,
rolloutBranchId: branchInfoToRollout.id,
percent,
runtimeVersion,
});
const channelObject = await this.getChannelObjectAsync(ctx, runtimeVersion);
const defaultBranch = getUpdateBranch(channelObject, defaultBranchId);
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const rolloutInfo = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
const rolledOutBranchUpdateGroup = await this.getLatestUpdateGroupOnBranchAsync(
ctx,
branchInfoToRollout,
runtimeVersion
);
const rolledOutBranch = composeUpdateBranchObject(
branchInfoToRollout,
rolledOutBranchUpdateGroup ? [rolledOutBranchUpdateGroup] : []
);
const rollout = composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
displayRolloutDetails(this.channelInfo.name, rollout);
Log.log(
formatBranchWithUpdateGroup(defaultUpdateGroup, defaultBranch, 100 - rollout.percentRolledOut)
);
Log.addNewLineIfNone();
Log.log(
formatBranchWithUpdateGroup(
rolledOutBranchUpdateGroup,
rolledOutBranch,
rollout.percentRolledOut
)
);
Log.addNewLineIfNone();
const didConfirm = await this.confirmCreationAsync(ctx);
if (!didConfirm) {
throw new Error('Aborting...');
}
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: this.channelInfo.id,
branchMapping: JSON.stringify(rolloutBranchMapping),
});
Log.succeed('✅ Successfully created rollout');
return newChannelInfo;
}
async confirmCreationAsync(ctx: EASUpdateContext): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
return await confirmAsync({
message: `Create rollout?`,
});
}
async getChannelObjectAsync(
ctx: EASUpdateContext,
runtimeVersion: string
): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
filter: { runtimeVersions: [runtimeVersion] },
});
}
async getLatestUpdateGroupOnBranchAsync(
ctx: EASUpdateContext,
branchInfo: UpdateBranchBasicInfoFragment,
runtimeVersion: string
): Promise<UpdateFragment[] | null> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
const updateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, {
appId: projectId,
branchName: branchInfo.name,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtimeVersion],
},
});
assert(
updateGroups.length < 2,
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
);
return updateGroups[0] ?? null;
}
async selectRuntimeVersionAsync(
ctx: EASUpdateContext,
branchToRollout: UpdateBranchBasicInfoFragment,
defaultBranchId: string
): Promise<string> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
// we just want the branch name, not the update group
const channelObjectRtvAgnostic = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
});
const defaultBranchRtvAgnostic = getUpdateBranch(channelObjectRtvAgnostic, defaultBranchId);
const selectSharedRuntimeAction = new SelectRuntime(branchToRollout, {
anotherBranchToIntersectRuntimesBy: defaultBranchRtvAgnostic,
});
const sharedRuntime = await selectSharedRuntimeAction.runAsync(ctx);
if (sharedRuntime) {
return sharedRuntime;
}
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
ctx,
branchToRollout,
defaultBranchRtvAgnostic
);
}
async selectRuntimeVersionFromAlternativeSourceAsync(
ctx: EASUpdateContext,
branchToRollout: UpdateBranchBasicInfoFragment,
defaultBranch: UpdateBranchBasicInfoFragment
): Promise<string> {
const { runtimeSource: selectedRuntimeSource } = await promptAsync({
type: 'select',
name: 'runtimeSource',
message: `What would you like to do?`,
choices: [
{
value: 'DEFAULT_BRANCH_RUNTIME',
title: `Find a runtime supported on the ${defaultBranch.name} branch`,
},
{
value: 'ROLLED_OUT_BRANCH_RUNTIME',
title: `Find a runtime supported on the ${branchToRollout.name} branch`,
},
{
value: 'PROJECT_RUNTIME',
title: 'Use the runtime specified in your project config',
},
],
});
if (selectedRuntimeSource === 'DEFAULT_BRANCH_RUNTIME') {
const selectDefaultBranchRuntimeAction = new SelectRuntime(defaultBranch);
const defaultBranchRuntime = await selectDefaultBranchRuntimeAction.runAsync(ctx);
if (defaultBranchRuntime) {
return defaultBranchRuntime;
}
} else if (selectedRuntimeSource === 'ROLLED_OUT_BRANCH_RUNTIME') {
const selectBranchToRolloutRuntimeAction = new SelectRuntime(branchToRollout);
const branchToRolloutRuntime = await selectBranchToRolloutRuntimeAction.runAsync(ctx);
if (branchToRolloutRuntime) {
return branchToRolloutRuntime;
}
} else if (selectedRuntimeSource === 'PROJECT_RUNTIME') {
return await this.selectRuntimeVersionFromProjectConfigAsync(ctx);
} else {
throw new Error(`Unexpected runtime source: ${selectedRuntimeSource}`);
}
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
ctx,
branchToRollout,
defaultBranch
);
}
async selectRuntimeVersionFromProjectConfigAsync(ctx: EASUpdateContext): Promise<string> {
const platforms: ('ios' | 'android')[] = ['ios', 'android'];
const runtimes = platforms
.map(platform => Updates.getRuntimeVersion(ctx.app.exp, platform))
.filter(truthy);
const dedupedRuntimes = [...new Set(runtimes)];
if (dedupedRuntimes.length === 0) {
throw new Error(
`Your project config doesn't specify a runtime. Ensure your project is configured correctly for EAS Update by running \`eas update:configure\``
);
} else if (dedupedRuntimes.length === 1) {
const runtime = dedupedRuntimes[0];
Log.log(`🔧 Your project config currently supports runtime ${runtime}`);
return runtime;
}
const { runtime: selectedRuntime } = await promptAsync({
type: 'select',
name: 'runtime',
message: `Select a runtime supported by your project config`,
choices: runtimes.map((runtime, index) => ({
title: `${runtime} ${index === 0 ? '[iOS runtime]' : '[Android runtime]'}`,
value: runtime,
})),
});
return selectedRuntime;
}
async selectBranchAsync(
ctx: EASUpdateContext,
defaultBranchId: string
): Promise<UpdateBranchBasicInfoFragment> {
const selectBranchAction = new SelectBranch({
printedType: 'branch to roll out',
// we don't want to show the default branch as an option
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
branchInfo.id !== defaultBranchId,
});
const branchInfo = await selectBranchAction.runAsync(ctx);
if (!branchInfo) {
// We know the user has at least one branch, since we have `defaultBranchId`
throw new Error(
`You don't have a second branch to roll out. Create it with 'eas branch:create'`
);
}
return branchInfo;
}
async resolveBranchNameAsync(
ctx: EASUpdateContext,
branchName: string
): Promise<UpdateBranchBasicInfoFragment> {
const { graphqlClient, app } = ctx;
return await BranchQuery.getBranchByNameAsync(graphqlClient, {
appId: app.projectId,
name: branchName,
});
}
}
@@ -0,0 +1,118 @@
import assert from 'assert';
import chalk from 'chalk';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
import {
editRolloutBranchMapping,
getRollout,
getRolloutBranchMapping,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { promptForRolloutPercentAsync } from '../utils';
export type NonInteractiveOptions = {
percent: number;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.percent;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--percent is required for editing a rollout in non-interactive mode.'
);
}
/**
* Edit an existing rollout for the project.
*/
export class EditRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { nonInteractive, graphqlClient } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
const channelObject = await this.getChannelObjectAsync(ctx);
const rollout = getRollout(channelObject);
const { rolledOutBranch, defaultBranch } = rollout;
const promptMessage = `What percent of users should be rolled out to the ${rolledOutBranch.name} branch ?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
if (percent === 0 || percent === 100) {
Log.warn(
`Editing the percent to ${percent} will not end the rollout. You'll need to end the rollout from the main menu.`
);
}
const oldBranchMapping = getRolloutBranchMapping(channelObject.branchMapping);
const newBranchMapping = editRolloutBranchMapping(oldBranchMapping, percent);
Log.newLine();
Log.log(
`📝 ${chalk.bold(percent)}% of users will be rolled out to the ${chalk.bold(
rolledOutBranch.name
)} branch and ${chalk.bold(100 - percent)}% will remain on the ${chalk.bold(
defaultBranch.name
)} branch.`
);
const confirmEdit = await this.confirmEditAsync(ctx);
if (!confirmEdit) {
throw new Error('Aborting...');
}
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: channelObject.id,
branchMapping: JSON.stringify(newBranchMapping),
});
Log.addNewLineIfNone();
Log.log(`✅ Successfuly updated rollout`);
return newChannelInfo;
}
async confirmEditAsync(ctx: EASUpdateContext): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
return await confirmAsync({
message: `Confirm changes?`,
});
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To end a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
}
@@ -0,0 +1,220 @@
import assert from 'assert';
import chalk from 'chalk';
import { getAlwaysTrueBranchMapping } from '../../channel/branch-mapping';
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import {
ChannelQuery,
UpdateBranchObject,
UpdateChannelObject,
} from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { republishAsync } from '../../update/republish';
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
import formatFields from '../../utils/formatFields';
import {
Rollout,
getRollout,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { formatBranchWithUpdateGroup } from '../utils';
export enum EndOutcome {
REPUBLISH_AND_REVERT = 'republish-and-revert',
REVERT = 'revert',
}
export type GeneralOptions = {
privateKeyPath: string | null;
};
export type NonInteractiveOptions = {
outcome: EndOutcome;
};
function isNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): options is NonInteractiveOptions {
return !!options.outcome;
}
function assertNonInteractiveOptions(
options: Partial<NonInteractiveOptions>
): asserts options is NonInteractiveOptions {
assert(
isNonInteractiveOptions(options),
'--outcome is required for ending a rollout in non-interactive mode.'
);
}
/**
* End an existing rollout for the project.
*/
export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: Partial<NonInteractiveOptions> & GeneralOptions
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
const { nonInteractive } = ctx;
if (nonInteractive) {
assertNonInteractiveOptions(this.options);
}
const channelObject = await this.getChannelObjectAsync(ctx);
const rollout = getRollout(channelObject);
const { rolledOutBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
let outcome: EndOutcome;
if (!rolledOutUpdateGroup) {
Log.log(`⚠️ There is no update group being served on the ${rolledOutBranch.name} branch.`);
assert(
this.options.outcome !== EndOutcome.REPUBLISH_AND_REVERT,
`The only valid outcome for this rollout is to revert users back to the ${rollout.defaultBranch.name} branch. `
);
outcome = EndOutcome.REVERT;
} else {
outcome = this.options.outcome ?? (await this.selectOutcomeAsync(rollout));
}
const didConfirm = await this.confirmOutcomeAsync(ctx, outcome, rollout);
if (!didConfirm) {
throw new Error('Aborting...');
}
return await this.performOutcomeAsync(ctx, rollout, outcome);
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To end a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
async selectOutcomeAsync(rollout: Rollout<UpdateBranchObject>): Promise<EndOutcome> {
const { rolledOutBranch, percentRolledOut, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const outcomes = [
{
value: EndOutcome.REPUBLISH_AND_REVERT,
title: formatBranchWithUpdateGroup(rolledOutUpdateGroup, rolledOutBranch, percentRolledOut),
},
{
value: EndOutcome.REVERT,
title: formatBranchWithUpdateGroup(
defaultUpdateGroup,
defaultBranch,
100 - percentRolledOut
),
},
];
const { outcome: selectedOutcome } = await promptAsync({
type: 'select',
name: 'outcome',
message: `Which update group would you like to serve?`,
choices: outcomes,
});
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
Log.log(
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(rolledOutBranch.name)}`
);
} else {
Log.log(
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(defaultBranch.name)}`
);
}
return selectedOutcome;
}
async performOutcomeAsync(
ctx: EASUpdateContext,
rollout: Rollout<UpdateBranchObject>,
outcome: EndOutcome
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
const { rolledOutBranch, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
if (outcome === EndOutcome.REPUBLISH_AND_REVERT) {
const codeSigningInfo = await getCodeSigningInfoAsync(
ctx.app.exp,
this.options.privateKeyPath ?? undefined
);
const arbitraryUpdate = rolledOutUpdateGroup[0];
const { message: oldUpdateMessage, group: oldGroupId } = arbitraryUpdate;
const newUpdateMessage = `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`;
await republishAsync({
graphqlClient,
app,
updatesToPublish: rolledOutUpdateGroup.map(update => ({
...update,
groupId: update.group,
branchId: update.branch.id,
branchName: update.branch.name,
})),
codeSigningInfo,
targetBranch: { branchId: defaultBranch.id, branchName: defaultBranch.name },
updateMessage: newUpdateMessage,
});
}
const alwaysTrueDefaultBranchMapping = getAlwaysTrueBranchMapping(defaultBranch.id);
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
channelId: this.channelInfo.id,
branchMapping: JSON.stringify(alwaysTrueDefaultBranchMapping),
});
Log.addNewLineIfNone();
Log.log(`⬅️ Reverted all users back to branch ${chalk.bold(defaultBranch.name)}`);
Log.log(`✅ Successfully ended rollout`);
return newChannelInfo;
}
async confirmOutcomeAsync(
ctx: EASUpdateContext,
selectedOutcome: EndOutcome,
rollout: Rollout<UpdateBranchObject>
): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
const { rolledOutBranch, defaultBranch } = rollout;
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
Log.log(`Ending the rollout will do the following:`);
const actions = formatFields([
{
label: '1.',
value: `🔁 Republish the update group from ${chalk.bold(
rolledOutBranch.name
)} onto ${chalk.bold(defaultBranch.name)}`,
},
{ label: '2.', value: `⬅️ Revert all users back to ${chalk.bold(defaultBranch.name)}` },
]);
Log.log(actions);
} else {
Log.log(
`⬅️ Ending the rollout will revert all users back to ${chalk.bold(defaultBranch.name)}`
);
}
return await confirmAsync({
message: `Continue?`,
});
}
}
@@ -0,0 +1,104 @@
import assert from 'assert';
import chalk from 'chalk';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../prompts';
import { getRolloutInfo, isConstrainedRolloutInfo, isRollout } from '../branch-mapping';
import { printRollout } from '../utils';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import {
EndRollout,
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from './EndRollout';
export enum ManageRolloutActions {
EDIT = 'Edit',
END = 'End',
VIEW = 'View',
GO_BACK = 'Go back',
}
/**
* Manage a rollout for the project.
*/
export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
constructor(
private channelInfo: UpdateChannelBasicInfoFragment,
private options: {
callingAction?: EASUpdateAction;
action?: ManageRolloutActions.EDIT | ManageRolloutActions.END | ManageRolloutActions.VIEW;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions
) {}
public async runAsync(ctx: EASUpdateContext): Promise<EASUpdateAction> {
const { nonInteractive } = ctx;
if (nonInteractive) {
throw new Error(`rollout selection cannot be run in non-interactive mode.`);
}
const channelObject = await this.getChannelObjectAsync(ctx);
printRollout(channelObject);
const action = this.options.action ?? (await this.selectActionAsync());
switch (action as ManageRolloutActions) {
case ManageRolloutActions.EDIT:
return new EditRollout(this.channelInfo, this.options);
case ManageRolloutActions.END:
return new EndRollout(this.channelInfo, this.options);
case ManageRolloutActions.VIEW:
// Rollout is automatically printed in interactive mode
return new Noop();
case ManageRolloutActions.GO_BACK:
assert(this.options.callingAction, 'calling action must be defined');
return this.options.callingAction;
}
}
async selectActionAsync(): Promise<ManageRolloutActions> {
const manageOptions = [ManageRolloutActions.EDIT, ManageRolloutActions.END];
if (this.options.callingAction) {
manageOptions.push(ManageRolloutActions.GO_BACK);
}
const { manageOption: selectedManageOption } = await promptAsync({
type: 'select',
name: 'manageOption',
message: `What would you like to do?`,
choices: manageOptions.map(manageOption => ({
value: manageOption,
title: manageOption,
})),
});
return selectedManageOption;
}
async getChannelObjectAsync(ctx: EASUpdateContext): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
if (!isRollout(this.channelInfo)) {
throw new Error(
`The channel ${chalk.bold(
this.channelInfo.name
)} is not a rollout. To manage a rollout, you must specify a channel with an ongoing rollout.`
);
}
const rolloutInfo = getRolloutInfo(this.channelInfo);
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName: this.channelInfo.name,
...(isConstrainedRolloutInfo(rolloutInfo)
? { filter: { runtimeVersions: [rolloutInfo.runtimeVersion] } }
: {}),
});
}
}
class Noop {
public async runAsync(): Promise<void> {}
}
@@ -0,0 +1,170 @@
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import {
ChannelQuery,
UpdateBranchObject,
UpdateChannelObject,
} from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { printJsonOnlyOutput } from '../../utils/json';
import { getRollout, getRolloutInfo, isConstrainedRolloutInfo, isRollout } from '../branch-mapping';
import { printRollout } from '../utils';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import {
EndRollout,
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from './EndRollout';
import { ManageRolloutActions } from './ManageRollout';
import { MainMenuActions, RolloutActions } from './RolloutMainMenu';
type JSONRolloutOutput = {
defaultBranch: UpdateBranchObject;
rolledOutBranch: UpdateBranchObject;
percentRolledOut: number;
runtimeVersion?: string;
updatedAt: Date;
};
type JSONOutput = {
hasRollout: boolean;
originalRolloutInfo?: JSONRolloutOutput;
currentRolloutInfo?: JSONRolloutOutput;
};
/**
* Control a rollout in non interactive mode.
*/
export class NonInteractiveRollout implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
json?: boolean;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>
) {}
public async runAsync(ctx: EASUpdateContext): Promise<void> {
const { channelName, action } = this.options;
const { nonInteractive, app, graphqlClient } = ctx;
if (!nonInteractive) {
throw new Error(`This action is meant for non-interactive mode.`);
}
if (!channelName) {
throw new Error(
`The channel argument is required in non-interactive mode. Run eas channel:rollout [channel-name]`
);
}
const channelInfo = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: app.projectId,
channelName,
});
const channelObject = await this.getChannelObjectAsync(ctx, {
channelName,
runtimeVersion: this.getRuntimeVersion(channelInfo),
});
if (!action) {
throw new Error(`--action is required in non-interactive mode.`);
}
const updatedChannelInfo = await this.runActionAsync(ctx, action, channelObject);
const updatedChannelObject = await this.getChannelObjectAsync(ctx, {
channelName,
runtimeVersion: this.getRuntimeVersion(updatedChannelInfo),
});
if (this.options.json) {
const json = await this.getJsonAsync({
originalChannelObject: channelObject,
updatedChannelObject,
});
printJsonOnlyOutput(json);
}
}
async runActionAsync(
ctx: EASUpdateContext,
action: RolloutActions,
channelObject: UpdateChannelObject
): Promise<UpdateChannelBasicInfoFragment> {
switch (action) {
case MainMenuActions.CREATE_NEW:
return await new CreateRollout(channelObject, this.options).runAsync(ctx);
case ManageRolloutActions.EDIT:
return await new EditRollout(channelObject, this.options).runAsync(ctx);
case ManageRolloutActions.END:
return await new EndRollout(channelObject, this.options).runAsync(ctx);
case ManageRolloutActions.VIEW:
return this.viewRollout(channelObject);
}
}
viewRollout(channelObject: UpdateChannelObject): UpdateChannelObject {
if (!this.options.json) {
printRollout(channelObject);
Log.warn('For formatted output, add the --json flag to your command.');
}
return channelObject;
}
async getJsonAsync({
originalChannelObject,
updatedChannelObject,
}: {
originalChannelObject: UpdateChannelObject;
updatedChannelObject: UpdateChannelObject;
}): Promise<JSONOutput> {
return {
hasRollout: isRollout(updatedChannelObject),
...(isRollout(originalChannelObject)
? { originalRolloutInfo: await this.getRolloutJsonAsync(originalChannelObject) }
: {}),
...(isRollout(updatedChannelObject)
? { currentRolloutInfo: await this.getRolloutJsonAsync(updatedChannelObject) }
: {}),
};
}
async getRolloutJsonAsync(channelObject: UpdateChannelObject): Promise<JSONRolloutOutput> {
const rollout = getRollout(channelObject);
return {
defaultBranch: rollout.defaultBranch,
rolledOutBranch: rollout.rolledOutBranch,
percentRolledOut: rollout.percentRolledOut,
runtimeVersion: this.getRuntimeVersion(channelObject) ?? undefined,
updatedAt: channelObject.updatedAt,
};
}
getRuntimeVersion(channelInfo: UpdateChannelBasicInfoFragment): string | undefined {
if (isRollout(channelInfo)) {
const updatedRolloutInfo = getRolloutInfo(channelInfo);
if (isConstrainedRolloutInfo(updatedRolloutInfo)) {
return updatedRolloutInfo.runtimeVersion;
}
}
return undefined;
}
async getChannelObjectAsync(
ctx: EASUpdateContext,
{ channelName, runtimeVersion }: { channelName: string; runtimeVersion?: string }
): Promise<UpdateChannelObject> {
const { graphqlClient, app } = ctx;
const { projectId } = app;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: projectId,
channelName,
...(runtimeVersion ? { filter: { runtimeVersions: [runtimeVersion] } } : {}),
});
}
}
@@ -0,0 +1,154 @@
import assert from 'assert';
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
import { isRollout } from '../branch-mapping';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from './EditRollout';
import {
GeneralOptions as EndRolloutGeneralOptions,
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
} from './EndRollout';
import { ManageRollout, ManageRolloutActions } from './ManageRollout';
import { SelectRollout } from './SelectRollout';
export enum MainMenuActions {
CREATE_NEW = 'Create a new rollout',
MANAGE_EXISTING = 'Manage an existing rollout',
}
export type RolloutActions =
| MainMenuActions.CREATE_NEW
| ManageRolloutActions.EDIT
| ManageRolloutActions.END
| ManageRolloutActions.VIEW;
/**
* Manage a rollout for the project.
*/
export class RolloutMainMenu implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
EndRolloutGeneralOptions &
Partial<CreateRolloutNonInteractiveOptions>
) {}
public async runAsync(ctx: EASUpdateContext): Promise<void> {
const { action } = this.options;
const { nonInteractive } = ctx;
if (nonInteractive) {
throw new Error(`rollout main menu cannot be run in non-interactive mode.`);
}
const menuOption = action ? this.toMainMenuAction(action) : await this.promptMenuActionAsync();
await this.runActionAsync(ctx, menuOption);
}
async runActionAsync(ctx: EASUpdateContext, menuAction: MainMenuActions): Promise<null> {
const { channelName } = this.options;
switch (menuAction) {
case MainMenuActions.CREATE_NEW: {
const channelInfo = channelName
? await this.resolveChannelNameAsync(ctx, channelName)
: await this.selectChannelToRolloutAsync(ctx);
await new CreateRollout(channelInfo, this.options).runAsync(ctx);
return null;
}
case MainMenuActions.MANAGE_EXISTING: {
const channelInfo = channelName
? await this.resolveChannelNameAsync(ctx, channelName)
: await this.selectRolloutAsync(ctx);
if (!channelInfo) {
Log.log('You dont have any rollouts.');
} else {
assert(
this.options.action !== MainMenuActions.CREATE_NEW,
'Invalid route for create action'
);
const manageAction = await new ManageRollout(channelInfo, {
...this.options,
action: this.options.action,
callingAction: this,
}).runAsync(ctx);
await manageAction.runAsync(ctx);
}
return null;
}
}
}
async selectRolloutAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const selectRollout = new SelectRollout();
const channelInfo = await selectRollout.runAsync(ctx);
return channelInfo;
}
async selectChannelToRolloutAsync(
ctx: EASUpdateContext
): Promise<UpdateChannelBasicInfoFragment> {
let hasSomeChannel = false;
const selectChannelAction = new SelectChannel({
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => {
hasSomeChannel = true;
return !isRollout(channelInfo);
},
});
const channelInfo = await selectChannelAction.runAsync(ctx);
if (!channelInfo) {
const error = hasSomeChannel
? new Error('All your channels are already part of a rollout.')
: new Error(`You dont have any channels. Create one with \`eas channel:create\``);
throw error;
}
return channelInfo;
}
async resolveChannelNameAsync(
ctx: EASUpdateContext,
channelName: string
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
return await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
appId: app.projectId,
channelName,
});
}
toMainMenuAction(action: RolloutActions): MainMenuActions {
if (action === MainMenuActions.CREATE_NEW) {
return MainMenuActions.CREATE_NEW;
} else if (
action === ManageRolloutActions.EDIT ||
action === ManageRolloutActions.END ||
action === ManageRolloutActions.VIEW
) {
return MainMenuActions.MANAGE_EXISTING;
} else {
throw new Error(`Action not supported yet: ` + action);
}
}
async promptMenuActionAsync(): Promise<MainMenuActions> {
const menuOptions = [MainMenuActions.CREATE_NEW, MainMenuActions.MANAGE_EXISTING];
const { menuOption: selectedMenuOption } = await promptAsync({
type: 'select',
name: 'menuOption',
message: `What would you like to do?`,
choices: menuOptions.map(menuOption => ({
value: menuOption,
title: menuOption,
})),
});
return selectedMenuOption;
}
}
@@ -0,0 +1,38 @@
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { Ora, ora } from '../../ora';
import { Edge, QueryParams } from '../../utils/relay';
import { isRollout } from '../branch-mapping';
/**
* Select an existing rollout for the project.
*/
export class SelectRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
let assetSpinner: Ora | null = null;
const afterEachFilterQuery = (
_externalQueryParams: QueryParams,
totalNodesFetched: number,
_dataset: Edge<UpdateChannelBasicInfoFragment>[],
willFetchAgain: boolean
): void => {
if (willFetchAgain && !assetSpinner) {
assetSpinner = ora().start('Fetching channels...');
}
if (assetSpinner) {
assetSpinner.text = `Fetched ${totalNodesFetched} channels`;
}
};
const selectChannelAction = new SelectChannel({
printedType: 'rollout',
filterPredicate: isRollout,
afterEachFilterQuery,
});
const channelInfo = await selectChannelAction.runAsync(ctx);
if (assetSpinner) {
(assetSpinner as Ora).succeed(`Fetched all channels`);
}
return channelInfo;
}
}
@@ -0,0 +1,201 @@
import assert from 'assert';
import chalk from 'chalk';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { RuntimeFragment, UpdateBranchBasicInfoFragment } from '../../graphql/generated';
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import Log, { learnMore } from '../../log';
import { confirmAsync } from '../../prompts';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { formatRuntimeWithUpdateGroup } from '../utils';
/**
* Select a runtime from a branch
*/
export class SelectRuntime implements EASUpdateAction<string | null> {
private printedType;
constructor(
private branchInfo: UpdateBranchBasicInfoFragment,
private options: {
anotherBranchToIntersectRuntimesBy?: UpdateBranchBasicInfoFragment;
} = {}
) {
this.printedType = options.anotherBranchToIntersectRuntimesBy
? `compatible runtime`
: `runtime`;
}
warnNoRuntime(): void {
if (this.options.anotherBranchToIntersectRuntimesBy) {
const intersectBranchName = this.options.anotherBranchToIntersectRuntimesBy.name;
Log.warn(
`⚠️ Branches ${this.branchInfo.name} and ${intersectBranchName} dont have any updates with the same runtime.`
);
Log.warn(
`Your updates could be misconfigured. ${learnMore('https://expo.fyi/eas-update-rollouts')}`
);
} else {
// no runtime on branch means no updates published on branch
Log.warn(`⚠️ There are no updates published on branch ${this.branchInfo.name}.`);
}
}
formatCantFindRuntime(): string {
return `🕵️ Not finding the update you were looking for? ${learnMore(
'https://expo.fyi/eas-update-rollouts'
)}`;
}
public async runAsync(ctx: EASUpdateContext): Promise<string | null> {
const { nonInteractive, graphqlClient, app } = ctx;
const { projectId } = app;
if (nonInteractive) {
throw new NonInteractiveError(`runtime selection cannot be run in non-interactive mode.`);
}
const newestRuntimeConnection = await this.getNewestRuntimeAsync(graphqlClient, {
appId: projectId,
branchName: this.branchInfo.name,
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
});
if (newestRuntimeConnection.edges.length === 0) {
Log.addNewLineIfNone();
this.warnNoRuntime();
return null;
}
const onlyOneRuntime =
newestRuntimeConnection.edges.length === 1 && !newestRuntimeConnection.pageInfo.hasNextPage;
Log.log(`✅ ${beginSentence(this.printedType)}${onlyOneRuntime ? '' : 's'} detected`);
if (onlyOneRuntime) {
const runtime = newestRuntimeConnection.edges[0].node;
const formattedRuntimeWithGroup = await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId: projectId,
branchName: this.branchInfo.name,
runtime,
});
Log.addNewLineIfNone();
Log.log(formattedRuntimeWithGroup);
Log.addNewLineIfNone();
const useRuntime = await confirmAsync({
message: `Target ${this.printedType} ${chalk.bold(runtime.version)}?`,
});
if (useRuntime) {
return runtime.version;
} else {
Log.newLine();
Log.warn(this.formatCantFindRuntime());
return null;
}
}
Log.log(this.formatCantFindRuntime());
const selectedRuntime = await this.selectRuntimesAsync(graphqlClient, {
appId: projectId,
});
if (!selectedRuntime) {
throw new Error(`No ${this.printedType} selected`);
}
return selectedRuntime.version;
}
async getNewestRuntimeAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
anotherBranchIdToIntersectRuntimesBy,
}: {
appId: string;
branchName: string;
anotherBranchIdToIntersectRuntimesBy?: string;
}
): Promise<Connection<RuntimeFragment>> {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: 1,
filter: {
branchId: anotherBranchIdToIntersectRuntimesBy,
},
});
}
async displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName,
runtime,
}: {
graphqlClient: ExpoGraphqlClient;
appId: string;
branchName: string;
runtime: RuntimeFragment;
}): Promise<string> {
const updateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, {
appId,
branchName,
limit: 1,
offset: 0,
filter: {
runtimeVersions: [runtime.version],
},
});
assert(
updateGroups.length < 2,
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
);
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime, branchName);
}
async selectRuntimesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
batchSize = 5,
}: {
appId: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: this.branchInfo.name,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
filter: {
branchId: this.options.anotherBranchToIntersectRuntimesBy?.id,
},
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName: this.branchInfo.name,
runtime,
});
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
});
}
}
function beginSentence(phrase: string): string {
if (typeof phrase !== 'string' || phrase.length === 0) {
return phrase; // Return the input without any modification if it's not a string or empty
}
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more