Compare commits

...
11 Commits
Author SHA1 Message Date
Juwan Wheatley 9021887072 v0.27.1 2021-09-10 18:59:11 -04:00
Juwan Wheatley 6a80d4c8e9 [eas-cli] fix secrets mutations (#606)
* [eas-cli] fix secrets mutations

* add changelog entry

* fix typo
2021-09-10 18:42:10 -04:00
Dominik Sokal 471e26817a update CHANGELOG 2021-09-10 14:42:07 +02:00
Dominik Sokal 14bbd1ee0f v0.27.0 2021-09-10 14:30:41 +02:00
Dominik Sokal 22b06b444d [eas-json] allow env vars in android.serviceAccountKeyPath (#604)
* [eas-json] allow env vars in android.serviceAccountKeyPath

* update CHANGELOG
2021-09-10 14:28:21 +02:00
Dominik Sokal cebd29e40d [ENG-755] implement auto-submits (#603)
* better experience when building for all platforms

* implement auto-submits

* address PR feedback

* update CHANGELOG

* refactor build command class

* better UX when Android submission completes without error

* address PR feedback

* small ux improvement
2021-09-10 12:32:00 +02:00
Dominik Sokal acf7e55b5b [ENG-755] add --non-interactive flag for eas submit (#600)
* add --non-interactive flag for eas submit

* update CHANGELOG

* add basic tests for --non-interactive mode

* fix long-running tests
2021-09-09 17:12:40 +02:00
Wojciech KozyraandDominik Sokal 7c29aa3b38 [eas-cli] fix google service detection on windows (#602)
* normalize path (C:/path/to/repo -> C:\path\to\repo)

* update CHANGELOG

* Update CHANGELOG.md

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

Co-authored-by: Dominik Sokal <[email protected]>
2021-09-09 15:03:00 +02:00
Dominik Sokal dee5b4cf60 mock generateRandomKeystoreAsync to make test execution faster 2021-09-09 11:19:39 +02:00
Dominik Sokal 896e14b1e4 [ENG-755] eas submit - implement --platform all (#598)
* reorder platform prompt options

* move RequestedPlatform enum

* unify selecting platforms in eas build/submit

* implement --platform all

* update CHANGELOG
2021-09-09 09:50:43 +02:00
Dominik Sokal 42c20d6ef5 update CHANGELOG 2021-09-08 10:12:38 +02:00
46 changed files with 780 additions and 415 deletions

No files matched your search

+26 -3
View File
@@ -6,7 +6,32 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- Require `eas submit` to be configured with eas.json submit profiles. Drop support for CLI params. ([#590](https://github.com/expo/eas-cli/pull/590) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
### 🐛 Bug fixes
- Fixed force-creation, app secret creation, and secret deletion mutations. ([#606](https://github.com/expo/eas-cli/pull/606) by [@fiberjw](https://github.com/fiberjw))
### 🧹 Chores
## [0.27.0](https://github.com/expo/eas-cli/releases/tag/v0.27.0) - 2021-09-10
### 🎉 New features
- Implement support for `--platform all` in `eas submit`. ([#598](https://github.com/expo/eas-cli/pull/598) by [@dsokal](https://github.com/dsokal))
- Implement support for `--non-interactive` in `eas submit`. ([#600](https://github.com/expo/eas-cli/pull/600) by [@dsokal](https://github.com/dsokal))
- Add auto-submit feature. Run `eas build --auto-submit` to submit automatically on build complete. ([#603](https://github.com/expo/eas-cli/pull/603) by [@dsokal](https://github.com/dsokal))
- Allow using env var for `android.serviceAccountKeyPath` in submit profiles. ([#604](https://github.com/expo/eas-cli/pull/604) by [@dsokal](https://github.com/dsokal))
### 🐛 Bug fixes
- Fix detecting `googleServicesFile` on Windows. ([#602](https://github.com/expo/eas-cli/pull/602) by [@wkozyra95](https://github.com/wkozyra95))
## [0.26.0](https://github.com/expo/eas-cli/releases/tag/v0.26.0) - 2021-09-08
### 🛠 Breaking changes
- Require `eas submit` to be configured with eas.json submit profiles (see [https://docs.expo.dev/submit/eas-json/](https://docs.expo.dev/submit/eas-json/) for details). Drop support for CLI params. ([#590](https://github.com/expo/eas-cli/pull/590) by [@dsokal](https://github.com/dsokal))
### 🎉 New features
@@ -17,8 +42,6 @@ This is the log of notable changes to EAS CLI and related packages.
- Fix `eas build` from throwing an exception when detecting the git `core.ignorecase` setting. ([#592](https://github.com/expo/eas-cli/pull/592) by [@mwillbanks](https://github.com/mwillbanks))
- Support app names that consist only of unicode characters. ([#596](https://github.com/expo/eas-cli/pull/596) by [@wkozyra95](https://github.com/wkozyra95))
### 🧹 Chores
## [0.25.0](https://github.com/expo/eas-cli/releases/tag/v0.25.0) - 2021-09-01
### 🛠 Breaking changes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.26.0"
"version": "0.27.1"
}
+59 -41
View File
@@ -73,7 +73,7 @@ ALIASES
$ eas login
```
_See code: [src/commands/account/login.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/account/login.js)_
_See code: [src/commands/account/login.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/account/login.js)_
## `eas account:logout`
@@ -87,7 +87,7 @@ ALIASES
$ eas logout
```
_See code: [src/commands/account/logout.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/account/logout.js)_
_See code: [src/commands/account/logout.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/account/logout.js)_
## `eas account:view`
@@ -101,7 +101,7 @@ ALIASES
$ eas whoami
```
_See code: [src/commands/account/view.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/account/view.js)_
_See code: [src/commands/account/view.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/account/view.js)_
## `eas analytics [STATUS]`
@@ -112,7 +112,7 @@ USAGE
$ eas analytics [STATUS]
```
_See code: [src/commands/analytics.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/analytics.js)_
_See code: [src/commands/analytics.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/analytics.js)_
## `eas build`
@@ -124,16 +124,28 @@ USAGE
OPTIONS
-p, --platform=(android|ios|all)
--clear-cache Clear cache before the build
--json Enable JSON output, non-JSON messages will be printed to stderr
--local Run build locally [experimental]
--non-interactive Run command in --non-interactive mode
--profile=profile [default: release] Name of the build profile from eas.json
--skip-project-configuration Skip project configuration
--[no-]wait Wait for build(s) to complete
--auto-submit Submit on build complete using the submit profile with the same name as the
build profile
--auto-submit-with-profile=PROFILE_NAME Submit on build complete using the submit profile with provided name
--clear-cache Clear cache before the build
--json Enable JSON output, non-JSON messages will be printed to stderr
--local Run build locally [experimental]
--non-interactive Run command in non-interactive mode
--profile=PROFILE_NAME [default: release] Name of the build profile from eas.json
--skip-project-configuration Skip project configuration
--[no-]wait Wait for build(s) to complete
```
_See code: [src/commands/build/index.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/build/index.js)_
_See code: [src/commands/build/index.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/build/index.js)_
## `eas build:cancel [BUILD_ID]`
@@ -144,7 +156,7 @@ USAGE
$ eas build:cancel [BUILD_ID]
```
_See code: [src/commands/build/cancel.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/build/cancel.js)_
_See code: [src/commands/build/cancel.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/build/cancel.js)_
## `eas build:configure`
@@ -158,7 +170,7 @@ OPTIONS
-p, --platform=(android|ios|all) Platform to configure
```
_See code: [src/commands/build/configure.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/build/configure.js)_
_See code: [src/commands/build/configure.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/build/configure.js)_
## `eas build:list`
@@ -191,7 +203,7 @@ OPTIONS
--status=(new|in-queue|in-progress|errored|finished|canceled)
```
_See code: [src/commands/build/list.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/build/list.js)_
_See code: [src/commands/build/list.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/build/list.js)_
## `eas build:view [BUILD_ID]`
@@ -205,7 +217,7 @@ OPTIONS
--json Enable JSON output, non-JSON messages will be printed to stderr
```
_See code: [src/commands/build/view.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/build/view.js)_
_See code: [src/commands/build/view.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/build/view.js)_
## `eas config`
@@ -220,7 +232,7 @@ OPTIONS
--profile=profile
```
_See code: [src/commands/config.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/config.js)_
_See code: [src/commands/config.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/config.js)_
## `eas credentials`
@@ -231,7 +243,7 @@ USAGE
$ eas credentials
```
_See code: [src/commands/credentials.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/credentials.js)_
_See code: [src/commands/credentials.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/credentials.js)_
## `eas device:create`
@@ -242,7 +254,7 @@ USAGE
$ eas device:create
```
_See code: [src/commands/device/create.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/device/create.js)_
_See code: [src/commands/device/create.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/device/create.js)_
## `eas device:list`
@@ -256,7 +268,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [src/commands/device/list.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/device/list.js)_
_See code: [src/commands/device/list.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/device/list.js)_
## `eas device:view [UDID]`
@@ -267,7 +279,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [src/commands/device/view.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/device/view.js)_
_See code: [src/commands/device/view.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/device/view.js)_
## `eas diagnostics`
@@ -278,7 +290,7 @@ USAGE
$ eas diagnostics
```
_See code: [src/commands/diagnostics.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/diagnostics.js)_
_See code: [src/commands/diagnostics.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/diagnostics.js)_
## `eas help [COMMAND]`
@@ -306,7 +318,7 @@ USAGE
$ eas project:info
```
_See code: [src/commands/project/info.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/project/info.js)_
_See code: [src/commands/project/info.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/project/info.js)_
## `eas project:init`
@@ -320,7 +332,7 @@ ALIASES
$ eas init
```
_See code: [src/commands/project/init.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/project/init.js)_
_See code: [src/commands/project/init.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/project/init.js)_
## `eas secret:create`
@@ -337,7 +349,7 @@ OPTIONS
--value=value Value of the secret
```
_See code: [src/commands/secret/create.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/secret/create.js)_
_See code: [src/commands/secret/create.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/secret/create.js)_
## `eas secret:delete`
@@ -354,7 +366,7 @@ DESCRIPTION
Unsure where to find the secret's ID? Run eas secrets:list
```
_See code: [src/commands/secret/delete.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/secret/delete.js)_
_See code: [src/commands/secret/delete.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/secret/delete.js)_
## `eas secret:list`
@@ -365,7 +377,7 @@ USAGE
$ eas secret:list
```
_See code: [src/commands/secret/list.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/secret/list.js)_
_See code: [src/commands/secret/list.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/secret/list.js)_
## `eas submit`
@@ -376,14 +388,20 @@ USAGE
$ eas submit
OPTIONS
-p, --platform=(android|ios)
--id=id ID of the build to submit
--latest Submit the latest build for specified platform
--path=path Path to the .apk/.aab/.ipa file
--profile=profile Name of the submit profile from eas.json. Defaults to "release" if defined in eas.json.
--url=url App archive url
--verbose Always print logs from Submission Service
--[no-]wait Wait for submission to complete
-p, --platform=(android|ios|all)
--id=id ID of the build to submit
--latest Submit the latest build for specified platform
--non-interactive Run command in non-interactive mode
--path=path Path to the .apk/.aab/.ipa file
--profile=profile Name of the submit profile from eas.json. Defaults to "release" if defined in
eas.json.
--url=url App archive url
--verbose Always print logs from Submission Service
--[no-]wait Wait for submission to complete
DESCRIPTION
See how to configure submits with eas.json: https://docs.expo.dev/submit/eas-json/
@@ -392,7 +410,7 @@ ALIASES
$ eas build:submit
```
_See code: [src/commands/submit.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/submit.js)_
_See code: [src/commands/submit.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/submit.js)_
## `eas webhook:create`
@@ -411,7 +429,7 @@ OPTIONS
--url=url Webhook URL
```
_See code: [src/commands/webhook/create.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/webhook/create.js)_
_See code: [src/commands/webhook/create.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/webhook/create.js)_
## `eas webhook:delete [ID]`
@@ -425,7 +443,7 @@ ARGUMENTS
ID ID of the webhook to delete
```
_See code: [src/commands/webhook/delete.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/webhook/delete.js)_
_See code: [src/commands/webhook/delete.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/webhook/delete.js)_
## `eas webhook:list`
@@ -439,7 +457,7 @@ OPTIONS
--event=(BUILD) Event type that triggers the webhook
```
_See code: [src/commands/webhook/list.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/webhook/list.js)_
_See code: [src/commands/webhook/list.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/webhook/list.js)_
## `eas webhook:update`
@@ -459,7 +477,7 @@ OPTIONS
--url=url Webhook URL
```
_See code: [src/commands/webhook/update.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/webhook/update.js)_
_See code: [src/commands/webhook/update.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/webhook/update.js)_
## `eas webhook:view ID`
@@ -473,5 +491,5 @@ ARGUMENTS
ID ID of the webhook to view
```
_See code: [src/commands/webhook/view.js](https://github.com/expo/eas-cli/blob/v0.26.0/packages/eas-cli/src/commands/webhook/view.js)_
_See code: [src/commands/webhook/view.js](https://github.com/expo/eas-cli/blob/v0.27.1/packages/eas-cli/src/commands/webhook/view.js)_
<!-- commandsstop -->
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.26.0",
"version": "0.27.1",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -14,7 +14,7 @@
"@expo/config": "3.3.42",
"@expo/config-plugins": "3.1.0",
"@expo/eas-build-job": "0.2.46",
"@expo/eas-json": "^0.26.0",
"@expo/eas-json": "^0.27.0",
"@expo/json-file": "8.2.25",
"@expo/pkcs12": "0.0.4",
"@expo/plist": "0.0.14",
+1 -1
View File
@@ -9,13 +9,13 @@ import { BuildFragment, BuildStatus, UploadSessionType } from '../graphql/genera
import { BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore } from '../log';
import { requestedPlatformDisplayNames } from '../platform';
import { promptAsync } from '../prompts';
import { uploadAsync } from '../uploads';
import { formatBytes } from '../utils/files';
import { createProgressTracker } from '../utils/progress';
import { sleep } from '../utils/promise';
import vcs from '../vcs';
import { requestedPlatformDisplayNames } from './constants';
import { BuildContext } from './context';
import { runLocalBuildAsync } from './local';
import { MetadataContext, collectMetadata } from './metadata';
+1 -1
View File
@@ -6,6 +6,7 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import Log, { learnMore } from '../log';
import { RequestedPlatform } from '../platform';
import { resolveWorkflowAsync } from '../project/workflow';
import { confirmAsync, promptAsync } from '../prompts';
import { ensureLoggedInAsync } from '../user/actions';
@@ -13,7 +14,6 @@ import vcs from '../vcs';
import { configureAndroidAsync } from './android/configure';
import { ConfigureContext } from './context';
import { configureIosAsync } from './ios/configure';
import { RequestedPlatform } from './types';
import { commitPromptAsync, maybeBailOnRepoStatusAsync } from './utils/repository';
const configureCommitMessage = {
-7
View File
@@ -1,7 +0,0 @@
import { RequestedPlatform } from './types';
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
[RequestedPlatform.Ios]: 'iOS',
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.All]: 'Android and iOS',
};
+2 -1
View File
@@ -5,13 +5,14 @@ import JsonFile from '@expo/json-file';
import resolveFrom from 'resolve-from';
import { v4 as uuidv4 } from 'uuid';
import { RequestedPlatform } from '../platform';
import { getExpoConfig } from '../project/expoConfig';
import { getProjectAccountName, getProjectIdAsync } from '../project/projectUtils';
import { resolveWorkflowAsync } from '../project/workflow';
import { findAccountByName } from '../user/Account';
import { Actor } from '../user/User';
import { ensureLoggedInAsync } from '../user/actions';
import { RequestedPlatform, TrackingContext } from './types';
import { TrackingContext } from './types';
import Analytics, { Event } from './utils/analytics';
export interface ConfigureContext {
-6
View File
@@ -1,9 +1,3 @@
export enum RequestedPlatform {
Android = 'android',
Ios = 'ios',
All = 'all',
}
export enum BuildStatus {
NEW = 'new',
IN_QUEUE = 'in-queue',
@@ -3,7 +3,7 @@ import { CredentialsSource } from '@expo/eas-json';
import chalk from 'chalk';
import Log from '../../log';
import { requestedPlatformDisplayNames } from '../constants';
import { requestedPlatformDisplayNames } from '../../platform';
export function logCredentialsSource(credentialsSource: CredentialsSource, platform: Platform) {
let message = `Using ${credentialsSource} ${requestedPlatformDisplayNames[platform]} credentials`;
+1 -1
View File
@@ -25,7 +25,7 @@ export async function checkGoogleServicesFileAsync<T extends Platform>(
if (!googleServicesFilePath) {
return;
}
const rootDir = await vcs.getRootPathAsync();
const rootDir = path.normalize(await vcs.getRootPathAsync());
const absGoogleServicesFilePath = path.resolve(ctx.projectDir, googleServicesFilePath);
if (
(await fs.pathExists(absGoogleServicesFilePath)) &&
@@ -2,8 +2,8 @@ import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import { configureAsync } from '../../build/configure';
import { RequestedPlatform } from '../../build/types';
import Log, { learnMore } from '../../log';
import { RequestedPlatform } from '../../platform';
import { findProjectRootAsync } from '../../project/projectUtils';
import { promptAsync } from '../../prompts';
import { ensureLoggedInAsync } from '../../user/actions';
+165 -55
View File
@@ -6,26 +6,40 @@ import {
migrateAsync,
} from '@expo/eas-json';
import { flags } from '@oclif/command';
import { error, exit } from '@oclif/errors';
import { error } from '@oclif/errors';
import chalk from 'chalk';
import { prepareAndroidBuildAsync } from '../../build/android/build';
import { BuildRequestSender, waitForBuildEndAsync } from '../../build/build';
import { ensureProjectConfiguredAsync } from '../../build/configure';
import { BuildContext, createBuildContextAsync } from '../../build/context';
import { prepareIosBuildAsync } from '../../build/ios/build';
import { RequestedPlatform } from '../../build/types';
import { printBuildResults, printLogsUrls } from '../../build/utils/printBuildInfo';
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
import EasCommand from '../../commandUtils/EasCommand';
import { BuildFragment, BuildStatus } from '../../graphql/generated';
import { BuildFragment, BuildStatus, SubmissionFragment } from '../../graphql/generated';
import { toAppPlatform, toPlatform } from '../../graphql/types/AppPlatform';
import Log from '../../log';
import {
RequestedPlatform,
appPlatformDisplayNames,
appPlatformEmojis,
selectRequestedPlatformAsync,
toPlatforms,
} from '../../platform';
import {
EAS_UNAVAILABLE_MESSAGE,
isEasEnabledForProjectAsync,
} from '../../project/isEasEnabledForProject';
import { validateMetroConfigForManagedWorkflowAsync } from '../../project/metroConfig';
import { findProjectRootAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { confirmAsync } from '../../prompts';
import { createSubmissionContext } from '../../submissions/context';
import {
submitAsync,
waitToCompleteAsync as waitForSubmissionsToCompleteAsync,
} from '../../submissions/submit';
import { printSubmissionDetailsUrls } from '../../submissions/utils/urls';
import { enableJsonOutput } from '../../utils/json';
import vcs from '../../vcs';
@@ -39,6 +53,8 @@ interface RawBuildFlags {
wait: boolean;
'clear-cache': boolean;
json: boolean;
'auto-submit': boolean;
'auto-submit-with-profile'?: string;
}
interface BuildFlags {
@@ -50,13 +66,18 @@ interface BuildFlags {
wait: boolean;
clearCache: boolean;
json: boolean;
autoSubmit: boolean;
submitProfile?: string;
}
export default class Build extends EasCommand {
static description = 'start a build';
static flags = {
platform: flags.enum({ char: 'p', options: ['android', 'ios', 'all'] }),
platform: flags.enum({
char: 'p',
options: ['android', 'ios', 'all'],
}),
'skip-credentials-check': flags.boolean({
default: false,
hidden: true,
@@ -72,10 +93,11 @@ export default class Build extends EasCommand {
profile: flags.string({
default: 'release',
description: 'Name of the build profile from eas.json',
helpValue: 'PROFILE_NAME',
}),
'non-interactive': flags.boolean({
default: false,
description: 'Run command in --non-interactive mode',
description: 'Run command in non-interactive mode',
}),
local: flags.boolean({
default: false,
@@ -90,8 +112,21 @@ export default class Build extends EasCommand {
default: false,
description: 'Clear cache before the build',
}),
'auto-submit': flags.boolean({
default: false,
description:
'Submit on build complete using the submit profile with the same name as the build profile',
exclusive: ['auto-submit-with-profile'],
}),
'auto-submit-with-profile': flags.string({
description: 'Submit on build complete using the submit profile with provided name',
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
};
private metroConfigValidated = false;
async run(): Promise<void> {
const { flags: rawFlags } = this.parse(Build);
if (rawFlags.json) {
@@ -108,33 +143,16 @@ export default class Build extends EasCommand {
await ensureProjectConfiguredAsync(projectDir, requestedPlatform);
const easJsonReader = new EasJsonReader(projectDir);
const platformsToBuild = this.getPlatformsToBuild(requestedPlatform);
const platforms = toPlatforms(requestedPlatform);
const startedBuilds: BuildFragment[] = [];
let metroConfigValidated = false;
for (const platform of platformsToBuild) {
const ctx = await createBuildContextAsync({
buildProfileName: flags.profile,
clearCache: flags.clearCache,
buildProfile: await easJsonReader.readBuildProfileAsync(platform, flags.profile),
local: flags.local,
nonInteractive: flags.nonInteractive,
platform,
for (const platform of platforms) {
const maybeBuild = await this.prepareAndStartBuildAsync({
projectDir,
skipProjectConfiguration: flags.skipProjectConfiguration,
platform,
flags,
moreBuilds: platforms.length > 1,
});
if (ctx.workflow === Workflow.MANAGED && !metroConfigValidated) {
await validateMetroConfigForManagedWorkflowAsync(ctx);
metroConfigValidated = true;
}
if (!ctx.local && !(await isEasEnabledForProjectAsync(ctx.projectId))) {
error(EAS_UNAVAILABLE_MESSAGE, { exit: 1 });
}
const maybeBuild = await this.startBuildAsync(ctx);
if (maybeBuild) {
startedBuilds.push(maybeBuild);
}
@@ -147,10 +165,38 @@ export default class Build extends EasCommand {
printLogsUrls(startedBuilds);
Log.newLine();
if (flags.wait) {
const builds = await waitForBuildEndAsync(startedBuilds.map(build => build.id));
printBuildResults(builds, flags.json);
const submissions: SubmissionFragment[] = [];
if (flags.autoSubmit) {
for (const build of startedBuilds) {
const submission = await this.prepareAndStartSubmissionAsync({
projectDir,
build,
flags,
moreBuilds: startedBuilds.length > 1,
});
submissions.push(submission);
}
Log.newLine();
printSubmissionDetailsUrls(submissions);
Log.newLine();
}
if (!flags.wait) {
return;
}
const builds = await waitForBuildEndAsync(startedBuilds.map(build => build.id));
printBuildResults(builds, flags.json);
const haveAllBuildsFailedOrCanceled = builds.every(
build => build?.status && [BuildStatus.Errored, BuildStatus.Canceled].includes(build?.status)
);
if (haveAllBuildsFailedOrCanceled || !flags.autoSubmit) {
this.exitWithNonZeroCodeIfSomeBuildsFailed(builds);
} else {
// the following function also exits with non zero code if any of the submissions failed
await waitForSubmissionsToCompleteAsync(submissions);
}
}
@@ -162,10 +208,14 @@ export default class Build extends EasCommand {
if (flags.json && !nonInteractive) {
error('--json is allowed only when building in non-interactive mode', { exit: 1 });
}
const requestedPlatform =
(flags.platform as RequestedPlatform | undefined) ?? (await this.promptForPlatformAsync());
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
if (flags.local) {
if (flags['auto-submit'] || flags['auto-submit-with-profile'] !== undefined) {
// TODO: implement this
error('Auto-submits are not yet supported when building locally', { exit: 1 });
}
if (requestedPlatform === RequestedPlatform.All) {
error('Builds for multiple platforms are not supported with flag --local', { exit: 1 });
} else if (process.platform !== 'darwin' && requestedPlatform === RequestedPlatform.Ios) {
@@ -181,40 +231,65 @@ export default class Build extends EasCommand {
Log.newLine();
}
const profile = flags['profile'];
return {
requestedPlatform,
skipProjectConfiguration: flags['skip-project-configuration'],
profile: flags['profile'],
profile,
nonInteractive,
local: flags['local'],
wait: flags['wait'],
clearCache: flags['clear-cache'],
json: flags['json'],
autoSubmit: flags['auto-submit'],
submitProfile: flags['auto-submit-with-profile'] ?? profile,
};
}
private async promptForPlatformAsync(): Promise<RequestedPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Build for platforms',
name: 'platform',
choices: [
{ title: 'All', value: RequestedPlatform.All },
{ title: 'iOS', value: RequestedPlatform.Ios },
{ title: 'Android', value: RequestedPlatform.Android },
],
private async prepareAndStartBuildAsync({
projectDir,
platform,
flags,
moreBuilds,
}: {
projectDir: string;
platform: Platform;
flags: BuildFlags;
moreBuilds: boolean;
}): Promise<BuildFragment | undefined> {
const easJsonReader = new EasJsonReader(projectDir);
const buildProfile = await easJsonReader.readBuildProfileAsync(platform, flags.profile);
const buildCtx = await createBuildContextAsync({
buildProfileName: flags.profile,
clearCache: flags.clearCache,
buildProfile,
local: flags.local,
nonInteractive: flags.nonInteractive,
platform,
projectDir,
skipProjectConfiguration: flags.skipProjectConfiguration,
});
return platform;
}
private getPlatformsToBuild(requestedPlatform: RequestedPlatform): Platform[] {
if (requestedPlatform === RequestedPlatform.All) {
return [Platform.ANDROID, Platform.IOS];
} else if (requestedPlatform === RequestedPlatform.Android) {
return [Platform.ANDROID];
} else {
return [Platform.IOS];
if (moreBuilds) {
Log.newLine();
const appPlatform = toAppPlatform(platform);
Log.log(
`${appPlatformEmojis[appPlatform]} ${chalk.bold(
`${appPlatformDisplayNames[appPlatform]} build`
)}`
);
}
if (buildCtx.workflow === Workflow.MANAGED && !this.metroConfigValidated) {
await validateMetroConfigForManagedWorkflowAsync(buildCtx);
this.metroConfigValidated = true;
}
if (!buildCtx.local && !(await isEasEnabledForProjectAsync(buildCtx.projectId))) {
error(EAS_UNAVAILABLE_MESSAGE, { exit: 1 });
}
return await this.startBuildAsync(buildCtx);
}
private async startBuildAsync(ctx: BuildContext<Platform>): Promise<BuildFragment | undefined> {
@@ -227,12 +302,47 @@ export default class Build extends EasCommand {
return await sendBuildRequestAsync();
}
private async prepareAndStartSubmissionAsync({
projectDir,
build,
flags,
moreBuilds,
}: {
projectDir: string;
build: BuildFragment;
flags: BuildFlags;
moreBuilds: boolean;
}): Promise<SubmissionFragment> {
const easJsonReader = new EasJsonReader(projectDir);
const platform = toPlatform(build.platform);
const submitProfile = await easJsonReader.readSubmitProfileAsync(platform, flags.submitProfile);
const submissionCtx = createSubmissionContext({
platform,
projectDir,
projectId: build.project.id,
profile: submitProfile,
archiveFlags: { id: build.id },
nonInteractive: flags.nonInteractive,
});
if (moreBuilds) {
Log.newLine();
Log.log(
`${appPlatformEmojis[build.platform]} ${chalk.bold(
`${appPlatformDisplayNames[build.platform]} submission`
)}`
);
}
return await submitAsync(submissionCtx);
}
private exitWithNonZeroCodeIfSomeBuildsFailed(maybeBuilds: (BuildFragment | null)[]): void {
const failedBuilds = (maybeBuilds.filter(i => i) as BuildFragment[]).filter(
i => i.status === BuildStatus.Errored
);
if (failedBuilds.length > 0) {
exit(1);
process.exit(1);
}
}
}
+2 -1
View File
@@ -3,7 +3,7 @@ import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import ora from 'ora';
import { BuildDistributionType, BuildStatus, RequestedPlatform } from '../../build/types';
import { BuildDistributionType, BuildStatus } from '../../build/types';
import { formatGraphQLBuild } from '../../build/utils/formatBuild';
import {
AppPlatform,
@@ -12,6 +12,7 @@ import {
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import Log from '../../log';
import { RequestedPlatform } from '../../platform';
import {
findProjectRootAsync,
getProjectFullNameAsync,
@@ -149,6 +149,7 @@ export default class EnvironmentSecretCreate extends Command {
);
} else if (scope === EnvironmentSecretScope.ACCOUNT) {
const ownerAccount = findAccountByName(actor.accounts, accountName);
if (!ownerAccount) {
Log.warn(
`Your account (${getActorDisplayName(actor)}) doesn't have access to the ${chalk.bold(
@@ -159,11 +160,12 @@ export default class EnvironmentSecretCreate extends Command {
}
if (force) {
const existingSecrets = await EnvironmentSecretsQuery.byAcccountNameAsync(projectId);
const existingSecrets = await EnvironmentSecretsQuery.byAccountNameAsync(ownerAccount.name);
const existingSecret = existingSecrets.find(secret => secret.name === name);
if (existingSecret) {
await EnvironmentSecretMutation.delete(existingSecret.id);
Log.withTick(
`Deleting existing secret ${chalk.bold(name)} on account ${chalk.bold(
ownerAccount.name
+86 -112
View File
@@ -1,33 +1,48 @@
import { getConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { EasJsonReader, IosSubmitProfile } from '@expo/eas-json';
import { EasJsonReader } from '@expo/eas-json';
import { flags } from '@oclif/command';
import { exit } from '@oclif/errors';
import { error } from '@oclif/errors';
import chalk from 'chalk';
import EasCommand from '../commandUtils/EasCommand';
import { AppPlatform, SubmissionFragment, SubmissionStatus } from '../graphql/generated';
import { SubmissionFragment } from '../graphql/generated';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log, { learnMore } from '../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
import {
RequestedPlatform,
appPlatformDisplayNames,
appPlatformEmojis,
selectRequestedPlatformAsync,
toPlatforms,
} from '../platform';
import { isEasEnabledForProjectAsync, warnEasUnavailable } from '../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../project/projectUtils';
import { promptAsync } from '../prompts';
import AndroidSubmitCommand from '../submissions/android/AndroidSubmitCommand';
import IosSubmitCommand from '../submissions/ios/IosSubmitCommand';
import { SubmitArchiveFlags } from '../submissions/types';
import { displayLogsAsync } from '../submissions/utils/logs';
import { SubmitArchiveFlags, createSubmissionContext } from '../submissions/context';
import { submitAsync, waitToCompleteAsync } from '../submissions/submit';
import { printSubmissionDetailsUrls } from '../submissions/utils/urls';
import { waitForSubmissionsEndAsync } from '../submissions/utils/wait';
interface Flags {
interface RawFlags {
platform?: string;
profile?: string;
latest?: boolean;
id?: string;
path?: string;
url?: string;
verbose: boolean;
wait: boolean;
platform?: Platform;
profile?: string;
archiveFlags: SubmitArchiveFlags;
'non-interactive': boolean;
}
export default class BuildSubmit extends EasCommand {
interface Flags {
requestedPlatform: RequestedPlatform;
profile?: string;
archiveFlags: SubmitArchiveFlags;
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
}
export default class Submit extends EasCommand {
static description = `submit build archive to app store
See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/submit/eas-json/', {
learnMoreMessage: '',
@@ -37,7 +52,7 @@ See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/s
static flags = {
platform: flags.enum({
char: 'p',
options: ['android', 'ios'],
options: ['android', 'ios', 'all'],
}),
profile: flags.string({
description:
@@ -46,7 +61,6 @@ See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/s
latest: flags.boolean({
description: 'Submit the latest build for specified platform',
exclusive: ['id', 'path', 'url'],
default: false,
}),
id: flags.string({
description: 'ID of the build to submit',
@@ -69,13 +83,15 @@ See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/s
default: true,
allowNo: true,
}),
'non-interactive': flags.boolean({
default: false,
description: 'Run command in non-interactive mode',
}),
};
async run(): Promise<void> {
const flags = this.parseFlags();
const platform =
(flags.platform?.toUpperCase() as AppPlatform | undefined) ??
(await this.promptForPlatformAsync());
const { flags: rawFlags } = this.parse(Submit);
const flags = await this.sanitizeFlagsAsync(rawFlags);
const projectDir = (await findProjectRootAsync()) ?? process.cwd();
const { exp } = getConfig(projectDir, { skipSDKVersionRequirement: true });
@@ -88,114 +104,72 @@ See how to configure submits with eas.json: ${learnMore('https://docs.expo.dev/s
}
const easJsonReader = new EasJsonReader(projectDir);
const platforms = toPlatforms(flags.requestedPlatform);
const submissions: SubmissionFragment[] = [];
let iosSubmitProfile: IosSubmitProfile | null = null;
if (platform === AppPlatform.Android) {
const submitProfile = await easJsonReader.readSubmitProfileAsync(
Platform.ANDROID,
flags.profile
);
const ctx = AndroidSubmitCommand.createContext({
for (const platform of platforms) {
const profile = await easJsonReader.readSubmitProfileAsync(platform, flags.profile);
const ctx = createSubmissionContext({
platform,
projectDir,
projectId,
profile: submitProfile,
profile,
archiveFlags: flags.archiveFlags,
nonInteractive: flags.nonInteractive,
});
const command = new AndroidSubmitCommand(ctx);
submissions.push(await command.runAsync());
} else {
iosSubmitProfile = await easJsonReader.readSubmitProfileAsync(Platform.IOS, flags.profile);
const ctx = IosSubmitCommand.createContext({
projectDir,
projectId,
profile: iosSubmitProfile,
archiveFlags: flags.archiveFlags,
});
const command = new IosSubmitCommand(ctx);
submissions.push(await command.runAsync());
if (platforms.length > 1) {
Log.newLine();
const appPlatform = toAppPlatform(platform);
Log.log(
`${appPlatformEmojis[appPlatform]} ${chalk.bold(
`${appPlatformDisplayNames[appPlatform]} submission`
)}`
);
}
const submission = await submitAsync(ctx);
submissions.push(submission);
}
Log.newLine();
printSubmissionDetailsUrls(submissions);
if (flags.wait) {
Log.newLine();
const completedSubmissions = await waitForSubmissionsEndAsync(submissions);
for (const submission of completedSubmissions) {
if (completedSubmissions.length > 1) {
Log.log(
`${appPlatformEmojis[submission.platform]}${chalk.bold(
`${appPlatformDisplayNames[submission.platform]} submission`
)}`
);
}
if (
submission.platform === AppPlatform.Ios &&
submission.status === SubmissionStatus.Finished
) {
const logMsg = [
chalk.bold('Your binary has been successfully uploaded to App Store Connect!'),
'- It is now being processed by Apple - you will receive an e-mail when the processing finishes.',
'- It usually takes about 5-10 minutes depending on how busy Apple servers are.',
// ascAppIdentifier should be always available for ios submissions but check it anyway
submission.iosConfig?.ascAppIdentifier &&
`- When it’s done, you can see your build here: ${learnMore(
`https://appstoreconnect.apple.com/apps/${submission.iosConfig?.ascAppIdentifier}/appstore/ios`,
{ learnMoreMessage: '' }
)}`,
].join('\n');
Log.addNewLineIfNone();
Log.log(logMsg);
}
await displayLogsAsync(submission, { verbose: flags.verbose });
if (completedSubmissions.length > 1) {
Log.newLine();
}
}
this.exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions);
await waitToCompleteAsync(submissions, { verbose: flags.verbose });
}
}
private parseFlags(): Flags {
private async sanitizeFlagsAsync(flags: RawFlags): Promise<Flags> {
const {
flags: { platform, profile, verbose, wait, ...archiveFlags },
} = this.parse(BuildSubmit);
return {
platform: platform as Platform,
profile,
platform,
verbose,
wait,
profile,
'non-interactive': nonInteractive,
...archiveFlags
} = flags;
if (!flags.platform && nonInteractive) {
error('--platform is required when building in non-interactive mode', { exit: 1 });
}
const requestedPlatform = await selectRequestedPlatformAsync(flags.platform);
if (requestedPlatform === RequestedPlatform.All) {
if (archiveFlags.id || archiveFlags.path || archiveFlags.url) {
error(
'--id, --path, and --url params are only supported when performing a single-platform submit',
{ exit: 1 }
);
}
}
return {
archiveFlags,
requestedPlatform,
verbose,
wait,
profile,
nonInteractive,
};
}
private async promptForPlatformAsync(): Promise<AppPlatform> {
const { platform } = await promptAsync({
type: 'select',
message: 'Submit to platform',
name: 'platform',
choices: [
{
title: 'iOS',
value: AppPlatform.Ios,
},
{
title: 'Android',
value: AppPlatform.Android,
},
],
});
return platform;
}
private exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(submissions: SubmissionFragment[]): void {
const nonFinishedSubmissions = submissions.filter(
({ status }) => status !== SubmissionStatus.Finished
);
if (nonFinishedSubmissions.length > 0) {
exit(1);
}
}
}
@@ -3,15 +3,20 @@ import { jester as mockJester } from '../../../../credentials/__tests__/fixtures
import { testKeystore } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { askForUserProvidedAsync } from '../../../utils/promptForCredentials';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { CreateKeystore } from '../CreateKeystore';
jest.mock('../../../utils/promptForCredentials');
jest.mock('../../../../project/ensureProjectExists');
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../utils/promptForCredentials');
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
describe('CreateKeystore', () => {
beforeEach(() => {
asMock(generateRandomKeystoreAsync).mockReset();
});
it('creates a keystore in Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: false });
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
@@ -19,7 +24,8 @@ describe('CreateKeystore', () => {
await createKeystoreAction.runAsync(ctx);
// expect keystore to be created on expo servers
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync).toHaveBeenCalled();
expect(generateRandomKeystoreAsync).toHaveBeenCalled();
});
it('creates a keystore by uploading', async () => {
asMock(askForUserProvidedAsync).mockImplementationOnce(() => testKeystore);
@@ -29,11 +35,11 @@ describe('CreateKeystore', () => {
await createKeystoreAction.runAsync(ctx);
// expect keystore to be created on expo servers
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledWith(
expect(ctx.android.createKeystoreAsync).toHaveBeenCalledWith(
appLookupParams.account,
expect.objectContaining(testKeystore)
);
expect(generateRandomKeystoreAsync).not.toHaveBeenCalled();
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({ nonInteractive: true });
@@ -1,3 +1,4 @@
import { asMock } from '../../../../__tests__/utils';
import {
getNewAndroidApiMock,
testAndroidBuildCredentialsFragment,
@@ -6,14 +7,20 @@ import {
import { jester as mockJester } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetupBuildCredentials } from '../SetupBuildCredentials';
jest.mock('../../../../project/ensureProjectExists');
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
describe('SetupBuildCredentials', () => {
beforeEach(() => {
asMock(generateRandomKeystoreAsync).mockReset();
});
it('skips setup when there are prior credentials', async () => {
const ctx = createCtxMock({
nonInteractive: false,
@@ -29,7 +36,7 @@ describe('SetupBuildCredentials', () => {
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore not to be created
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(0);
expect(ctx.android.createKeystoreAsync).not.toHaveBeenCalled();
});
it('sets up credentials when there are no prior credentials', async () => {
const ctx = createCtxMock({
@@ -44,7 +51,8 @@ describe('SetupBuildCredentials', () => {
await setupBuildCredentialsAction.runAsync(ctx);
// expect keystore to be created
expect(ctx.android.createKeystoreAsync as any).toHaveBeenCalledTimes(1);
expect(ctx.android.createKeystoreAsync).toHaveBeenCalled();
expect(generateRandomKeystoreAsync).toHaveBeenCalled();
});
it('errors in Non-Interactive Mode', async () => {
const ctx = createCtxMock({
@@ -87,7 +87,7 @@ export default class IosCredentialsProvider {
const isPushKeySetup = await setupPushKeyAction.isPushKeySetupAsync(ctx);
if (isPushKeySetup) {
Log.succeed(
`Push Notifications setup for ${app.projectName}:${applicationTarget.bundleIdentifier}`
`Push Notifications setup for ${app.projectName}: ${applicationTarget.bundleIdentifier}`
);
return null;
}
@@ -48,7 +48,7 @@ export const EnvironmentSecretMutation = {
): Promise<EnvironmentSecretFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<CreateEnvironmentSecretForAppMutation>(
.mutation<CreateEnvironmentSecretForAppMutation>(
gql`
mutation CreateEnvironmentSecretForApp(
$input: CreateEnvironmentSecretInput!
@@ -73,7 +73,7 @@ export const EnvironmentSecretMutation = {
async delete(id: string): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeleteEnvironmentSecretMutation>(
.mutation<DeleteEnvironmentSecretMutation>(
gql`
mutation DeleteEnvironmentSecret($id: String!) {
environmentSecret {
@@ -19,7 +19,7 @@ export type EnvironmentSecretWithScope = EnvironmentSecretFragment & {
};
export const EnvironmentSecretsQuery = {
async byAcccountNameAsync(accountName: string): Promise<EnvironmentSecretFragment[]> {
async byAccountNameAsync(accountName: string): Promise<EnvironmentSecretFragment[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<EnvironmentSecretsByAccountNameQuery>(
@@ -74,7 +74,7 @@ export const EnvironmentSecretsQuery = {
projectFullName: string
): Promise<EnvironmentSecretWithScope[]> {
const [accountSecrets, appSecrets] = await Promise.all([
this.byAcccountNameAsync(projectAccountName),
this.byAccountNameAsync(projectAccountName),
this.byAppIdAsync(projectFullName),
]);
@@ -11,3 +11,11 @@ export function toAppPlatform(platform: Platform): AppPlatform {
throw new Error(`Unsupported platform: ${platform}`);
}
}
export function toPlatform(appPlatform: AppPlatform): Platform {
if (appPlatform === AppPlatform.Android) {
return Platform.ANDROID;
} else {
return Platform.IOS;
}
}
+47
View File
@@ -1,4 +1,7 @@
import { Platform } from '@expo/eas-build-job';
import { AppPlatform } from './graphql/generated';
import { promptAsync } from './prompts';
export const appPlatformDisplayNames: Record<AppPlatform, string> = {
[AppPlatform.Android]: 'Android',
@@ -9,3 +12,47 @@ export const appPlatformEmojis = {
[AppPlatform.Ios]: '🍎',
[AppPlatform.Android]: '🤖',
};
// for `eas build` and `eas submit`
export enum RequestedPlatform {
Android = 'android',
Ios = 'ios',
All = 'all',
}
export const requestedPlatformDisplayNames: Record<RequestedPlatform, string> = {
[RequestedPlatform.Android]: 'Android',
[RequestedPlatform.Ios]: 'iOS',
[RequestedPlatform.All]: 'Android and iOS',
};
export async function selectRequestedPlatformAsync(platform?: string): Promise<RequestedPlatform> {
if (
platform &&
Object.values(RequestedPlatform).includes(platform.toLowerCase() as RequestedPlatform)
) {
return platform.toLowerCase() as RequestedPlatform;
}
const { requestedPlatform } = await promptAsync({
type: 'select',
message: 'Select platform',
name: 'requestedPlatform',
choices: [
{ title: 'All', value: RequestedPlatform.All },
{ title: 'Android', value: RequestedPlatform.Android },
{ title: 'iOS', value: RequestedPlatform.Ios },
],
});
return requestedPlatform;
}
export function toPlatforms(requestedPlatform: RequestedPlatform): Platform[] {
if (requestedPlatform === RequestedPlatform.All) {
return [Platform.ANDROID, Platform.IOS];
} else if (requestedPlatform === RequestedPlatform.Android) {
return [Platform.ANDROID];
} else {
return [Platform.IOS];
}
}
@@ -16,18 +16,24 @@ interface ProjectInfo {
privacy?: AppPrivacy;
}
const projectCache: Record<string, string> = {};
/**
* Ensures project exists on EAS servers. Registers it when it doesn't
* @returns The project ID
*/
export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promise<string> {
const { accountName, projectName } = projectInfo;
const projectFullName = `@${accountName}/${projectName}`;
if (projectFullName in projectCache) {
return projectCache[projectFullName];
}
const actor = await ensureLoggedInAsync();
const account = findAccountByName(actor.accounts, accountName);
assert(account, `You must have access to the ${accountName} account to run this command`);
const projectFullName = `@${accountName}/${projectName}`;
const projectDashboardUrl = getProjectDashboardUrl(accountName, projectName);
const projectLink = terminalLink(projectFullName, projectDashboardUrl);
@@ -36,6 +42,7 @@ export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promis
const maybeId = await findProjectIdByAccountNameAndSlugNullableAsync(accountName, projectName);
if (maybeId) {
spinner.succeed(`Linked to project ${chalk.bold(projectLink)}`);
projectCache[projectFullName] = maybeId;
return maybeId;
}
@@ -47,6 +54,7 @@ export async function ensureProjectExistsAsync(projectInfo: ProjectInfo): Promis
privacy: projectInfo.privacy,
});
spinner.succeed(`Created ${chalk.bold(projectLink)} on Expo`);
projectCache[projectFullName] = id;
return id;
} catch (err) {
spinner.fail();
@@ -84,8 +92,8 @@ async function findProjectIdByAccountNameAndSlugAsync(
accountName: string,
slug: string
): Promise<string> {
const project = await ProjectQuery.byUsernameAndSlugAsync(accountName, slug);
return project.id;
const { id } = await ProjectQuery.byUsernameAndSlugAsync(accountName, slug);
return id;
}
/**
+1 -2
View File
@@ -1,5 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { exit } from '@oclif/errors';
import chalk from 'chalk';
import type MetroConfig from 'metro-config';
import resolveFrom from 'resolve-from';
@@ -38,7 +37,7 @@ export async function validateMetroConfigForManagedWorkflowAsync(ctx: BuildConte
});
if (shouldAbort) {
Log.log('Aborting...');
exit();
process.exit(1);
}
}
}
@@ -50,7 +50,7 @@ interface ArchivePromptSource extends ArchiveSourceBase {
export interface Archive {
build?: BuildFragment;
source: ArchiveSource;
url: string;
url?: string;
}
export type ArchiveSource =
@@ -62,8 +62,9 @@ export type ArchiveSource =
export async function getArchiveAsync(source: ArchiveSource): Promise<Archive> {
switch (source.sourceType) {
case ArchiveSourceType.prompt:
case ArchiveSourceType.prompt: {
return await handlePromptSourceAsync(source);
}
case ArchiveSourceType.url: {
return await handleUrlSourceAsync(source);
}
@@ -115,7 +116,6 @@ async function handleLatestSourceAsync(source: ArchiveLatestSource): Promise<Arc
return {
build: latestBuild,
url: latestBuild.artifacts.buildUrl,
source,
};
} catch (err) {
@@ -147,7 +147,6 @@ async function handleBuildIdSourceAsync(source: ArchiveBuildIdSource): Promise<A
return {
build,
source,
url: build.artifacts.buildUrl,
};
} catch (err) {
Log.error(chalk.bold(`Couldn't find build for id ${source.id}`));
@@ -166,7 +165,7 @@ async function handlePromptSourceAsync(source: ArchivePromptSource): Promise<Arc
message: 'What would you like to submit?',
choices: [
{
title: 'Latest build from EAS',
title: 'Latest finished build from EAS',
value: ArchiveSourceType.latest,
},
{ title: 'I have a url to the app archive', value: ArchiveSourceType.url },
@@ -6,8 +6,10 @@ import {
IosSubmissionConfigInput,
SubmissionFragment,
} from '../graphql/generated';
import { toAppPlatform } from '../graphql/types/AppPlatform';
import Log from '../log';
import { SubmissionContext } from './types';
import { appPlatformDisplayNames } from '../platform';
import { SubmissionContext } from './context';
export interface SubmissionInput<P extends Platform> {
projectId: string;
@@ -25,13 +27,14 @@ export default abstract class BaseSubmitter<P extends Platform, SubmissionOption
submissionInput: SubmissionInput<P>
): Promise<SubmissionFragment> {
Log.addNewLineIfNone();
const scheduleSpinner = ora('Scheduling submission').start();
const platformDisplayName = appPlatformDisplayNames[toAppPlatform(this.ctx.platform)];
const scheduleSpinner = ora(`Scheduling ${platformDisplayName} submission`).start();
try {
const submission = this.createPlatformSubmissionAsync(submissionInput);
scheduleSpinner.succeed();
scheduleSpinner.succeed(`Scheduled ${platformDisplayName} submission`);
return submission;
} catch (err) {
scheduleSpinner.fail('Failed to schedule submission');
scheduleSpinner.fail(`Failed to schedule ${platformDisplayName} submission`);
throw err;
}
}
@@ -185,5 +185,7 @@ function assertArchiveResult(
expectedUrl: string = ARCHIVE_URL
) {
expect(archive.source.sourceType).toBe(expectedSourceType);
expect(archive.url).toBe(expectedUrl);
if (archive.url) {
expect(archive.url).toBe(expectedUrl);
}
}
@@ -1,6 +1,6 @@
import { getConfig } from '@expo/config';
import { Platform } from '@expo/eas-build-job';
import { AndroidReleaseStatus, AndroidReleaseTrack, AndroidSubmitProfile } from '@expo/eas-json';
import { AndroidReleaseStatus, AndroidReleaseTrack } from '@expo/eas-json';
import { Result, result } from '@expo/results';
import capitalize from 'lodash/capitalize';
@@ -13,32 +13,12 @@ import Log from '../../log';
import { getApplicationIdAsync } from '../../project/android/applicationId';
import { ArchiveSource } from '../ArchiveSource';
import { resolveArchiveSource } from '../commons';
import { SubmissionContext, SubmitArchiveFlags } from '../types';
import { SubmissionContext } from '../context';
import { AndroidPackageSource, AndroidPackageSourceType } from './AndroidPackageSource';
import AndroidSubmitter, { AndroidSubmissionOptions } from './AndroidSubmitter';
import { ServiceAccountSource, ServiceAccountSourceType } from './ServiceAccountSource';
export default class AndroidSubmitCommand {
static createContext({
archiveFlags,
profile,
projectDir,
projectId,
}: {
archiveFlags: SubmitArchiveFlags;
profile: AndroidSubmitProfile;
projectDir: string;
projectId: string;
}): SubmissionContext<Platform.ANDROID> {
return {
archiveFlags,
platform: Platform.ANDROID,
profile,
projectDir,
projectId,
};
}
constructor(private ctx: SubmissionContext<Platform.ANDROID>) {}
async runAsync(): Promise<SubmissionFragment> {
@@ -65,7 +45,7 @@ export default class AndroidSubmitCommand {
if (errored.length > 0) {
const message = errored.map(err => err.reason?.message).join('\n');
Log.error(message);
throw new Error('Failed to submit the app');
throw new Error('Submission failed');
}
return {
@@ -95,6 +75,8 @@ export default class AndroidSubmitCommand {
sourceType: AndroidPackageSourceType.userDefined,
androidPackage,
});
} else if (this.ctx.nonInteractive) {
return result(new Error("Couldn't resolve the Android package."));
} else {
return result({
sourceType: AndroidPackageSourceType.prompt,
@@ -147,7 +129,11 @@ export default class AndroidSubmitCommand {
}
private resolveArchiveSource(): Result<ArchiveSource> {
return result(resolveArchiveSource(this.ctx, Platform.ANDROID));
try {
return result(resolveArchiveSource(this.ctx, Platform.ANDROID));
} catch (err: any) {
return result(err);
}
}
private resolveServiceAccountSource(): Result<ServiceAccountSource> {
@@ -157,6 +143,8 @@ export default class AndroidSubmitCommand {
sourceType: ServiceAccountSourceType.path,
path: serviceAccountKeyPath,
});
} else if (this.ctx.nonInteractive) {
return result(new Error('Set serviceAccountKeyPath in the submit profile (eas.json).'));
} else {
return result({
sourceType: ServiceAccountSourceType.detect,
@@ -1,3 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { AndroidReleaseStatus, AndroidReleaseTrack } from '@expo/eas-json';
import { vol } from 'memfs';
import { v4 as uuidv4 } from 'uuid';
@@ -13,6 +14,7 @@ import {
import { SubmissionMutation } from '../../../graphql/mutations/SubmissionMutation';
import { createTestProject } from '../../../project/__tests__/project-utils';
import { getProjectIdAsync } from '../../../project/projectUtils';
import { createSubmissionContext } from '../../context';
import { getLatestBuildForSubmissionAsync } from '../../utils/builds';
import AndroidSubmitCommand from '../AndroidSubmitCommand';
@@ -71,11 +73,35 @@ describe(AndroidSubmitCommand, () => {
asMock(getProjectIdAsync).mockClear();
});
describe('non-interactive mode', () => {
it("throws error if didn't provide serviceAccountKeyPath in the submit profile", async () => {
const projectId = uuidv4();
const ctx = createSubmissionContext({
platform: Platform.ANDROID,
projectDir: testProject.projectRoot,
projectId,
archiveFlags: {
url: 'http://expo.dev/fake.apk',
},
profile: {
track: AndroidReleaseTrack.internal,
releaseStatus: AndroidReleaseStatus.draft,
changesNotSentForReview: false,
},
nonInteractive: true,
});
const command = new AndroidSubmitCommand(ctx);
await expect(command.runAsync()).rejects.toThrowError();
});
});
describe('sending submission', () => {
it('sends a request to Submission Service', async () => {
const projectId = uuidv4();
const ctx = AndroidSubmitCommand.createContext({
const ctx = createSubmissionContext({
platform: Platform.ANDROID,
projectDir: testProject.projectRoot,
projectId,
archiveFlags: {
@@ -87,6 +113,7 @@ describe(AndroidSubmitCommand, () => {
releaseStatus: AndroidReleaseStatus.draft,
changesNotSentForReview: false,
},
nonInteractive: false,
});
const command = new AndroidSubmitCommand(ctx);
await command.runAsync();
@@ -108,7 +135,8 @@ describe(AndroidSubmitCommand, () => {
const projectId = uuidv4();
asMock(getLatestBuildForSubmissionAsync).mockResolvedValueOnce(fakeBuildFragment);
const ctx = AndroidSubmitCommand.createContext({
const ctx = createSubmissionContext({
platform: Platform.ANDROID,
projectDir: testProject.projectRoot,
projectId,
archiveFlags: {
@@ -120,6 +148,7 @@ describe(AndroidSubmitCommand, () => {
releaseStatus: AndroidReleaseStatus.draft,
changesNotSentForReview: false,
},
nonInteractive: false,
});
const command = new AndroidSubmitCommand(ctx);
await command.runAsync();
@@ -127,7 +156,6 @@ describe(AndroidSubmitCommand, () => {
expect(SubmissionMutation.createAndroidSubmissionAsync).toHaveBeenCalledWith({
appId: projectId,
config: {
archiveUrl: 'http://expo.dev/fake.apk',
applicationIdentifier: testProject.appJSON.expo.android?.package,
googleServiceAccountKeyJson: fakeFiles['/google-service-account.json'],
releaseStatus: SubmissionAndroidReleaseStatus.Draft,
+3 -1
View File
@@ -2,7 +2,7 @@ import { Platform } from '@expo/eas-build-job';
import * as uuid from 'uuid';
import { ArchiveSource, ArchiveSourceType } from './ArchiveSource';
import { SubmissionContext } from './types';
import { SubmissionContext } from './context';
export function resolveArchiveSource<T extends Platform>(
ctx: SubmissionContext<T>,
@@ -44,6 +44,8 @@ export function resolveArchiveSource<T extends Platform>(
platform,
projectId: ctx.projectId,
};
} else if (ctx.nonInteractive) {
throw new Error('You need to specify the archive source when running in non-interactive mode ');
} else {
return {
sourceType: ArchiveSourceType.prompt,
@@ -7,6 +7,7 @@ export interface SubmissionContext<T extends Platform> {
profile: SubmitProfile<T>;
projectDir: string;
projectId: string;
nonInteractive: boolean;
}
export interface SubmitArchiveFlags {
@@ -15,3 +16,14 @@ export interface SubmitArchiveFlags {
path?: string;
url?: string;
}
export function createSubmissionContext<T extends Platform>(params: {
platform: T;
archiveFlags: SubmitArchiveFlags;
profile: SubmitProfile<T>;
projectDir: string;
projectId: string;
nonInteractive: boolean;
}): SubmissionContext<T> {
return params;
}
@@ -11,7 +11,7 @@ import {
import Log from '../../log';
import { getBundleIdentifierAsync } from '../../project/ios/bundleIdentifier';
import { promptAsync } from '../../prompts';
import { SubmissionContext } from '../types';
import { SubmissionContext } from '../context';
import { sanitizeLanguage } from './utils/language';
interface CreateAppOptions {
@@ -32,11 +32,8 @@ type AppStoreResult = {
export async function ensureAppStoreConnectAppExistsAsync(
ctx: SubmissionContext<Platform.IOS>
): Promise<AppStoreResult> {
const projectConfig = getConfig(ctx.projectDir, { skipSDKVersionRequirement: true });
const { exp } = projectConfig;
const { exp } = getConfig(ctx.projectDir, { skipSDKVersionRequirement: true });
const { appName, language } = ctx.profile;
const options = {
...ctx.profile,
bundleIdentifier: await getBundleIdentifierAsync(ctx.projectDir, exp),
@@ -1,5 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { IosSubmitProfile } from '@expo/eas-json';
import { Result, result } from '@expo/results';
import chalk from 'chalk';
import getenv from 'getenv';
@@ -11,7 +10,7 @@ import { promptAsync } from '../../prompts';
import UserSettings from '../../user/UserSettings';
import { ArchiveSource } from '../ArchiveSource';
import { resolveArchiveSource } from '../commons';
import { SubmissionContext, SubmitArchiveFlags } from '../types';
import { SubmissionContext } from '../context';
import { ensureAppStoreConnectAppExistsAsync } from './AppProduce';
import {
AppSpecificPasswordSource,
@@ -20,26 +19,6 @@ import {
import IosSubmitter, { IosSubmissionOptions } from './IosSubmitter';
export default class IosSubmitCommand {
static createContext({
archiveFlags,
profile,
projectDir,
projectId,
}: {
archiveFlags: SubmitArchiveFlags;
profile: IosSubmitProfile;
projectDir: string;
projectId: string;
}): SubmissionContext<Platform.IOS> {
return {
archiveFlags,
platform: Platform.IOS,
profile,
projectDir,
projectId,
};
}
constructor(private ctx: SubmissionContext<Platform.IOS>) {}
async runAsync(): Promise<SubmissionFragment> {
@@ -52,20 +31,25 @@ export default class IosSubmitCommand {
private async resolveSubmissionOptionsAsync(): Promise<IosSubmissionOptions> {
const archiveSource = this.resolveArchiveSource();
const appSpecificPasswordSource = this.resolveAppSpecificPasswordSource();
const ascAppIdentifier = await this.resolveAscAppIdentifierAsync();
const appleIdUsername = await this.resolveAppleIdUsernameAsync();
const errored = [archiveSource, appSpecificPasswordSource].filter(r => !r.ok);
const errored = [
archiveSource,
appSpecificPasswordSource,
ascAppIdentifier,
appleIdUsername,
].filter(r => !r.ok);
if (errored.length > 0) {
const message = errored.map(err => err.reason?.message).join('\n');
Log.error(message);
throw new Error('Failed to submit the app');
throw new Error('Submission failed');
}
const { appleIdUsername, ascAppIdentifier } = await this.getAppStoreInfoAsync();
return {
projectId: this.ctx.projectId,
appleIdUsername,
ascAppIdentifier,
appleIdUsername: appleIdUsername.enforceValue(),
ascAppIdentifier: ascAppIdentifier.enforceValue(),
archiveSource: archiveSource.enforceValue(),
appSpecificPasswordSource: appSpecificPasswordSource.enforceValue(),
};
@@ -79,77 +63,79 @@ export default class IosSubmitCommand {
sourceType: AppSpecificPasswordSourceType.userDefined,
appSpecificPassword: envAppSpecificPassword,
});
} else if (this.ctx.nonInteractive) {
return result(new Error('Set the EXPO_APPLE_APP_SPECIFIC_PASSWORD environment variable.'));
} else {
return result({
sourceType: AppSpecificPasswordSourceType.prompt,
});
}
return result({
sourceType: AppSpecificPasswordSourceType.prompt,
});
}
private resolveArchiveSource(): Result<ArchiveSource> {
return result(resolveArchiveSource(this.ctx, Platform.IOS));
try {
return result(resolveArchiveSource(this.ctx, Platform.IOS));
} catch (err: any) {
return result(err);
}
}
/**
* Returns App Store related information required for build submission
* It is:
* - User Apple ID
* - App Store Connect app ID (appAppleId)
*/
private async getAppStoreInfoAsync(): Promise<{
appleIdUsername: string;
ascAppIdentifier: string;
}> {
private async resolveAscAppIdentifierAsync(): Promise<Result<string>> {
const { ascAppId } = this.ctx.profile;
if (ascAppId) {
return {
appleIdUsername: await this.getAppleIdAsync(),
ascAppIdentifier: ascAppId,
};
return result(ascAppId);
} else if (this.ctx.nonInteractive) {
return result(new Error('Set ascAppId in the submit profile (eas.json).'));
} else {
Log.log(
wrapAnsi(
chalk.italic(
`Ensuring your app exists on App Store Connect. This step can be skipped by providing ascAppId in the submit profile. ${learnMore(
'https://expo.fyi/asc-app-id'
)}`
),
process.stdout.columns || 80
)
);
Log.addNewLineIfNone();
try {
const { ascAppIdentifier } = await ensureAppStoreConnectAppExistsAsync(this.ctx);
return result(ascAppIdentifier);
} catch (err: any) {
return result(err);
}
}
}
private async resolveAppleIdUsernameAsync(): Promise<Result<string>> {
if (this.ctx.profile.appleId) {
return result(this.ctx.profile.appleId);
}
Log.log(
wrapAnsi(
chalk.italic(
`Ensuring your app exists on App Store Connect. This step can be skipped by providing ascAppId in the submit profile. ${learnMore(
'https://expo.fyi/asc-app-id'
)}`
),
process.stdout.columns || 80
)
);
Log.addNewLineIfNone();
return await ensureAppStoreConnectAppExistsAsync(this.ctx);
}
/**
* This is going to be used only when `produce` is not being run,
* and we don't need to call full credentials.authenticateAsync()
* and we just need apple ID
*/
private async getAppleIdAsync(): Promise<string> {
const { appleId } = this.ctx.profile;
const envAppleId = getenv.string('EXPO_APPLE_ID', '');
if (appleId) {
return appleId;
} else if (envAppleId) {
return envAppleId;
if (envAppleId) {
return result(envAppleId);
}
// Get the email address that was last used and set it as
// the default value for quicker authentication.
const lastAppleId = await UserSettings.getAsync('appleId', null);
const { appleId: promptAppleId } = await promptAsync({
if (this.ctx.nonInteractive) {
if (lastAppleId) {
return result(lastAppleId);
} else {
return result(new Error('Set appleId in the submit profile (eas.json).'));
}
}
const { appleId } = await promptAsync({
type: 'text',
name: 'appleId',
message: `Enter your Apple ID:`,
validate: (val: string) => !!val,
initial: lastAppleId ?? undefined,
});
return promptAppleId;
return result(appleId);
}
}
@@ -63,7 +63,6 @@ export default class IosSubmitter extends BaseSubmitter<Platform.IOS, IosSubmiss
const appSpecificPassword = await getAppSpecificPasswordAsync(
this.options.appSpecificPasswordSource
);
return {
archive,
appSpecificPassword,
@@ -1,3 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { v4 as uuidv4 } from 'uuid';
@@ -6,6 +7,7 @@ import { jester as mockJester } from '../../../credentials/__tests__/fixtures-co
import { SubmissionMutation } from '../../../graphql/mutations/SubmissionMutation';
import { createTestProject } from '../../../project/__tests__/project-utils';
import { getProjectIdAsync } from '../../../project/projectUtils';
import { createSubmissionContext } from '../../context';
import IosSubmitCommand from '../IosSubmitCommand';
jest.mock('fs');
@@ -51,13 +53,35 @@ describe(IosSubmitCommand, () => {
asMock(getProjectIdAsync).mockClear();
});
describe('non-interactive mode', () => {
it("throws error if didn't provide appleId and ascAppId in the submit profile", async () => {
const projectId = uuidv4();
const ctx = createSubmissionContext({
platform: Platform.IOS,
projectDir: testProject.projectRoot,
projectId,
archiveFlags: {
url: 'http://expo.dev/fake.ipa',
},
profile: {
language: 'en-US',
},
nonInteractive: true,
});
const command = new IosSubmitCommand(ctx);
await expect(command.runAsync()).rejects.toThrowError();
});
});
describe('sending submission', () => {
it('sends a request to Submission Service', async () => {
const projectId = uuidv4();
process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD = 'supersecret';
const ctx = IosSubmitCommand.createContext({
const ctx = createSubmissionContext({
platform: Platform.IOS,
projectDir: testProject.projectRoot,
projectId,
archiveFlags: {
@@ -68,6 +92,7 @@ describe(IosSubmitCommand, () => {
appleId: '[email protected]',
ascAppId: '12345678',
},
nonInteractive: false,
});
const command = new IosSubmitCommand(ctx);
await command.runAsync();
@@ -0,0 +1,86 @@
import { Platform } from '@expo/eas-build-job';
import chalk from 'chalk';
import { AppPlatform, SubmissionFragment, SubmissionStatus } from '../graphql/generated';
import Log, { learnMore } from '../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
import AndroidSubmitCommand from './android/AndroidSubmitCommand';
import { SubmissionContext } from './context';
import IosSubmitCommand from './ios/IosSubmitCommand';
import { displayLogsAsync } from './utils/logs';
import { waitForSubmissionsEndAsync } from './utils/wait';
export async function submitAsync<T extends Platform>(
ctx: SubmissionContext<T>
): Promise<SubmissionFragment> {
const command =
ctx.platform === Platform.ANDROID
? new AndroidSubmitCommand(ctx as SubmissionContext<Platform.ANDROID>)
: new IosSubmitCommand(ctx as SubmissionContext<Platform.IOS>);
return command.runAsync();
}
export async function waitToCompleteAsync(
submissions: SubmissionFragment[],
{ verbose = false }: { verbose?: boolean } = {}
): Promise<void> {
Log.newLine();
const completedSubmissions = await waitForSubmissionsEndAsync(submissions);
const moreSubmissions = completedSubmissions.length > 1;
if (moreSubmissions) {
Log.newLine();
}
for (const submission of completedSubmissions) {
if (moreSubmissions) {
Log.log(
`${appPlatformEmojis[submission.platform]} ${chalk.bold(
`${appPlatformDisplayNames[submission.platform]} submission`
)}`
);
}
if (submission.platform === AppPlatform.Android) {
printInstructionsForAndroidSubmission(submission);
} else {
printInstructionsForIosSubmission(submission);
}
await displayLogsAsync(submission, { verbose, moreSubmissions });
if (moreSubmissions) {
Log.newLine();
}
}
exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(completedSubmissions);
}
function printInstructionsForAndroidSubmission(submission: SubmissionFragment): void {
if (submission.status === SubmissionStatus.Finished) {
Log.addNewLineIfNone();
Log.log('All done!');
}
}
function printInstructionsForIosSubmission(submission: SubmissionFragment): void {
if (submission.status === SubmissionStatus.Finished) {
const logMsg = [
chalk.bold('Your binary has been successfully uploaded to App Store Connect!'),
'- It is now being processed by Apple - you will receive an e-mail when the processing finishes.',
'- It usually takes about 5-10 minutes depending on how busy Apple servers are.',
// ascAppIdentifier should be always available for ios submissions but check it anyway
submission.iosConfig?.ascAppIdentifier &&
`- When it’s done, you can see your build here: ${learnMore(
`https://appstoreconnect.apple.com/apps/${submission.iosConfig?.ascAppIdentifier}/appstore/ios`,
{ learnMoreMessage: '' }
)}`,
].join('\n');
Log.addNewLineIfNone();
Log.log(logMsg);
}
}
function exitWithNonZeroCodeIfSomeSubmissionsDidntFinish(submissions: SubmissionFragment[]): void {
const nonFinishedSubmissions = submissions.filter(
({ status }) => status !== SubmissionStatus.Finished
);
if (nonFinishedSubmissions.length > 0) {
process.exit(1);
}
}
@@ -1,56 +1,32 @@
import { AppPlatform, BuildArtifacts, BuildFragment, BuildStatus } from '../../graphql/generated';
import { AppPlatform, BuildFragment, BuildStatus } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
// `BuildFragment` with non-null `artifacts.buildUrl`
type BuildFragmentWithArtifact = BuildFragment & {
artifacts: BuildArtifacts & { buildUrl: string };
};
/**
* Gets build by ID and ensures that `artifacts.buildUrl` exists
*/
export async function getBuildByIdForSubmissionAsync(
platform: AppPlatform,
buildId: string
): Promise<BuildFragmentWithArtifact> {
): Promise<BuildFragment> {
const build = await BuildQuery.byIdAsync(buildId);
if (build.platform !== platform) {
throw new Error("Build platform doesn't match!");
}
if (!buildFragmentHasArtifact(build)) {
throw new Error('Build has no artifacts or build URL is not defined.');
}
return build;
}
export async function getLatestBuildForSubmissionAsync(
platform: AppPlatform,
appId: string
): Promise<BuildFragmentWithArtifact | null> {
const builds = await BuildQuery.allForAppAsync(appId, {
): Promise<BuildFragment | null> {
const [build] = await BuildQuery.allForAppAsync(appId, {
limit: 1,
filter: {
platform,
status: BuildStatus.Finished,
},
});
if (builds.length < 1) {
return null;
}
const build = builds[0];
if (!buildFragmentHasArtifact(build)) {
return null;
}
return build;
}
// Utility function needed to make TypeScript happy
function buildFragmentHasArtifact(build: BuildFragment): build is BuildFragmentWithArtifact {
return build.artifacts?.buildUrl != null;
}
+13 -10
View File
@@ -2,16 +2,19 @@ import chalk from 'chalk';
import got from 'got';
import { SubmissionFragment, SubmissionStatus } from '../../graphql/generated';
import { default as LogModule } from '../../log';
import Log from '../../log';
import { printSubmissionError } from './errors';
export async function displayLogsAsync(
submission: SubmissionFragment,
{ verbose = false }: { verbose?: boolean } = {}
{ verbose = false, moreSubmissions = false } = {}
): Promise<void> {
let printedUnknownError = false;
if (submission.status === SubmissionStatus.Errored && submission.error) {
printedUnknownError = printSubmissionError(submission.error);
} else if (submission.status === SubmissionStatus.Canceled && moreSubmissions) {
Log.newLine();
Log.error('Submission has been canceled');
}
if (printedUnknownError || verbose) {
await downloadAndPrintSubmissionLogsAsync(submission);
@@ -24,28 +27,28 @@ async function downloadAndPrintSubmissionLogsAsync(submission: SubmissionFragmen
}
const { body: data } = await got.get(submission.logsUrl);
const logs = parseLogs(data);
LogModule.addNewLineIfNone();
Log.addNewLineIfNone();
const prefix = chalk.blueBright('[logs] ');
for (const { level, msg } of logs) {
const msgWithPrefix = `${prefix}${msg}`;
if (level === 'error') {
LogModule.error(msgWithPrefix);
Log.error(msgWithPrefix);
} else if (level === 'warn') {
LogModule.warn(msgWithPrefix);
Log.warn(msgWithPrefix);
} else {
LogModule.log(msgWithPrefix);
Log.log(msgWithPrefix);
}
}
}
interface Log {
interface LogLine {
level: 'error' | 'warn' | 'info';
msg: string;
}
function parseLogs(logs: string): Log[] {
function parseLogs(logs: string): LogLine[] {
const lines = logs.split('\n');
const result: Log[] = [];
const result: LogLine[] = [];
for (const line of lines) {
let parsedLine;
try {
@@ -53,7 +56,7 @@ function parseLogs(logs: string): Log[] {
} catch (error) {
continue;
}
let level: Log['level'];
let level: LogLine['level'];
const { level: levelNumber, msg } = parsedLine;
if (levelNumber >= 50) {
level = 'error';
@@ -68,7 +68,7 @@ export function printSummary<T>(summary: T, keyMap: Record<keyof T, string>): vo
fields.push({ label, value });
}
Log.newLine();
Log.addNewLineIfNone();
Log.log(formatFields(fields, { labelFormat: chalk.bold.cyan }));
Log.addNewLineIfNone();
}
+2 -1
View File
@@ -1,11 +1,12 @@
{
"name": "@expo/eas-json",
"description": "A library for interacting with the eas.json",
"version": "0.26.0",
"version": "0.27.0",
"author": "Expo <[email protected]>",
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/eas-build-job": "0.2.46",
"env-string": "1.0.1",
"fs-extra": "10.0.0",
"joi": "17.4.2",
"tslib": "2.3.1"
+25 -2
View File
@@ -1,4 +1,5 @@
import { Platform } from '@expo/eas-build-job';
import envString from 'env-string';
import fs from 'fs-extra';
import path from 'path';
@@ -10,7 +11,11 @@ import {
IosSubmitProfileSchema,
MinimalEasJsonSchema,
} from './EasJsonSchema';
import { SubmitProfile } from './EasSubmit.types';
import {
AndroidSubmitProfileFieldsToEvaluate,
IosSubmitProfileFieldsToEvaluate,
SubmitProfile,
} from './EasSubmit.types';
interface EasJsonPreValidation {
build: { [profile: string]: object };
@@ -93,7 +98,7 @@ export class EasJsonReader {
if (!profile) {
throw new Error(`There is no profile named ${profileName} in eas.json for ${platform}.`);
}
return profile as SubmitProfile<T>;
return this.evaluateFields(platform, profile as SubmitProfile<T>);
}
public async readAndValidateAsync(): Promise<EasJson> {
@@ -151,6 +156,24 @@ export class EasJsonReader {
throw new Error(`There is no profile named ${profileName} in eas.json.`);
}
}
private evaluateFields<T extends Platform>(
platform: T,
profile: SubmitProfile<T>
): SubmitProfile<T> {
const fields =
platform === Platform.ANDROID
? AndroidSubmitProfileFieldsToEvaluate
: IosSubmitProfileFieldsToEvaluate;
const evaluatedProfile = { ...profile };
for (const field of fields) {
if (field in evaluatedProfile) {
// @ts-ignore
evaluatedProfile[field] = envString(evaluatedProfile[field], process.env);
}
}
return evaluatedProfile;
}
}
export function profileMerge(base: RawBuildProfile, update: RawBuildProfile): RawBuildProfile {
+6
View File
@@ -21,6 +21,10 @@ export interface AndroidSubmitProfile {
changesNotSentForReview: boolean;
}
export const AndroidSubmitProfileFieldsToEvaluate: (keyof AndroidSubmitProfile)[] = [
'serviceAccountKeyPath',
];
export interface IosSubmitProfile {
appleId?: string;
ascAppId?: string;
@@ -31,6 +35,8 @@ export interface IosSubmitProfile {
appName?: string;
}
export const IosSubmitProfileFieldsToEvaluate: (keyof IosSubmitProfile)[] = [];
export type SubmitProfile<TPlatform extends Platform = Platform> =
TPlatform extends Platform.ANDROID
? AndroidSubmitProfile
@@ -59,6 +59,35 @@ test('android config with all required values', async () => {
});
});
test('android config with serviceAccountKeyPath set to env var', async () => {
await fs.writeJson('/project/eas.json', {
submit: {
release: {
android: {
serviceAccountKeyPath: '$GOOGLE_SERVICE_ACCOUNT',
track: 'beta',
releaseStatus: 'completed',
},
},
},
});
try {
process.env.GOOGLE_SERVICE_ACCOUNT = './path.json';
const reader = new EasJsonReader('/project');
const androidProfile = await reader.readSubmitProfileAsync(Platform.ANDROID, 'release');
expect(androidProfile).toEqual({
serviceAccountKeyPath: './path.json',
track: 'beta',
releaseStatus: 'completed',
changesNotSentForReview: false,
});
} finally {
process.env.GOOGLE_SERVICE_ACCOUNT = undefined;
}
});
test('ios config with all required values', async () => {
await fs.writeJson('/project/eas.json', {
submit: {
+3
View File
@@ -0,0 +1,3 @@
declare module 'env-string' {
export default function envString(str: string, env?: Record<string, string | undefined>): string;
}
Generated
+5
View File
@@ -6029,6 +6029,11 @@ [email protected], env-paths@^2.2.0:
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43"
integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==
[email protected]:
version "1.0.1"
resolved "https://registry.yarnpkg.com/env-string/-/env-string-1.0.1.tgz#92ddefeb651c0f2d15bf89dc13be7596733801a4"
integrity sha512-/DhCJDf5DSFK32joQiWRpWrT0h7p3hVQfMKxiBb7Nt8C8IF8BYyPtclDnuGGLOoj16d/8udKeiE7JbkotDmorQ==
[email protected], envinfo@^7.7.4:
version "7.8.1"
resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475"