Compare commits

...
12 Commits
Author SHA1 Message Date
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
53 changed files with 4734 additions and 784 deletions

No files matched your search

+23 -2
View File
@@ -8,11 +8,32 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 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))
### 🐛 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))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "3.18.0",
"version": "3.18.2",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+55 -55
View File
@@ -133,7 +133,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/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.18.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -693,7 +693,7 @@ 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.18.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -716,7 +716,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -737,7 +737,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -754,7 +754,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -768,7 +768,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.18.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -788,7 +788,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -809,7 +809,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -830,7 +830,7 @@ DESCRIPTION
rename a registered device
```
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -844,7 +844,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -858,7 +858,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -946,7 +946,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -963,7 +963,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.18.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -980,7 +980,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.18.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas open`
@@ -994,7 +994,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/open.ts)_
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1008,7 +1008,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1030,7 +1030,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -1053,7 +1053,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.18.0/packages/eas-cli/src/commands/secret/create.ts)_
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -1071,7 +1071,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/secret/delete.ts)_
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -1085,7 +1085,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.18.0/packages/eas-cli/src/commands/secret/list.ts)_
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/list.ts)_
## `eas secret:push`
@@ -1105,7 +1105,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.18.0/packages/eas-cli/src/commands/secret/push.ts)_
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/push.ts)_
## `eas submit`
@@ -1135,7 +1135,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1168,7 +1168,7 @@ DESCRIPTION
publish an update group
```
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1186,7 +1186,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1207,7 +1207,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.18.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:list`
@@ -1229,7 +1229,7 @@ DESCRIPTION
view the recent updates
```
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -1253,7 +1253,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:view GROUPID`
@@ -1273,7 +1273,7 @@ DESCRIPTION
update group details
```
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/view.ts)_
## `eas webhook:create`
@@ -1294,7 +1294,7 @@ DESCRIPTION
create a webhook
```
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -1314,7 +1314,7 @@ DESCRIPTION
delete a webhook
```
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -1332,7 +1332,7 @@ DESCRIPTION
list webhooks
```
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -1354,7 +1354,7 @@ DESCRIPTION
update a webhook
```
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -1371,7 +1371,7 @@ DESCRIPTION
view a webhook
```
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.18.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
+896 -99
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "3.18.0",
"version": "3.18.2",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -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,88 @@
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('returns the branch without prompting if project only one branch', async () => {
const ctx = createCtxMock();
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectBranch = new SelectBranch();
const branch = await selectBranch.runAsync(ctx);
expect(branch).toBe(testBasicBranchInfo);
});
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);
});
});
+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.31';
const PLUGIN_PACKAGE_VERSION = '1.0.33';
export enum LocalBuildMode {
/**
@@ -117,7 +117,6 @@ export async function runBuildAndSubmitAsync(
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
Log.log(
`Loaded "env" configuration for the "${buildProfiles[0].profileName}" profile: ${
@@ -184,7 +183,6 @@ export async function runBuildAndSubmitAsync(
platforms,
profileName: flags.submitProfile,
type: 'submit',
projectDir,
});
for (const startedBuild of startedBuilds) {
const submitProfile = nullthrows(
@@ -0,0 +1,35 @@
import { rolloutBranchMapping, standardBranchMapping } from '../../rollout/__tests__/fixtures';
import {
BranchMappingValidationError,
getAlwaysTrueBranchMapping,
getStandardBranchId,
isAlwaysTrueBranchMapping,
} from '../branch-mapping';
import { testChannelObject } from './fixtures';
describe(isAlwaysTrueBranchMapping, () => {
it('detects always true branch mappings', () => {
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).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(testChannelObject)).toThrowError(BranchMappingValidationError);
});
it('gets a standard branch id', () => {
const channelObjectWithStandardMapping = { ...testChannelObject };
channelObjectWithStandardMapping.branchMapping = JSON.stringify(standardBranchMapping);
expect(getStandardBranchId(channelObjectWithStandardMapping)).toBe(
standardBranchMapping.data[0].branchId
);
});
});
@@ -1,7 +1,4 @@
import {
UpdateChannelBasicInfoFragment,
ViewUpdateChannelsPaginatedOnAppQuery,
} from '../../graphql/generated';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
export const testUpdateBranch1: UpdateBranchObject = {
@@ -148,37 +145,3 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = {
'{"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',
};
export const paginatedChannels: ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated'] =
{
edges: [
{
node: {
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',
},
__typename: 'AppChannelEdge',
},
{
node: {
id: '95630b3c-5b91-48cb-bd31-a84315a13246',
name: 'gh/quinlanj/3/orig',
branchMapping:
'{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":"true"}],"version":0}',
__typename: 'UpdateChannel',
},
__typename: 'AppChannelEdge',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'IjlkNWM3YmYwLWQ1MmQtNDc0Yy05MTQwLWMxYmFkN2YwZGU5ZCI',
endCursor: 'ImE4Y2M5ZWVmLTFkZjMtNDNmZi05YTZkLTRmYTZjMTFlYmEwYiI',
__typename: 'PageInfo',
},
__typename: 'AppChannelsConnection',
};
@@ -1,7 +1,13 @@
import { EASUpdateAction, EASUpdateContext, NonInteractiveError } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { promptAsync } from '../../prompts';
import { getChannelsDatasetAsync } from '../queries';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
Connection,
Edge,
FilterPagination,
QueryParams,
selectPaginatedAsync,
} from '../../utils/relay';
/**
* Select a channel for the project.
@@ -9,45 +15,93 @@ import { getChannelsDatasetAsync } from '../queries';
* @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;
} = {}
) {}
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const { nonInteractive, graphqlClient, app } = ctx;
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 channels = await getChannelsDatasetAsync(graphqlClient, {
appId: projectId,
filterPredicate,
});
const queryAsync = async (
queryParams: QueryParams
): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
filterPredicate
? this.filterQueryAsync(ctx, queryParams, filterPredicate)
: this.queryAsync(ctx, queryParams);
if (channels.length === 0) {
return null;
} else if (channels.length === 1) {
return channels[0];
}
const getTitleAsync = async (channelInfo: UpdateChannelBasicInfoFragment): Promise<string> =>
channelInfo.name;
const { channel: selectedChannel } = await promptAsync({
type: 'select',
name: 'channel',
message: `Select a ${printedType}`,
choices: channels.map(channel => ({
value: channel,
title: channel.name,
})),
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
return selectedChannel;
}
}
@@ -1,42 +1,81 @@
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 { getChannelsDatasetAsync } from '../../queries';
import { SelectChannel } from '../SelectChannel';
jest.mock('../../queries');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectChannel, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no channels', async () => {
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([]);
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('returns the channel without prompting if project only one channel', async () => {
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([testBasicChannelInfo]);
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectChannel = new SelectChannel();
const channel = await selectChannel.runAsync(ctx);
expect(channel).toBe(testBasicChannelInfo);
});
it('returns the selected channel', async () => {
jest
.mocked(getChannelsDatasetAsync)
.mockResolvedValue([testBasicChannelInfo, testBasicChannelInfo2]);
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
channel: testBasicChannelInfo,
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,
+74 -12
View File
@@ -1,4 +1,4 @@
import assert from 'assert';
import { UpdateChannelBasicInfoFragment } from '../graphql/generated';
// TODO(quin): move this into a common package with www
export type BranchMappingOperator =
@@ -35,7 +35,17 @@ export type BranchMapping = {
}[];
};
export function getAlwaysTrueBranchMapping(branchId: string): BranchMapping {
export type AlwaysTrueBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
return {
version: 0,
data: [
@@ -47,6 +57,36 @@ export function getAlwaysTrueBranchMapping(branchId: string): BranchMapping {
};
}
export function hasStandardBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isAlwaysTrueBranchMapping(branchMapping);
}
export function getStandardBranchId(channelInfo: UpdateChannelBasicInfoFragment): string {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
assertAlwaysTrueBranchMapping(branchMapping);
return getBranchIdFromStandardMapping(branchMapping);
}
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);
@@ -94,23 +134,45 @@ export function hashLtOperator(): BranchMappingOperator {
}
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
assert(
isStatement(node),
'Branch mapping node must be a statement. Received: ' + JSON.stringify(node)
);
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 {
assert(
isNodeObject(node),
'Branch mapping node must be an object. Received: ' + JSON.stringify(node)
);
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 {
assert(typeof operand === 'number', 'Expected a number. Received: ' + JSON.stringify(operand));
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 {
assert(typeof operand === 'string', 'Expected a string. Received: ' + JSON.stringify(operand));
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 {}
-47
View File
@@ -8,8 +8,6 @@ import { withErrorHandlingAsync } from '../graphql/client';
import {
CreateUpdateChannelOnAppMutation,
CreateUpdateChannelOnAppMutationVariables,
PageInfo,
UpdateChannelBasicInfoFragment,
ViewBranchesOnUpdateChannelQueryVariables,
ViewUpdateChannelsOnAppQueryVariables,
} from '../graphql/generated';
@@ -17,14 +15,12 @@ import { BranchQuery, UpdateBranchOnChannelObject } from '../graphql/queries/Bra
import { ChannelQuery, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo';
import Log from '../log';
import { ora } from '../ora';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { Connection, QueryParams, getPaginatedDatasetAsync } from '../utils/relay';
import { logChannelDetails } from './utils';
export const CHANNELS_LIMIT = 25;
@@ -268,46 +264,3 @@ export async function ensureChannelExistsAsync(
}
}
}
export async function getChannelsDatasetAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
filterPredicate,
batchSize = 100,
}: {
appId: string;
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean;
batchSize?: number;
}
): Promise<UpdateChannelBasicInfoFragment[]> {
const queryAsync = async ({
first,
after,
}: QueryParams): Promise<Connection<UpdateChannelBasicInfoFragment>> =>
await ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync(graphqlClient, {
appId,
first,
after,
});
const assetSpinner = ora().start('Fetching channels...');
const afterEachQuery = (
totalNodesFetched: number,
_dataset: UpdateChannelBasicInfoFragment[],
_batch: UpdateChannelBasicInfoFragment[],
pageInfo: PageInfo
): void => {
if (pageInfo.hasNextPage) {
assetSpinner.text = `Fetched ${totalNodesFetched} channels`;
}
};
const dataset = await getPaginatedDatasetAsync({
queryAsync,
afterEachQuery,
filterPredicate,
batchSize,
});
assetSpinner.succeed(`Fetched all channels`);
return dataset;
}
@@ -68,7 +68,6 @@ 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,7 +80,6 @@ export default class BuildVersionSyncView extends EasCommand {
easJsonAccessor,
platforms,
profileName: flags.profile ?? undefined,
projectDir,
});
for (const profileInfo of buildProfiles) {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
@@ -0,0 +1,147 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
import Log from '../../log';
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
import {
EndOutcome,
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';
enum ActionRawFlagValue {
CREATE = 'create',
EDIT = 'edit',
END = 'end',
}
type ChannelRolloutRawArgsAndFlags = {
channel?: string;
action?: ActionRawFlagValue;
percent?: number;
outcome?: EndOutcome;
'non-interactive': boolean;
branch?: string;
'runtime-version'?: string;
json?: boolean;
};
type ChannelRolloutArgsAndFlags = {
channelName?: string;
action?: RolloutActions;
nonInteractive: boolean;
json?: boolean;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
Partial<CreateRolloutNonInteractiveOptions>;
export default class ChannelRolloutPreview extends EasCommand {
static override hidden = true;
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = [
{
name: 'channel',
description: 'channel on which the rollout should be done',
},
];
static override flags = {
action: Flags.enum({
description: 'Rollout action to perform',
options: Object.values(ActionRawFlagValue),
required: false,
}),
percent: Flags.integer({
description:
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
required: 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 rollout. Use with --action=create',
required: false,
}),
'runtime-version': Flags.string({
description: 'Runtime version to target. Use with --action=create',
required: false,
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { args, flags } = await this.parse(ChannelRolloutPreview);
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
const {
privateProjectConfig: { exp, projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelRolloutPreview, {
nonInteractive: argsAndFlags.nonInteractive,
});
if (argsAndFlags.json) {
// TODO(quin): implement json output
throw new Error('Developer Preview doesnt support JSON output yet');
}
const app = { projectId, exp };
const ctx = {
projectId,
nonInteractive: argsAndFlags.nonInteractive,
graphqlClient,
app,
};
if (argsAndFlags.nonInteractive) {
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
} else {
Log.addNewLineIfNone();
Log.warn(
`✨ This command is in Developer Preview and has not been released to production yet`
);
Log.addNewLineIfNone();
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
}
}
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;
}
}
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'],
action: action ? this.getAction(action) : undefined,
nonInteractive: rawFlags['non-interactive'],
json: rawFlags.json,
};
}
}
@@ -47,7 +47,6 @@ export default class MetadataLint extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
@@ -49,7 +49,6 @@ export default class MetadataPull extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms: [Platform.IOS],
profileName: flags.profile,
projectDir,
});
if (submitProfiles.length !== 1) {
@@ -47,7 +47,6 @@ 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,7 +118,6 @@ export default class Submit extends EasCommand {
easJsonAccessor: EasJsonAccessor.fromProjectPath(projectDir),
platforms,
profileName: flagsWithPlatform.profile,
projectDir,
});
const submissions: SubmissionFragment[] = [];
+24 -116
View File
@@ -1,23 +1,18 @@
import { Platform } from '@expo/config';
import { Flags } from '@oclif/core';
import { getUpdateGroupUrl } from '../../build/utils/url';
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 { enableJsonOutput } from '../../utils/json';
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
@@ -41,20 +36,6 @@ type UpdateRepublishFlags = {
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';
@@ -138,89 +119,16 @@ export default class UpdateRepublish extends EasCommand {
);
}
// 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,
json: flags.json,
});
}
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
@@ -258,15 +166,15 @@ 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,
}));
}
@@ -303,17 +211,17 @@ async function askUpdatesFromBranchNameAsync(
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 */
+148 -24
View File
@@ -1009,6 +1009,7 @@ export type App = Project & {
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
fullName: Scalars['String'];
githubBuildTriggers: Array<GitHubBuildTrigger>;
githubRepository?: Maybe<GitHubRepository>;
githubRepositorySettings?: Maybe<GitHubRepositorySettings>;
/** githubUrl field from most recent classic update manifest */
@@ -1365,16 +1366,19 @@ export type AppInsights = {
export type AppInsightsTotalUniqueUsersArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppInsightsUniqueUsersByAppVersionOverTimeArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppInsightsUniqueUsersByPlatformOverTimeArgs = {
timespan: InsightsTimespan;
useDeprecatedBackend?: InputMaybe<Scalars['Boolean']>;
};
export type AppMutation = {
@@ -2605,6 +2609,19 @@ export type CreateGitHubAppInstallationInput = {
installationIdentifier: Scalars['Int'];
};
export type CreateGitHubBuildTriggerInput = {
appId: Scalars['ID'];
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
isActive: Scalars['Boolean'];
platform: AppPlatform;
/** A branch or tag name, or a wildcard pattern where the code change originates from. For example, `main` or `release/*`. */
sourcePattern: Scalars['String'];
/** A branch name or a wildcard pattern that the pull request targets. For example, `main` or `release/*`. */
targetPattern?: InputMaybe<Scalars['String']>;
type: GitHubBuildTriggerType;
};
export type CreateGitHubRepositoryInput = {
appId: Scalars['ID'];
githubAppInstallationId: Scalars['ID'];
@@ -3110,6 +3127,54 @@ export type GitHubBuildInput = {
platform: AppPlatform;
};
export type GitHubBuildTrigger = {
__typename?: 'GitHubBuildTrigger';
app: App;
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
isActive: Scalars['Boolean'];
lastRunAt?: Maybe<Scalars['DateTime']>;
platform: AppPlatform;
sourcePattern: Scalars['String'];
targetPattern?: Maybe<Scalars['String']>;
type: GitHubBuildTriggerType;
updatedAt: Scalars['DateTime'];
};
export type GitHubBuildTriggerMutation = {
__typename?: 'GitHubBuildTriggerMutation';
/** Create GitHub build trigger for an App */
createGitHubBuildTrigger: GitHubBuildTrigger;
/** Delete GitHub build trigger by ID */
deleteGitHubBuildTrigger: GitHubBuildTrigger;
/** Update a GitHub build trigger by ID */
updateGitHubBuildTrigger: GitHubBuildTrigger;
};
export type GitHubBuildTriggerMutationCreateGitHubBuildTriggerArgs = {
githubBuildTriggerData: CreateGitHubBuildTriggerInput;
};
export type GitHubBuildTriggerMutationDeleteGitHubBuildTriggerArgs = {
githubBuildTriggerId: Scalars['ID'];
};
export type GitHubBuildTriggerMutationUpdateGitHubBuildTriggerArgs = {
githubBuildTriggerData: UpdateGitHubBuildTriggerInput;
githubBuildTriggerId: Scalars['ID'];
};
export enum GitHubBuildTriggerType {
PullRequestUpdated = 'PULL_REQUEST_UPDATED',
PushToBranch = 'PUSH_TO_BRANCH',
TagUpdated = 'TAG_UPDATED'
}
export type GitHubRepository = {
__typename?: 'GitHubRepository';
app: App;
@@ -3782,6 +3847,7 @@ export type Notification = {
createdAt: Scalars['DateTime'];
event: NotificationEvent;
id: Scalars['ID'];
isRead: Scalars['Boolean'];
metadata?: Maybe<NotificationMetadata>;
type: NotificationType;
updatedAt: Scalars['DateTime'];
@@ -4131,6 +4197,8 @@ export type RootMutation = {
githubApp: GitHubAppMutation;
/** Mutations for GitHub App installations */
githubAppInstallation: GitHubAppInstallationMutation;
/** Mutations for GitHub build triggers */
githubBuildTrigger: GitHubBuildTriggerMutation;
/** Mutations for GitHub repositories */
githubRepository: GitHubRepositoryMutation;
/** Mutations for GitHub repository settings */
@@ -4241,14 +4309,15 @@ export type RootQuery = {
meUserActor?: Maybe<UserActor>;
project: ProjectQuery;
snack: SnackQuery;
/** Top-level query object for querying SSO Users. */
ssoUser: SsoUserQuery;
/** Top-level query object for querying Expo status page services. */
statuspageService: StatuspageServiceQuery;
submissions: SubmissionQuery;
/** fetch all updates in a group */
updatesByGroup: Array<Update>;
/** Top-level query object for querying Users. */
/**
* Top-level query object for querying Users.
* @deprecated Public user queries are no longer supported
*/
user: UserQuery;
/** Top-level query object for querying UserActors. */
userActor: UserActorQuery;
@@ -4305,6 +4374,23 @@ export type Runtime = {
version: Scalars['String'];
};
export type RuntimeEdge = {
__typename?: 'RuntimeEdge';
cursor: Scalars['String'];
node: Runtime;
};
export type RuntimeFilterInput = {
/** Only return runtimes shared with this branch */
branchId?: InputMaybe<Scalars['String']>;
};
export type RuntimesConnection = {
__typename?: 'RuntimesConnection';
edges: Array<RuntimeEdge>;
pageInfo: PageInfo;
};
/** Represents a human SSO (not robot) actor. */
export type SsoUser = Actor & UserActor & {
__typename?: 'SSOUser';
@@ -4394,24 +4480,6 @@ export type SsoUserDataInput = {
lastName?: InputMaybe<Scalars['String']>;
};
export type SsoUserQuery = {
__typename?: 'SSOUserQuery';
/** Query an SSOUser by ID */
byId: SsoUser;
/** Query an SSOUser by username */
byUsername: SsoUser;
};
export type SsoUserQueryByIdArgs = {
userId: Scalars['ID'];
};
export type SsoUserQueryByUsernameArgs = {
username: Scalars['String'];
};
export type SecondFactorBooleanResult = {
__typename?: 'SecondFactorBooleanResult';
success: Scalars['Boolean'];
@@ -4854,12 +4922,22 @@ export type UpdateBranch = {
createdAt: Scalars['DateTime'];
id: Scalars['ID'];
name: Scalars['String'];
runtimes: RuntimesConnection;
updateGroups: Array<Array<Update>>;
updatedAt: Scalars['DateTime'];
updates: Array<Update>;
};
export type UpdateBranchRuntimesArgs = {
after?: InputMaybe<Scalars['String']>;
before?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<RuntimeFilterInput>;
first?: InputMaybe<Scalars['Int']>;
last?: InputMaybe<Scalars['Int']>;
};
export type UpdateBranchUpdateGroupsArgs = {
filter?: InputMaybe<UpdatesFilter>;
limit: Scalars['Int'];
@@ -4965,6 +5043,16 @@ export type UpdateChannelMutationEditUpdateChannelArgs = {
channelId: Scalars['ID'];
};
export type UpdateGitHubBuildTriggerInput = {
autoSubmit: Scalars['Boolean'];
buildProfile: Scalars['String'];
isActive: Scalars['Boolean'];
platform: AppPlatform;
sourcePattern: Scalars['String'];
targetPattern?: InputMaybe<Scalars['String']>;
type: GitHubBuildTriggerType;
};
export type UpdateGitHubRepositorySettingsInput = {
baseDirectory: Scalars['String'];
};
@@ -5419,9 +5507,15 @@ export type UserPermission = {
export type UserQuery = {
__typename?: 'UserQuery';
/** Query a User by ID */
/**
* Query a User by ID
* @deprecated Public user queries are no longer supported
*/
byId: User;
/** Query a User by username */
/**
* Query a User by username
* @deprecated Public user queries are no longer supported
*/
byUsername: User;
};
@@ -6187,6 +6281,17 @@ export type BranchesByAppQueryVariables = Exact<{
export type BranchesByAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }> }> } } };
export type BranchesBasicPaginatedOnAppQueryVariables = Exact<{
appId: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
}>;
export type BranchesBasicPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, branchesPaginated: { __typename?: 'AppBranchesConnection', edges: Array<{ __typename?: 'AppBranchEdge', cursor: string, node: { __typename?: 'UpdateBranch', id: string, name: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewBranchesOnUpdateChannelQueryVariables = Exact<{
appId: Scalars['String'];
channelName: Scalars['String'];
@@ -6243,10 +6348,12 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
appId: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
}>;
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String'];
@@ -6269,6 +6376,19 @@ export type GetAssetLimitPerUpdateGroupForAppQueryVariables = Exact<{
export type GetAssetLimitPerUpdateGroupForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, assetLimitPerUpdateGroup: number } } };
export type ViewRuntimesOnBranchQueryVariables = Exact<{
appId: Scalars['String'];
name: Scalars['String'];
first?: InputMaybe<Scalars['Int']>;
after?: InputMaybe<Scalars['String']>;
last?: InputMaybe<Scalars['Int']>;
before?: InputMaybe<Scalars['String']>;
filter?: InputMaybe<RuntimeFilterInput>;
}>;
export type ViewRuntimesOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, runtimes: { __typename?: 'RuntimesConnection', edges: Array<{ __typename?: 'RuntimeEdge', cursor: string, node: { __typename?: 'Runtime', id: string, version: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } | null } } };
export type StatuspageServiceByServiceNamesQueryVariables = Exact<{
serviceNames: Array<StatuspageServiceName> | StatuspageServiceName;
}>;
@@ -6352,6 +6472,8 @@ export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, s
export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any };
export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
export type SubmissionFragment = { __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logsUrl?: string | null, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: SubmissionAndroidTrack, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null };
@@ -6360,6 +6482,8 @@ export type UpdateFragment = { __typename?: 'Update', id: string, group: string,
export type UpdateBranchFragment = { __typename?: 'UpdateBranch', id: string, name: string, updates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, actor?: { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null }> };
export type UpdateBranchBasicInfoFragment = { __typename?: 'UpdateBranch', id: string, name: string };
export type UpdateChannelBasicInfoFragment = { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string };
export type WebhookFragment = { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any };
@@ -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,6 +5,8 @@ import { ChannelNotFoundError } from '../../channel/errors';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
UpdateBranchBasicInfoFragment,
UpdateFragment,
ViewUpdateChannelOnAppQuery,
ViewUpdateChannelOnAppQueryVariables,
ViewUpdateChannelsOnAppQuery,
@@ -28,6 +30,16 @@ export type UpdateChannelObject = ViewUpdateChannelsOnAppObject & UpdateChannelB
export type UpdateBranchObject = UpdateChannelObject['updateBranches'][number];
export function composeUpdateBranchObject(
branchInfo: UpdateBranchBasicInfoFragment,
updateGroups: UpdateFragment[][]
): UpdateBranchObject {
return {
...branchInfo,
updateGroups,
};
}
export const ChannelQuery = {
async viewUpdateChannelAsync(
graphqlClient: ExpoGraphqlClient,
@@ -123,7 +135,7 @@ export const ChannelQuery = {
},
async viewUpdateChannelsBasicInfoPaginatedOnAppAsync(
graphqlClient: ExpoGraphqlClient,
{ appId, first, after }: ViewUpdateChannelsPaginatedOnAppQueryVariables
{ appId, first, after, last, before }: ViewUpdateChannelsPaginatedOnAppQueryVariables
): Promise<ViewUpdateChannelsPaginatedOnAppQuery['app']['byId']['channelsPaginated']> {
const response = await withErrorHandlingAsync(
graphqlClient
@@ -132,16 +144,23 @@ export const ChannelQuery = {
ViewUpdateChannelsPaginatedOnAppQueryVariables
>(
gql`
query ViewUpdateChannelsPaginatedOnApp($appId: String!, $first: Int, $after: String) {
query ViewUpdateChannelsPaginatedOnApp(
$appId: String!
$first: Int
$after: String
$last: Int
$before: String
) {
app {
byId(appId: $appId) {
id
channelsPaginated(first: $first, after: $after) {
channelsPaginated(first: $first, after: $after, before: $before, last: $last) {
edges {
node {
id
...UpdateChannelBasicInfoFragment
}
cursor
}
pageInfo {
hasNextPage
@@ -155,7 +174,7 @@ export const ChannelQuery = {
}
${print(UpdateChannelBasicInfoFragmentNode)}
`,
{ appId, first, after },
{ appId, first, after, last, before },
{ additionalTypenames: ['UpdateChannel', 'UpdateBranch', 'Update'] }
)
.toPromise()
@@ -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
}
`;
@@ -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
}
`;
@@ -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,
@@ -7,19 +7,24 @@ import {
} from '../../channel/__tests__/fixtures';
import {
BranchMappingOperator,
BranchMappingValidationError,
alwaysTrue,
andStatement,
equalsOperator,
hashLtOperator,
} from '../../channel/branch-mapping';
import {
assertRolloutBranchMapping,
composeRollout,
createRolloutBranchMapping,
editRolloutBranchMapping,
getRollout,
getRolloutInfo,
getRolloutInfoFromBranchMapping,
isConstrainedRollout,
isConstrainedRolloutInfo,
isLegacyRolloutInfo,
isRollout,
isRolloutBranchMapping,
} from '../branch-mapping';
import {
rolloutBranchMapping,
@@ -27,6 +32,73 @@ import {
standardBranchMapping,
} from './fixtures';
describe(composeRollout, () => {
it('composes a rollout', () => {
const rollout = getRollout(testChannelObject);
const rolloutInfo = getRolloutInfo(testChannelObject);
const composedRollout = composeRollout(rolloutInfo, testUpdateBranch2, testUpdateBranch1);
expect(composedRollout).toEqual(rollout);
});
it('throws if the branches to not match the rollout info', () => {
const rolloutInfo = getRolloutInfo(testChannelObject);
expect(() => composeRollout(rolloutInfo, testUpdateBranch1, testUpdateBranch2)).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);
@@ -78,9 +150,6 @@ describe(getRollout, () => {
});
describe(getRolloutInfoFromBranchMapping, () => {
it('doesnt get the mapping if it isnt a rollout', () => {
expect(() => getRolloutInfoFromBranchMapping(standardBranchMapping)).toThrowError();
});
it('gets a constrained rollout', () => {
const rollout = getRolloutInfoFromBranchMapping(rolloutBranchMapping);
expect(rollout.percentRolledOut).toEqual(10);
@@ -98,13 +167,14 @@ describe(getRolloutInfoFromBranchMapping, () => {
});
describe(editRolloutBranchMapping, () => {
it('doesnt edit the branch mapping if it isnt a rollout', () => {
expect(() => editRolloutBranchMapping(standardBranchMapping, 50)).toThrowError();
});
it('doesnt edit the branch mapping if the input is out of range', () => {
expect(() => editRolloutBranchMapping(standardBranchMapping, -1)).toThrowError();
expect(() => editRolloutBranchMapping(standardBranchMapping, 101)).toThrowError();
expect(() => editRolloutBranchMapping(standardBranchMapping, 0.1)).toThrowError();
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);
@@ -120,10 +190,10 @@ describe(editRolloutBranchMapping, () => {
});
});
describe(isRollout, () => {
describe(isRolloutBranchMapping, () => {
it('detects a rollout made by the cli', () => {
expect(isRollout(rolloutBranchMappingLegacy)).toBe(true);
expect(isRollout(rolloutBranchMapping)).toBe(true);
expect(isRolloutBranchMapping(rolloutBranchMappingLegacy)).toBe(true);
expect(isRolloutBranchMapping(rolloutBranchMapping)).toBe(true);
});
it('detects custom mappings equivalent to rollouts made by the cli', () => {
const customMapping1 = {
@@ -147,10 +217,10 @@ describe(isRollout, () => {
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping1)).toBe(true);
expect(isRolloutBranchMapping(customMapping1)).toBe(true);
});
it('correctly classifies branchMappings that arent rollouts', () => {
expect(isRollout(standardBranchMapping)).toBe(false);
expect(isRolloutBranchMapping(standardBranchMapping)).toBe(false);
const customMapping1 = {
version: 0,
@@ -166,7 +236,7 @@ describe(isRollout, () => {
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping1)).toBe(false);
expect(isRolloutBranchMapping(customMapping1)).toBe(false);
const customMapping2 = {
version: 0,
@@ -189,7 +259,7 @@ describe(isRollout, () => {
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping2)).toBe(false);
expect(isRolloutBranchMapping(customMapping2)).toBe(false);
const customMapping3 = {
version: 0,
@@ -205,7 +275,7 @@ describe(isRollout, () => {
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping3)).toBe(false);
expect(isRolloutBranchMapping(customMapping3)).toBe(false);
const customMapping4 = {
version: 0,
@@ -220,7 +290,7 @@ describe(isRollout, () => {
},
],
};
expect(isRollout(customMapping4)).toBe(false);
expect(isRolloutBranchMapping(customMapping4)).toBe(false);
const customMapping5 = {
version: 0,
@@ -248,7 +318,7 @@ describe(isRollout, () => {
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
expect(isRollout(customMapping5)).toBe(false);
expect(isRolloutBranchMapping(customMapping5)).toBe(false);
const customMapping6 = {
version: 0,
@@ -264,6 +334,6 @@ describe(isRollout, () => {
},
],
};
expect(isRollout(customMapping6)).toBe(false);
expect(isRolloutBranchMapping(customMapping6)).toBe(false);
});
});
@@ -1,35 +1,55 @@
import { v4 as uuidv4 } from 'uuid';
import {
alwaysTrue,
andStatement,
equalsOperator,
hashLtOperator,
} from '../../channel/branch-mapping';
import { BranchMapping, alwaysTrue } from '../../channel/branch-mapping';
import { ConstrainedRolloutBranchMapping, LegacyRolloutBranchMapping } from '../branch-mapping';
export const rolloutBranchMapping = {
export const rolloutBranchMapping: ConstrainedRolloutBranchMapping = {
version: 0,
data: [
{
branchId: uuidv4(),
branchMappingLogic: andStatement([
branchMappingLogic: [
'and',
{
operand: '1.0.0',
clientKey: 'runtimeVersion',
branchMappingOperator: equalsOperator(),
branchMappingOperator: '==',
},
{
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
branchMappingOperator: 'hash_lt',
},
]),
],
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const rolloutBranchMappingLegacy = {
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: [
{
@@ -37,14 +57,14 @@ export const rolloutBranchMappingLegacy = {
branchMappingLogic: {
operand: 10 / 100,
clientKey: 'rolloutToken',
branchMappingOperator: hashLtOperator(),
branchMappingOperator: 'hash_lt',
},
},
{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() },
],
};
export const standardBranchMapping = {
export const standardBranchMapping: BranchMapping = {
version: 0,
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
};
@@ -0,0 +1,232 @@
import assert from 'assert';
import { SelectBranch } from '../../branch/actions/SelectBranch';
import { getStandardBranchId, 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 } from '../../prompts';
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 (!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 routed 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 selectRuntimeAction = new SelectRuntime(branchToRollout, {
anotherBranchToIntersectRuntimesBy: defaultBranchRtvAgnostic,
});
return await selectRuntimeAction.runAsync(ctx);
}
async selectBranchAsync(
ctx: EASUpdateContext,
defaultBranchId: string
): Promise<UpdateBranchBasicInfoFragment> {
const selectBranchAction = new SelectBranch({
printedType: 'branch to rollout',
// 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) {
throw new Error(`You dont have any branches. Create one 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,110 @@
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 directed to the ${rolledOutBranch.name} branch ?`;
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
const oldBranchMapping = getRolloutBranchMapping(channelObject.branchMapping);
const newBranchMapping = editRolloutBranchMapping(oldBranchMapping, percent);
Log.newLine();
Log.log(
`📝 The updated rollout will send ${chalk.bold(percent)}% of users to the ${chalk.bold(
rolledOutBranch.name
)} branch and ${chalk.bold(100 - percent)}% to 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,212 @@
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, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
import Log from '../../log';
import { confirmAsync, promptAsync } from '../../prompts';
import { republishAsync } from '../../update/republish';
import formatFields from '../../utils/formatFields';
import {
Rollout,
getRollout,
getRolloutInfo,
isConstrainedRolloutInfo,
isRollout,
} from '../branch-mapping';
import { formatBranchWithUpdateGroup } from '../utils';
export enum EndOutcome {
REPUBLISH_AND_ROUTE_BACK = 'republish-and-route-back',
ROUTE_BACK = 'route-back',
}
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> = {}
) {}
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 ${rolledOutBranch.name}.`);
assert(
this.options.outcome !== EndOutcome.REPUBLISH_AND_ROUTE_BACK,
'The only valid outcome for this rollout is to route back to the default branch. '
);
outcome = EndOutcome.ROUTE_BACK;
} else {
outcome = this.options.outcome ?? (await this.selectOutcomeAsync(rollout));
}
const didConfirm = await this.confirmOutcomeAsync(ctx, outcome, rollout);
if (!didConfirm) {
if (!rolledOutUpdateGroup) {
Log.log(
`If you wish to stop serving updates to your users, you can edit your rollout to 100% on ${chalk.bold(
rolledOutBranch.name
)} instead`
);
}
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): Promise<EndOutcome> {
const { rolledOutBranch, percentRolledOut, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
const defaultUpdateGroup = defaultBranch.updateGroups[0];
const outcomes = [
{
value: EndOutcome.REPUBLISH_AND_ROUTE_BACK,
title: formatBranchWithUpdateGroup(rolledOutUpdateGroup, rolledOutBranch, percentRolledOut),
},
{
value: EndOutcome.ROUTE_BACK,
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_ROUTE_BACK) {
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,
outcome: EndOutcome
): Promise<UpdateChannelBasicInfoFragment> {
const { graphqlClient, app } = ctx;
const { rolledOutBranch, defaultBranch } = rollout;
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
if (outcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
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,
})),
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(`🚗 Routed all traffic back to branch ${chalk.bold(defaultBranch.name)}`);
Log.log(`✅ Successfully ended rollout`);
return newChannelInfo;
}
async confirmOutcomeAsync(
ctx: EASUpdateContext,
selectedOutcome: EndOutcome,
rollout: Rollout
): Promise<boolean> {
const { nonInteractive } = ctx;
if (nonInteractive) {
return true;
}
const { rolledOutBranch, defaultBranch } = rollout;
Log.newLine();
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
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: `⬅️ Route all users back to ${chalk.bold(defaultBranch.name)}` },
]);
Log.log(actions);
} else {
Log.log(
`⬅️ Ending the rollout will route all users back to ${chalk.bold(defaultBranch.name)}`
);
}
return await confirmAsync({
message: `Continue?`,
});
}
}
@@ -0,0 +1,91 @@
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, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
export enum ManageRolloutActions {
EDIT = 'Edit',
END = 'End',
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;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> = {}
) {}
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.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] } }
: {}),
});
}
}
@@ -0,0 +1,69 @@
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
import {
CreateRollout,
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
} from './CreateRollout';
import {
EditRollout,
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
} from './EditRollout';
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
import { ManageRolloutActions } from './ManageRollout';
import { MainMenuActions, RolloutActions } from './RolloutMainMenu';
/**
* Control a rollout in non interactive mode.
*/
export class NonInteractiveRollout implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
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,
});
if (!action) {
throw new Error(`--action is required in non-interactive mode.`);
}
await this.runActionAsync(ctx, action, channelInfo, this.options);
}
async runActionAsync(
ctx: EASUpdateContext,
action: RolloutActions,
channelInfo: UpdateChannelBasicInfoFragment,
options: Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
Partial<CreateRolloutNonInteractiveOptions>
): Promise<UpdateChannelBasicInfoFragment> {
switch (action) {
case MainMenuActions.CREATE_NEW:
return await new CreateRollout(channelInfo, options).runAsync(ctx);
case ManageRolloutActions.EDIT:
return await new EditRollout(channelInfo, options).runAsync(ctx);
case ManageRolloutActions.END:
return await new EndRollout(channelInfo, options).runAsync(ctx);
}
}
}
@@ -0,0 +1,137 @@
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 { 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;
/**
* Manage a rollout for the project.
*/
export class RolloutMainMenu implements EASUpdateAction<void> {
constructor(
private options: {
channelName?: string;
action?: RolloutActions;
} & Partial<EditRolloutNonInteractiveOptions> &
Partial<EndRolloutNonInteractiveOptions> &
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.selectChannelAsync(ctx, channelInfo => !isRollout(channelInfo));
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 selectChannelAsync(
ctx: EASUpdateContext,
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
): Promise<UpdateChannelBasicInfoFragment> {
const selectChannelAction = new SelectChannel({ filterPredicate });
const channelInfo = await selectChannelAction.runAsync(ctx);
if (!channelInfo) {
throw new Error(`You dont have any channels. Create one with <TODO>`);
}
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) {
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;
}
}
@@ -1,7 +1,8 @@
import { SelectChannel } from '../../channel/actions/SelectChannel';
import { getBranchMapping } from '../../channel/branch-mapping';
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';
/**
@@ -9,14 +10,29 @@ import { isRollout } from '../branch-mapping';
*/
export class SelectRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment | null> {
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment | null> {
const rolloutFilterPredicate = (channelInfo: UpdateChannelBasicInfoFragment): boolean => {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isRollout(branchMapping);
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 selectChannel = new SelectChannel({
const selectChannelAction = new SelectChannel({
printedType: 'rollout',
filterPredicate: rolloutFilterPredicate,
filterPredicate: isRollout,
afterEachFilterQuery,
});
return await selectChannel.runAsync(ctx);
const channelInfo = await selectChannelAction.runAsync(ctx);
if (assetSpinner) {
(assetSpinner as Ora).succeed(`Fetched all channels`);
}
return channelInfo;
}
}
@@ -0,0 +1,219 @@
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, promptAsync } from '../../prompts';
import { Connection, QueryParams, selectPaginatedAsync } from '../../utils/relay';
import { formatRuntimeWithUpdateGroup } from '../utils';
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);
}
/**
* Select a runtime from a branch
*/
export class SelectRuntime implements EASUpdateAction<string> {
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.`
);
// TODO(quin): write a learn more
Log.warn(`Your updates could be misconfigured. ${learnMore('https://expo.fyi/todo')}`);
} 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/todo')}`;
}
public async runAsync(ctx: EASUpdateContext): Promise<string> {
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) {
Log.addNewLineIfNone();
this.warnNoRuntime();
return await this.promptForRuntimeAsync();
}
const moreThanOneRuntime = newestRuntimeConnection.edges.length > 1;
Log.log(`✅ ${beginSentence(this.printedType)}${moreThanOneRuntime ? 's' : ''} detected`);
if (!moreThanOneRuntime) {
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 await this.promptForRuntimeAsync();
}
}
Log.log(this.formatCantFindRuntime());
const selectedRuntime = await this.selectRuntimesAsync(graphqlClient, {
appId: projectId,
branchName: this.branchInfo.name,
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
});
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> | null> {
const connection = await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: 1,
filter: {
branchId: anotherBranchIdToIntersectRuntimesBy,
},
});
const { edges } = connection;
if (edges.length === 0) {
return null;
}
return connection;
}
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);
}
async selectRuntimesAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
branchName,
anotherBranchIdToIntersectRuntimesBy,
batchSize = 5,
}: {
appId: string;
branchName: string;
anotherBranchIdToIntersectRuntimesBy?: string;
batchSize?: number;
}
): Promise<RuntimeFragment | null> {
const queryAsync = async (queryParams: QueryParams): Promise<Connection<RuntimeFragment>> => {
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
appId,
name: branchName,
first: queryParams.first,
after: queryParams.after,
last: queryParams.last,
before: queryParams.before,
filter: {
branchId: anotherBranchIdToIntersectRuntimesBy,
},
});
};
const getTitleAsync = async (runtime: RuntimeFragment): Promise<string> => {
return await this.displayLatestUpdateGroupAsync({
graphqlClient,
appId,
branchName,
runtime,
});
};
return await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType: 'target runtime',
pageSize: batchSize,
});
}
async promptForRuntimeAsync(): Promise<string> {
Log.log(`You can input a runtime manually then publish an update later.`);
const { runtimeVersion } = await promptAsync({
type: 'text',
name: 'runtimeVersion',
message: 'Input a runtime version:',
initial: '1.0.0',
});
return runtimeVersion;
}
}
@@ -1,37 +1,56 @@
import { testBasicChannelInfo, testBasicChannelInfo2 } from '../../../channel/__tests__/fixtures';
import { getChannelsDatasetAsync } from '../../../channel/queries';
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
import { NonInteractiveError } from '../../../eas-update/utils';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import { promptAsync } from '../../../prompts';
import { standardBranchMapping } from '../../__tests__/fixtures';
import { SelectRollout } from '../SelectRollout';
jest.mock('../../../channel/queries');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/ChannelQuery');
describe(SelectRollout, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('returns null if project has no rollouts', async () => {
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([]);
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBeNull();
});
it('returns the rollout without prompting if project only one rollout', async () => {
jest.mocked(getChannelsDatasetAsync).mockResolvedValue([testBasicChannelInfo]);
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBe(testBasicChannelInfo);
});
it('returns the selected rollout', async () => {
jest
.mocked(getChannelsDatasetAsync)
.mockResolvedValue([testBasicChannelInfo, testBasicChannelInfo2]);
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [
{ node: testBasicChannelInfo, cursor: 'cursor' },
{ node: testBasicChannelInfo2, cursor: 'cursor' },
],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
jest.mocked(promptAsync).mockImplementation(async () => ({
channel: testBasicChannelInfo,
item: testBasicChannelInfo,
}));
const ctx = createCtxMock();
const selectRollout = new SelectRollout();
@@ -41,12 +60,14 @@ describe(SelectRollout, () => {
it('filters the channels for rollouts', async () => {
const standardBranchMappingChannel = { ...testBasicChannelInfo };
standardBranchMappingChannel.branchMapping = JSON.stringify(standardBranchMapping);
const mockDataset = [standardBranchMappingChannel];
jest.mocked(getChannelsDatasetAsync).mockImplementation((_gqlClient, args: any) => {
const filterPredicate = args.filterPredicate as any;
return mockDataset.filter(filterPredicate) as any;
});
const ctx = createCtxMock();
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
edges: [{ node: standardBranchMappingChannel, cursor: 'cursor' }],
pageInfo: {
hasPreviousPage: false,
hasNextPage: false,
},
});
const selectRollout = new SelectRollout();
const rollout = await selectRollout.runAsync(ctx);
expect(rollout).toBeNull();
+164 -36
View File
@@ -2,7 +2,10 @@ import assert from 'assert';
import {
BranchMapping,
BranchMappingAlwaysTrue,
BranchMappingNode,
BranchMappingValidationError,
alwaysTrue,
assertNodeObject,
assertNumber,
assertStatement,
@@ -38,6 +41,52 @@ type LegacyRolloutInfo = {
defaultBranchId: string;
};
export type RolloutBranchMapping = LegacyRolloutBranchMapping | ConstrainedRolloutBranchMapping;
type RolloutNode = {
clientKey: 'rolloutToken';
branchMappingOperator: 'hash_lt';
operand: number;
};
type RuntimeVersionNode = {
clientKey: 'runtimeVersion';
branchMappingOperator: '==';
operand: string;
};
type RtvConstrainedRolloutNode =
| ['and', RolloutNode, RuntimeVersionNode]
| ['and', RuntimeVersionNode, RolloutNode];
export type LegacyRolloutBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: RolloutNode;
},
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export type ConstrainedRolloutBranchMapping = {
version: number;
data: [
{
branchId: string;
branchMappingLogic: RtvConstrainedRolloutNode;
},
{
branchId: string;
branchMappingLogic: BranchMappingAlwaysTrue;
}
];
};
export function isLegacyRolloutInfo(rollout: RolloutInfo): rollout is LegacyRolloutInfo {
return !isConstrainedRolloutInfo(rollout);
}
@@ -51,13 +100,11 @@ export function isConstrainedRollout(rollout: Rollout): rollout is ConstrainedRo
}
export function getRolloutInfo(basicChannelInfo: UpdateChannelBasicInfoFragment): RolloutInfo {
const branchMapping = getBranchMapping(basicChannelInfo.branchMapping);
assertRollout(branchMapping);
return getRolloutInfoFromBranchMapping(branchMapping);
const rolloutBranchMapping = getRolloutBranchMapping(basicChannelInfo.branchMapping);
return getRolloutInfoFromBranchMapping(rolloutBranchMapping);
}
export function getRolloutInfoFromBranchMapping(branchMapping: BranchMapping): RolloutInfo {
assertRollout(branchMapping);
export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapping): RolloutInfo {
const rolledOutBranchId = branchMapping.data[0].branchId;
const defaultBranchId = branchMapping.data[1].branchId;
@@ -97,14 +144,32 @@ export function getRolloutInfoFromBranchMapping(branchMapping: BranchMapping): R
}
export function getRollout(channel: UpdateChannelObject): Rollout {
const branchMapping = getBranchMapping(channel.branchMapping);
assertRollout(branchMapping);
const rolledOutBranchId = branchMapping.data[0].branchId;
const rolloutBranchMapping = getRolloutBranchMapping(channel.branchMapping);
const rolledOutBranchId = rolloutBranchMapping.data[0].branchId;
const rolledOutBranch = getUpdateBranch(channel, rolledOutBranchId);
const defaultBranchId = branchMapping.data[1].branchId;
const defaultBranchId = rolloutBranchMapping.data[1].branchId;
const defaultBranch = getUpdateBranch(channel, defaultBranchId);
const rolloutInfo = getRolloutInfo(channel);
return composeRollout(rolloutInfo, defaultBranch, rolledOutBranch);
}
export function composeRollout(
rolloutInfo: RolloutInfo,
defaultBranch: UpdateBranchObject,
rolledOutBranch: UpdateBranchObject
): Rollout {
if (rolloutInfo.defaultBranchId !== defaultBranch.id) {
throw new BranchMappingValidationError(
`Default branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${defaultBranch.id}`
);
}
if (rolloutInfo.rolledOutBranchId !== rolledOutBranch.id) {
throw new BranchMappingValidationError(
`Rolled out branch id must match. Received: ${JSON.stringify(rolloutInfo)} ${
rolledOutBranch.id
}`
);
}
return {
...rolloutInfo,
rolledOutBranch,
@@ -112,6 +177,12 @@ export function getRollout(channel: UpdateChannelObject): Rollout {
};
}
export function getRolloutBranchMapping(branchMappingString: string): RolloutBranchMapping {
const branchMapping = getBranchMapping(branchMappingString);
assertRolloutBranchMapping(branchMapping);
return branchMapping;
}
/**
* Detect if a branch mapping is a rollout.
*
@@ -163,19 +234,58 @@ export function getRollout(channel: UpdateChannelObject): Rollout {
],
}
*/
export function isRollout(branchMapping: BranchMapping): boolean {
export function isRolloutBranchMapping(
branchMapping: BranchMapping
): branchMapping is RolloutBranchMapping {
return isUnconstrainedRollout(branchMapping) || isRtvConstrainedRollout(branchMapping);
}
export function isRollout(channelInfo: UpdateChannelBasicInfoFragment): boolean {
const branchMapping = getBranchMapping(channelInfo.branchMapping);
return isRolloutBranchMapping(branchMapping);
}
export function createRolloutBranchMapping({
defaultBranchId,
rolloutBranchId,
percent,
runtimeVersion,
}: {
defaultBranchId: string;
rolloutBranchId: string;
percent: number;
runtimeVersion: string;
}): ConstrainedRolloutBranchMapping {
assertPercent(percent);
return {
version: 0,
data: [
{
branchId: rolloutBranchId,
branchMappingLogic: [
'and',
{
operand: runtimeVersion,
clientKey: 'runtimeVersion',
branchMappingOperator: '==',
},
{
operand: percent / 100,
clientKey: 'rolloutToken',
branchMappingOperator: 'hash_lt',
},
],
},
{ branchId: defaultBranchId, branchMappingLogic: alwaysTrue() },
],
};
}
export function editRolloutBranchMapping(
branchMapping: BranchMapping,
branchMapping: RolloutBranchMapping,
percent: number
): BranchMapping {
assert(
Number.isInteger(percent) && percent >= 0 && percent <= 100,
'The rollout percentage must be an integer between 0 and 100 inclusive.'
);
assertRollout(branchMapping);
): RolloutBranchMapping {
assertPercent(percent);
if (isRtvConstrainedRollout(branchMapping)) {
return editRtvConstrainedRollout(branchMapping, percent);
} else {
@@ -183,45 +293,42 @@ export function editRolloutBranchMapping(
}
}
function editRtvConstrainedRollout(branchMapping: BranchMapping, percent: number): BranchMapping {
function editRtvConstrainedRollout(
branchMapping: ConstrainedRolloutBranchMapping,
percent: number
): ConstrainedRolloutBranchMapping {
const newBranchMapping = { ...branchMapping };
const statementNode = newBranchMapping.data[0].branchMappingLogic;
assertStatement(statementNode);
const nodesFromStatement = getNodesFromStatement(statementNode);
const rolloutNode = nodesFromStatement.find(isRolloutNode);
assert(rolloutNode, 'Rollout node must be defined.');
assertNodeObject(rolloutNode);
rolloutNode.operand = percent / 100;
return newBranchMapping;
}
function editLegacyRollout(branchMapping: BranchMapping, percent: number): BranchMapping {
function editLegacyRollout(
branchMapping: LegacyRolloutBranchMapping,
percent: number
): LegacyRolloutBranchMapping {
const newBranchMapping = { ...branchMapping };
const rolloutNode = newBranchMapping.data[0].branchMappingLogic;
assertNodeObject(rolloutNode);
rolloutNode.operand = percent / 100;
return newBranchMapping;
}
function assertRollout(branchMapping: BranchMapping): asserts branchMapping is BranchMapping {
assert(
isRollout(branchMapping),
'Branch mapping node must be a rollout. Received: ' + JSON.stringify(branchMapping)
);
}
function isRtvConstrainedRollout(branchMapping: BranchMapping): boolean {
function isRtvConstrainedRollout(
branchMapping: BranchMapping
): branchMapping is ConstrainedRolloutBranchMapping {
if (branchMapping.data.length !== 2) {
return false;
}
const hasRtvRolloutNode = isRtvConstrainedRolloutNode(branchMapping.data[0].branchMappingLogic);
const defaultsToAlwaysTrueNode = isAlwaysTrue(branchMapping.data[1].branchMappingLogic);
return hasRtvRolloutNode && defaultsToAlwaysTrueNode;
}
function isRtvConstrainedRolloutNode(node: BranchMappingNode): boolean {
function isRtvConstrainedRolloutNode(node: BranchMappingNode): node is RtvConstrainedRolloutNode {
if (!isStatement(node) || !isAndStatement(node)) {
return false;
}
@@ -235,7 +342,9 @@ function isRtvConstrainedRolloutNode(node: BranchMappingNode): boolean {
return hasRuntimeVersionNode && hasRolloutNode;
}
function isUnconstrainedRollout(branchMapping: BranchMapping): boolean {
function isUnconstrainedRollout(
branchMapping: BranchMapping
): branchMapping is LegacyRolloutBranchMapping {
if (branchMapping.data.length !== 2) {
return false;
}
@@ -244,7 +353,7 @@ function isUnconstrainedRollout(branchMapping: BranchMapping): boolean {
return hasRolloutNode && defaultsToAlwaysTrueNode;
}
function isRuntimeVersionNode(node: BranchMappingNode): boolean {
function isRuntimeVersionNode(node: BranchMappingNode): node is RuntimeVersionNode {
if (typeof node === 'string') {
return false;
}
@@ -254,7 +363,7 @@ function isRuntimeVersionNode(node: BranchMappingNode): boolean {
return node.clientKey === 'runtimeVersion' && node.branchMappingOperator === '==';
}
function isRolloutNode(node: BranchMappingNode): boolean {
function isRolloutNode(node: BranchMappingNode): node is RolloutNode {
if (typeof node === 'string') {
return false;
}
@@ -263,3 +372,22 @@ function isRolloutNode(node: BranchMappingNode): boolean {
}
return node.clientKey === 'rolloutToken' && node.branchMappingOperator === 'hash_lt';
}
export function assertRolloutBranchMapping(
branchMapping: BranchMapping
): asserts branchMapping is RolloutBranchMapping {
if (!isRolloutBranchMapping(branchMapping)) {
throw new BranchMappingValidationError(
'Branch mapping node must be a rollout. Received: ' + JSON.stringify(branchMapping)
);
}
}
function assertPercent(percent: number): void {
const isPercent = Number.isInteger(percent) && percent >= 0 && percent <= 100;
if (!isPercent) {
throw new BranchMappingValidationError(
`The percentage must be an integer between 0 and 100 inclusive. Received: ${percent}`
);
}
}
+76 -25
View File
@@ -1,18 +1,25 @@
import chalk from 'chalk';
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import { RuntimeFragment, UpdateFragment } from '../graphql/generated';
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
import Log from '../log';
import { promptAsync } from '../prompts';
import { FormattedUpdateGroupDescription, getUpdateGroupDescriptions } from '../update/utils';
import formatFields from '../utils/formatFields';
import { getRollout, isConstrainedRollout } from './branch-mapping';
import { Rollout, getRollout, isConstrainedRollout } from './branch-mapping';
export function printRollout(channel: UpdateChannelObject): void {
const rollout = getRollout(channel);
displayRolloutDetails(channel.name, rollout);
}
export function displayRolloutDetails(channelName: string, rollout: Rollout): void {
const rolledOutPercent = rollout.percentRolledOut;
Log.addNewLineIfNone();
Log.log(chalk.bold('Rollout:'));
Log.newLine();
Log.log(chalk.bold('🚀 Rollout:'));
Log.log(
formatFields([
{ label: 'Channel', value: channel.name },
{ label: 'Channel', value: channelName },
...(isConstrainedRollout(rollout)
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
: []),
@@ -27,24 +34,68 @@ export function printRollout(channel: UpdateChannelObject): void {
Log.addNewLineIfNone();
}
export function printBranch(channel: UpdateChannelObject): void {
const rollout = getRollout(channel);
const rolledOutPercent = rollout.percentRolledOut;
Log.addNewLineIfNone();
Log.log(chalk.bold('Rollout:'));
Log.log(
formatFields([
{ label: 'Channel', value: channel.name },
...(isConstrainedRollout(rollout)
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
: []),
{
label: 'Branches',
value: `${rollout.rolledOutBranch.name} (${rolledOutPercent}%), ${
rollout.defaultBranch.name
} (${100 - rolledOutPercent}%)`,
},
])
);
Log.addNewLineIfNone();
export function formatBranchWithUpdateGroup(
maybeUpdateGroup: UpdateFragment[] | undefined | null,
branch: UpdateBranchObject,
percentRolledOut: number
): string {
const lines: string[] = [];
lines.push(chalk.bold(`🍽️ Served by branch ${chalk.bold(branch.name)} (${percentRolledOut}%)`));
if (!maybeUpdateGroup) {
lines.push(`No updates for target runtime`);
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([maybeUpdateGroup]);
lines.push(...formatUpdateGroup(updateGroupDescription));
}
return lines.join('\n ');
}
export function formatRuntimeWithUpdateGroup(
maybeUpdateGroup: UpdateFragment[] | undefined | null,
runtime: RuntimeFragment
): string {
const lines: string[] = [];
lines.push(chalk.bold(`🍽️ Served by runtime ${chalk.bold(runtime.version)}:`));
if (!maybeUpdateGroup) {
lines.push(`No updates published for this runtime`);
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([maybeUpdateGroup]);
lines.push(...formatUpdateGroup(updateGroupDescription));
}
return lines.join('\n ');
}
function formatUpdateGroup(updateGroup: FormattedUpdateGroupDescription): string[] {
const lines: string[] = [];
const formattedLines = formatFields([
{ label: 'Message', value: updateGroup.message ?? 'N/A' },
{ label: 'Runtime Version', value: updateGroup.runtimeVersion ?? 'N/A' },
{ label: 'Platforms', value: updateGroup.platforms ?? 'N/A' },
{ label: 'Group ID', value: updateGroup.group ?? 'N/A' },
]).split('\n');
lines.push(...formattedLines);
return lines;
}
export 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;
}
@@ -0,0 +1,52 @@
import { Workflow } from '@expo/eas-build-job';
import {
DEFAULT_BARE_RUNTIME_VERSION,
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49,
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48,
getDefaultRuntimeVersion,
} from '../configure';
describe(getDefaultRuntimeVersion, () => {
it('gets the right rtv version/policy', () => {
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '48.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '49.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, '50.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, 'sdf')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.MANAGED, undefined)).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '48.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '49.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, '50.0.0')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, 'sdf')).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.UNKNOWN, undefined)).toBe(
DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48
);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '48.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '49.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, '50.0.0')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, 'sdf')).toBe(DEFAULT_BARE_RUNTIME_VERSION);
expect(getDefaultRuntimeVersion(Workflow.GENERIC, undefined)).toBe(
DEFAULT_BARE_RUNTIME_VERSION
);
});
});
@@ -115,6 +115,7 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
createdAt: '2022-12-07T02:24:29.786Z',
updatedAt: '2022-12-07T02:24:29.786Z',
updates: [],
runtimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
updateGroups: [
[
{
@@ -148,6 +149,7 @@ function mockUpdateBranches(branchNames: string[]): UpdateBranch[] {
updatedAt: '2022-12-07T02:24:29.786Z',
updateGroups: [],
updates: [],
runtimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
__typename: 'UpdateBranch' as const,
},
codeSigningInfo: null,
+19 -7
View File
@@ -3,6 +3,7 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { EasJsonAccessor } from '@expo/eas-json';
import chalk from 'chalk';
import fs from 'fs-extra';
import semver from 'semver';
import { getEASUpdateURL } from '../api';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
@@ -20,13 +21,22 @@ import { confirmAsync } from '../prompts';
import { syncUpdatesConfigurationAsync as syncAndroidUpdatesConfigurationAsync } from './android/UpdatesModule';
import { syncUpdatesConfigurationAsync as syncIosUpdatesConfigurationAsync } from './ios/UpdatesModule';
export const DEFAULT_MANAGED_RUNTIME_VERSION = { policy: 'sdkVersion' } as const;
export const DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49 = { policy: 'appVersion' } as const;
export const DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48 = { policy: 'sdkVersion' } as const;
export const DEFAULT_BARE_RUNTIME_VERSION = '1.0.0' as const;
function getDefaultRuntimeVersion(workflow: Workflow): NonNullable<ExpoConfig['runtimeVersion']> {
return workflow === Workflow.GENERIC
? DEFAULT_BARE_RUNTIME_VERSION
: DEFAULT_MANAGED_RUNTIME_VERSION;
export function getDefaultRuntimeVersion(
workflow: Workflow,
sdkVersion: string | undefined
): NonNullable<ExpoConfig['runtimeVersion']> {
if (workflow === Workflow.GENERIC) {
return DEFAULT_BARE_RUNTIME_VERSION;
}
// Expo Go supports loading appVersion SDK 49 and above
const hasSupportedSdk = sdkVersion && semver.satisfies(sdkVersion, '>= 49.0.0');
return hasSupportedSdk
? DEFAULT_MANAGED_RUNTIME_VERSION_GTE_SDK_49
: DEFAULT_MANAGED_RUNTIME_VERSION_LTE_SDK_48;
}
function isRuntimeEqual(
@@ -108,8 +118,10 @@ async function ensureEASUpdatesIsConfiguredInExpoConfigAsync({
(['all', 'android'].includes(platform) && !androidRuntimeVersion) ||
(['all', 'ios'].includes(platform) && !iosRuntimeVersion)
) {
androidRuntimeVersion = androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android);
iosRuntimeVersion = iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios);
androidRuntimeVersion =
androidRuntimeVersion ?? getDefaultRuntimeVersion(workflows.android, exp.sdkVersion);
iosRuntimeVersion =
iosRuntimeVersion ?? getDefaultRuntimeVersion(workflows.ios, exp.sdkVersion);
if (platform === 'all' && isRuntimeEqual(androidRuntimeVersion, iosRuntimeVersion)) {
modifyConfig.runtimeVersion = androidRuntimeVersion;
+138
View File
@@ -0,0 +1,138 @@
import { ExpoConfig } from '@expo/config';
import assert from 'assert';
import { getUpdateGroupUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { Update } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import Log, { link } from '../log';
import { ora } from '../ora';
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
import formatFields from '../utils/formatFields';
import { printJsonOnlyOutput } from '../utils/json';
export type UpdateToRepublish = {
groupId: string;
branchId: string;
branchName: string;
} & Pick<
Update,
| 'message'
| 'runtimeVersion'
| 'manifestFragment'
| 'platform'
| 'gitCommitHash'
| 'codeSigningInfo'
>;
/**
* @param updatesToPublish The update group to republish
* @param targetBranch The branch to repubish the update group on
*/
export async function republishAsync({
graphqlClient,
app,
updatesToPublish,
targetBranch,
updateMessage,
json,
}: {
graphqlClient: ExpoGraphqlClient;
app: { exp: ExpoConfig; projectId: string };
updatesToPublish: UpdateToRepublish[];
targetBranch: { branchName: string; branchId: string };
updateMessage: string;
json?: boolean;
}): Promise<void> {
const { branchName: targetBranchName, branchId: targetBranchId } = targetBranch;
// The update group properties are the same for all updates
assert(updatesToPublish.length > 0, 'Updates to republish must be provided');
const arbitraryUpdate = updatesToPublish[0];
const isSameGroup = (update: UpdateToRepublish): boolean =>
update.groupId === arbitraryUpdate.groupId &&
update.branchId === arbitraryUpdate.branchId &&
update.branchName === arbitraryUpdate.branchName &&
update.runtimeVersion === arbitraryUpdate.runtimeVersion;
assert(updatesToPublish.every(isSameGroup), 'All updates must belong to the same update group');
const { runtimeVersion } = arbitraryUpdate;
// 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: targetBranchId,
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 (json) {
return printJsonOnlyOutput(updatesRepublished);
}
const updatesRepublishedByPlatform = Object.fromEntries(
updatesRepublished.map(update => [update.platform, update])
);
const arbitraryRepublishedUpdate = updatesRepublished[0];
const updateGroupUrl = getUpdateGroupUrl(
(await getOwnerAccountForProjectIdAsync(graphqlClient, app.projectId)).name,
app.exp.slug,
arbitraryRepublishedUpdate.group
);
Log.addNewLineIfNone();
Log.log(
formatFields([
{ label: 'Branch', value: targetBranchName },
{ label: 'Runtime version', value: arbitraryRepublishedUpdate.runtimeVersion },
{ label: 'Platform', value: updatesRepublished.map(update => update.platform).join(', ') },
{ label: 'Update Group ID', value: arbitraryRepublishedUpdate.group },
...(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 }) },
])
);
}
@@ -1,8 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJsonAccessor, EasJsonUtils, errors } from '@expo/eas-json';
import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import Log from '../../log';
import { selectAsync } from '../../prompts';
@@ -12,7 +9,6 @@ import {
maybePrintBuildProfileDeprecationWarningsAsync,
} from '../profiles';
jest.mock('fs');
jest.mock('../../prompts');
jest.mock('@expo/eas-json', () => {
const actual = jest.requireActual('@expo/eas-json');
@@ -36,7 +32,6 @@ const getBuildProfileDeprecationWarningsAsync = jest.spyOn(
);
const newLineSpy = jest.spyOn(Log, 'newLine');
const warnSpy = jest.spyOn(Log, 'warn');
const projectDir = '/app';
describe(getProfilesAsync, () => {
afterEach(() => {
@@ -56,7 +51,6 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
projectDir,
});
expect(result[0].profileName).toBe('production');
@@ -77,7 +71,6 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID],
profileName: undefined,
type: 'build',
projectDir,
})
).rejects.toThrowError(errors.MissingProfileError);
});
@@ -93,7 +86,6 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID, Platform.IOS],
profileName: 'custom-profile',
type: 'build',
projectDir,
});
expect(result[0].profileName).toBe('custom-profile');
@@ -106,52 +98,6 @@ describe(getProfilesAsync, () => {
expect(getBuildProfileAsync).toBeCalledWith(easJsonAccessor, Platform.IOS, 'custom-profile');
});
describe('node version', () => {
const nodeVersion = '14.17.1';
it('is read from profile', async () => {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
getBuildProfileAsync.mockImplementation(async () => {
return { node: nodeVersion } as BuildProfile<Platform.ANDROID>;
});
const result = await getProfilesAsync({
easJsonAccessor,
platforms: [Platform.ANDROID],
profileName: 'custom-profile',
type: 'build',
projectDir,
});
expect(result[0].profile.node).toBe(nodeVersion);
});
describe('with .nvmrc', () => {
beforeEach(async () => {
vol.reset();
await fs.mkdirp(os.tmpdir());
vol.fromJSON({ '.nvmrc': nodeVersion }, projectDir);
});
it('is read from .nvmrc', async () => {
const easJsonAccessor = EasJsonAccessor.fromProjectPath(projectDir);
getBuildProfileAsync.mockImplementation(async () => {
return {} as BuildProfile<Platform.ANDROID>;
});
const result = await getProfilesAsync({
easJsonAccessor,
platforms: [Platform.ANDROID],
profileName: 'custom-profile',
type: 'build',
projectDir,
});
expect(result[0].profile.node).toBe(nodeVersion);
});
});
});
it('throws validation error if eas.json is invalid', async () => {
getBuildProfileAsync.mockImplementation(() => {
throw new errors.InvalidEasJsonError('eas.json is not valid');
@@ -163,7 +109,6 @@ describe(getProfilesAsync, () => {
platforms: [Platform.ANDROID, Platform.IOS],
profileName: undefined,
type: 'build',
projectDir,
})
).rejects.toThrowError(/eas.json is not valid/);
});
@@ -1,94 +1,795 @@
import { getPaginatedDatasetAsync } from '../relay';
import { promptAsync } from '../../prompts';
import {
Connection,
FilterPagination,
NEXT_PAGE_OPTION,
PREV_PAGE_OPTION,
QueryParams,
selectPaginatedAsync,
} from '../relay';
describe('getPaginatedDatasetAsync', () => {
const mockQueryAsync = jest.fn(({ first, after }) =>
Promise.resolve({
edges: Array(first).fill({ node: 'node', cursor: 'cursor' }),
pageInfo: {
hasNextPage: after !== 'next',
endCursor: after ? null : 'next',
hasPreviousPage: false,
},
})
);
jest.mock('../../prompts');
describe(FilterPagination, () => {
describe(FilterPagination.getPageAsync, () => {
let queryParams: QueryParams;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let filterPredicate: () => boolean;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
beforeEach(() => {
queryParams = {};
queryAsync = jest.fn();
filterPredicate = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
test('should call getFirstItemsAsync when isFirstAfter is true', async () => {
const originalFirstAfter = FilterPagination.isFirstAfter;
const originalGetFirstItemsAsync = FilterPagination.getFirstItemsAsync;
FilterPagination.isFirstAfter = jest.fn().mockReturnValue(true) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
first: number;
after?: string;
};
FilterPagination.getFirstItemsAsync = jest.fn().mockResolvedValue({});
await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
});
expect(FilterPagination.isFirstAfter).toHaveBeenCalledWith(queryParams);
expect(FilterPagination.getFirstItemsAsync).toHaveBeenCalledWith(
queryParams,
expect.any(Object)
);
FilterPagination.isFirstAfter = originalFirstAfter;
FilterPagination.getFirstItemsAsync = originalGetFirstItemsAsync;
});
test('should call getLastItemsAsync when isLastBefore is true', async () => {
const originalIsLastBefore = FilterPagination.isLastBefore;
const originalGetLastItemsAsync = FilterPagination.getLastItemsAsync;
FilterPagination.isLastBefore = jest.fn().mockReturnValue(true) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
last: number;
before?: string;
};
FilterPagination.getLastItemsAsync = jest.fn().mockResolvedValue({});
await FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
});
expect(FilterPagination.isLastBefore).toHaveBeenCalledWith(queryParams);
expect(FilterPagination.getLastItemsAsync).toHaveBeenCalledWith(
queryParams,
expect.any(Object)
);
FilterPagination.isLastBefore = originalIsLastBefore;
FilterPagination.getLastItemsAsync = originalGetLastItemsAsync;
});
test('should throw an error for invalid query params', async () => {
const originalIsFirstAfter = FilterPagination.isFirstAfter;
const originalIsLastBefore = FilterPagination.isLastBefore;
FilterPagination.isFirstAfter = jest.fn().mockReturnValue(false) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
first: number;
after?: string;
};
FilterPagination.isLastBefore = jest.fn().mockReturnValue(false) as unknown as (
connectionArgs: QueryParams
) => connectionArgs is {
last: number;
before?: string;
};
await expect(
FilterPagination.getPageAsync({
queryParams,
queryAsync,
filterPredicate,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError('Invalid query params');
FilterPagination.isFirstAfter = originalIsFirstAfter;
FilterPagination.isLastBefore = originalIsLastBefore;
});
});
describe(FilterPagination.isFirstAfter, () => {
test('should return true for object with first property', () => {
const connectionArgs = { first: 10 };
expect(FilterPagination.isFirstAfter(connectionArgs)).toBe(true);
});
test('should return false for object without first property', () => {
const connectionArgs = { last: 10 };
expect(FilterPagination.isFirstAfter(connectionArgs)).toBe(false);
});
});
describe(FilterPagination.isLastBefore, () => {
test('should return true for object with last property', () => {
const connectionArgs = { last: 10 };
expect(FilterPagination.isLastBefore(connectionArgs)).toBe(true);
});
test('should return false for object without last property', () => {
const connectionArgs = { first: 10 };
expect(FilterPagination.isLastBefore(connectionArgs)).toBe(false);
});
});
describe(FilterPagination.getFirstItemsAsync, () => {
let queryArgs: {
first: number;
after?: string | undefined;
};
let internalBatchSize: number;
let maxNodesFetched: number;
let filterPredicate: () => boolean;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryArgs = { first: 10, after: 'cursor' };
internalBatchSize = 100;
maxNodesFetched = 10000;
filterPredicate = jest.fn();
queryAsync = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('should call queryAsync with internalBatchSize and after', async () => {
const pageInfo = { hasNextPage: false };
const result = { edges: [], pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(queryAsync).toHaveBeenCalledWith({ first: internalBatchSize, after: queryArgs.after });
});
test('should call filterPredicate for each batch edge', async () => {
const pageInfo = { hasNextPage: false };
const edges = [{ node: 'node1' }, { node: 'node2' }, { node: 'node3' }];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(filterPredicate).toHaveBeenCalledTimes(edges.length);
expect(filterPredicate).toHaveBeenCalledWith(edges[0].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[1].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[2].node);
});
test('should append batch edges to the dataset', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const connection = await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(connection.edges).toEqual(edges);
});
test('should stop appending batch edges when dataset reaches limit', async () => {
const pageInfo = { hasNextPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
{ node: 'node3', cursor: 'cursor3' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const limit = 1;
const connection = await FilterPagination.getFirstItemsAsync(
{ first: limit },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}
);
expect(connection.edges.length).toBe(limit);
expect(connection.edges).toEqual([edges[0]]);
});
test('should call beforeEachQuery before each query', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalled();
});
test('should call afterEachQuery after each query', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalled();
});
test('should pass the correct arguments to beforeEachQuery', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalledWith(queryArgs, 0, expect.any(Array));
});
test('should pass the correct arguments to afterEachQuery', async () => {
const pageInfo = { hasNextPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalledWith(
queryArgs,
edges.length,
expect.any(Array),
pageInfo.hasNextPage
);
});
test('should throw an error when maxNodesFetched is exceeded', async () => {
const pageInfo = { hasNextPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
maxNodesFetched = edges.length;
await expect(
FilterPagination.getFirstItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError(`Max nodes of ${maxNodesFetched} fetched`);
});
});
describe(FilterPagination.getLastItemsAsync, () => {
let queryArgs: {
last: number;
before?: string | undefined;
};
let internalBatchSize: number;
let maxNodesFetched: number;
let filterPredicate: () => boolean;
let queryAsync: (queryParams: QueryParams) => Promise<Connection<any>>;
let beforeEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[]
) => void;
let afterEachQuery: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: any[],
willFetchAgain: boolean
) => void;
beforeEach(() => {
queryArgs = { last: 10, before: 'cursor' };
internalBatchSize = 100;
maxNodesFetched = 10000;
filterPredicate = jest.fn();
queryAsync = jest.fn();
beforeEachQuery = jest.fn();
afterEachQuery = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('should call queryAsync with internalBatchSize and after', async () => {
const pageInfo = { hasPreviousPage: false };
const result = { edges: [], pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(queryAsync).toHaveBeenCalledWith({
last: internalBatchSize,
before: queryArgs.before,
});
});
test('should call filterPredicate for each batch edge', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [{ node: 'node1' }, { node: 'node2' }, { node: 'node3' }];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(filterPredicate).toHaveBeenCalledTimes(edges.length);
expect(filterPredicate).toHaveBeenCalledWith(edges[0].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[1].node);
expect(filterPredicate).toHaveBeenCalledWith(edges[2].node);
});
test('should append batch edges to the dataset', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const connection = await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(connection.edges).toEqual(edges);
});
test('should stop appending batch edges when dataset reaches limit', async () => {
const pageInfo = { hasPreviousPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
{ node: 'node3', cursor: 'cursor3' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
(filterPredicate as jest.Mock).mockResolvedValue(true);
const limit = 1;
const connection = await FilterPagination.getLastItemsAsync(
{ last: limit },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}
);
expect(connection.edges.length).toBe(limit);
expect(connection.edges).toEqual([edges[2]]);
});
test('should call beforeEachQuery before each query', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalled();
});
test('should call afterEachQuery after each query', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalled();
});
test('should pass the correct arguments to beforeEachQuery', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(beforeEachQuery).toHaveBeenCalledWith(queryArgs, 0, expect.any(Array));
});
test('should pass the correct arguments to afterEachQuery', async () => {
const pageInfo = { hasPreviousPage: false };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
await FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
});
expect(afterEachQuery).toHaveBeenCalledWith(
queryArgs,
edges.length,
expect.any(Array),
pageInfo.hasPreviousPage
);
});
test('should throw an error when maxNodesFetched is exceeded', async () => {
const pageInfo = { hasPreviousPage: true };
const edges = [
{ node: 'node1', cursor: 'cursor1' },
{ node: 'node2', cursor: 'cursor2' },
];
const result = { edges, pageInfo };
(queryAsync as jest.Mock).mockResolvedValue(result);
maxNodesFetched = edges.length;
await expect(
FilterPagination.getLastItemsAsync(queryArgs, {
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
})
).rejects.toThrowError(`Max nodes of ${maxNodesFetched} fetched`);
});
});
});
describe(selectPaginatedAsync, () => {
const queryAsync = jest.fn();
const getTitleAsync = jest.fn();
const printedType = 'Node';
const pageSize = 10;
afterEach(() => {
jest.clearAllMocks();
});
it('should query data page by page until no more data', async () => {
const dataset = await getPaginatedDatasetAsync({
queryAsync: mockQueryAsync,
batchSize: 1,
test('returns null when there are no items', async () => {
queryAsync.mockResolvedValueOnce({
edges: [],
});
expect(mockQueryAsync).toHaveBeenCalledTimes(2);
expect(mockQueryAsync).toHaveBeenCalledWith({ first: 1, after: undefined });
expect(mockQueryAsync).toHaveBeenCalledWith({ first: 1, after: 'next' });
expect(dataset).toHaveLength(2);
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toBeNull();
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
});
it('should call beforeEachQuery and afterEachQuery with correct parameters', async () => {
// dataset is pass by reference, so we make a copy at each call
const datasetAtNthCall_beforeEachQuery = [] as any;
const mockBeforeEachQuery = jest.fn((totalNodesFetched, dataset) => {
datasetAtNthCall_beforeEachQuery.push([...dataset]);
test('returns the only item when there is only one', async () => {
const node = { id: '1', name: 'Node 1' };
queryAsync.mockResolvedValueOnce({
edges: [{ node }],
});
// dataset is pass by reference, so we make a copy at each call
const datasetAtNthCall_afterEachQuery = [] as any;
const mockAfterEachQuery = jest.fn((totalNodesFetched, dataset, batch, pageInfo) => {
datasetAtNthCall_afterEachQuery.push([...dataset]);
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
await getPaginatedDatasetAsync({
queryAsync: mockQueryAsync,
beforeEachQuery: mockBeforeEachQuery,
afterEachQuery: mockAfterEachQuery,
batchSize: 1,
expect(result).toEqual(node);
expect(queryAsync).toHaveBeenCalledWith({
first: pageSize,
});
expect(mockBeforeEachQuery).toHaveBeenCalledTimes(2);
expect(mockBeforeEachQuery).toHaveBeenNthCalledWith(1, 0, expect.any(Array));
expect(datasetAtNthCall_beforeEachQuery[0]).toStrictEqual([]);
expect(mockBeforeEachQuery).toHaveBeenNthCalledWith(2, 1, expect.any(Array));
expect(datasetAtNthCall_beforeEachQuery[1]).toStrictEqual(['node']);
expect(mockAfterEachQuery).toHaveBeenCalledTimes(2);
expect(mockAfterEachQuery).toHaveBeenNthCalledWith(1, 1, expect.any(Array), ['node'], {
hasNextPage: true,
endCursor: 'next',
hasPreviousPage: false,
});
expect(datasetAtNthCall_afterEachQuery[0]).toStrictEqual(['node']);
expect(mockAfterEachQuery).toHaveBeenNthCalledWith(2, 2, expect.any(Array), ['node'], {
hasNextPage: false,
endCursor: null,
hasPreviousPage: false,
});
expect(datasetAtNthCall_afterEachQuery[1]).toStrictEqual(['node', 'node']);
});
it('should filter nodes if filterPredicate is provided', async () => {
const filterPredicate = (node: string): boolean => node !== 'node';
test('prompts for selection when there are multiple items', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
queryAsync.mockResolvedValue({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: { hasNextPage: false },
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
jest.mocked(promptAsync).mockImplementation(async () => ({
item: node1,
}));
const dataset = await getPaginatedDatasetAsync({
queryAsync: mockQueryAsync,
filterPredicate,
batchSize: 1,
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(dataset).toEqual([]); // expecting an empty array because our filterPredicate will filter out 'node'
expect(result).toEqual(node1);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
});
it('should stop fetching when maxNodesFetched limit is reached', async () => {
const dataset = await getPaginatedDatasetAsync({
queryAsync: mockQueryAsync,
batchSize: 2,
maxNodesFetched: 2,
test('selects the previous page', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
const node3 = { id: '3', name: 'Node 3' };
// for preflight
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
expect(dataset).toHaveLength(2);
expect(mockQueryAsync).toHaveBeenCalledTimes(1);
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node3 }],
pageInfo: {
startCursor: 'startCursor',
hasPreviousPage: true,
},
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
getTitleAsync.mockResolvedValueOnce('Node 3');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: NEXT_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: PREV_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: node1,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node1);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(queryAsync).toHaveBeenCalledWith({
last: pageSize,
before: 'startCursor',
});
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
expect(getTitleAsync).toHaveBeenCalledWith(node3);
});
test('selects the next page', async () => {
const node1 = { id: '1', name: 'Node 1' };
const node2 = { id: '2', name: 'Node 2' };
const node3 = { id: '3', name: 'Node 3' };
// for preflight
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node1 }, { node: node2 }],
pageInfo: {
endCursor: 'endCursor',
hasNextPage: true,
},
});
queryAsync.mockResolvedValueOnce({
edges: [{ node: node3 }],
pageInfo: {
startCursor: 'startCursor',
hasPreviousPage: true,
},
});
getTitleAsync.mockResolvedValueOnce('Node 1');
getTitleAsync.mockResolvedValueOnce('Node 2');
getTitleAsync.mockResolvedValueOnce('Node 3');
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: NEXT_PAGE_OPTION.value,
}));
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
item: node3,
}));
const result = await selectPaginatedAsync({
queryAsync,
getTitleAsync,
printedType,
pageSize,
});
expect(result).toEqual(node3);
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
expect(queryAsync).toHaveBeenCalledWith({
first: pageSize,
after: 'endCursor',
});
expect(getTitleAsync).toHaveBeenCalledWith(node1);
expect(getTitleAsync).toHaveBeenCalledWith(node2);
expect(getTitleAsync).toHaveBeenCalledWith(node3);
});
});
+2 -47
View File
@@ -6,9 +6,6 @@ import {
ProfileType,
SubmitProfile,
} from '@expo/eas-json';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import Log, { learnMore } from '../log';
@@ -27,23 +24,19 @@ export async function getProfilesAsync<T extends ProfileType>({
platforms,
profileName,
type,
projectDir,
}: {
easJsonAccessor: EasJsonAccessor;
platforms: Platform[];
profileName?: string;
projectDir: string;
type: T;
}): Promise<ProfileData<T>[]> {
const results = platforms.map(async function (platform) {
const profile = await readProfileWithOverridesAsync({
const profile = await readProfileAsync({
easJsonAccessor,
platform,
type,
profileName,
projectDir,
});
return {
profile,
profileName: profileName ?? 'production',
@@ -54,53 +47,16 @@ export async function getProfilesAsync<T extends ProfileType>({
return await Promise.all(results);
}
async function setNodeVersionFromFileAsync(
projectDir: string,
profile: BuildProfile<Platform>
): Promise<void> {
if (profile?.node) {
return;
}
const nodeVersion = await getNodeVersionFromFileAsync(projectDir);
if (nodeVersion) {
Log.log(
`The EAS build profile does not specify a Node.js version. Using the version specified in .nvmrc: ${nodeVersion} `
);
profile.node = nodeVersion;
}
}
async function getNodeVersionFromFileAsync(projectDir: string): Promise<string | undefined> {
const nvmrcPath = path.join(projectDir, '.nvmrc');
if (!(await fs.pathExists(nvmrcPath))) {
return;
}
let nodeVersion: string;
try {
nodeVersion = (await fs.readFile(nvmrcPath, 'utf8')).toString().trim();
} catch {
return undefined;
}
if (!semver.valid(nodeVersion)) {
throw new Error(`Invalid node version in .nvmrc: ${nodeVersion}`);
}
return nodeVersion;
}
async function readProfileWithOverridesAsync<T extends ProfileType>({
async function readProfileAsync<T extends ProfileType>({
easJsonAccessor,
platform,
type,
profileName,
projectDir,
}: {
easJsonAccessor: EasJsonAccessor;
platform: Platform;
type: T;
profileName?: string;
projectDir: string;
}): Promise<EasProfile<T>> {
if (type === 'build') {
const buildProfile = await EasJsonUtils.getBuildProfileAsync(
@@ -110,7 +66,6 @@ async function readProfileWithOverridesAsync<T extends ProfileType>({
);
await maybePrintBuildProfileDeprecationWarningsAsync(easJsonAccessor, platform, profileName);
await setNodeVersionFromFileAsync(projectDir, buildProfile);
return buildProfile as EasProfile<T>;
} else {
+338 -48
View File
@@ -1,73 +1,363 @@
import assert from 'assert';
import { PageInfo } from '../graphql/generated';
import { promptAsync } from '../prompts';
export type Connection<T> = {
edges: Edge<T>[];
pageInfo: PageInfo;
};
type Edge<T> = {
export type Edge<T> = {
cursor: string;
node: T;
};
export type QueryParams = {
first: number;
first?: number;
after?: string;
last?: number;
before?: string;
};
/**
* Fetches dataset in paginated manner (batch by batch) using GraphQL queries.
*
* Pagination that performs client side filtering on the nodes returned from a relay compliant datasource.
*
* @param queryParams The query params for the pagination.
* @param queryAsync A promise based function for querying.
* @param filterPredicate A predicate function to filter the node.
* @param beforeEachQuery Optional. A callback function to be called before each query
* @param afterEachQuery Optional. A callback function to be called after each query.
* @param filterPredicate Optional. A predicate function to filter the node.
* @param batchSize Optional. The batch size of the pagination. Defaults to 100.
* @param internalBatchSize Optional. The batch size of queryAsync. Defaults to 100.
* @param maxNodesFetched Optional. The maximum number of nodes to fetch. Defaults to 10_000.
* @param beforeEachQuery Optional. A callback function to 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 afterEachQuery Optional. A callback function to 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.
*
* @return {Promise<T[]>} - A promise that resolves to an array (the dataset).
* @throws {Error} - If an error occurs during execution of the query or pagination.
*/
export async function getPaginatedDatasetAsync<T>({
queryAsync,
beforeEachQuery,
afterEachQuery,
filterPredicate,
batchSize = 100,
maxNodesFetched = 10_000,
}: {
queryAsync: ({ first, after }: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (totalNodesFetched: number, dataset: T[]) => void;
afterEachQuery?: (
totalNodesFetched: number,
dataset: T[],
batch: T[],
pageInfo: PageInfo
) => void;
filterPredicate?: (node: T) => boolean;
batchSize?: number;
maxNodesFetched?: number;
}): Promise<T[]> {
const dataset = [];
let hasMore = true;
let after: string | undefined;
let totalNodesFetched = 0;
while (hasMore) {
if (beforeEachQuery) {
beforeEachQuery(totalNodesFetched, dataset);
}
const result = await queryAsync({ first: batchSize, after });
const { edges, pageInfo } = result;
const nodes = edges.map(edge => edge.node);
const batch = filterPredicate ? nodes.filter(filterPredicate) : nodes;
dataset.push(...batch);
hasMore = pageInfo.hasNextPage;
after = pageInfo.endCursor ?? undefined;
totalNodesFetched += nodes.length;
if (afterEachQuery) {
afterEachQuery(totalNodesFetched, dataset, batch, pageInfo);
}
if (totalNodesFetched >= maxNodesFetched) {
return dataset;
export class FilterPagination {
static async getPageAsync<T>({
queryParams,
queryAsync,
filterPredicate,
internalBatchSize = 100,
maxNodesFetched = 10_000,
beforeEachQuery,
afterEachQuery,
}: {
queryParams: QueryParams;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
filterPredicate: (node: T) => boolean;
internalBatchSize?: number;
maxNodesFetched?: number;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}): Promise<Connection<T>> {
if (this.isFirstAfter(queryParams)) {
return await this.getFirstItemsAsync(queryParams, {
queryAsync,
filterPredicate,
internalBatchSize,
maxNodesFetched,
beforeEachQuery,
afterEachQuery,
});
} else if (this.isLastBefore(queryParams)) {
return await this.getLastItemsAsync(queryParams, {
queryAsync,
filterPredicate,
internalBatchSize,
maxNodesFetched,
beforeEachQuery,
afterEachQuery,
});
}
throw new Error('Invalid query params');
}
static isFirstAfter(connectionArgs: QueryParams): connectionArgs is {
first: number;
after?: string;
} {
return 'first' in connectionArgs;
}
static isLastBefore(connectionArgs: {
first?: number;
after?: string;
last?: number;
before?: string;
}): connectionArgs is {
last: number;
before?: string;
} {
return 'last' in connectionArgs;
}
static async getFirstItemsAsync<T>(
{ first, after }: { first: number; after?: string },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}: {
internalBatchSize?: number;
maxNodesFetched: number;
filterPredicate: (node: T) => boolean;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}
): Promise<Connection<T>> {
const limit = first + 1;
const dataset = [] as Edge<T>[];
let hasMore = true;
let afterInternal: string | undefined = after;
let totalNodesFetched = 0;
while (hasMore && dataset.length < limit) {
if (beforeEachQuery) {
beforeEachQuery({ first, after }, totalNodesFetched, dataset);
}
const result = await queryAsync({ first: internalBatchSize, after: afterInternal });
const { edges: batchEdges, pageInfo } = result;
const batch = batchEdges.filter(edge => filterPredicate(edge.node));
const nodesRemaining = limit - dataset.length;
dataset.push(...batch.slice(0, nodesRemaining));
hasMore = pageInfo.hasNextPage;
afterInternal = pageInfo.endCursor ?? undefined;
totalNodesFetched += batchEdges.length;
if (afterEachQuery) {
afterEachQuery(
{ first, after },
totalNodesFetched,
dataset,
hasMore && dataset.length < limit
);
}
if (totalNodesFetched >= maxNodesFetched) {
throw new Error(`Max nodes of ${maxNodesFetched} fetched`);
}
}
const edges = dataset.slice(0, first);
return {
edges,
pageInfo: {
hasNextPage: dataset.length > first,
hasPreviousPage: false, // cannot be computed efficiently
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
static async getLastItemsAsync<T>(
{ last, before }: { last: number; before?: string },
{
internalBatchSize,
maxNodesFetched,
filterPredicate,
queryAsync,
beforeEachQuery,
afterEachQuery,
}: {
internalBatchSize?: number;
maxNodesFetched: number;
filterPredicate: (node: T) => boolean;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
beforeEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[]
) => void;
afterEachQuery?: (
externalQueryParams: QueryParams,
totalNodesFetched: number,
dataset: Edge<T>[],
willFetchAgain: boolean
) => void;
}
): Promise<Connection<T>> {
const limit = last + 1;
const dataset = [] as Edge<T>[];
let hasMore = true;
let beforeInternal: string | undefined = before;
let totalNodesFetched = 0;
while (hasMore && dataset.length < limit) {
if (beforeEachQuery) {
beforeEachQuery({ last, before }, totalNodesFetched, dataset);
}
const result = await queryAsync({ last: internalBatchSize, before: beforeInternal });
const { edges: batchEdges, pageInfo } = result;
const batch = batchEdges.filter(edge => filterPredicate(edge.node));
const nodesRemaining = limit - dataset.length;
// relay orders pages from first to last, so we reverse the batch to to choose the last n
const nodesChosen = batch.reverse().slice(0, nodesRemaining);
dataset.push(...nodesChosen);
hasMore = pageInfo.hasPreviousPage;
beforeInternal = pageInfo.startCursor ?? undefined;
totalNodesFetched += batchEdges.length;
if (afterEachQuery) {
afterEachQuery(
{ last, before },
totalNodesFetched,
dataset,
hasMore && dataset.length < limit
);
}
if (totalNodesFetched >= maxNodesFetched) {
throw new Error(`Max nodes of ${maxNodesFetched} fetched`);
}
}
// we reverse our dataset again to restore the original order of first to last to match relay
const edges = dataset.slice(0, last).reverse();
return {
edges,
pageInfo: {
hasNextPage: false, // cannot be computed efficiently,
hasPreviousPage: dataset.length > last,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
}
}
export async function selectPaginatedAsync<T>({
queryAsync,
getTitleAsync,
printedType,
pageSize,
}: {
pageSize: number;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
getTitleAsync: (node: T) => Promise<string>;
printedType: string;
}): Promise<T | null> {
// Dont bother prompting if there are 0 or 1 items
const connectionPreflight = await queryAsync({ first: pageSize });
const { edges } = connectionPreflight;
if (edges.length === 0) {
return null;
} else if (edges.length === 1) {
return edges[0].node;
}
return await selectPaginatedInternalAsync({
queryAsync,
getTitleAsync,
printedType,
queryParams: { first: pageSize },
});
}
export const PREV_PAGE_OPTION = {
value: Symbol('PREV_PAGE'),
title: '⬆️ Previous page',
};
export const NEXT_PAGE_OPTION = {
value: Symbol('NEXT_PAGE'),
title: '⬇️ Next page',
};
async function selectPaginatedInternalAsync<T>({
queryAsync,
getTitleAsync,
printedType,
queryParams,
}: {
queryParams: QueryParams;
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
getTitleAsync: (node: T) => Promise<string>;
printedType: string;
}): Promise<T> {
const limit = queryParams.first ?? queryParams.last;
assert(limit, 'queryParams must have either first or last');
const connection = await queryAsync(queryParams);
const { edges, pageInfo } = connection;
/*
* The Relay spec has a weird definition on hasNextPage and hasPreviousPage:
* 'If the client is paginating with last/before, then the server must return true if prior edges
* exist, otherwise false. If the client is paginating with first/after, then the client may
* return true if edges prior to after exist, if it can do so efficiently, otherwise may return false.'
*
* This means if we are paginating with first/after, we can't rely on pageInfo.hasPreviousPage and vice versa.
*/
const {
endCursor,
hasNextPage: serverResponseHasNextPage,
startCursor,
hasPreviousPage: serverResponseHasPreviousPage,
} = pageInfo;
const hasPreviousPage = serverResponseHasPreviousPage || queryParams.after;
const hasNextPage = serverResponseHasNextPage || queryParams.before;
const nodes = edges.map(edge => edge.node);
const options: { value: symbol | T; title: string }[] = [];
if (hasPreviousPage) {
options.push(PREV_PAGE_OPTION);
}
const nodeTitles = await Promise.all(nodes.map(node => getTitleAsync(node)));
options.push(...nodes.map((node, index) => ({ value: node, title: nodeTitles[index] })));
if (hasNextPage) {
options.push(NEXT_PAGE_OPTION);
}
const { item: selectedItem } = await promptAsync({
type: 'select',
name: 'item',
message: `Select a ${printedType}`,
choices: options.map(option => ({
value: option.value,
title: option.title,
})),
});
if (selectedItem === PREV_PAGE_OPTION.value) {
return await selectPaginatedInternalAsync({
queryParams: {
last: limit,
before: startCursor ?? undefined,
},
queryAsync,
getTitleAsync,
printedType,
});
} else if (selectedItem === NEXT_PAGE_OPTION.value) {
return await selectPaginatedInternalAsync({
queryParams: {
first: limit,
after: endCursor ?? undefined,
},
queryAsync,
getTitleAsync,
printedType,
});
} else {
return selectedItem as T;
}
return dataset;
}