mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afaee9df38 | ||
|
|
de39d6f00d | ||
|
|
401751fffa | ||
|
|
7f7f1c6a9a | ||
|
|
279d6716d2 | ||
|
|
92977d9a53 | ||
|
|
31871a9eda | ||
|
|
f122f42149 | ||
|
|
a9a49d6b97 | ||
|
|
1d94414334 | ||
|
|
0b73dc32f6 | ||
|
|
ef8ad24639 | ||
|
|
24dbe6e7cc | ||
|
|
aad026febb | ||
|
|
6ae8125c0e | ||
|
|
87d143b310 | ||
|
|
346dee9ea5 | ||
|
|
4308d21927 | ||
|
|
207f623338 |
No files matched your search
@@ -10,6 +10,48 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix `eas channel:*` to work with empty branch mappings. ([#1992](https://github.com/expo/eas-cli/pull/1992) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [4.0.0](https://github.com/expo/eas-cli/releases/tag/v4.0.0) - 2023-08-07
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Release redesigned `eas channel:rollout` into developer preview. The set of flag arguments are different, in addition to the workflow. ([#1986](https://github.com/expo/eas-cli/pull/1986) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Option to add current Apple Silicon device without the need to manually provide the provisioning UDID. ([#1943](https://github.com/expo/eas-cli/pull/1943) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix rollout-preview ending with republish with code signing. ([#1978](https://github.com/expo/eas-cli/pull/1978) by [@wschurman](https://github.com/wschurman))
|
||||
- Rollouts: fix fp precision. ([#1985](https://github.com/expo/eas-cli/pull/1985) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Fixing more grammar errors. ([#1980](https://github.com/expo/eas-cli/pull/1980) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Handle rollout edit edge case. ([#1981](https://github.com/expo/eas-cli/pull/1981) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Linked expo.fyi rollout article. ([#1991](https://github.com/expo/eas-cli/pull/1991) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Rollout ux improvements. ([#1984](https://github.com/expo/eas-cli/pull/1984) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.3](https://github.com/expo/eas-cli/releases/tag/v3.18.3) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Fix republishing with code signing. ([#1973](https://github.com/expo/eas-cli/pull/1973) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Add relationships flag to rollouts-preview. ([#1972](https://github.com/expo/eas-cli/pull/1972) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Get channel:{view,list,edit} to play nice with rollouts. ([#1974](https://github.com/expo/eas-cli/pull/1974) by [@quinlanj](https://github.com/quinlanj))
|
||||
- Use 'roll out' instead of rollout for verbs. ([#1979](https://github.com/expo/eas-cli/pull/1979) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [3.18.2](https://github.com/expo/eas-cli/releases/tag/v3.18.2) - 2023-08-03
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "3.18.2",
|
||||
"version": "4.0.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+76
-65
@@ -133,7 +133,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v4.0.1/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.2/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -677,23 +677,31 @@ Roll a new branch out on a channel incrementally.
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
|
||||
$ eas channel:rollout [CHANNEL] [--action create|edit|end] [--percent <value>] [--outcome
|
||||
republish-and-revert|revert] [--branch <value>] [--runtime-version <value>] [--private-key-path <value>] [--json
|
||||
--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
CHANNEL channel on which the rollout should be done
|
||||
|
||||
FLAGS
|
||||
--branch=<value> branch to rollout
|
||||
--end end the rollout
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--percent=<value> percent of users to send to the new branch
|
||||
--action=(create|edit|end) Rollout action to perform
|
||||
--branch=<value> Branch to roll out. Use with --action=create
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--outcome=(republish-and-revert|revert) End outcome of rollout. Use with --action=end
|
||||
--percent=<value> Percent of users to send to the new branch. Use with --action=edit or
|
||||
--action=create
|
||||
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate
|
||||
in expo-updates' configuration. Defaults to a file named "private-key.pem" in
|
||||
the certificate's directory.
|
||||
--runtime-version=<value> Runtime version to target. Use with --action=create
|
||||
|
||||
DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -716,7 +724,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -737,7 +745,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -754,7 +762,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/credentials.ts)_
|
||||
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/credentials.ts)_
|
||||
|
||||
## `eas device:create`
|
||||
|
||||
@@ -768,7 +776,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -788,7 +796,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -809,7 +817,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -830,7 +838,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -844,7 +852,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -858,7 +866,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -946,7 +954,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -963,7 +971,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -980,7 +988,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas open`
|
||||
|
||||
@@ -994,7 +1002,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1008,7 +1016,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1030,7 +1038,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas secret:create`
|
||||
|
||||
@@ -1053,7 +1061,7 @@ DESCRIPTION
|
||||
create an environment secret on the current project or owner account
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/secret/create.ts)_
|
||||
|
||||
## `eas secret:delete`
|
||||
|
||||
@@ -1071,7 +1079,7 @@ DESCRIPTION
|
||||
delete an environment secret by ID
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/delete.ts)_
|
||||
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/secret/delete.ts)_
|
||||
|
||||
## `eas secret:list`
|
||||
|
||||
@@ -1085,7 +1093,7 @@ DESCRIPTION
|
||||
list environment secrets available for your current app
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/list.ts)_
|
||||
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/secret/list.ts)_
|
||||
|
||||
## `eas secret:push`
|
||||
|
||||
@@ -1105,7 +1113,7 @@ DESCRIPTION
|
||||
read environment secrets from env file and store on the server
|
||||
```
|
||||
|
||||
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/secret/push.ts)_
|
||||
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/secret/push.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1135,7 +1143,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1168,7 +1176,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1186,7 +1194,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.2/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1207,7 +1215,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.2/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1229,7 +1237,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -1238,22 +1246,25 @@ roll back to an existing update
|
||||
```
|
||||
USAGE
|
||||
$ eas update:republish [--channel <value> | --branch <value> | --group <value>] [-m <value>] [-p android|ios|all]
|
||||
[--json --non-interactive]
|
||||
[--private-key-path <value>] [--json --non-interactive]
|
||||
|
||||
FLAGS
|
||||
-m, --message=<value> Short message describing the republished update
|
||||
-m, --message=<value> Short message describing the republished update group
|
||||
-p, --platform=(android|ios|all) [default: all]
|
||||
--branch=<value> Branch name to select an update to republish from
|
||||
--channel=<value> Channel name to select an update to republish from
|
||||
--branch=<value> Branch name to select an update group to republish from
|
||||
--channel=<value> Channel name to select an update group to republish from
|
||||
--group=<value> Update group ID to republish
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--private-key-path=<value> File containing the PEM-encoded private key corresponding to the certificate in
|
||||
expo-updates' configuration. Defaults to a file named "private-key.pem" in the
|
||||
certificate's directory.
|
||||
|
||||
DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -1273,7 +1284,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -1294,7 +1305,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -1314,7 +1325,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -1332,7 +1343,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -1354,7 +1365,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -1371,7 +1382,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_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)_
|
||||
_See code: [src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v4.0.1/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
|
||||
Generated
+9
-3
@@ -11233,6 +11233,12 @@
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MAC",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
@@ -31123,7 +31129,7 @@
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webNotifications",
|
||||
"name": "websiteNotifications",
|
||||
"description": "Web notifications linked to a user",
|
||||
"args": [],
|
||||
"type": {
|
||||
@@ -36504,7 +36510,7 @@
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webNotifications",
|
||||
"name": "websiteNotifications",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
@@ -37131,7 +37137,7 @@
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webNotifications",
|
||||
"name": "websiteNotifications",
|
||||
"description": "Web notifications linked to a user",
|
||||
"args": [],
|
||||
"type": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"description": "EAS command line tool",
|
||||
"version": "3.18.2",
|
||||
"version": "4.0.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
@@ -14,7 +14,7 @@
|
||||
"@expo/config-plugins": "7.2.4",
|
||||
"@expo/config-types": "49.0.0",
|
||||
"@expo/eas-build-job": "1.0.30",
|
||||
"@expo/eas-json": "3.18.0",
|
||||
"@expo/eas-json": "4.0.0",
|
||||
"@expo/json-file": "8.2.37",
|
||||
"@expo/multipart-body-parser": "1.1.0",
|
||||
"@expo/osascript": "2.0.33",
|
||||
|
||||
@@ -25,7 +25,7 @@ describe(SelectBranch, () => {
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBeNull();
|
||||
});
|
||||
it('returns the branch without prompting if project only one branch', async () => {
|
||||
it('still prompts if project has only one branch', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicBranchInfo, cursor: 'cursor' }],
|
||||
@@ -34,9 +34,13 @@ describe(SelectBranch, () => {
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicBranchInfo,
|
||||
}));
|
||||
const selectBranch = new SelectBranch();
|
||||
const branch = await selectBranch.runAsync(ctx);
|
||||
expect(branch).toBe(testBasicBranchInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected branch', async () => {
|
||||
jest.mocked(BranchQuery.listBranchesBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
import { rolloutBranchMapping, standardBranchMapping } from '../../rollout/__tests__/fixtures';
|
||||
import {
|
||||
emptyBranchMapping,
|
||||
rolloutBranchMapping,
|
||||
standardBranchMapping,
|
||||
} from '../../rollout/__tests__/fixtures';
|
||||
import {
|
||||
BranchMappingValidationError,
|
||||
assertVersion,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
getStandardBranchId,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../branch-mapping';
|
||||
import { testChannelObject } from './fixtures';
|
||||
|
||||
describe(assertVersion, () => {
|
||||
it('throws if the branch mapping is not the correct version', () => {
|
||||
expect(() => assertVersion(testChannelObject, 5)).toThrowError(BranchMappingValidationError);
|
||||
});
|
||||
it('asserts the correct version', () => {
|
||||
assertVersion(testChannelObject, 0);
|
||||
expect(getBranchMapping(testChannelObject.branchMapping).version).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEmptyBranchMapping, () => {
|
||||
it('detects empty branch mappings', () => {
|
||||
expect(isEmptyBranchMapping(emptyBranchMapping)).toBe(true);
|
||||
expect(isEmptyBranchMapping(standardBranchMapping)).toBe(false);
|
||||
expect(isEmptyBranchMapping(rolloutBranchMapping)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isAlwaysTrueBranchMapping, () => {
|
||||
it('detects always true branch mappings', () => {
|
||||
expect(isAlwaysTrueBranchMapping(standardBranchMapping)).toBe(true);
|
||||
expect(isAlwaysTrueBranchMapping(rolloutBranchMapping)).toBe(false);
|
||||
expect(isAlwaysTrueBranchMapping(emptyBranchMapping)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ describe(SelectChannel, () => {
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBeNull();
|
||||
});
|
||||
it('returns the channel without prompting if project only one channel', async () => {
|
||||
it('still prompts if project has only one channel', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
|
||||
@@ -34,9 +34,13 @@ describe(SelectChannel, () => {
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const selectChannel = new SelectChannel();
|
||||
const channel = await selectChannel.runAsync(ctx);
|
||||
expect(channel).toBe(testBasicChannelInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected channel', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
|
||||
@@ -45,6 +45,11 @@ export type AlwaysTrueBranchMapping = {
|
||||
];
|
||||
};
|
||||
|
||||
export type EmptyBranchMapping = {
|
||||
version: number;
|
||||
data: [];
|
||||
};
|
||||
|
||||
export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMapping {
|
||||
return {
|
||||
version: 0,
|
||||
@@ -57,6 +62,11 @@ export function getAlwaysTrueBranchMapping(branchId: string): AlwaysTrueBranchMa
|
||||
};
|
||||
}
|
||||
|
||||
export function hasEmptyBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isEmptyBranchMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function hasStandardBranchMap(channelInfo: UpdateChannelBasicInfoFragment): boolean {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
return isAlwaysTrueBranchMapping(branchMapping);
|
||||
@@ -68,6 +78,12 @@ export function getStandardBranchId(channelInfo: UpdateChannelBasicInfoFragment)
|
||||
return getBranchIdFromStandardMapping(branchMapping);
|
||||
}
|
||||
|
||||
export function isEmptyBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is EmptyBranchMapping {
|
||||
return branchMapping.data.length === 0;
|
||||
}
|
||||
|
||||
export function isAlwaysTrueBranchMapping(
|
||||
branchMapping: BranchMapping
|
||||
): branchMapping is AlwaysTrueBranchMapping {
|
||||
@@ -133,6 +149,19 @@ export function hashLtOperator(): BranchMappingOperator {
|
||||
return 'hash_lt';
|
||||
}
|
||||
|
||||
function isVersion(branchMapping: BranchMapping, version: number): boolean {
|
||||
return branchMapping.version === version;
|
||||
}
|
||||
|
||||
export function assertVersion(channelInfo: UpdateChannelBasicInfoFragment, version: number): void {
|
||||
const branchMapping = getBranchMapping(channelInfo.branchMapping);
|
||||
if (!isVersion(branchMapping, version)) {
|
||||
throw new BranchMappingValidationError(
|
||||
`Expected branch mapping version ${version}. Received: ${JSON.stringify(branchMapping)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStatement(node: BranchMappingNode): asserts node is BranchMappingStatement {
|
||||
if (!isStatement(node)) {
|
||||
throw new BranchMappingValidationError(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import { getRollout, isRollout } from '../rollout/branch-mapping';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
import { assertVersion, hasEmptyBranchMap, hasStandardBranchMap } from './branch-mapping';
|
||||
|
||||
export function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
assertVersion(channel, 0);
|
||||
assert(
|
||||
hasEmptyBranchMap(channel) || hasStandardBranchMap(channel) || isRollout(channel),
|
||||
'Only standard branch mappings and rollouts are supported.'
|
||||
);
|
||||
|
||||
const branchDescription = channel.updateBranches.flatMap(branch => {
|
||||
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
const maybeRollout = isRollout(channel) ? getRollout(channel) : null;
|
||||
let maybePercentOnBranch: number | undefined = undefined;
|
||||
if (maybeRollout) {
|
||||
maybePercentOnBranch =
|
||||
maybeRollout.rolledOutBranchId === branch.id
|
||||
? maybeRollout.percentRolledOut
|
||||
: 100 - maybeRollout.percentRolledOut;
|
||||
}
|
||||
|
||||
return updateGroupWithBranchDescriptions.map(
|
||||
({ branch, ...updateGroup }): FormattedBranchDescription => ({
|
||||
branch,
|
||||
branchRolloutPercentage: maybePercentOnBranch,
|
||||
update: updateGroup,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (branchDescription.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
branchDescription
|
||||
.map(description => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
paginatedQueryWithConfirmPromptAsync,
|
||||
paginatedQueryWithSelectPromptAsync,
|
||||
} from '../utils/queries';
|
||||
import { logChannelDetails } from './utils';
|
||||
import { logChannelDetails } from './print-utils';
|
||||
|
||||
export const CHANNELS_LIMIT = 25;
|
||||
|
||||
|
||||
@@ -1,114 +1,4 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateBranchObject, UpdateChannelObject } from '../graphql/queries/ChannelQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
FormattedBranchDescription,
|
||||
formatBranch,
|
||||
getUpdateGroupDescriptionsWithBranch,
|
||||
} from '../update/utils';
|
||||
import { BranchMapping, assertNodeObject, assertNumber, isNodeObject } from './branch-mapping';
|
||||
|
||||
/**
|
||||
* Get the branch mapping and determine whether it is a rollout.
|
||||
* Ensure that the branch mapping is properly formatted.
|
||||
*/
|
||||
export function getBranchMapping(branchMappingString?: string): {
|
||||
branchMapping: BranchMapping;
|
||||
isRollout: boolean;
|
||||
rolloutPercent?: number;
|
||||
} {
|
||||
if (!branchMappingString) {
|
||||
throw new Error('Missing branch mapping.');
|
||||
}
|
||||
let branchMapping: BranchMapping;
|
||||
try {
|
||||
branchMapping = JSON.parse(branchMappingString);
|
||||
} catch {
|
||||
throw new Error(`Could not parse branchMapping string into a JSON: "${branchMappingString}"`);
|
||||
}
|
||||
assert(branchMapping, 'Branch Mapping must be defined.');
|
||||
|
||||
if (branchMapping.version !== 0) {
|
||||
throw new Error('Branch mapping must be version 0.');
|
||||
}
|
||||
|
||||
const isRollout = branchMapping.data.length === 2;
|
||||
const branchMappingNode = branchMapping.data[0]?.branchMappingLogic;
|
||||
let rolloutPercent: number | undefined;
|
||||
if (isNodeObject(branchMappingNode)) {
|
||||
assertNumber(branchMappingNode.operand);
|
||||
rolloutPercent = branchMappingNode.operand;
|
||||
}
|
||||
switch (branchMapping.data.length) {
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
if (branchMapping.data[0].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a single branch must be "true"');
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
assertNodeObject(branchMappingNode);
|
||||
if (branchMappingNode.clientKey !== 'rolloutToken') {
|
||||
throw new Error('Client key of initial branch mapping must be "rolloutToken"');
|
||||
}
|
||||
if (branchMappingNode.branchMappingOperator !== 'hash_lt') {
|
||||
throw new Error('Branch mapping operator of initial branch mapping must be "hash_lt"');
|
||||
}
|
||||
if (rolloutPercent == null) {
|
||||
throw new Error('Branch mapping is missing a "rolloutPercent"');
|
||||
}
|
||||
if (branchMapping.data[1].branchMappingLogic !== 'true') {
|
||||
throw new Error('Branch mapping logic for a the second branch of a rollout must be "true"');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Branch mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
return { branchMapping, isRollout, rolloutPercent };
|
||||
}
|
||||
|
||||
export function logChannelDetails(channel: UpdateChannelObject): void {
|
||||
const { branchMapping, isRollout, rolloutPercent } = getBranchMapping(channel.branchMapping);
|
||||
if (branchMapping.data.length > 2) {
|
||||
throw new Error('Branch Mapping data must have length less than or equal to 2.');
|
||||
}
|
||||
|
||||
const rolloutBranchIds = branchMapping.data.map(data => data.branchId);
|
||||
const branchDescription = channel.updateBranches.flatMap(branch => {
|
||||
const updateGroupWithBranchDescriptions = getUpdateGroupDescriptionsWithBranch(
|
||||
branch.updateGroups
|
||||
);
|
||||
|
||||
const isRolloutBranch = isRollout && rolloutBranchIds.includes(branch.id);
|
||||
const isBaseBranch = rolloutBranchIds.length > 0 && rolloutBranchIds[0] === branch.id;
|
||||
let rolloutPercentNumber: number | undefined = undefined;
|
||||
if (isRolloutBranch) {
|
||||
rolloutPercentNumber = isBaseBranch ? rolloutPercent! * 100 : (1 - rolloutPercent!) * 100;
|
||||
}
|
||||
|
||||
return updateGroupWithBranchDescriptions.map(
|
||||
({ branch, ...updateGroup }): FormattedBranchDescription => ({
|
||||
branch,
|
||||
branchRolloutPercentage: rolloutPercentNumber,
|
||||
update: updateGroup,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (branchDescription.length === 0) {
|
||||
Log.log(chalk.dim('No branches are pointed to this channel.'));
|
||||
} else {
|
||||
Log.log(
|
||||
branchDescription
|
||||
.map(description => formatBranch(description))
|
||||
.join(`\n\n${chalk.dim('———')}\n\n`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateBranchNullable(
|
||||
channel: UpdateChannelObject,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import { hasEmptyBranchMap, hasStandardBranchMap } from '../../channel/branch-mapping';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -18,6 +19,7 @@ import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../../log';
|
||||
import { isRollout } from '../../rollout/branch-mapping';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export async function updateChannelBranchMappingAsync(
|
||||
@@ -98,8 +100,10 @@ export default class ChannelEdit extends EasCommand {
|
||||
paginatedQueryOptions: { json, nonInteractive, offset: 0 },
|
||||
});
|
||||
|
||||
if (existingChannel.updateBranches.length > 1) {
|
||||
if (isRollout(existingChannel)) {
|
||||
throw new Error('There is a rollout in progress. Manage it with "channel:rollout" instead.');
|
||||
} else if (!hasStandardBranchMap(existingChannel) && !hasEmptyBranchMap(existingChannel)) {
|
||||
throw new Error('Only standard branch mappings can be edited with this command.');
|
||||
}
|
||||
|
||||
const branch = branchFlag
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,306 +1,49 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import {
|
||||
BranchMapping,
|
||||
BranchMappingObject,
|
||||
assertNodeObject,
|
||||
assertNumber,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import { getBranchMapping } from '../../channel/utils';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { UpdateBranch } from '../../graphql/generated';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../project/projectUtils';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { updateChannelBranchMappingAsync } from './edit';
|
||||
import { NonInteractiveOptions as CreateRolloutNonInteractiveOptions } from '../../rollout/actions/CreateRollout';
|
||||
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from '../../rollout/actions/EditRollout';
|
||||
import {
|
||||
EndOutcome,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from '../../rollout/actions/EndRollout';
|
||||
import { ManageRolloutActions } from '../../rollout/actions/ManageRollout';
|
||||
import { NonInteractiveRollout } from '../../rollout/actions/NonInteractiveRollout';
|
||||
import {
|
||||
MainMenuActions,
|
||||
RolloutActions,
|
||||
RolloutMainMenu,
|
||||
} from '../../rollout/actions/RolloutMainMenu';
|
||||
|
||||
async function promptForRolloutPercentAsync({
|
||||
promptMessage,
|
||||
}: {
|
||||
promptMessage: string;
|
||||
}): Promise<number> {
|
||||
const { name: rolloutPercent } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
format: value => {
|
||||
return parseInt(value, 10);
|
||||
},
|
||||
message: promptMessage,
|
||||
initial: 0,
|
||||
validate: (rolloutPercent: string): true | string => {
|
||||
const floatValue = parseFloat(rolloutPercent);
|
||||
return Number.isInteger(floatValue) && floatValue >= 0 && floatValue <= 100
|
||||
? true
|
||||
: 'The rollout percentage must be an integer between 0 and 100 inclusive.';
|
||||
},
|
||||
});
|
||||
return rolloutPercent;
|
||||
enum ActionRawFlagValue {
|
||||
CREATE = 'create',
|
||||
EDIT = 'edit',
|
||||
END = 'end',
|
||||
}
|
||||
type ChannelRolloutRawArgsAndFlags = {
|
||||
channel?: string;
|
||||
action?: ActionRawFlagValue;
|
||||
percent?: number;
|
||||
outcome?: EndOutcome;
|
||||
'non-interactive': boolean;
|
||||
branch?: string;
|
||||
'runtime-version'?: string;
|
||||
'private-key-path'?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
function getRolloutInfo(channel: UpdateChannelObject): {
|
||||
newBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
oldBranch: Pick<UpdateBranch, 'name' | 'id'>;
|
||||
currentPercent: number;
|
||||
} {
|
||||
const { branchMapping } = getBranchMapping(channel.branchMapping);
|
||||
const [newBranchId, oldBranchId] = branchMapping.data.map(d => d.branchId);
|
||||
const newBranch = channel.updateBranches.filter(branch => branch.id === newBranchId)[0];
|
||||
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
|
||||
|
||||
if (!newBranch || !oldBranch) {
|
||||
throw new Error(`Branch mapping rollout is missing a branch for channel "${channel.name}".`);
|
||||
}
|
||||
|
||||
const branchMappingNode = branchMapping.data[0]?.branchMappingLogic;
|
||||
assertNodeObject(branchMappingNode);
|
||||
assertNumber(branchMappingNode.operand);
|
||||
const currentPercent = 100 * branchMappingNode.operand;
|
||||
return { newBranch, oldBranch, currentPercent };
|
||||
}
|
||||
|
||||
async function startRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
branchName,
|
||||
percent,
|
||||
projectId,
|
||||
displayName,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
nonInteractive,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName: string;
|
||||
percent?: number;
|
||||
projectId: string;
|
||||
displayName: string;
|
||||
currentBranchMapping: BranchMapping;
|
||||
channel: UpdateChannelObject;
|
||||
nonInteractive: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: branchName,
|
||||
});
|
||||
|
||||
const oldBranchId = currentBranchMapping.data[0].branchId;
|
||||
if (branch.id === oldBranchId) {
|
||||
throw new Error(
|
||||
`channel "${channelName}" is already pointing at branch "${branchName}". Rollouts must be done with distinct branches.`
|
||||
);
|
||||
}
|
||||
|
||||
if (percent == null) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'You must specify a percent with the --percent flag when initiating a rollout with the --non-interactive flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `What percent of users should be directed to the branch "${branchName}"?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: branch.id,
|
||||
branchMappingLogic: {
|
||||
operand: percent / 100,
|
||||
clientKey: 'rolloutToken',
|
||||
branchMappingOperator: 'hash_lt',
|
||||
},
|
||||
},
|
||||
currentBranchMapping.data[0],
|
||||
],
|
||||
};
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const oldBranch = channel.updateBranches.filter(branch => branch.id === oldBranchId)[0];
|
||||
if (!oldBranch) {
|
||||
throw new Error(
|
||||
`Branch mapping is missing its only branch for channel "${channelName}" on app "${displayName}"`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessage = `Started a rollout of branch ${chalk.bold(branchName)} on channel ${chalk.bold(
|
||||
channelName!
|
||||
)}! ${chalk.bold(percent)}% of users will be directed to branch ${chalk.bold(
|
||||
branchName
|
||||
)}, ${chalk.bold(100 - percent)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function editRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
percent,
|
||||
nonInteractive,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
}: {
|
||||
channelName?: string;
|
||||
percent?: number;
|
||||
nonInteractive: boolean;
|
||||
currentBranchMapping: BranchMapping;
|
||||
channel: UpdateChannelObject;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
|
||||
|
||||
if (percent == null) {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'A rollout is already in progress. If you wish to modify it you must use specify the new rollout percentage with the --percent flag.'
|
||||
);
|
||||
}
|
||||
const promptMessage = `Currently ${currentPercent}% of all users are routed to branch ${
|
||||
newBranch.name
|
||||
} and ${100 - currentPercent}% of all users are routed to branch ${
|
||||
oldBranch.name
|
||||
}. What percent of users should be directed to the branch ${newBranch.name}?`;
|
||||
percent = await promptForRolloutPercentAsync({ promptMessage });
|
||||
}
|
||||
|
||||
const newBranchMapping = { ...currentBranchMapping };
|
||||
(newBranchMapping.data[0].branchMappingLogic as BranchMappingObject).operand = percent / 100;
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
|
||||
const logMessage = `Rollout of branch ${chalk.bold(newBranch.name)} on channel ${chalk.bold(
|
||||
channelName!
|
||||
)} updated from ${chalk.bold(currentPercent)}% to ${chalk.bold(percent)}%. ${chalk.bold(
|
||||
percent
|
||||
)}% of users will be directed to branch ${chalk.bold(newBranch.name)}, ${chalk.bold(
|
||||
100 - percent
|
||||
)}% to branch ${chalk.bold(oldBranch.name)}.`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
|
||||
async function endRolloutAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
channelName,
|
||||
branchName,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
channel,
|
||||
}: {
|
||||
channelName?: string;
|
||||
branchName?: string;
|
||||
nonInteractive: boolean;
|
||||
projectId: string;
|
||||
channel: UpdateChannelObject;
|
||||
}
|
||||
): Promise<{
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
}> {
|
||||
// end rollout
|
||||
const { newBranch, oldBranch, currentPercent } = getRolloutInfo(channel);
|
||||
|
||||
let endOnNewBranch;
|
||||
if (branchName) {
|
||||
const branch = await BranchQuery.getBranchByNameAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: branchName,
|
||||
});
|
||||
|
||||
switch (branch.id) {
|
||||
case newBranch.id:
|
||||
endOnNewBranch = true;
|
||||
break;
|
||||
case oldBranch.id:
|
||||
endOnNewBranch = false;
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`The branch "${branchName}" specified by --branch must be one of the branches involved in the rollout: "${newBranch.name}" or "${oldBranch.name}".`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'Branch name must be specified with the --branch flag when both the --end and --non-interactive flag are true.'
|
||||
);
|
||||
}
|
||||
endOnNewBranch = await selectAsync<boolean>(
|
||||
'Ending the rollout will send all traffic to a single branch. Which one should that be?',
|
||||
[
|
||||
{
|
||||
title: `${newBranch.name} ${chalk.grey(`- current percent: ${currentPercent}%`)}`,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: `${oldBranch.name} ${chalk.grey(`- current percent: ${100 - currentPercent}%`)}`,
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
if (endOnNewBranch == null) {
|
||||
throw new Error('Branch to end on is undefined.');
|
||||
}
|
||||
|
||||
const newBranchMapping = {
|
||||
version: 0,
|
||||
data: [
|
||||
{
|
||||
branchId: endOnNewBranch ? newBranch.id : oldBranch.id,
|
||||
branchMappingLogic: 'true',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(newBranchMapping),
|
||||
});
|
||||
const logMessage = `Rollout on channel ${chalk.bold(
|
||||
channelName
|
||||
)} ended. All traffic is now sent to branch ${chalk.bold(
|
||||
endOnNewBranch ? newBranch.name : oldBranch.name
|
||||
)}`;
|
||||
|
||||
return { newChannelInfo, logMessage };
|
||||
}
|
||||
type ChannelRolloutArgsAndFlags = {
|
||||
channelName?: string;
|
||||
action?: RolloutActions;
|
||||
nonInteractive: boolean;
|
||||
json?: boolean;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>;
|
||||
|
||||
export default class ChannelRollout extends EasCommand {
|
||||
static override description = 'Roll a new branch out on a channel incrementally.';
|
||||
@@ -313,17 +56,68 @@ export default class ChannelRollout extends EasCommand {
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
branch: Flags.string({
|
||||
description: 'branch to rollout',
|
||||
action: Flags.enum({
|
||||
description: 'Rollout action to perform',
|
||||
options: Object.values(ActionRawFlagValue),
|
||||
required: false,
|
||||
relationships: [
|
||||
{
|
||||
type: 'all',
|
||||
flags: [
|
||||
{
|
||||
name: 'percent',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags => {
|
||||
return (
|
||||
!!flags['non-interactive'] &&
|
||||
(flags['action'] === ActionRawFlagValue.CREATE ||
|
||||
flags['action'] === ActionRawFlagValue.EDIT)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'outcome',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.END,
|
||||
},
|
||||
{
|
||||
name: 'branch',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
|
||||
},
|
||||
{
|
||||
name: 'runtime-version',
|
||||
// eslint-disable-next-line async-protect/async-suffix
|
||||
when: async flags =>
|
||||
!!flags['non-interactive'] && flags['action'] === ActionRawFlagValue.CREATE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
percent: Flags.integer({
|
||||
description: 'percent of users to send to the new branch',
|
||||
description:
|
||||
'Percent of users to send to the new branch. Use with --action=edit or --action=create',
|
||||
required: false,
|
||||
}),
|
||||
end: Flags.boolean({
|
||||
description: 'end the rollout',
|
||||
default: false,
|
||||
outcome: Flags.enum({
|
||||
description: 'End outcome of rollout. Use with --action=end',
|
||||
options: Object.values(EndOutcome),
|
||||
required: false,
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch to roll out. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Runtime version to target. Use with --action=create',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -334,164 +128,63 @@ export default class ChannelRollout extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(ChannelRollout);
|
||||
const argsAndFlags = this.sanitizeArgsAndFlags({ ...flags, ...args });
|
||||
const {
|
||||
args: { channel: channelNameArg },
|
||||
flags: {
|
||||
json: jsonFlag,
|
||||
end: endFlag,
|
||||
branch: branchName,
|
||||
percent,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(ChannelRollout);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
privateProjectConfig: { exp, projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelRollout, {
|
||||
nonInteractive,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
if (argsAndFlags.json) {
|
||||
// TODO(quin): implement json output
|
||||
throw new Error('Developer Preview doesnt support JSON output yet');
|
||||
}
|
||||
|
||||
const projectDisplayName = await getDisplayNameForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let channelName: string = channelNameArg;
|
||||
if (!channelName) {
|
||||
const { name } = await selectChannelOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
selectionPromptTitle: 'Select a channel on which to perform a rollout',
|
||||
paginatedQueryOptions: {
|
||||
json: jsonFlag,
|
||||
nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
channelName = name;
|
||||
}
|
||||
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
});
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`Could not find a channel named "${channelName}". Check which channels exist on this project with ${chalk.bold(
|
||||
'eas channel:list'
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { branchMapping: currentBranchMapping, isRollout } = getBranchMapping(
|
||||
channel.branchMapping
|
||||
);
|
||||
|
||||
if (currentBranchMapping.data.length === 0) {
|
||||
throw new Error('The channel is not pointing at any branches.');
|
||||
}
|
||||
if (currentBranchMapping.data.length > 2) {
|
||||
throw new Error('"channel:rollout" cannot handle branch mappings with more than 2 branches.');
|
||||
}
|
||||
// This combination doesn't make sense. Throw an error explaining the options.
|
||||
if (isRollout && branchName && !endFlag) {
|
||||
throw new Error(
|
||||
`There is a rollout in progress. You can only either edit the rollout percent or 'end' it.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This if/else block has three branches:
|
||||
* 1. The branch mapping is not a rollout, i.e. it is pointing to a single branch.
|
||||
* 2. The branch mapping is a rollout.
|
||||
* a. increase/decrease the rollout percentage.
|
||||
* b. end the rollout.
|
||||
*/
|
||||
let rolloutMutationResult: {
|
||||
newChannelInfo: {
|
||||
id: string;
|
||||
name: string;
|
||||
branchMapping: string;
|
||||
};
|
||||
logMessage: string;
|
||||
const app = { projectId, exp };
|
||||
const ctx = {
|
||||
projectId,
|
||||
nonInteractive: argsAndFlags.nonInteractive,
|
||||
graphqlClient,
|
||||
app,
|
||||
};
|
||||
if (!isRollout) {
|
||||
rolloutMutationResult = await startRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
branchName:
|
||||
branchName ??
|
||||
(await promptForBranchNameAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
channelName,
|
||||
nonInteractive,
|
||||
json: jsonFlag,
|
||||
})),
|
||||
percent,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
displayName: projectDisplayName,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
});
|
||||
} else if (endFlag) {
|
||||
rolloutMutationResult = await endRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
branchName,
|
||||
nonInteractive,
|
||||
projectId,
|
||||
channel,
|
||||
});
|
||||
if (argsAndFlags.nonInteractive) {
|
||||
await new NonInteractiveRollout(argsAndFlags).runAsync(ctx);
|
||||
} else {
|
||||
rolloutMutationResult = await editRolloutAsync(graphqlClient, {
|
||||
channelName,
|
||||
percent,
|
||||
nonInteractive,
|
||||
currentBranchMapping,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
if (!rolloutMutationResult) {
|
||||
throw new Error('rollout result is empty');
|
||||
}
|
||||
const { newChannelInfo, logMessage } = rolloutMutationResult;
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(newChannelInfo);
|
||||
} else {
|
||||
Log.withTick(logMessage);
|
||||
Log.addNewLineIfNone();
|
||||
Log.warn(
|
||||
`✨ This command is in Developer Preview and has not been released to production yet`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
await new RolloutMainMenu(argsAndFlags).runAsync(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptForBranchNameAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
channelName,
|
||||
nonInteractive,
|
||||
json,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
channelName: string;
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
}): Promise<string> {
|
||||
if (nonInteractive) {
|
||||
throw new Error('Must supply branch flag in non-interactive mode');
|
||||
private getAction(action: ActionRawFlagValue): RolloutActions {
|
||||
switch (action) {
|
||||
case ActionRawFlagValue.CREATE:
|
||||
return MainMenuActions.CREATE_NEW;
|
||||
case ActionRawFlagValue.EDIT:
|
||||
return ManageRolloutActions.EDIT;
|
||||
case ActionRawFlagValue.END:
|
||||
return ManageRolloutActions.END;
|
||||
}
|
||||
}
|
||||
|
||||
const { name: branchName } = await selectBranchOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
promptTitle: `Which branch would you like roll out on ${channelName}?`,
|
||||
displayTextForListItem: updateBranch => ({
|
||||
title: updateBranch.name,
|
||||
}),
|
||||
// discard limit and offset because this query is not their intended target
|
||||
paginatedQueryOptions: {
|
||||
json,
|
||||
nonInteractive,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return branchName;
|
||||
private sanitizeArgsAndFlags(
|
||||
rawFlags: ChannelRolloutRawArgsAndFlags
|
||||
): ChannelRolloutArgsAndFlags {
|
||||
const action = rawFlags.action;
|
||||
return {
|
||||
channelName: rawFlags.channel,
|
||||
percent: rawFlags.percent,
|
||||
outcome: rawFlags.outcome,
|
||||
branchNameToRollout: rawFlags.branch,
|
||||
runtimeVersion: rawFlags['runtime-version'],
|
||||
privateKeyPath: rawFlags['private-key-path'] ?? null,
|
||||
action: action ? this.getAction(action) : undefined,
|
||||
nonInteractive: rawFlags['non-interactive'],
|
||||
json: rawFlags.json,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppJSONConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { vol } from 'memfs';
|
||||
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { DirectoryJSON, vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
|
||||
@@ -8,12 +8,17 @@ import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/cr
|
||||
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { CodeSigningInfo, UpdateFragment } from '../../../graphql/generated';
|
||||
import { UpdateFragment } from '../../../graphql/generated';
|
||||
import { PublishMutation } from '../../../graphql/mutations/PublishMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import { getBranchNameFromChannelNameAsync } from '../../../update/getBranchNameFromChannelNameAsync';
|
||||
import { selectUpdateGroupOnBranchAsync } from '../../../update/queries';
|
||||
import {
|
||||
getCodeSigningInfoAsync,
|
||||
getManifestBodyAsync,
|
||||
signBody,
|
||||
} from '../../../utils/code-signing';
|
||||
import UpdateRepublish from '../republish';
|
||||
|
||||
const projectRoot = '/test-project';
|
||||
@@ -33,12 +38,6 @@ const updateStub: UpdateFragment = {
|
||||
createdAt: '2022-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
const codeSigningStub: CodeSigningInfo = {
|
||||
keyid: 'keyid',
|
||||
alg: 'alg',
|
||||
sig: 'sig',
|
||||
};
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
@@ -52,6 +51,8 @@ jest.mock('../../../ora', () => ({
|
||||
start: () => ({ succeed: () => {}, fail: () => {} }),
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/code-signing');
|
||||
jest.mock('../../../fetch');
|
||||
|
||||
describe(UpdateRepublish.name, () => {
|
||||
afterEach(() => vol.reset());
|
||||
@@ -156,18 +157,45 @@ describe(UpdateRepublish.name, () => {
|
||||
});
|
||||
|
||||
it('re-creates update using codesigning with --group and --message', async () => {
|
||||
const flags = ['--group=1234', '--message=test-republish'];
|
||||
const flags = [
|
||||
'--group=1234',
|
||||
'--message=test-republish',
|
||||
`--private-key-path=./keys/test-private-key.pem`,
|
||||
];
|
||||
const codeSigning = {
|
||||
alg: 'alg',
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
keyid: 'keyid',
|
||||
sig: 'sig',
|
||||
};
|
||||
|
||||
mockTestProject();
|
||||
mockTestProject({
|
||||
extraManifest: {
|
||||
updates: {
|
||||
codeSigningCertificate: './keys/test-certificate.pem',
|
||||
codeSigningMetadata: {
|
||||
alg: 'rsa-v1_5-sha256',
|
||||
keyid: 'keyid',
|
||||
},
|
||||
},
|
||||
},
|
||||
extraVol: {
|
||||
'./keys/test-private-key.pem': 'testpemprivate',
|
||||
'./keys/test-certificate.pem': 'testpemcertificate',
|
||||
},
|
||||
});
|
||||
|
||||
jest.mocked(getCodeSigningInfoAsync).mockResolvedValue({
|
||||
privateKey: '' as any,
|
||||
certificate: '' as any,
|
||||
codeSigningMetadata: { alg: 'rsa-v1_5-sha256', keyid: 'keyid' },
|
||||
});
|
||||
jest.mocked(getManifestBodyAsync).mockResolvedValue('test');
|
||||
jest.mocked(signBody).mockReturnValue('sig');
|
||||
|
||||
// Mock queries to retrieve the update and code signing info
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigningStub }]);
|
||||
.mockResolvedValue([{ ...updateStub, codeSigningInfo: codeSigning }]);
|
||||
// Mock mutations to store the new update
|
||||
jest.mocked(PublishMutation.publishUpdateGroupAsync).mockResolvedValue([
|
||||
{
|
||||
@@ -287,8 +315,12 @@ describe(UpdateRepublish.name, () => {
|
||||
/** Create a new in-memory project, based on src/commands/project/__tests__/init.test.ts */
|
||||
function mockTestProject({
|
||||
configuredProjectId = '1234',
|
||||
extraManifest,
|
||||
extraVol,
|
||||
}: {
|
||||
configuredProjectId?: string;
|
||||
extraManifest?: Partial<ExpoConfig>;
|
||||
extraVol?: DirectoryJSON;
|
||||
} = {}): void {
|
||||
const packageJSON: PackageJSONConfig = {
|
||||
name: 'testing123',
|
||||
@@ -309,6 +341,7 @@ function mockTestProject({
|
||||
projectId: configuredProjectId,
|
||||
},
|
||||
},
|
||||
...extraManifest,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -316,6 +349,7 @@ function mockTestProject({
|
||||
{
|
||||
'package.json': JSON.stringify(packageJSON),
|
||||
'app.json': JSON.stringify(appJSON),
|
||||
...extraVol,
|
||||
},
|
||||
projectRoot
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getBranchNameFromChannelNameAsync } from '../../update/getBranchNameFro
|
||||
import { selectUpdateGroupOnBranchAsync } from '../../update/queries';
|
||||
import { UpdateToRepublish, republishAsync } from '../../update/republish';
|
||||
import { truncateString as truncateUpdateMessage } from '../../update/utils';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
const defaultRepublishPlatforms: Platform[] = ['android', 'ios'];
|
||||
@@ -22,6 +23,7 @@ type UpdateRepublishRawFlags = {
|
||||
group?: string;
|
||||
message?: string;
|
||||
platform: string;
|
||||
'private-key-path'?: string;
|
||||
'non-interactive': boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -32,6 +34,7 @@ type UpdateRepublishFlags = {
|
||||
groupId?: string;
|
||||
updateMessage?: string;
|
||||
platform: Platform[];
|
||||
privateKeyPath?: string;
|
||||
nonInteractive: boolean;
|
||||
json: boolean;
|
||||
};
|
||||
@@ -41,11 +44,11 @@ export default class UpdateRepublish extends EasCommand {
|
||||
|
||||
static override flags = {
|
||||
channel: Flags.string({
|
||||
description: 'Channel name to select an update to republish from',
|
||||
description: 'Channel name to select an update group to republish from',
|
||||
exclusive: ['branch', 'group'],
|
||||
}),
|
||||
branch: Flags.string({
|
||||
description: 'Branch name to select an update to republish from',
|
||||
description: 'Branch name to select an update group to republish from',
|
||||
exclusive: ['channel', 'group'],
|
||||
}),
|
||||
group: Flags.string({
|
||||
@@ -54,7 +57,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
}),
|
||||
message: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Short message describing the republished update',
|
||||
description: 'Short message describing the republished update group',
|
||||
required: false,
|
||||
}),
|
||||
platform: Flags.enum({
|
||||
@@ -63,6 +66,10 @@ export default class UpdateRepublish extends EasCommand {
|
||||
default: 'all',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory.`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
@@ -86,6 +93,8 @@ export default class UpdateRepublish extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(exp, flags.privateKeyPath);
|
||||
|
||||
const existingUpdates = await getOrAskUpdatesAsync(graphqlClient, projectId, flags);
|
||||
const updatesToPublish = existingUpdates.filter(update =>
|
||||
flags.platform.includes(update.platform as Platform)
|
||||
@@ -105,15 +114,15 @@ export default class UpdateRepublish extends EasCommand {
|
||||
}
|
||||
|
||||
if (rawFlags.platform === 'all') {
|
||||
Log.withTick(`The republished update will appear only on: ${rawFlags.platform}`);
|
||||
Log.withTick(`The republished update group will appear only on: ${rawFlags.platform}`);
|
||||
} else {
|
||||
const platformsFromUpdates = updatesToPublish.map(update => update.platform);
|
||||
if (platformsFromUpdates.length < defaultRepublishPlatforms.length) {
|
||||
Log.warn(`You are republishing an update that wasn't published for all platforms.`);
|
||||
Log.warn(`You are republishing an update group that wasn't published for all platforms.`);
|
||||
}
|
||||
|
||||
Log.withTick(
|
||||
`The republished update will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
|
||||
`The republished update group will appear on the same platforms it was originally published on: ${platformsFromUpdates.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
@@ -127,15 +136,17 @@ export default class UpdateRepublish extends EasCommand {
|
||||
updatesToPublish,
|
||||
targetBranch: { branchId: arbitraryUpdate.branchId, branchName: arbitraryUpdate.branchName },
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json: flags.json,
|
||||
});
|
||||
}
|
||||
|
||||
sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
|
||||
private sanitizeFlags(rawFlags: UpdateRepublishRawFlags): UpdateRepublishFlags {
|
||||
const branchName = rawFlags.branch;
|
||||
const channelName = rawFlags.channel;
|
||||
const groupId = rawFlags.group;
|
||||
const nonInteractive = rawFlags['non-interactive'];
|
||||
const privateKeyPath = rawFlags['private-key-path'];
|
||||
|
||||
if (nonInteractive && !groupId) {
|
||||
throw new Error('Only --group can be used in non-interactive mode');
|
||||
@@ -153,6 +164,7 @@ export default class UpdateRepublish extends EasCommand {
|
||||
groupId,
|
||||
platform,
|
||||
updateMessage: rawFlags.message,
|
||||
privateKeyPath,
|
||||
json: rawFlags.json ?? false,
|
||||
nonInteractive,
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const runCurrentMachineMethodAsync = jest.fn();
|
||||
@@ -5,6 +5,7 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
|
||||
import AppStoreApi from '../../../../credentials/ios/appstore/AppStoreApi';
|
||||
import { AccountFragment, Role } from '../../../../graphql/generated';
|
||||
import DeviceCreateAction, { RegistrationMethod } from '../action';
|
||||
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
|
||||
import { runDeveloperPortalMethodAsync } from '../developerPortalMethod';
|
||||
import { runInputMethodAsync } from '../inputMethod';
|
||||
import { runRegistrationUrlMethodAsync } from '../registrationUrlMethod';
|
||||
@@ -13,6 +14,7 @@ jest.mock('prompts');
|
||||
jest.mock('../developerPortalMethod');
|
||||
jest.mock('../inputMethod');
|
||||
jest.mock('../registrationUrlMethod');
|
||||
jest.mock('../currentMachineMethod');
|
||||
|
||||
beforeEach(() => {
|
||||
const promptsMock = jest.mocked(prompts);
|
||||
@@ -22,6 +24,7 @@ beforeEach(() => {
|
||||
});
|
||||
jest.mocked(runRegistrationUrlMethodAsync).mockClear();
|
||||
jest.mocked(runInputMethodAsync).mockClear();
|
||||
jest.mocked(runCurrentMachineMethodAsync).mockClear();
|
||||
});
|
||||
|
||||
describe(DeviceCreateAction, () => {
|
||||
@@ -118,5 +121,36 @@ describe(DeviceCreateAction, () => {
|
||||
|
||||
expect(runDeveloperPortalMethodAsync).toBeCalled();
|
||||
});
|
||||
|
||||
it('calls runCurrentMachineMethodAsync if user chooses the current machine method', async () => {
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({
|
||||
method: RegistrationMethod.CURRENT_MACHINE,
|
||||
}));
|
||||
const appStoreApiMock = mock<AppStoreApi>();
|
||||
const appStoreApi = instance(appStoreApiMock);
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
|
||||
const account: AccountFragment = {
|
||||
id: 'account_id',
|
||||
name: 'foobar',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'user_id',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const appleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123Y',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
const action = new DeviceCreateAction(graphqlClient, appStoreApi, account, appleTeam);
|
||||
await action.runAsync();
|
||||
|
||||
expect(runCurrentMachineMethodAsync).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import os from 'os';
|
||||
import prompts from 'prompts';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleTeam } from '../../../../graphql/generated';
|
||||
import { DeviceCreateError } from '../../../utils/errors';
|
||||
import { runCurrentMachineMethodAsync } from '../currentMachineMethod';
|
||||
|
||||
jest.mock('../../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation');
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('os', () => {
|
||||
return {
|
||||
...jest.requireActual('os'),
|
||||
cpus: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/spawn-async', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
...jest.requireActual('@expo/spawn-async'),
|
||||
default: jest.fn(async () => {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
SPHardwareDataType: [
|
||||
{
|
||||
provisioning_UDID: 'fake_udid',
|
||||
machine_name: 'fake_machine_name',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let actualPlatform: string;
|
||||
beforeAll(() => {
|
||||
actualPlatform = process.platform;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(prompts).mockReset();
|
||||
jest.mocked(prompts).mockImplementation(() => {
|
||||
throw new Error(`unhandled prompts call - this shouldn't happen - fix tests!`);
|
||||
});
|
||||
jest.mocked(AppleDeviceMutation.createAppleDeviceAsync).mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', { value: actualPlatform });
|
||||
});
|
||||
|
||||
describe(runCurrentMachineMethodAsync, () => {
|
||||
it('allows registering the default Mac device class', async () => {
|
||||
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proceeds with registration if user approves, only once', async () => {
|
||||
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exits registration if user cancels', async () => {
|
||||
jest.mocked(os.cpus).mockImplementation(() => [{ model: 'Apple M1' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('exits registration if current Mac machine is not of Apple Silicon type', async () => {
|
||||
jest
|
||||
.mocked(os.cpus)
|
||||
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
mockDeviceData('my old Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
|
||||
).rejects.toThrowError(
|
||||
new DeviceCreateError(
|
||||
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
|
||||
)
|
||||
);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('exits registration if current machine is not Mac', async () => {
|
||||
jest
|
||||
.mocked(os.cpus)
|
||||
.mockImplementation(() => [{ model: 'Intel(R) Core(TM) i5' } as os.CpuInfo]);
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
mockDeviceData('my Windows PC');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('my other Mac');
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const accountId = 'account-id';
|
||||
// @ts-expect-error appleTeam is missing properties of AppleTeam GraphQL type
|
||||
const appleTeam: AppleTeam = {
|
||||
id: 'apple-team-id',
|
||||
appleTeamIdentifier: 'ABC123XY',
|
||||
appleTeamName: 'John Doe (Individual)',
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCurrentMachineMethodAsync(graphqlClient, accountId, appleTeam)
|
||||
).rejects.toThrowError(
|
||||
new DeviceCreateError(
|
||||
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
|
||||
)
|
||||
);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
function mockDeviceData(name: string): void {
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ name }));
|
||||
}
|
||||
@@ -21,6 +21,8 @@ describe(runInputMethodAsync, () => {
|
||||
it('should allow for multiple device registration', async () => {
|
||||
mockDeviceData('00001111-001122334455662E', 'my iPhone', AppleDeviceClass.Iphone);
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('00001111-001122334455662F', 'my Mac', AppleDeviceClass.Mac);
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: true }));
|
||||
mockDeviceData('b12cba9856d89c932ab7a4b813c4d932534e1679', 'my iPad', AppleDeviceClass.Ipad);
|
||||
jest.mocked(prompts).mockImplementationOnce(async () => ({ value: false }));
|
||||
|
||||
@@ -35,7 +37,7 @@ describe(runInputMethodAsync, () => {
|
||||
|
||||
await runInputMethodAsync(graphqlClient, accountId, appleTeam);
|
||||
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(2);
|
||||
expect(AppleDeviceMutation.createAppleDeviceAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import AppStoreApi from '../../../credentials/ios/appstore/AppStoreApi';
|
||||
import { AccountFragment, AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { runCurrentMachineMethodAsync } from './currentMachineMethod';
|
||||
import { runDeveloperPortalMethodAsync } from './developerPortalMethod';
|
||||
import { runInputMethodAsync } from './inputMethod';
|
||||
import { runRegistrationUrlMethodAsync } from './registrationUrlMethod';
|
||||
@@ -13,6 +14,7 @@ export enum RegistrationMethod {
|
||||
WEBSITE,
|
||||
INPUT,
|
||||
DEVELOPER_PORTAL,
|
||||
CURRENT_MACHINE,
|
||||
EXIT,
|
||||
}
|
||||
|
||||
@@ -37,6 +39,8 @@ export default class DeviceCreateAction {
|
||||
);
|
||||
} else if (method === RegistrationMethod.INPUT) {
|
||||
await runInputMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
|
||||
} else if (method === RegistrationMethod.CURRENT_MACHINE) {
|
||||
await runCurrentMachineMethodAsync(this.graphqlClient, this.account.id, this.appleTeam);
|
||||
} else if (method === RegistrationMethod.EXIT) {
|
||||
Log.log('Bye!');
|
||||
process.exit(0);
|
||||
@@ -66,6 +70,12 @@ export default class DeviceCreateAction {
|
||||
title: `${chalk.bold('Input')} - allows you to type in UDIDs (advanced option)`,
|
||||
value: RegistrationMethod.INPUT,
|
||||
},
|
||||
{
|
||||
title: `${chalk.bold(
|
||||
'Current Machine'
|
||||
)} - automatically sets the provisioning UDID of the current Apple Silicon machine`,
|
||||
value: RegistrationMethod.CURRENT_MACHINE,
|
||||
},
|
||||
{ title: chalk.bold('Exit'), value: RegistrationMethod.EXIT },
|
||||
],
|
||||
validate: (val: RegistrationMethod) => Object.values(RegistrationMethod).includes(val),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import chalk from 'chalk';
|
||||
import os from 'os';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { DeviceCreateError } from '../../utils/errors';
|
||||
import { DeviceData, printDeviceData, promptForNameAsync } from './utils';
|
||||
|
||||
export async function runCurrentMachineMethodAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
|
||||
): Promise<void> {
|
||||
Log.newLine();
|
||||
Log.log(chalk.white('Checking if current machine is an Apple Silicon one.'));
|
||||
if (!isMachineAppleSilicon()) {
|
||||
throw new DeviceCreateError(
|
||||
"Current machine is not of Apple Silicon type - provisioning UDID can't be added automatically."
|
||||
);
|
||||
}
|
||||
Log.log(chalk.green('Check successful.'));
|
||||
|
||||
await collectDataAndRegisterDeviceAsync(graphqlClient, { accountId, appleTeam });
|
||||
}
|
||||
|
||||
function isMachineAppleSilicon(): boolean {
|
||||
return os.cpus()[0].model.includes('Apple M') && process.platform === 'darwin';
|
||||
}
|
||||
|
||||
async function collectDataAndRegisterDeviceAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
accountId,
|
||||
appleTeam,
|
||||
}: {
|
||||
accountId: string;
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>;
|
||||
}
|
||||
): Promise<void> {
|
||||
const deviceData = await collectDeviceDataAsync(appleTeam);
|
||||
if (!deviceData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { udid, deviceClass, name } = deviceData;
|
||||
const spinner = ora(`Registering Apple device on Expo`).start();
|
||||
try {
|
||||
await AppleDeviceMutation.createAppleDeviceAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appleTeamId: appleTeam.id,
|
||||
identifier: udid,
|
||||
name,
|
||||
deviceClass: deviceClass ?? undefined,
|
||||
},
|
||||
accountId
|
||||
);
|
||||
} catch (err) {
|
||||
spinner.fail();
|
||||
throw err;
|
||||
}
|
||||
spinner.succeed();
|
||||
}
|
||||
|
||||
async function collectDeviceDataAsync(
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>,
|
||||
initialValues: Partial<DeviceData> = {}
|
||||
): Promise<DeviceData | null> {
|
||||
Log.log(chalk.white('Fetching the provisioning UDID.'));
|
||||
const [udid, defaultMachineName] = await fetchCurrentMachineUDIDAsync();
|
||||
Log.log(chalk.green(`Fetched the provisioning UDID - ${udid}`));
|
||||
const name = await promptForNameAsync(defaultMachineName ?? initialValues.name);
|
||||
const deviceClass = AppleDeviceClass.Mac;
|
||||
const deviceData: DeviceData = {
|
||||
udid,
|
||||
name,
|
||||
deviceClass,
|
||||
};
|
||||
printDeviceData(deviceData, appleTeam);
|
||||
|
||||
const registrationConfirmed = await confirmAsync({
|
||||
message: 'Is this what you want to register?',
|
||||
});
|
||||
if (!registrationConfirmed) {
|
||||
Log.log('No worries, just try again.');
|
||||
Log.newLine();
|
||||
return null;
|
||||
} else {
|
||||
return deviceData;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentMachineUDIDAsync(): Promise<[string, string?]> {
|
||||
try {
|
||||
const profilerData = (
|
||||
await spawnAsync('system_profiler', ['-json', 'SPHardwareDataType'])
|
||||
).stdout.trim();
|
||||
if (!profilerData) {
|
||||
const message = 'Failed to fetch the provisioning UDID from system profiler';
|
||||
Log.error(message);
|
||||
throw new DeviceCreateError(message);
|
||||
}
|
||||
|
||||
const profilerDataJSON = JSON.parse(profilerData);
|
||||
const provisioningUDID = profilerDataJSON?.SPHardwareDataType[0]?.provisioning_UDID;
|
||||
if (!provisioningUDID) {
|
||||
const message = 'System profiler data did not contain the provisioning UDID';
|
||||
Log.error(message);
|
||||
throw new DeviceCreateError(message);
|
||||
}
|
||||
|
||||
const defaultMachineName = profilerDataJSON?.SPHardwareDataType[0]?.machine_name;
|
||||
return [provisioningUDID, defaultMachineName];
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof DeviceCreateError)) {
|
||||
const message = `Failed to fetch the provisioning UDID of the current machine - ${err.message}`;
|
||||
Log.error(message);
|
||||
throw new DeviceCreateError(message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const DEVICE_IMPORT_CHUNK_SIZE = 10;
|
||||
const DEVICE_CLASS_TO_GRAPHQL_TYPE: Partial<Record<DeviceClass, AppleDeviceClass>> = {
|
||||
[DeviceClass.IPAD]: AppleDeviceClass.Ipad,
|
||||
[DeviceClass.IPHONE]: AppleDeviceClass.Iphone,
|
||||
[DeviceClass.MAC]: AppleDeviceClass.Mac,
|
||||
};
|
||||
|
||||
export async function runDeveloperPortalMethodAsync(
|
||||
@@ -105,7 +106,7 @@ async function findUnregisteredPortalDevicesAsync(
|
||||
return portalDevices.filter(
|
||||
portalDevice =>
|
||||
!(portalDevice.attributes.udid in expoRegisteredDevicesByUdid) &&
|
||||
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV].includes(
|
||||
[DeviceClass.IPAD, DeviceClass.IPHONE, DeviceClass.APPLE_TV, DeviceClass.MAC].includes(
|
||||
portalDevice.attributes.deviceClass
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,18 +2,17 @@ import chalk from 'chalk';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppleDeviceMutation } from '../../../credentials/ios/api/graphql/mutations/AppleDeviceMutation';
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import { AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync, promptAsync } from '../../../prompts';
|
||||
import { isValidUDID, normalizeUDID } from '../../udids';
|
||||
import { formatNewDevice } from '../../utils/formatDevice';
|
||||
|
||||
interface DeviceData {
|
||||
udid: string;
|
||||
name?: string;
|
||||
deviceClass: AppleDeviceClass | null;
|
||||
}
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import {
|
||||
DeviceData,
|
||||
printDeviceData,
|
||||
promptForDeviceClassAsync,
|
||||
promptForNameAsync,
|
||||
promptForUDIDAsync,
|
||||
} from './utils';
|
||||
|
||||
export async function runInputMethodAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -78,16 +77,7 @@ async function collectDeviceDataAsync(
|
||||
deviceClass,
|
||||
};
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`We are going to register the following device in our database.
|
||||
This device will ${chalk.bold(
|
||||
'not'
|
||||
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
|
||||
);
|
||||
Log.newLine();
|
||||
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
|
||||
Log.newLine();
|
||||
printDeviceData(deviceData, appleTeam);
|
||||
|
||||
const registrationConfirmed = await confirmAsync({
|
||||
message: 'Is this what you want to register?',
|
||||
@@ -100,56 +90,3 @@ This device will ${chalk.bold(
|
||||
return deviceData;
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptForUDIDAsync(initial?: string): Promise<string> {
|
||||
const { udid } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'udid',
|
||||
message: 'UDID:',
|
||||
initial,
|
||||
validate: (rawVal: string) => {
|
||||
const val = normalizeUDID(rawVal);
|
||||
if (!val || val === '') {
|
||||
return 'UDID cannot be empty';
|
||||
} else if (val.length !== 25 && val.length !== 40) {
|
||||
return 'UDID should be a 25 or 40-character string';
|
||||
} else if (!isValidUDID(val)) {
|
||||
return 'UDID is invalid';
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
format: (val: string) => normalizeUDID(val),
|
||||
});
|
||||
return udid;
|
||||
}
|
||||
|
||||
async function promptForNameAsync(initial?: string): Promise<string | undefined> {
|
||||
const { name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Device name (optional):',
|
||||
initial,
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
async function promptForDeviceClassAsync(
|
||||
initial?: AppleDeviceClass | null
|
||||
): Promise<AppleDeviceClass | null> {
|
||||
const choices = [
|
||||
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
|
||||
{ title: 'iPad', value: AppleDeviceClass.Ipad },
|
||||
{ title: 'Not sure (leave empty)', value: null },
|
||||
];
|
||||
const values = choices.map(({ value }) => value);
|
||||
|
||||
const { deviceClass } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'deviceClass',
|
||||
message: 'Device class (optional):',
|
||||
choices,
|
||||
initial: initial !== undefined && values.indexOf(initial),
|
||||
});
|
||||
return deviceClass;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppleDeviceClass, AppleTeam } from '../../../graphql/generated';
|
||||
import Log from '../../../log';
|
||||
import { promptAsync } from '../../../prompts';
|
||||
import { isValidUDID, normalizeUDID } from '../../udids';
|
||||
import { formatNewDevice } from '../../utils/formatDevice';
|
||||
|
||||
export interface DeviceData {
|
||||
udid: string;
|
||||
name?: string;
|
||||
deviceClass: AppleDeviceClass | null;
|
||||
}
|
||||
|
||||
export async function promptForDeviceClassAsync(
|
||||
initial?: AppleDeviceClass | null
|
||||
): Promise<AppleDeviceClass | null> {
|
||||
const choices = [
|
||||
{ title: 'iPhone', value: AppleDeviceClass.Iphone },
|
||||
{ title: 'iPad', value: AppleDeviceClass.Ipad },
|
||||
{ title: 'Mac', value: AppleDeviceClass.Mac },
|
||||
{ title: 'Not sure (leave empty)', value: null },
|
||||
];
|
||||
const values = choices.map(({ value }) => value);
|
||||
|
||||
const { deviceClass } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'deviceClass',
|
||||
message: 'Device class (optional):',
|
||||
choices,
|
||||
initial: initial !== undefined && values.indexOf(initial),
|
||||
});
|
||||
return deviceClass;
|
||||
}
|
||||
|
||||
export async function promptForNameAsync(initial?: string): Promise<string | undefined> {
|
||||
const { name } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'name',
|
||||
message: 'Device name (optional):',
|
||||
initial,
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
export async function promptForUDIDAsync(initial?: string): Promise<string> {
|
||||
const { udid } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'udid',
|
||||
message: 'UDID:',
|
||||
initial,
|
||||
validate: (rawVal: string) => {
|
||||
const val = normalizeUDID(rawVal);
|
||||
if (!val || val === '') {
|
||||
return 'UDID cannot be empty';
|
||||
} else if (val.length !== 25 && val.length !== 40) {
|
||||
return 'UDID should be a 25 or 40-character string';
|
||||
} else if (!isValidUDID(val)) {
|
||||
return 'UDID is invalid';
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
format: (val: string) => normalizeUDID(val),
|
||||
});
|
||||
return udid;
|
||||
}
|
||||
|
||||
export function printDeviceData(
|
||||
deviceData: DeviceData,
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName'>
|
||||
): void {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`We are going to register the following device in our database.
|
||||
This device will ${chalk.bold(
|
||||
'not'
|
||||
)} be registered on the Apple Developer Portal until it is chosen for an internal distribution build.`
|
||||
);
|
||||
Log.newLine();
|
||||
Log.log(formatNewDevice({ ...deviceData, identifier: deviceData.udid }, appleTeam));
|
||||
Log.newLine();
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import { Actor } from '../user/User';
|
||||
import DeviceCreateAction from './actions/create/action';
|
||||
import { DeviceManagerContext } from './context';
|
||||
|
||||
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones and iPads) for internal distribution of your app.
|
||||
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones, iPads and Macs) for internal distribution of your app.
|
||||
Internal distribution means that you won't need to upload your app archive to App Store / Testflight.
|
||||
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisiong profile.
|
||||
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones and iPads.
|
||||
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisioning profile.
|
||||
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones, iPads and Macs.
|
||||
|
||||
First of all, choose the Expo account under which you want to register your devices.
|
||||
Later, authenticate with Apple and choose your desired Apple Team (if your Apple ID has access to multiple teams).`;
|
||||
|
||||
@@ -3,3 +3,9 @@ export class DeviceNotFoundError extends Error {
|
||||
super(message ?? 'Device not found.');
|
||||
}
|
||||
}
|
||||
|
||||
export class DeviceCreateError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message ?? 'Failed to create a device.');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type AppleTeamIdAndName = Pick<AppleTeam, 'appleTeamIdentifier' | 'appleT
|
||||
const DEVICE_CLASS_DISPLAY_NAMES: Record<AppleDeviceClass, string> = {
|
||||
[AppleDeviceClass.Iphone]: 'iPhone',
|
||||
[AppleDeviceClass.Ipad]: 'iPad',
|
||||
[AppleDeviceClass.Mac]: 'Mac',
|
||||
};
|
||||
|
||||
function formatDeviceClass(device: Device | NewDevice): string {
|
||||
|
||||
+5
-4
@@ -1657,7 +1657,8 @@ export type AppleDevice = {
|
||||
|
||||
export enum AppleDeviceClass {
|
||||
Ipad = 'IPAD',
|
||||
Iphone = 'IPHONE'
|
||||
Iphone = 'IPHONE',
|
||||
Mac = 'MAC'
|
||||
}
|
||||
|
||||
export type AppleDeviceInput = {
|
||||
@@ -4437,7 +4438,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
/** Web notifications linked to a user */
|
||||
webNotifications: Array<Notification>;
|
||||
websiteNotifications: Array<Notification>;
|
||||
};
|
||||
|
||||
|
||||
@@ -5198,7 +5199,7 @@ export type User = Actor & UserActor & {
|
||||
/** @deprecated No longer supported */
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
webNotifications: Array<Notification>;
|
||||
websiteNotifications: Array<Notification>;
|
||||
};
|
||||
|
||||
|
||||
@@ -5288,7 +5289,7 @@ export type UserActor = {
|
||||
twitterUsername?: Maybe<Scalars['String']>;
|
||||
username: Scalars['String'];
|
||||
/** Web notifications linked to a user */
|
||||
webNotifications: Array<Notification>;
|
||||
websiteNotifications: Array<Notification>;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppleDeviceClass } from '../../generated';
|
||||
export const APPLE_DEVICE_CLASS_LABELS: Record<AppleDeviceClass, string> = {
|
||||
[AppleDeviceClass.Ipad]: 'iPad',
|
||||
[AppleDeviceClass.Iphone]: 'iPhone',
|
||||
[AppleDeviceClass.Mac]: 'Mac',
|
||||
};
|
||||
|
||||
export const AppleDeviceFragmentNode = gql`
|
||||
|
||||
@@ -68,3 +68,8 @@ export const standardBranchMapping: BranchMapping = {
|
||||
version: 0,
|
||||
data: [{ branchId: uuidv4(), branchMappingLogic: alwaysTrue() }],
|
||||
};
|
||||
|
||||
export const emptyBranchMapping: BranchMapping = {
|
||||
version: 0,
|
||||
data: [],
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Updates } from '@expo/config-plugins';
|
||||
import assert from 'assert';
|
||||
|
||||
import { SelectBranch } from '../../branch/actions/SelectBranch';
|
||||
import { getStandardBranchId, hasStandardBranchMap } from '../../channel/branch-mapping';
|
||||
import {
|
||||
getStandardBranchId,
|
||||
hasEmptyBranchMap,
|
||||
hasStandardBranchMap,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { getUpdateBranch } from '../../channel/utils';
|
||||
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
@@ -18,7 +23,8 @@ import {
|
||||
} from '../../graphql/queries/ChannelQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import Log from '../../log';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { truthy } from '../../utils/expodash/filter';
|
||||
import {
|
||||
composeRollout,
|
||||
createRolloutBranchMapping,
|
||||
@@ -69,6 +75,11 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
if (isRollout(this.channelInfo)) {
|
||||
throw new Error(`A rollout is already in progress for channel ${this.channelInfo.name}`);
|
||||
}
|
||||
if (hasEmptyBranchMap(this.channelInfo)) {
|
||||
throw new Error(
|
||||
`Your channel needs to be linked to a branch before a rollout can be created. Do this by running 'eas channel:edit'`
|
||||
);
|
||||
}
|
||||
if (!hasStandardBranchMap(this.channelInfo)) {
|
||||
throw new Error(
|
||||
`You have a custom branch mapping. Map your channel to a single branch before creating a rollout. Received: ${this.channelInfo.branchMapping}`
|
||||
@@ -88,7 +99,7 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
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 promptMessage = `What percent of users should be rolled out to branch ${branchInfoToRollout.name}?`;
|
||||
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
|
||||
const rolloutBranchMapping = createRolloutBranchMapping({
|
||||
defaultBranchId,
|
||||
@@ -196,10 +207,96 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
channelName: this.channelInfo.name,
|
||||
});
|
||||
const defaultBranchRtvAgnostic = getUpdateBranch(channelObjectRtvAgnostic, defaultBranchId);
|
||||
const selectRuntimeAction = new SelectRuntime(branchToRollout, {
|
||||
const selectSharedRuntimeAction = new SelectRuntime(branchToRollout, {
|
||||
anotherBranchToIntersectRuntimesBy: defaultBranchRtvAgnostic,
|
||||
});
|
||||
return await selectRuntimeAction.runAsync(ctx);
|
||||
const sharedRuntime = await selectSharedRuntimeAction.runAsync(ctx);
|
||||
if (sharedRuntime) {
|
||||
return sharedRuntime;
|
||||
}
|
||||
|
||||
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
|
||||
ctx,
|
||||
branchToRollout,
|
||||
defaultBranchRtvAgnostic
|
||||
);
|
||||
}
|
||||
|
||||
async selectRuntimeVersionFromAlternativeSourceAsync(
|
||||
ctx: EASUpdateContext,
|
||||
branchToRollout: UpdateBranchBasicInfoFragment,
|
||||
defaultBranch: UpdateBranchBasicInfoFragment
|
||||
): Promise<string> {
|
||||
const { runtimeSource: selectedRuntimeSource } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'runtimeSource',
|
||||
message: `What would you like to do?`,
|
||||
choices: [
|
||||
{
|
||||
value: 'DEFAULT_BRANCH_RUNTIME',
|
||||
title: `Find a runtime supported on the ${defaultBranch.name} branch`,
|
||||
},
|
||||
{
|
||||
value: 'ROLLED_OUT_BRANCH_RUNTIME',
|
||||
title: `Find a runtime supported on the ${branchToRollout.name} branch`,
|
||||
},
|
||||
{
|
||||
value: 'PROJECT_RUNTIME',
|
||||
title: 'Use the runtime specified in your project config',
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selectedRuntimeSource === 'DEFAULT_BRANCH_RUNTIME') {
|
||||
const selectDefaultBranchRuntimeAction = new SelectRuntime(defaultBranch);
|
||||
const defaultBranchRuntime = await selectDefaultBranchRuntimeAction.runAsync(ctx);
|
||||
if (defaultBranchRuntime) {
|
||||
return defaultBranchRuntime;
|
||||
}
|
||||
} else if (selectedRuntimeSource === 'ROLLED_OUT_BRANCH_RUNTIME') {
|
||||
const selectBranchToRolloutRuntimeAction = new SelectRuntime(branchToRollout);
|
||||
const branchToRolloutRuntime = await selectBranchToRolloutRuntimeAction.runAsync(ctx);
|
||||
if (branchToRolloutRuntime) {
|
||||
return branchToRolloutRuntime;
|
||||
}
|
||||
} else if (selectedRuntimeSource === 'PROJECT_RUNTIME') {
|
||||
return await this.selectRuntimeVersionFromProjectConfigAsync(ctx);
|
||||
} else {
|
||||
throw new Error(`Unexpected runtime source: ${selectedRuntimeSource}`);
|
||||
}
|
||||
return await this.selectRuntimeVersionFromAlternativeSourceAsync(
|
||||
ctx,
|
||||
branchToRollout,
|
||||
defaultBranch
|
||||
);
|
||||
}
|
||||
|
||||
async selectRuntimeVersionFromProjectConfigAsync(ctx: EASUpdateContext): Promise<string> {
|
||||
const platforms: ('ios' | 'android')[] = ['ios', 'android'];
|
||||
const runtimes = platforms
|
||||
.map(platform => Updates.getRuntimeVersion(ctx.app.exp, platform))
|
||||
.filter(truthy);
|
||||
const dedupedRuntimes = [...new Set(runtimes)];
|
||||
|
||||
if (dedupedRuntimes.length === 0) {
|
||||
throw new Error(
|
||||
`Your project config doesn't specify a runtime. Ensure your project is configured correctly for EAS Update by running \`eas update:configure\``
|
||||
);
|
||||
} else if (dedupedRuntimes.length === 1) {
|
||||
const runtime = dedupedRuntimes[0];
|
||||
Log.log(`🔧 Your project config currently supports runtime ${runtime}`);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const { runtime: selectedRuntime } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'runtime',
|
||||
message: `Select a runtime supported by your project config`,
|
||||
choices: runtimes.map((runtime, index) => ({
|
||||
title: `${runtime} ${index === 0 ? '[iOS runtime]' : '[Android runtime]'}`,
|
||||
value: runtime,
|
||||
})),
|
||||
});
|
||||
return selectedRuntime;
|
||||
}
|
||||
|
||||
async selectBranchAsync(
|
||||
@@ -207,14 +304,17 @@ export class CreateRollout implements EASUpdateAction<UpdateChannelBasicInfoFrag
|
||||
defaultBranchId: string
|
||||
): Promise<UpdateBranchBasicInfoFragment> {
|
||||
const selectBranchAction = new SelectBranch({
|
||||
printedType: 'branch to rollout',
|
||||
printedType: 'branch to roll out',
|
||||
// we don't want to show the default branch as an option
|
||||
filterPredicate: (branchInfo: UpdateBranchBasicInfoFragment) =>
|
||||
branchInfo.id !== defaultBranchId,
|
||||
});
|
||||
const branchInfo = await selectBranchAction.runAsync(ctx);
|
||||
if (!branchInfo) {
|
||||
throw new Error(`You dont have any branches. Create one with 'eas branch:create'`);
|
||||
// We know the user has at least one branch, since we have `defaultBranchId`
|
||||
throw new Error(
|
||||
`You don't have a second branch to roll out. Create it with 'eas branch:create'`
|
||||
);
|
||||
}
|
||||
return branchInfo;
|
||||
}
|
||||
|
||||
@@ -51,17 +51,25 @@ export class EditRollout implements EASUpdateAction<UpdateChannelBasicInfoFragme
|
||||
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 promptMessage = `What percent of users should be rolled out to the ${rolledOutBranch.name} branch ?`;
|
||||
const percent = this.options.percent ?? (await promptForRolloutPercentAsync({ promptMessage }));
|
||||
|
||||
if (percent === 0 || percent === 100) {
|
||||
Log.warn(
|
||||
`Editing the percent to ${percent} will not end the rollout. You'll need to end the rollout from the main menu.`
|
||||
);
|
||||
}
|
||||
|
||||
const oldBranchMapping = getRolloutBranchMapping(channelObject.branchMapping);
|
||||
const newBranchMapping = editRolloutBranchMapping(oldBranchMapping, percent);
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`📝 The updated rollout will send ${chalk.bold(percent)}% of users to the ${chalk.bold(
|
||||
`📝 ${chalk.bold(percent)}% of users will be rolled out to the ${chalk.bold(
|
||||
rolledOutBranch.name
|
||||
)} branch and ${chalk.bold(100 - percent)}% to the ${chalk.bold(defaultBranch.name)} branch.`
|
||||
)} branch and ${chalk.bold(100 - percent)}% will remain on the ${chalk.bold(
|
||||
defaultBranch.name
|
||||
)} branch.`
|
||||
);
|
||||
const confirmEdit = await this.confirmEditAsync(ctx);
|
||||
if (!confirmEdit) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/Channel
|
||||
import Log from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { republishAsync } from '../../update/republish';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import {
|
||||
Rollout,
|
||||
@@ -20,10 +21,14 @@ import {
|
||||
import { formatBranchWithUpdateGroup } from '../utils';
|
||||
|
||||
export enum EndOutcome {
|
||||
REPUBLISH_AND_ROUTE_BACK = 'republish-and-route-back',
|
||||
ROUTE_BACK = 'route-back',
|
||||
REPUBLISH_AND_REVERT = 'republish-and-revert',
|
||||
REVERT = 'revert',
|
||||
}
|
||||
|
||||
export type GeneralOptions = {
|
||||
privateKeyPath: string | null;
|
||||
};
|
||||
|
||||
export type NonInteractiveOptions = {
|
||||
outcome: EndOutcome;
|
||||
};
|
||||
@@ -47,7 +52,7 @@ function assertNonInteractiveOptions(
|
||||
export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragment> {
|
||||
constructor(
|
||||
private channelInfo: UpdateChannelBasicInfoFragment,
|
||||
private options: Partial<NonInteractiveOptions> = {}
|
||||
private options: Partial<NonInteractiveOptions> & GeneralOptions
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<UpdateChannelBasicInfoFragment> {
|
||||
@@ -55,30 +60,24 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
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}.`);
|
||||
Log.log(`⚠️ There is no update group being served on the ${rolledOutBranch.name} branch.`);
|
||||
assert(
|
||||
this.options.outcome !== EndOutcome.REPUBLISH_AND_ROUTE_BACK,
|
||||
'The only valid outcome for this rollout is to route back to the default branch. '
|
||||
this.options.outcome !== EndOutcome.REPUBLISH_AND_REVERT,
|
||||
`The only valid outcome for this rollout is to revert users back to the ${rollout.defaultBranch.name} branch. `
|
||||
);
|
||||
outcome = EndOutcome.ROUTE_BACK;
|
||||
outcome = EndOutcome.REVERT;
|
||||
} 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);
|
||||
@@ -110,11 +109,11 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
const defaultUpdateGroup = defaultBranch.updateGroups[0];
|
||||
const outcomes = [
|
||||
{
|
||||
value: EndOutcome.REPUBLISH_AND_ROUTE_BACK,
|
||||
value: EndOutcome.REPUBLISH_AND_REVERT,
|
||||
title: formatBranchWithUpdateGroup(rolledOutUpdateGroup, rolledOutBranch, percentRolledOut),
|
||||
},
|
||||
{
|
||||
value: EndOutcome.ROUTE_BACK,
|
||||
value: EndOutcome.REVERT,
|
||||
title: formatBranchWithUpdateGroup(
|
||||
defaultUpdateGroup,
|
||||
defaultBranch,
|
||||
@@ -125,17 +124,17 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
const { outcome: selectedOutcome } = await promptAsync({
|
||||
type: 'select',
|
||||
name: 'outcome',
|
||||
message: `Which Update Group would you like to serve?`,
|
||||
message: `Which update group would you like to serve?`,
|
||||
choices: outcomes,
|
||||
});
|
||||
Log.newLine();
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
|
||||
Log.log(
|
||||
`🍽️ The Update Group you chose is served by branch ${chalk.bold(rolledOutBranch.name)}`
|
||||
`➡️ 📱 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)}`
|
||||
`➡️ 📱 The update group you chose is served by branch ${chalk.bold(defaultBranch.name)}`
|
||||
);
|
||||
}
|
||||
return selectedOutcome;
|
||||
@@ -149,7 +148,11 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
const { graphqlClient, app } = ctx;
|
||||
const { rolledOutBranch, defaultBranch } = rollout;
|
||||
const rolledOutUpdateGroup = rolledOutBranch.updateGroups[0];
|
||||
if (outcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
|
||||
if (outcome === EndOutcome.REPUBLISH_AND_REVERT) {
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(
|
||||
ctx.app.exp,
|
||||
this.options.privateKeyPath ?? undefined
|
||||
);
|
||||
const arbitraryUpdate = rolledOutUpdateGroup[0];
|
||||
const { message: oldUpdateMessage, group: oldGroupId } = arbitraryUpdate;
|
||||
const newUpdateMessage = `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`;
|
||||
@@ -162,6 +165,7 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
})),
|
||||
codeSigningInfo,
|
||||
targetBranch: { branchId: defaultBranch.id, branchName: defaultBranch.name },
|
||||
updateMessage: newUpdateMessage,
|
||||
});
|
||||
@@ -172,7 +176,7 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
branchMapping: JSON.stringify(alwaysTrueDefaultBranchMapping),
|
||||
});
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`🚗 Routed all traffic back to branch ${chalk.bold(defaultBranch.name)}`);
|
||||
Log.log(`⬅️ Reverted all users back to branch ${chalk.bold(defaultBranch.name)}`);
|
||||
Log.log(`✅ Successfully ended rollout`);
|
||||
return newChannelInfo;
|
||||
}
|
||||
@@ -188,21 +192,21 @@ export class EndRollout implements EASUpdateAction<UpdateChannelBasicInfoFragmen
|
||||
}
|
||||
const { rolledOutBranch, defaultBranch } = rollout;
|
||||
Log.newLine();
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_ROUTE_BACK) {
|
||||
if (selectedOutcome === EndOutcome.REPUBLISH_AND_REVERT) {
|
||||
Log.log(`Ending the rollout will do the following:`);
|
||||
const actions = formatFields([
|
||||
{
|
||||
label: '1.',
|
||||
value: `🔁 Republish the Update Group from ${chalk.bold(
|
||||
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)}` },
|
||||
{ label: '2.', value: `⬅️ Revert 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)}`
|
||||
`⬅️ Ending the rollout will revert all users back to ${chalk.bold(defaultBranch.name)}`
|
||||
);
|
||||
}
|
||||
return await confirmAsync({
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
EditRollout,
|
||||
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
|
||||
} from './EditRollout';
|
||||
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
|
||||
import {
|
||||
EndRollout,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from './EndRollout';
|
||||
|
||||
export enum ManageRolloutActions {
|
||||
EDIT = 'Edit',
|
||||
@@ -29,7 +33,8 @@ export class ManageRollout implements EASUpdateAction<EASUpdateAction> {
|
||||
callingAction?: EASUpdateAction;
|
||||
action?: ManageRolloutActions.EDIT | ManageRolloutActions.END;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> = {}
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<EASUpdateAction> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { CodeSigningInfo } from '../../utils/code-signing';
|
||||
import {
|
||||
CreateRollout,
|
||||
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
|
||||
@@ -9,7 +10,11 @@ import {
|
||||
EditRollout,
|
||||
NonInteractiveOptions as EditRolloutNonInteractiveOptions,
|
||||
} from './EditRollout';
|
||||
import { EndRollout, NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
|
||||
import {
|
||||
EndRollout,
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from './EndRollout';
|
||||
import { ManageRolloutActions } from './ManageRollout';
|
||||
import { MainMenuActions, RolloutActions } from './RolloutMainMenu';
|
||||
|
||||
@@ -20,10 +25,12 @@ export class NonInteractiveRollout implements EASUpdateAction<void> {
|
||||
constructor(
|
||||
private options: {
|
||||
channelName?: string;
|
||||
codeSigningInfo?: CodeSigningInfo;
|
||||
action?: RolloutActions;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
Partial<CreateRolloutNonInteractiveOptions> = {}
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<void> {
|
||||
@@ -55,6 +62,7 @@ export class NonInteractiveRollout implements EASUpdateAction<void> {
|
||||
channelInfo: UpdateChannelBasicInfoFragment,
|
||||
options: Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
switch (action) {
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
NonInteractiveOptions as CreateRolloutNonInteractiveOptions,
|
||||
} from './CreateRollout';
|
||||
import { NonInteractiveOptions as EditRolloutNonInteractiveOptions } from './EditRollout';
|
||||
import { NonInteractiveOptions as EndRolloutNonInteractiveOptions } from './EndRollout';
|
||||
import {
|
||||
GeneralOptions as EndRolloutGeneralOptions,
|
||||
NonInteractiveOptions as EndRolloutNonInteractiveOptions,
|
||||
} from './EndRollout';
|
||||
import { ManageRollout, ManageRolloutActions } from './ManageRollout';
|
||||
import { SelectRollout } from './SelectRollout';
|
||||
|
||||
@@ -36,7 +39,8 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
action?: RolloutActions;
|
||||
} & Partial<EditRolloutNonInteractiveOptions> &
|
||||
Partial<EndRolloutNonInteractiveOptions> &
|
||||
Partial<CreateRolloutNonInteractiveOptions> = {}
|
||||
EndRolloutGeneralOptions &
|
||||
Partial<CreateRolloutNonInteractiveOptions>
|
||||
) {}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<void> {
|
||||
@@ -55,7 +59,7 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
case MainMenuActions.CREATE_NEW: {
|
||||
const channelInfo = channelName
|
||||
? await this.resolveChannelNameAsync(ctx, channelName)
|
||||
: await this.selectChannelAsync(ctx, channelInfo => !isRollout(channelInfo));
|
||||
: await this.selectChannelToRolloutAsync(ctx);
|
||||
await new CreateRollout(channelInfo, this.options).runAsync(ctx);
|
||||
return null;
|
||||
}
|
||||
@@ -88,14 +92,22 @@ export class RolloutMainMenu implements EASUpdateAction<void> {
|
||||
return channelInfo;
|
||||
}
|
||||
|
||||
async selectChannelAsync(
|
||||
ctx: EASUpdateContext,
|
||||
filterPredicate?: (channelInfo: UpdateChannelBasicInfoFragment) => boolean
|
||||
async selectChannelToRolloutAsync(
|
||||
ctx: EASUpdateContext
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const selectChannelAction = new SelectChannel({ filterPredicate });
|
||||
let hasSomeChannel = false;
|
||||
const selectChannelAction = new SelectChannel({
|
||||
filterPredicate: (channelInfo: UpdateChannelBasicInfoFragment) => {
|
||||
hasSomeChannel = true;
|
||||
return !isRollout(channelInfo);
|
||||
},
|
||||
});
|
||||
const channelInfo = await selectChannelAction.runAsync(ctx);
|
||||
if (!channelInfo) {
|
||||
throw new Error(`You dont have any channels. Create one with <TODO>`);
|
||||
const error = hasSomeChannel
|
||||
? new Error('All your channels are already part of a rollout.')
|
||||
: new Error(`You dont have any channels. Create one with \`eas channel:create\``);
|
||||
throw error;
|
||||
}
|
||||
return channelInfo;
|
||||
}
|
||||
|
||||
@@ -7,21 +7,14 @@ import { RuntimeFragment, UpdateBranchBasicInfoFragment } from '../../graphql/ge
|
||||
import { RuntimeQuery } from '../../graphql/queries/RuntimeQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import Log, { learnMore } from '../../log';
|
||||
import { confirmAsync, promptAsync } from '../../prompts';
|
||||
import { confirmAsync } 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> {
|
||||
export class SelectRuntime implements EASUpdateAction<string | null> {
|
||||
private printedType;
|
||||
constructor(
|
||||
private branchInfo: UpdateBranchBasicInfoFragment,
|
||||
@@ -40,8 +33,9 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
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')}`);
|
||||
Log.warn(
|
||||
`Your updates could be misconfigured. ${learnMore('https://expo.fyi/eas-update-rollouts')}`
|
||||
);
|
||||
} else {
|
||||
// no runtime on branch means no updates published on branch
|
||||
Log.warn(`⚠️ There are no updates published on branch ${this.branchInfo.name}.`);
|
||||
@@ -49,10 +43,12 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
}
|
||||
|
||||
formatCantFindRuntime(): string {
|
||||
return `🕵️ Not finding the update you were looking for? ${learnMore('https://expo.fyi/todo')}`;
|
||||
return `🕵️ Not finding the update you were looking for? ${learnMore(
|
||||
'https://expo.fyi/eas-update-rollouts'
|
||||
)}`;
|
||||
}
|
||||
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<string> {
|
||||
public async runAsync(ctx: EASUpdateContext): Promise<string | null> {
|
||||
const { nonInteractive, graphqlClient, app } = ctx;
|
||||
const { projectId } = app;
|
||||
if (nonInteractive) {
|
||||
@@ -65,16 +61,17 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
anotherBranchIdToIntersectRuntimesBy: this.options.anotherBranchToIntersectRuntimesBy?.id,
|
||||
});
|
||||
|
||||
if (!newestRuntimeConnection) {
|
||||
if (newestRuntimeConnection.edges.length === 0) {
|
||||
Log.addNewLineIfNone();
|
||||
this.warnNoRuntime();
|
||||
return await this.promptForRuntimeAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
const moreThanOneRuntime = newestRuntimeConnection.edges.length > 1;
|
||||
Log.log(`✅ ${beginSentence(this.printedType)}${moreThanOneRuntime ? 's' : ''} detected`);
|
||||
const onlyOneRuntime =
|
||||
newestRuntimeConnection.edges.length === 1 && !newestRuntimeConnection.pageInfo.hasNextPage;
|
||||
Log.log(`✅ ${beginSentence(this.printedType)}${onlyOneRuntime ? '' : 's'} detected`);
|
||||
|
||||
if (!moreThanOneRuntime) {
|
||||
if (onlyOneRuntime) {
|
||||
const runtime = newestRuntimeConnection.edges[0].node;
|
||||
const formattedRuntimeWithGroup = await this.displayLatestUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
@@ -93,15 +90,13 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
} else {
|
||||
Log.newLine();
|
||||
Log.warn(this.formatCantFindRuntime());
|
||||
return await this.promptForRuntimeAsync();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -120,8 +115,8 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
branchName: string;
|
||||
anotherBranchIdToIntersectRuntimesBy?: string;
|
||||
}
|
||||
): Promise<Connection<RuntimeFragment> | null> {
|
||||
const connection = await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
): Promise<Connection<RuntimeFragment>> {
|
||||
return await RuntimeQuery.getRuntimesOnBranchAsync(graphqlClient, {
|
||||
appId,
|
||||
name: branchName,
|
||||
first: 1,
|
||||
@@ -129,11 +124,6 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
branchId: anotherBranchIdToIntersectRuntimesBy,
|
||||
},
|
||||
});
|
||||
const { edges } = connection;
|
||||
if (edges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
async displayLatestUpdateGroupAsync({
|
||||
@@ -160,33 +150,29 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
updateGroups.length < 2,
|
||||
`Expected at most one update group. Received: ${JSON.stringify(updateGroups)}`
|
||||
);
|
||||
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime);
|
||||
return formatRuntimeWithUpdateGroup(updateGroups[0], runtime, branchName);
|
||||
}
|
||||
|
||||
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,
|
||||
name: this.branchInfo.name,
|
||||
first: queryParams.first,
|
||||
after: queryParams.after,
|
||||
last: queryParams.last,
|
||||
before: queryParams.before,
|
||||
filter: {
|
||||
branchId: anotherBranchIdToIntersectRuntimesBy,
|
||||
branchId: this.options.anotherBranchToIntersectRuntimesBy?.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -194,7 +180,7 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
return await this.displayLatestUpdateGroupAsync({
|
||||
graphqlClient,
|
||||
appId,
|
||||
branchName,
|
||||
branchName: this.branchInfo.name,
|
||||
runtime,
|
||||
});
|
||||
};
|
||||
@@ -205,15 +191,11 @@ export class SelectRuntime implements EASUpdateAction<string> {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ describe(SelectRollout, () => {
|
||||
const rollout = await selectRollout.runAsync(ctx);
|
||||
expect(rollout).toBeNull();
|
||||
});
|
||||
it('returns the rollout without prompting if project only one rollout', async () => {
|
||||
it('still prompts if project has only one rollout', async () => {
|
||||
const ctx = createCtxMock();
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
edges: [{ node: testBasicChannelInfo, cursor: 'cursor' }],
|
||||
@@ -34,9 +34,13 @@ describe(SelectRollout, () => {
|
||||
hasNextPage: false,
|
||||
},
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: testBasicChannelInfo,
|
||||
}));
|
||||
const selectRollout = new SelectRollout();
|
||||
const rollout = await selectRollout.runAsync(ctx);
|
||||
expect(rollout).toBe(testBasicChannelInfo);
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns the selected rollout', async () => {
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelsBasicInfoPaginatedOnAppAsync).mockResolvedValue({
|
||||
|
||||
@@ -126,7 +126,7 @@ export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapp
|
||||
assertNumber(operand);
|
||||
return {
|
||||
rolledOutBranchId,
|
||||
percentRolledOut: operand * 100,
|
||||
percentRolledOut: Math.round(operand * 100),
|
||||
runtimeVersion,
|
||||
defaultBranchId,
|
||||
};
|
||||
@@ -137,7 +137,7 @@ export function getRolloutInfoFromBranchMapping(branchMapping: RolloutBranchMapp
|
||||
assertNumber(operand);
|
||||
return {
|
||||
rolledOutBranchId,
|
||||
percentRolledOut: operand * 100,
|
||||
percentRolledOut: Math.round(operand * 100),
|
||||
defaultBranchId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function displayRolloutDetails(channelName: string, rollout: Rollout): vo
|
||||
formatFields([
|
||||
{ label: 'Channel', value: channelName },
|
||||
...(isConstrainedRollout(rollout)
|
||||
? [{ label: 'Runtime Version', value: rollout.runtimeVersion }]
|
||||
? [{ label: 'Runtime version', value: rollout.runtimeVersion }]
|
||||
: []),
|
||||
{
|
||||
label: 'Branches',
|
||||
@@ -40,7 +40,11 @@ export function formatBranchWithUpdateGroup(
|
||||
percentRolledOut: number
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold(`🍽️ Served by branch ${chalk.bold(branch.name)} (${percentRolledOut}%)`));
|
||||
lines.push(
|
||||
chalk.bold(
|
||||
`➡️ 📱 Latest update on the ${chalk.bold(branch.name)} branch (${percentRolledOut}%)`
|
||||
)
|
||||
);
|
||||
if (!maybeUpdateGroup) {
|
||||
lines.push(`No updates for target runtime`);
|
||||
} else {
|
||||
@@ -52,10 +56,17 @@ export function formatBranchWithUpdateGroup(
|
||||
|
||||
export function formatRuntimeWithUpdateGroup(
|
||||
maybeUpdateGroup: UpdateFragment[] | undefined | null,
|
||||
runtime: RuntimeFragment
|
||||
runtime: RuntimeFragment,
|
||||
branchName: string
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold(`🍽️ Served by runtime ${chalk.bold(runtime.version)}:`));
|
||||
lines.push(
|
||||
chalk.bold(
|
||||
`➡️ 📱 Latest update on the ${chalk.bold(branchName)} branch served to runtime ${chalk.bold(
|
||||
runtime.version
|
||||
)}:`
|
||||
)
|
||||
);
|
||||
if (!maybeUpdateGroup) {
|
||||
lines.push(`No updates published for this runtime`);
|
||||
} else {
|
||||
@@ -69,7 +80,7 @@ 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: 'Runtime version', value: updateGroup.runtimeVersion ?? 'N/A' },
|
||||
{ label: 'Platforms', value: updateGroup.platforms ?? 'N/A' },
|
||||
{ label: 'Group ID', value: updateGroup.group ?? 'N/A' },
|
||||
]).split('\n');
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import assert from 'assert';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getUpdateGroupUrl } from '../build/utils/url';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Update } from '../graphql/generated';
|
||||
import fetch from '../fetch';
|
||||
import { Update, UpdateInfoGroup } from '../graphql/generated';
|
||||
import { PublishMutation } from '../graphql/mutations/PublishMutation';
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import {
|
||||
CodeSigningInfo,
|
||||
checkManifestBodyAgainstUpdateInfoGroup,
|
||||
getManifestBodyAsync,
|
||||
signBody,
|
||||
} from '../utils/code-signing';
|
||||
import formatFields from '../utils/formatFields';
|
||||
import { printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
@@ -35,6 +43,7 @@ export async function republishAsync({
|
||||
updatesToPublish,
|
||||
targetBranch,
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
json,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
@@ -42,6 +51,7 @@ export async function republishAsync({
|
||||
updatesToPublish: UpdateToRepublish[];
|
||||
targetBranch: { branchName: string; branchId: string };
|
||||
updateMessage: string;
|
||||
codeSigningInfo?: CodeSigningInfo;
|
||||
json?: boolean;
|
||||
}): Promise<void> {
|
||||
const { branchName: targetBranchName, branchId: targetBranchId } = targetBranch;
|
||||
@@ -57,49 +67,80 @@ export async function republishAsync({
|
||||
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
|
||||
// If codesigning was created for the original update, we need to add it to the republish.
|
||||
// If one wishes to not sign the republish or sign with a different key, a normal publish should
|
||||
// be performed.
|
||||
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.`
|
||||
);
|
||||
if (!codeSigningInfo) {
|
||||
throw new Error(
|
||||
'Must specify --private-key-path argument to sign republished update group for code signing'
|
||||
);
|
||||
}
|
||||
|
||||
for (const update of updatesToPublish) {
|
||||
if (
|
||||
nullthrows(update.codeSigningInfo).alg !== codeSigningInfo.codeSigningMetadata.alg ||
|
||||
nullthrows(update.codeSigningInfo).keyid !== codeSigningInfo.codeSigningMetadata.keyid
|
||||
) {
|
||||
throw new Error(
|
||||
'Republished updates must use the same code signing key and algorithm as original update'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Log.withTick(`The republished update group will be signed`);
|
||||
}
|
||||
|
||||
const publishIndicator = ora('Republishing...').start();
|
||||
let updatesRepublished: Awaited<ReturnType<typeof PublishMutation.publishUpdateGroupAsync>>;
|
||||
|
||||
try {
|
||||
const updateInfoGroup = Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
|
||||
);
|
||||
|
||||
updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [
|
||||
{
|
||||
branchId: targetBranchId,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
updateInfoGroup: Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, JSON.parse(update.manifestFragment)])
|
||||
),
|
||||
updateInfoGroup,
|
||||
gitCommitHash: updatesToPublish[0].gitCommitHash,
|
||||
awaitingCodeSigningInfo: shouldRepublishWithCodesigning,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
},
|
||||
]);
|
||||
|
||||
if (shouldRepublishWithCodesigning) {
|
||||
const codeSigningByPlatform = Object.fromEntries(
|
||||
updatesToPublish.map(update => [update.platform, update.codeSigningInfo])
|
||||
);
|
||||
if (codeSigningInfo) {
|
||||
Log.log('🔒 Signing republished update group');
|
||||
|
||||
await Promise.all(
|
||||
updatesRepublished.map(async update => {
|
||||
const codeSigning = codeSigningByPlatform[update.platform];
|
||||
if (codeSigning) {
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, update.id, codeSigning);
|
||||
}
|
||||
updatesRepublished.map(async newUpdate => {
|
||||
const response = await fetch(newUpdate.manifestPermalink, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'multipart/mixed' },
|
||||
});
|
||||
const manifestBody = nullthrows(await getManifestBodyAsync(response));
|
||||
|
||||
checkManifestBodyAgainstUpdateInfoGroup(
|
||||
manifestBody,
|
||||
nullthrows(nullthrows(updateInfoGroup)[newUpdate.platform as keyof UpdateInfoGroup])
|
||||
);
|
||||
|
||||
const manifestSignature = signBody(manifestBody, codeSigningInfo);
|
||||
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
|
||||
alg: codeSigningInfo.codeSigningMetadata.alg,
|
||||
keyid: codeSigningInfo.codeSigningMetadata.keyid,
|
||||
sig: manifestSignature,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publishIndicator.succeed('Republished update');
|
||||
publishIndicator.succeed('Republished update group');
|
||||
} catch (error: any) {
|
||||
publishIndicator.fail('Failed to republish update');
|
||||
publishIndicator.fail('Failed to republish update group');
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -124,7 +165,7 @@ export async function republishAsync({
|
||||
{ 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 },
|
||||
{ label: 'Update group ID', value: arbitraryRepublishedUpdate.group },
|
||||
...(updatesRepublishedByPlatform.android
|
||||
? [{ label: 'Android update ID', value: updatesRepublishedByPlatform.android.id }]
|
||||
: []),
|
||||
|
||||
@@ -627,11 +627,15 @@ describe(selectPaginatedAsync, () => {
|
||||
expect(queryAsync).toHaveBeenCalledWith({ first: pageSize });
|
||||
});
|
||||
|
||||
test('returns the only item when there is only one', async () => {
|
||||
test('still prompts if there is only one item avaiable', async () => {
|
||||
const node = { id: '1', name: 'Node 1' };
|
||||
queryAsync.mockResolvedValueOnce({
|
||||
edges: [{ node }],
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
jest.mocked(promptAsync).mockImplementation(async () => ({
|
||||
item: node,
|
||||
}));
|
||||
|
||||
const result = await selectPaginatedAsync({
|
||||
queryAsync,
|
||||
@@ -644,6 +648,7 @@ describe(selectPaginatedAsync, () => {
|
||||
expect(queryAsync).toHaveBeenCalledWith({
|
||||
first: pageSize,
|
||||
});
|
||||
expect(promptAsync).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('prompts for selection when there are multiple items', async () => {
|
||||
@@ -677,15 +682,6 @@ describe(selectPaginatedAsync, () => {
|
||||
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: {
|
||||
@@ -739,15 +735,6 @@ describe(selectPaginatedAsync, () => {
|
||||
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: {
|
||||
|
||||
@@ -262,14 +262,6 @@ export async function selectPaginatedAsync<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,
|
||||
@@ -296,11 +288,14 @@ async function selectPaginatedInternalAsync<T>({
|
||||
queryAsync: (queryParams: QueryParams) => Promise<Connection<T>>;
|
||||
getTitleAsync: (node: T) => Promise<string>;
|
||||
printedType: string;
|
||||
}): Promise<T> {
|
||||
}): Promise<T | null> {
|
||||
const limit = queryParams.first ?? queryParams.last;
|
||||
assert(limit, 'queryParams must have either first or last');
|
||||
const connection = await queryAsync(queryParams);
|
||||
const { edges, pageInfo } = connection;
|
||||
if (edges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
/*
|
||||
* 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"version": "3.18.0",
|
||||
"version": "4.0.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"dependencies": {
|
||||
|
||||
Reference in new issue
Block a user