Compare commits

...
25 Commits
Author SHA1 Message Date
Szymon Dziedzic e369ec1e25 v4.1.0 2023-08-08 15:10:39 +02:00
Szymon Dziedzic b353441f31 upgrade eas-cli-local-build-plugin to 1.0.34 2023-08-08 15:10:24 +02:00
Szymon Dziedzic 4db35e73c6 [eas-cli] bump @expo/steps version (#1995) 2023-08-08 15:02:33 +02:00
Szymon Dziedzic 10bc872dc9 [eas-json][eas-cli] add global withoutCredentials property to eas.json (#1994)
* [eas-json][eas-cli] add global `withoutCredentials` property to `eas.json`

* update CHANGELOG.md

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

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

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

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

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

* more efficient 'only one' detection

* update CHANGELOG.md

* fix tsc

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

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

replace plate emoji

more grammar nits

* update CHANGELOG.md

* szdziedzic feedback

* wschurman feedback

* send -> put

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

* update CHANGELOG.md

* warn only

* warn only

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

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

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

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

* [eas-cli] Adjust log messages

Improved the log message from testing phase

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

* [eas-cli] Fix test

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

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

* [eas-cli] Adjust message

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

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

* [eas-cli] Fix tests

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

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

* [eas-cli] Remove timeout

Jest.timeout was leftover from testing

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

* [eas-cli] Re-generate gql schema

Generated schema again from production

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

* [eas-cli] Re-generate gql schema

Generated schema again from production

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

* [eas-cli] Fix test

Fix test that was failing after re-generating gql schema

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

* [eas-cli] Add mac device class

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

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

* [eas-cli] Generate gql schema

Generated the schema again after merge from main

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

* [eas-cli] Remove `arch` check

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

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

* update CHANGELOG.md

* [eas-cli] Set mac device class automatically

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

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

* [eas-cli] Remove unused import

Import became redundant after last changes

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

* [eas-cli] Check for platform

Updated the apple silicon check to check the platform as well

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

* [eas-cli] Update command description

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

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

* [eas-cli] Remove unused import

Remove unused import

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

* tests

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

* update CHANGELOG.md
2023-08-03 10:18:18 -07:00
Expo CI 207f623338 update CHANGELOG.md 2023-08-03 12:53:11 +00:00
53 changed files with 1380 additions and 1052 deletions

No files matched your search

+49
View File
@@ -8,6 +8,55 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Pass credentials to custom iOS builds. ([#1989](https://github.com/expo/eas-cli/pull/1989) by [@szdziedzic](https://github.com/szdziedzic))
- Add the `withoutCredentials` option as a common build profile field in `eas.json`. ([#1994](https://github.com/expo/eas-cli/pull/1994) by [@szdziedzic](https://github.com/szdziedzic))
### 🐛 Bug fixes
### 🧹 Chores
## [4.0.1](https://github.com/expo/eas-cli/releases/tag/v4.0.1) - 2023-08-08
### 🐛 Bug fixes
- Fix `eas channel:*` to work with empty branch mappings. ([#1992](https://github.com/expo/eas-cli/pull/1992) by [@quinlanj](https://github.com/quinlanj))
## [4.0.0](https://github.com/expo/eas-cli/releases/tag/v4.0.0) - 2023-08-07
### 🛠 Breaking changes
- Release redesigned `eas channel:rollout` into developer preview. The set of flag arguments are different, in addition to the workflow. ([#1986](https://github.com/expo/eas-cli/pull/1986) by [@quinlanj](https://github.com/quinlanj))
### 🎉 New features
- Option to add current Apple Silicon device without the need to manually provide the provisioning UDID. ([#1943](https://github.com/expo/eas-cli/pull/1943) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
### 🐛 Bug fixes
- Fix rollout-preview ending with republish with code signing. ([#1978](https://github.com/expo/eas-cli/pull/1978) by [@wschurman](https://github.com/wschurman))
- Rollouts: fix fp precision. ([#1985](https://github.com/expo/eas-cli/pull/1985) by [@quinlanj](https://github.com/quinlanj))
### 🧹 Chores
- Fixing more grammar errors. ([#1980](https://github.com/expo/eas-cli/pull/1980) by [@quinlanj](https://github.com/quinlanj))
- Handle rollout edit edge case. ([#1981](https://github.com/expo/eas-cli/pull/1981) by [@quinlanj](https://github.com/quinlanj))
- Linked expo.fyi rollout article. ([#1991](https://github.com/expo/eas-cli/pull/1991) by [@quinlanj](https://github.com/quinlanj))
- Rollout ux improvements. ([#1984](https://github.com/expo/eas-cli/pull/1984) by [@quinlanj](https://github.com/quinlanj))
## [3.18.3](https://github.com/expo/eas-cli/releases/tag/v3.18.3) - 2023-08-03
### 🐛 Bug fixes
- Fix republishing with code signing. ([#1973](https://github.com/expo/eas-cli/pull/1973) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- Add relationships flag to rollouts-preview. ([#1972](https://github.com/expo/eas-cli/pull/1972) by [@quinlanj](https://github.com/quinlanj))
- Get channel:{view,list,edit} to play nice with rollouts. ([#1974](https://github.com/expo/eas-cli/pull/1974) by [@quinlanj](https://github.com/quinlanj))
- Use 'roll out' instead of rollout for verbs. ([#1979](https://github.com/expo/eas-cli/pull/1979) by [@quinlanj](https://github.com/quinlanj))
## [3.18.2](https://github.com/expo/eas-cli/releases/tag/v3.18.2) - 2023-08-03
### 🐛 Bug fixes
- Revert adding `.nvmrc` support for setting node version. ([#1976](https://github.com/expo/eas-cli/pull/1976) by [@szdziedzic](https://github.com/szdziedzic))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "3.18.2",
"version": "4.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+76 -65
View File
@@ -133,7 +133,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/account/login.ts)_
_See code: [src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v4.1.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -150,7 +150,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:view`
@@ -167,7 +167,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -181,7 +181,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/analytics.ts)_
_See code: [src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v4.1.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -231,7 +231,7 @@ DESCRIPTION
create a branch
```
_See code: [src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -252,7 +252,7 @@ DESCRIPTION
delete a branch
```
_See code: [src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -272,7 +272,7 @@ DESCRIPTION
list all branches
```
_See code: [src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -292,7 +292,7 @@ DESCRIPTION
rename a branch
```
_See code: [src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -315,7 +315,7 @@ DESCRIPTION
view a branch
```
_See code: [src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -345,7 +345,7 @@ DESCRIPTION
start a build
```
_See code: [src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -362,7 +362,7 @@ DESCRIPTION
cancel a build
```
_See code: [src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -379,7 +379,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:inspect`
@@ -414,7 +414,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -452,7 +452,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -478,7 +478,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -502,7 +502,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -551,7 +551,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -570,7 +570,7 @@ DESCRIPTION
update version of an app
```
_See code: [src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -589,7 +589,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -606,7 +606,7 @@ DESCRIPTION
view a build for your project
```
_See code: [src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -627,7 +627,7 @@ DESCRIPTION
create a channel
```
_See code: [src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:edit [NAME]`
@@ -649,7 +649,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:list`
@@ -669,7 +669,7 @@ DESCRIPTION
list all channels
```
_See code: [src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -677,23 +677,31 @@ Roll a new branch out on a channel incrementally.
```
USAGE
$ eas channel:rollout [CHANNEL] [--branch <value>] [--percent <value>] [--end] [--json --non-interactive]
$ eas channel:rollout [CHANNEL] [--action create|edit|end] [--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.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -716,7 +724,7 @@ DESCRIPTION
view a channel
```
_See code: [src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -737,7 +745,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/config.ts)_
_See code: [src/commands/config.ts](https://github.com/expo/eas-cli/blob/v4.1.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -754,7 +762,7 @@ DESCRIPTION
manage credentials
```
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/credentials.ts)_
_See code: [src/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v4.1.0/packages/eas-cli/src/commands/credentials.ts)_
## `eas device:create`
@@ -768,7 +776,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -788,7 +796,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -809,7 +817,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -830,7 +838,7 @@ DESCRIPTION
rename a registered device
```
_See code: [src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -844,7 +852,7 @@ DESCRIPTION
view a device for your project
```
_See code: [src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -858,7 +866,7 @@ DESCRIPTION
display environment info
```
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v4.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas help [COMMAND]`
@@ -946,7 +954,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -963,7 +971,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -980,7 +988,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas open`
@@ -994,7 +1002,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/open.ts)_
_See code: [src/commands/open.ts](https://github.com/expo/eas-cli/blob/v4.1.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1008,7 +1016,7 @@ DESCRIPTION
information about the current project
```
_See code: [src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1030,7 +1038,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas secret:create`
@@ -1053,7 +1061,7 @@ DESCRIPTION
create an environment secret on the current project or owner account
```
_See code: [src/commands/secret/create.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/secret/create.ts)_
## `eas secret:delete`
@@ -1071,7 +1079,7 @@ DESCRIPTION
delete an environment secret by ID
```
_See code: [src/commands/secret/delete.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/secret/delete.ts)_
## `eas secret:list`
@@ -1085,7 +1093,7 @@ DESCRIPTION
list environment secrets available for your current app
```
_See code: [src/commands/secret/list.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/secret/list.ts)_
## `eas secret:push`
@@ -1105,7 +1113,7 @@ DESCRIPTION
read environment secrets from env file and store on the server
```
_See code: [src/commands/secret/push.ts](https://github.com/expo/eas-cli/blob/v3.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.1.0/packages/eas-cli/src/commands/secret/push.ts)_
## `eas submit`
@@ -1135,7 +1143,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v3.18.2/packages/eas-cli/src/commands/submit.ts)_
_See code: [src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v4.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/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.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
+9 -3
View File
@@ -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": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "3.18.2",
"version": "4.1.0",
"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.1.0",
"@expo/json-file": "8.2.37",
"@expo/multipart-body-parser": "1.1.0",
"@expo/osascript": "2.0.33",
@@ -27,7 +27,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.0",
"@expo/steps": "1.0.31",
"@expo/steps": "1.0.34",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "1.23.2",
"@oclif/plugin-autocomplete": "1.3.10",
@@ -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({
@@ -55,6 +55,5 @@ export async function ensureIosCredentialsForBuildResignAsync(
}
function shouldProvideCredentials(buildCtx: BuildContext<Platform.IOS>): boolean {
const isCustomBuild = buildCtx.buildProfile.config !== undefined;
return !buildCtx.buildProfile.simulator && !isCustomBuild;
return !buildCtx.buildProfile.simulator && !buildCtx.buildProfile.withoutCredentials;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import semver from 'semver';
import { ora } from '../ora';
const PLUGIN_PACKAGE_NAME = 'eas-cli-local-build-plugin';
const PLUGIN_PACKAGE_VERSION = '1.0.33';
const PLUGIN_PACKAGE_VERSION = '1.0.34';
export enum LocalBuildMode {
/**
@@ -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`)
);
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
paginatedQueryWithConfirmPromptAsync,
paginatedQueryWithSelectPromptAsync,
} from '../utils/queries';
import { logChannelDetails } from './utils';
import { logChannelDetails } from './print-utils';
export const CHANNELS_LIMIT = 25;
-110
View File
@@ -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,
};
}
}
+141 -448
View File
@@ -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();
}
+3 -3
View File
@@ -12,10 +12,10 @@ import { Actor } from '../user/User';
import DeviceCreateAction from './actions/create/action';
import { DeviceManagerContext } from './context';
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones and iPads) for internal distribution of your app.
const CREATE_COMMAND_DESCRIPTION = `This command lets you register your Apple devices (iPhones, iPads and Macs) for internal distribution of your app.
Internal distribution means that you won't need to upload your app archive to App Store / Testflight.
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisiong profile.
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones and iPads.
Your app archive (.ipa) will be installable on your equipment as long as you sign your application with an adhoc provisioning profile.
The provisioning profile needs to contain the UDIDs (unique identifiers) of your iPhones, iPads and Macs.
First of all, choose the Expo account under which you want to register your devices.
Later, authenticate with Apple and choose your desired Apple Team (if your Apple ID has access to multiple teams).`;
@@ -3,3 +3,9 @@ export class DeviceNotFoundError extends Error {
super(message ?? 'Device not found.');
}
}
export class DeviceCreateError extends Error {
constructor(message?: string) {
super(message ?? 'Failed to create a device.');
}
}
@@ -9,6 +9,7 @@ export type AppleTeamIdAndName = Pick<AppleTeam, 'appleTeamIdentifier' | 'appleT
const DEVICE_CLASS_DISPLAY_NAMES: Record<AppleDeviceClass, string> = {
[AppleDeviceClass.Iphone]: 'iPhone',
[AppleDeviceClass.Ipad]: 'iPad',
[AppleDeviceClass.Mac]: 'Mac',
};
function formatDeviceClass(device: Device | NewDevice): string {
+5 -4
View File
@@ -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,
};
}
+16 -5
View File
@@ -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');
+62 -21
View File
@@ -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: {
+4 -9
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with eas.json",
"version": "3.18.0",
"version": "4.1.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
+14 -2
View File
@@ -91,6 +91,12 @@
"type": "object",
"description": "The build profile configuration.",
"properties": {
"withoutCredentials": {
"type": "boolean",
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
"default": false
},
"extends": {
"type": "string",
"description": "The name of the build profile that the current one should inherit values from. This value can't be specified per platform."
@@ -208,8 +214,8 @@
"properties": {
"withoutCredentials": {
"type": "boolean",
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository.",
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository.",
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository or working with custom builds.",
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when you want to build debug binaries and the debug keystore is checked in to the repository or working with custom builds.",
"default": false
},
"image": {
@@ -301,6 +307,12 @@
"type": "object",
"description": "The iOS-specific build profile configuration.",
"properties": {
"withoutCredentials": {
"type": "boolean",
"description": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
"markdownDescription": "When set to `true`, EAS CLI won't require you to configure credentials when building the app. This comes in handy when working with custom builds.",
"default": false
},
"simulator": {
"type": "boolean",
"description": "If set to true, creates build for simulator.",
@@ -100,6 +100,105 @@ test('valid eas.json for development client builds', async () => {
});
});
test('valid eas.json with top level withoutCredentials property', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {},
debug: {
developmentClient: true,
withoutCredentials: true,
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'debug');
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.ANDROID,
'debug'
);
expect(androidProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
developmentClient: true,
withoutCredentials: true,
});
expect(iosProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
developmentClient: true,
withoutCredentials: true,
});
});
test('valid eas.json with iOS withoutCredentials property', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {},
debug: {
developmentClient: true,
ios: { withoutCredentials: true },
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'debug');
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.ANDROID,
'debug'
);
expect(androidProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
developmentClient: true,
});
expect(iosProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
developmentClient: true,
withoutCredentials: true,
});
});
test('valid eas.json with top level and platform-sepcific withoutCredentials property', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {},
debug: {
developmentClient: true,
withoutCredentials: true,
ios: { withoutCredentials: false },
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'debug');
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.ANDROID,
'debug'
);
expect(androidProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
developmentClient: true,
withoutCredentials: true,
});
expect(iosProfile).toEqual({
credentialsSource: 'remote',
distribution: 'store',
developmentClient: true,
withoutCredentials: false,
});
});
test(`valid eas.json with autoIncrement flag at build profile root`, async () => {
await fs.writeJson('/project/eas.json', {
build: {
+7 -5
View File
@@ -25,9 +25,11 @@ const CacheSchema = Joi.object({
cacheDefaultPaths: Joi.boolean(),
customPaths: Joi.array().items(Joi.string()),
paths: Joi.array().items(Joi.string()),
}).rename('customPaths', 'paths')
})
.rename('customPaths', 'paths')
.messages({
'object.rename.override': 'Cannot provide both "cache.customPaths" and "cache.paths" - use "cache.paths"'
'object.rename.override':
'Cannot provide both "cache.customPaths" and "cache.paths" - use "cache.paths"',
});
const CommonBuildProfileSchema = Joi.object({
@@ -63,6 +65,9 @@ const CommonBuildProfileSchema = Joi.object({
// custom build configuration
config: Joi.string(),
// credentials
withoutCredentials: Joi.boolean(),
});
const PlatformBuildProfileSchema = CommonBuildProfileSchema.concat(
@@ -84,9 +89,6 @@ const AndroidBuildProfileSchema = PlatformBuildProfileSchema.concat(
// build environment
ndk: Joi.string().empty(null).custom(semverCheck),
// credentials
withoutCredentials: Joi.boolean(),
// build configuration
gradleCommand: Joi.string(),
buildType: Joi.string().valid('apk', 'app-bundle'),
+3 -3
View File
@@ -70,6 +70,9 @@ export interface CommonBuildProfile {
// custom build configuration
config?: string;
// credentials
withoutCredentials?: boolean;
}
interface PlatformBuildProfile extends Omit<CommonBuildProfile, 'autoIncrement'> {
@@ -86,9 +89,6 @@ export interface AndroidBuildProfile extends PlatformBuildProfile {
image?: Android.BuilderEnvironment['image'];
ndk?: string;
// credentials
withoutCredentials?: boolean;
// build configuration
gradleCommand?: string;
buildType?: Android.BuildType.APK | Android.BuildType.APP_BUNDLE;
Generated
+4 -4
View File
@@ -1576,10 +1576,10 @@
dependencies:
cross-spawn "^7.0.3"
"@expo/[email protected]1":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.31.tgz#61e0456e54bc63bd714aa40b9be27503c032caf0"
integrity sha512-TPuaihUQ9MfVmgG5OzFdMKXcBAgv1dfLZWGpk/k5hWBjPZAoe01UXbw8GCt6F09tS2Z256d2G/kOTJzISLNcjQ==
"@expo/[email protected]4":
version "1.0.34"
resolved "https://registry.yarnpkg.com/@expo/steps/-/steps-1.0.34.tgz#ba2f27a9d3e163b2d0fe6ef7775b237f7d18a286"
integrity sha512-jQoxeMWWl2mUwLGyiUD38z/FNao2jbDP8Tjvkr/YAfIOHaro36E3Vmn1YsKTYmtc41TGcFdCzqly95faRNrhvQ==
dependencies:
"@expo/logger" "1.0.30"
"@expo/spawn-async" "^1.7.2"