Compare commits

...
8 Commits
18 changed files with 1055 additions and 162 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.1"
"version": "0.1.2"
}
+13 -13
View File
@@ -21,7 +21,7 @@ $ npm install -g eas-cli
$ eas COMMAND
running command...
$ eas (-v|--version|version)
eas-cli/0.1.1 darwin-x64 node-v14.15.0
eas-cli/0.1.2 darwin-x64 node-v14.15.0
$ eas --help [COMMAND]
USAGE
$ eas COMMAND
@@ -58,7 +58,7 @@ ALIASES
$ eas login
```
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/account/login.ts)_
_See code: [build/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/login.ts)_
## `eas account:logout`
@@ -72,7 +72,7 @@ ALIASES
$ eas logout
```
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/account/logout.ts)_
_See code: [build/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/logout.ts)_
## `eas account:view`
@@ -86,7 +86,7 @@ ALIASES
$ eas whoami
```
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/account/view.ts)_
_See code: [build/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/account/view.ts)_
## `eas build`
@@ -105,7 +105,7 @@ OPTIONS
--wait Wait for build(s) to complete
```
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/build/index.ts)_
_See code: [build/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/index.ts)_
## `eas build:configure`
@@ -120,7 +120,7 @@ OPTIONS
--allow-experimental Enable experimental configuration steps.
```
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/build/configure.ts)_
_See code: [build/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/configure.ts)_
## `eas build:list`
@@ -136,7 +136,7 @@ OPTIONS
--status=(in-queue|in-progress|errored|finished)
```
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/build/list.ts)_
_See code: [build/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/list.ts)_
## `eas build:view [BUILDID]`
@@ -147,7 +147,7 @@ USAGE
$ eas build:view [BUILDID]
```
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/build/view.ts)_
_See code: [build/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/build/view.ts)_
## `eas credentials`
@@ -158,7 +158,7 @@ USAGE
$ eas credentials
```
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/credentials.ts)_
_See code: [build/commands/credentials.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/credentials.ts)_
## `eas device:create`
@@ -169,7 +169,7 @@ USAGE
$ eas device:create
```
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/device/create.ts)_
_See code: [build/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/create.ts)_
## `eas device:list`
@@ -183,7 +183,7 @@ OPTIONS
--apple-team-id=apple-team-id
```
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/device/list.ts)_
_See code: [build/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/list.ts)_
## `eas device:view [UDID]`
@@ -194,7 +194,7 @@ USAGE
$ eas device:view [UDID]
```
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/device/view.ts)_
_See code: [build/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/device/view.ts)_
## `eas help [COMMAND]`
@@ -291,5 +291,5 @@ EXAMPLES
and provide its App ID
```
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.1/build/commands/submit.ts)_
_See code: [build/commands/submit.ts](https://github.com/expo/eas-cli/blob/v0.1.2/build/commands/submit.ts)_
<!-- commandsstop -->
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "eas-cli",
"description": "EAS command line tool",
"version": "0.1.1",
"version": "0.1.2",
"author": "Expo <[email protected]>",
"bin": {
"eas": "./bin/run"
@@ -14,7 +14,7 @@
"@expo/eas-build-job": "0.1.4",
"@expo/eas-json": "^0.1.0-alpha.20",
"@expo/json-file": "^8.2.24",
"@expo/plist": "^0.0.10",
"@expo/plist": "^0.0.11",
"@expo/plugin-warn-if-update-available": "^1.7.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "^1.5.0",
@@ -39,6 +39,7 @@
"keychain": "^1.3.0",
"lodash": "^4.17.20",
"md5-file": "^5.0.0",
"mime": "^2.4.7",
"minimatch": "^3.0.4",
"node-fetch": "^2.6.1",
"node-forge": "^0.10.0",
@@ -70,6 +71,7 @@
"@types/fs-extra": "^9.0.1",
"@types/getenv": "^1.0.0",
"@types/lodash": "^4.14.161",
"@types/mime": "^2.0.3",
"@types/node": "^12",
"@types/node-fetch": "^2.5.7",
"@types/node-forge": "^0.9.5",
@@ -83,6 +85,7 @@
"axios": "^0.21.0",
"babel-jest": "^26.3.0",
"memfs": "^3.2.0",
"mockdate": "^3.0.2",
"ts-node": "^8",
"typescript": "^3.9.7",
"wonka": "^4.0.14"
@@ -20,6 +20,8 @@ export default function formatBuild(build: Build, { accountName }: Options) {
label: 'Status',
get value() {
switch (build.status) {
case BuildStatus.IN_QUEUE:
return chalk.blue('in queue');
case BuildStatus.IN_PROGRESS:
return chalk.blue('in progress');
case BuildStatus.FINISHED:
@@ -38,7 +40,7 @@ export default function formatBuild(build: Build, { accountName }: Options) {
{
label: 'Artifact',
get value() {
if (build.status === BuildStatus.IN_PROGRESS) {
if (build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS) {
return '<in progress>';
}
@@ -55,7 +57,7 @@ export default function formatBuild(build: Build, { accountName }: Options) {
{
label: 'Finished at',
value:
build.status === BuildStatus.IN_PROGRESS
build.status === BuildStatus.IN_QUEUE || build.status === BuildStatus.IN_PROGRESS
? '<in progress>'
: new Date(build.updatedAt).toLocaleString(),
},
+82 -4
View File
@@ -4,19 +4,28 @@ import fs from 'fs-extra';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { apiClient } from '../../api';
import { configureAsync } from '../../build/configure';
import { createCommandContextAsync } from '../../build/context';
import { buildAsync } from '../../build/create';
import { AnalyticsEvent, RequestedPlatform } from '../../build/types';
import { AnalyticsEvent, Build as BuildType, RequestedPlatform } from '../../build/types';
import Analytics from '../../build/utils/analytics';
import formatBuild from '../../build/utils/formatBuild';
import { isGitStatusCleanAsync } from '../../build/utils/repository';
import { learnMore } from '../../log';
import { AppPlatform } from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import log, { learnMore } from '../../log';
import {
isEasEnabledForProjectAsync,
warnEasUnavailable,
} from '../../project/isEasEnabledForProject';
import { findProjectRootAsync, getProjectIdAsync } from '../../project/projectUtils';
import {
findProjectRootAsync,
getProjectAccountNameAsync,
getProjectIdAsync,
} from '../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../prompts';
import { Actor } from '../../user/User';
import { ensureLoggedInAsync } from '../../user/actions';
export default class Build extends Command {
@@ -48,7 +57,7 @@ export default class Build extends Command {
async run(): Promise<void> {
const { flags } = this.parse(Build);
await ensureLoggedInAsync();
const user = await ensureLoggedInAsync();
const nonInteractive = flags['non-interactive'];
if (!flags.platform && nonInteractive) {
@@ -66,6 +75,17 @@ export default class Build extends Command {
return;
}
const accountName = await getProjectAccountNameAsync(projectDir);
const accountHasPendingBuilds = await ensureNoPendingBuildsExistAsync({
accountName,
platform,
projectId,
user,
});
if (accountHasPendingBuilds) {
return;
}
await ensureProjectConfiguredAsync(projectDir);
const trackingCtx = {
@@ -89,6 +109,64 @@ export default class Build extends Command {
}
}
async function ensureNoPendingBuildsExistAsync({
user,
platform,
accountName,
projectId,
}: {
user: Actor;
platform: RequestedPlatform;
accountName: string;
projectId: string;
}): Promise<boolean> {
// allow expo admins to run as many builds as they wish
if (user.isExpoAdmin) {
return false;
}
const appPlatforms = toAppPlatforms(platform);
const maybePendingBuilds = await Promise.all(
appPlatforms.map(appPlatform => BuildQuery.getPendingBuildIdAsync(accountName, appPlatform))
);
const pendingBuilds = maybePendingBuilds.filter(i => i !== null);
if (pendingBuilds.length > 0) {
log.newLine();
log.error(
'Your other builds are still pending. Wait for them to complete before running this command again.'
);
log.newLine();
const results = await Promise.all(
pendingBuilds.map(pendingBuild => {
return apiClient.get<{ data: BuildType }>(
`projects/${projectId}/builds/${pendingBuild?.id}`,
{
responseType: 'json',
}
);
})
);
for (const result of results) {
log(formatBuild(result.body.data, { accountName }));
}
process.exitCode = 1;
return true;
}
return false;
}
function toAppPlatforms(requestedPlatform: RequestedPlatform): AppPlatform[] {
if (requestedPlatform === RequestedPlatform.All) {
return [AppPlatform.Android, AppPlatform.Ios];
} else if (requestedPlatform === RequestedPlatform.Android) {
return [AppPlatform.Android];
} else {
return [AppPlatform.Ios];
}
}
async function promptForPlatformAsync(): Promise<RequestedPlatform> {
const { platform } = await promptAsync({
type: 'select',
+1 -3
View File
@@ -80,9 +80,7 @@ export class AccountResolver {
)} account?`,
});
if (useProjectAccount) {
return projectAccount;
}
return useProjectAccount ? projectAccount : undefined;
}
private async promptForAccountAsync(): Promise<Account> {
+137 -123
View File
@@ -57,7 +57,7 @@ export type RootQuery = {
builds: BuildQuery;
clientBuilds: ClientBuildQuery;
project: ProjectQuery;
/** Search for users and apps */
/** Search for Snacks */
search: Array<SearchResult>;
/** @deprecated Use 'search' root field. */
searchUsersAndApps: Array<Maybe<SearchResult>>;
@@ -156,6 +156,7 @@ export type Account = {
/** @deprecated Build packs are no longer supported */
availableBuilds?: Maybe<Scalars['Int']>;
unlimitedBuilds?: Maybe<Scalars['Boolean']>;
/** @deprecated No longer needed */
subscriptionChangesPending?: Maybe<Scalars['Boolean']>;
/** @deprecated Build packs are no longer supported */
willAutoRenewBuilds?: Maybe<Scalars['Boolean']>;
@@ -170,7 +171,7 @@ export type Account = {
apps?: Maybe<Array<Maybe<App>>>;
/** Build Jobs associated with this account */
buildJobs?: Maybe<Array<Maybe<BuildJob>>>;
/** (Turtle v2) Builds associated with this account */
/** (EAS Build) Builds associated with this account */
builds?: Maybe<Array<Maybe<Build>>>;
/** Owning User of this account if personal account */
owner?: Maybe<User>;
@@ -236,6 +237,7 @@ export type AccountBuildsArgs = {
offset: Scalars['Int'];
limit: Scalars['Int'];
status?: Maybe<BuildStatus>;
platform?: Maybe<AppPlatform>;
};
@@ -372,6 +374,7 @@ export type App = Project & {
/** @deprecated Field no longer supported */
releases: Array<Maybe<AppRelease>>;
builds?: Maybe<Array<Maybe<Build>>>;
buildJobs: Array<Maybe<BuildJob>>;
/** @deprecated Field no longer supported */
users?: Maybe<Array<Maybe<User>>>;
/** @deprecated Field no longer supported */
@@ -427,6 +430,14 @@ export type AppBuildsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppBuildJobsArgs = {
offset: Scalars['Int'];
limit: Scalars['Int'];
status?: Maybe<BuildStatus>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppIosAppCredentialsArgs = {
filter?: Maybe<IosAppCredentialsFilter>;
@@ -676,7 +687,7 @@ export enum BuildStatus {
Finished = 'FINISHED'
}
/** Represents a Turtle v2 build */
/** Represents an EAS Build */
export type Build = ActivityTimelineProjectActivity & {
__typename?: 'Build';
id: Scalars['ID'];
@@ -696,6 +707,7 @@ export type Build = ActivityTimelineProjectActivity & {
appVersion?: Maybe<Scalars['String']>;
sdkVersion?: Maybe<Scalars['String']>;
releaseChannel?: Maybe<Scalars['String']>;
metrics?: Maybe<BuildMetrics>;
distribution?: Maybe<DistributionType>;
};
@@ -711,11 +723,65 @@ export type BuildArtifacts = {
xcodeBuildLogsUrl?: Maybe<Scalars['String']>;
};
export type BuildMetrics = {
__typename?: 'BuildMetrics';
buildQueueTime?: Maybe<Scalars['Int']>;
buildDuration?: Maybe<Scalars['Int']>;
};
export enum DistributionType {
Store = 'STORE',
Internal = 'INTERNAL'
}
/** Represents an Standalone App build job */
export type BuildJob = ActivityTimelineProjectActivity & {
__typename?: 'BuildJob';
id: Scalars['ID'];
actor?: Maybe<Actor>;
activityTimestamp: Scalars['DateTime'];
app?: Maybe<App>;
user?: Maybe<User>;
release?: Maybe<AppRelease>;
config?: Maybe<Scalars['JSON']>;
artifacts?: Maybe<BuildArtifact>;
logs?: Maybe<BuildLogs>;
created?: Maybe<Scalars['DateTime']>;
updated?: Maybe<Scalars['DateTime']>;
fullExperienceName?: Maybe<Scalars['String']>;
status?: Maybe<BuildJobStatus>;
expirationDate?: Maybe<Scalars['DateTime']>;
platform: AppPlatform;
sdkVersion?: Maybe<Scalars['String']>;
releaseChannel?: Maybe<Scalars['String']>;
};
export type BuildArtifact = {
__typename?: 'BuildArtifact';
url: Scalars['String'];
manifestPlistUrl?: Maybe<Scalars['String']>;
};
export type BuildLogs = {
__typename?: 'BuildLogs';
url?: Maybe<Scalars['String']>;
format?: Maybe<BuildJobLogsFormat>;
};
export enum BuildJobLogsFormat {
Raw = 'RAW',
Json = 'JSON'
}
export enum BuildJobStatus {
Pending = 'PENDING',
Started = 'STARTED',
InProgress = 'IN_PROGRESS',
Errored = 'ERRORED',
Finished = 'FINISHED',
SentToQueue = 'SENT_TO_QUEUE'
}
export type IosAppCredentialsFilter = {
appleAppIdentifierId?: Maybe<Scalars['String']>;
};
@@ -978,54 +1044,6 @@ export type Update = ActivityTimelineProjectActivity & {
releaseName: Scalars['String'];
};
export enum BuildJobStatus {
Pending = 'PENDING',
Started = 'STARTED',
InProgress = 'IN_PROGRESS',
Errored = 'ERRORED',
Finished = 'FINISHED',
SentToQueue = 'SENT_TO_QUEUE'
}
/** Represents an Standalone App build job */
export type BuildJob = ActivityTimelineProjectActivity & {
__typename?: 'BuildJob';
id: Scalars['ID'];
actor?: Maybe<Actor>;
activityTimestamp: Scalars['DateTime'];
app?: Maybe<App>;
user?: Maybe<User>;
release?: Maybe<AppRelease>;
config?: Maybe<Scalars['JSON']>;
artifacts?: Maybe<BuildArtifact>;
logs?: Maybe<BuildLogs>;
created?: Maybe<Scalars['DateTime']>;
updated?: Maybe<Scalars['DateTime']>;
fullExperienceName?: Maybe<Scalars['String']>;
status?: Maybe<BuildJobStatus>;
expirationDate?: Maybe<Scalars['DateTime']>;
platform: AppPlatform;
sdkVersion?: Maybe<Scalars['String']>;
releaseChannel?: Maybe<Scalars['String']>;
};
export type BuildArtifact = {
__typename?: 'BuildArtifact';
url: Scalars['String'];
manifestPlistUrl?: Maybe<Scalars['String']>;
};
export type BuildLogs = {
__typename?: 'BuildLogs';
url?: Maybe<Scalars['String']>;
format?: Maybe<BuildJobLogsFormat>;
};
export enum BuildJobLogsFormat {
Raw = 'RAW',
Json = 'JSON'
}
export type UserPermission = {
__typename?: 'UserPermission';
permissions?: Maybe<Array<Maybe<Permission>>>;
@@ -1229,7 +1247,10 @@ export type ProjectPublicData = {
export type BuildJobQuery = {
__typename?: 'BuildJobQuery';
/** get all build jobs by an optional filter */
/**
* get all build jobs by an optional filter
* @deprecated Prefer Account.buildJobs or App.buildJobs
*/
all: Array<Maybe<BuildJob>>;
byId: BuildJob;
};
@@ -1251,7 +1272,7 @@ export type BuildJobQueryByIdArgs = {
export type BuildQuery = {
__typename?: 'BuildQuery';
/** Look up Turtle v2 build by build id */
/** Look up EAS Build by build ID */
byId: Build;
/**
* Get all builds for a specific app.
@@ -2560,8 +2581,11 @@ export type AppSearchResult = SearchResult & {
/** Represents a search result for a user */
export type UserSearchResult = SearchResult & {
__typename?: 'UserSearchResult';
/** @deprecated Field no longer supported */
id: Scalars['ID'];
/** @deprecated Field no longer supported */
rank?: Maybe<Scalars['Int']>;
/** @deprecated Field no longer supported */
user: User;
};
@@ -2579,84 +2603,35 @@ export enum CacheControlScope {
}
export type AccountByNameQueryVariables = Exact<{
accountName: Scalars['String'];
export type GetSignedUploadMutationMutationVariables = Exact<{
contentTypes: Array<Scalars['String']>;
}>;
export type AccountByNameQuery = (
{ __typename?: 'RootQuery' }
& { account: (
{ __typename?: 'AccountQuery' }
& { byName: (
{ __typename?: 'Account' }
& Pick<Account, 'id'>
) }
) }
);
export type AppleDevicesByAccountNameQueryVariables = Exact<{
accountId: Scalars['ID'];
identifier: Scalars['String'];
}>;
export type AppleDevicesByAccountNameQuery = (
{ __typename?: 'RootQuery' }
& { appleTeam: (
{ __typename?: 'AppleTeamQuery' }
& { byAppleTeamIdentifier?: Maybe<(
{ __typename?: 'AppleTeam' }
& Pick<AppleTeam, 'id' | 'appleTeamName'>
& { appleDevices?: Maybe<Array<Maybe<(
{ __typename?: 'AppleDevice' }
& Pick<AppleDevice, 'id' | 'identifier' | 'name' | 'deviceClass' | 'enabled'>
)>>> }
export type GetSignedUploadMutationMutation = (
{ __typename?: 'RootMutation' }
& { asset: (
{ __typename?: 'AssetMutation' }
& { getSignedAssetUploadSpecifications?: Maybe<(
{ __typename?: 'GetSignedAssetUploadSpecificationsResult' }
& Pick<GetSignedAssetUploadSpecificationsResult, 'specifications'>
)> }
) }
);
export type AppleDevicesByIdentifierQueryVariables = Exact<{
accountName: Scalars['String'];
identifier: Scalars['String'];
export type PublishMutationMutationVariables = Exact<{
publishUpdateGroupInput?: Maybe<PublishUpdateGroupInput>;
}>;
export type AppleDevicesByIdentifierQuery = (
{ __typename?: 'RootQuery' }
& { account: (
{ __typename?: 'AccountQuery' }
& { byName: (
{ __typename?: 'Account' }
& Pick<Account, 'id'>
& { appleDevices?: Maybe<Array<Maybe<(
{ __typename?: 'AppleDevice' }
& Pick<AppleDevice, 'id' | 'identifier' | 'name' | 'deviceClass' | 'enabled'>
& { appleTeam: (
{ __typename?: 'AppleTeam' }
& Pick<AppleTeam, 'id' | 'appleTeamIdentifier' | 'appleTeamName'>
) }
)>>> }
) }
) }
);
export type AppleTeamsByAccountNameQueryVariables = Exact<{
accountName: Scalars['String'];
}>;
export type AppleTeamsByAccountNameQuery = (
{ __typename?: 'RootQuery' }
& { account: (
{ __typename?: 'AccountQuery' }
& { byName: (
{ __typename?: 'Account' }
& { appleTeams: Array<Maybe<(
{ __typename?: 'AppleTeam' }
& Pick<AppleTeam, 'id' | 'appleTeamName' | 'appleTeamIdentifier'>
)>> }
) }
export type PublishMutationMutation = (
{ __typename?: 'RootMutation' }
& { updateRelease: (
{ __typename?: 'UpdateReleaseMutation' }
& { publishUpdateGroup: Array<Maybe<(
{ __typename?: 'Update' }
& Pick<Update, 'updateGroup'>
)>> }
) }
);
@@ -2704,6 +2679,29 @@ export type BuildsForAppQueryQuery = (
) }
);
export type PendingBuildsForAccountAndPlatformQueryVariables = Exact<{
accountName: Scalars['String'];
platform: AppPlatform;
}>;
export type PendingBuildsForAccountAndPlatformQuery = (
{ __typename?: 'RootQuery' }
& { account: (
{ __typename?: 'AccountQuery' }
& { byName: (
{ __typename?: 'Account' }
& { inQueueBuilds?: Maybe<Array<Maybe<(
{ __typename?: 'Build' }
& Pick<Build, 'id' | 'platform'>
)>>>, inProgressBuilds?: Maybe<Array<Maybe<(
{ __typename?: 'Build' }
& Pick<Build, 'id' | 'platform'>
)>>> }
) }
) }
);
export type Unnamed_1_QueryVariables = Exact<{
username: Scalars['String'];
slug: Scalars['String'];
@@ -2724,6 +2722,22 @@ export type Unnamed_1_Query = (
) }
);
export type GetAssetMetadataQueryQueryVariables = Exact<{
storageKeys: Array<Scalars['String']>;
}>;
export type GetAssetMetadataQueryQuery = (
{ __typename?: 'RootQuery' }
& { asset: (
{ __typename?: 'AssetQuery' }
& { metadata: Array<(
{ __typename?: 'AssetMetadataResult' }
& Pick<AssetMetadataResult, 'storageKey' | 'status'>
)> }
) }
);
export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
@@ -2731,14 +2745,14 @@ export type CurrentUserQuery = (
{ __typename?: 'RootQuery' }
& { meActor?: Maybe<(
{ __typename: 'User' }
& Pick<User, 'username' | 'id'>
& Pick<User, 'username' | 'id' | 'isExpoAdmin'>
& { accounts?: Maybe<Array<Maybe<(
{ __typename?: 'Account' }
& Pick<Account, 'id' | 'name'>
)>>> }
) | (
{ __typename: 'Robot' }
& Pick<Robot, 'firstName' | 'id'>
& Pick<Robot, 'firstName' | 'id' | 'isExpoAdmin'>
& { accounts?: Maybe<Array<Maybe<(
{ __typename?: 'Account' }
& Pick<Account, 'id' | 'name'>
@@ -0,0 +1,58 @@
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import { PublishUpdateGroupInput, Update } from '../generated';
const PublishMutation = {
async getUploadURLsAsync(contentTypes: string[]): Promise<{ specifications: string[] }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ asset: { getSignedAssetUploadSpecifications: { specifications: string[] } } },
{ contentTypes: string[] }
>(
gql`
mutation GetSignedUploadMutation($contentTypes: [String!]!) {
asset {
getSignedAssetUploadSpecifications(assetContentTypes: $contentTypes) {
specifications
}
}
}
`,
{
contentTypes,
}
)
.toPromise()
);
return data.asset.getSignedAssetUploadSpecifications;
},
async publishUpdateGroupAsync(
publishUpdateGroupInput: PublishUpdateGroupInput
): Promise<Pick<Update, 'updateGroup'>> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
{ updateRelease: { publishUpdateGroup: Pick<Update, 'updateGroup'>[] } },
{ publishUpdateGroupInput: PublishUpdateGroupInput }
>(
gql`
mutation PublishMutation($publishUpdateGroupInput: PublishUpdateGroupInput) {
updateRelease {
publishUpdateGroup(publishUpdateGroupInput: $publishUpdateGroupInput) {
updateGroup
}
}
}
`,
{ publishUpdateGroupInput }
)
.toPromise()
);
return data.updateRelease.publishUpdateGroup[0];
},
};
export { PublishMutation };
@@ -1,7 +1,7 @@
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import { Build } from '../generated';
import { AppPlatform, Build } from '../generated';
type Filters = Partial<Pick<Build, 'platform' | 'status'>> & {
offset?: number;
@@ -9,6 +9,7 @@ type Filters = Partial<Pick<Build, 'platform' | 'status'>> & {
};
type BuildQueryResult = Pick<Build, 'platform' | 'artifacts'>;
type PendingBuildQueryResult = Pick<Build, 'id' | 'platform'>;
const BuildQuery = {
async byIdAsync(buildId: string): Promise<BuildQueryResult> {
@@ -70,6 +71,60 @@ const BuildQuery = {
return data.builds.allForApp;
},
async getPendingBuildIdAsync(
accountName: string,
platform: AppPlatform
): Promise<PendingBuildQueryResult | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<{
account: {
byName: {
inQueueBuilds: PendingBuildQueryResult[];
inProgressBuilds: PendingBuildQueryResult[];
};
};
}>(
gql`
query PendingBuildsForAccountAndPlatform(
$accountName: String!
$platform: AppPlatform!
) {
account {
byName(accountName: $accountName) {
inQueueBuilds: builds(
offset: 0
limit: 1
platform: $platform
status: IN_QUEUE
) {
id
platform
}
inProgressBuilds: builds(
offset: 0
limit: 1
platform: $platform
status: IN_PROGRESS
) {
id
platform
}
}
}
}
`,
{ accountName, platform }
)
.toPromise()
);
const pendingBuilds = [
...data.account.byName.inProgressBuilds,
...data.account.byName.inQueueBuilds,
];
return pendingBuilds.length > 0 ? pendingBuilds[0] : null;
},
};
export { BuildQuery };
@@ -0,0 +1,32 @@
import gql from 'graphql-tag';
import { graphqlClient, withErrorHandlingAsync } from '../client';
import { AssetMetadataResult } from '../generated';
const PublishQuery = {
async getAssetMetadataAsync(storageKeys: string[]): Promise<AssetMetadataResult[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<{ asset: { metadata: AssetMetadataResult[] } }, { storageKeys: string[] }>(
gql`
query GetAssetMetadataQuery($storageKeys: [String!]!) {
asset {
metadata(storageKeys: $storageKeys) {
storageKey
status
}
}
}
`,
{
storageKeys,
},
{ requestPolicy: 'network-only' } // Since we reptitively query this to monitor the asset upload, we need to ensure it is not cached.
)
.toPromise()
);
return data.asset.metadata;
},
};
export { PublishQuery };
@@ -23,6 +23,7 @@ const UserQuery = {
id
name
}
isExpoAdmin
}
}
`
@@ -0,0 +1,379 @@
import fs from 'fs';
import mockdate from 'mockdate';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { AssetMetadataStatus } from '../../graphql/generated';
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
import { PublishQuery } from '../../graphql/queries/PublishQuery';
import {
Platforms,
TIMEOUT_LIMIT,
buildUpdateInfoGroupAsync,
collectAssets,
convertAssetToUpdateInfoGroupFormatAsync,
filterOutAssetsThatAlreadyExistAsync,
getBase64URLEncoding,
getStorageKey,
getStorageKeyForAssetAsync,
guessContentTypeFromExtension,
resolveInputDirectory,
uploadAssetsAsync,
} from '../publish';
jest.mock('../../uploads');
jest.mock('fs');
const dummyFileBuffer = Buffer.from('dummy-file');
fs.mkdirSync(path.resolve(), { recursive: true });
fs.writeFileSync(path.resolve('md5-hash-of-file'), dummyFileBuffer);
describe(guessContentTypeFromExtension, () => {
it('returns the correct content type for jpg', () => {
expect(guessContentTypeFromExtension('jpg')).toBe('image/jpeg');
});
it('returns application/octet-stream when an ext is not recognized', () => {
expect(guessContentTypeFromExtension('does-not-exist')).toBe('application/octet-stream');
});
it('returns application/octet-stream when an ext is undefined', () => {
expect(guessContentTypeFromExtension(undefined)).toBe('application/octet-stream');
});
});
describe(getBase64URLEncoding, () => {
it('computes the correct encoding', () => {
expect(getBase64URLEncoding(Buffer.from('test-string'))).toBe('dGVzdC1zdHJpbmc');
});
});
describe(getStorageKey, () => {
it('computes the correct key', () => {
const key = getStorageKey('image/jpeg', 'blibblab');
expect(key).toBe('j0iiW9hDbR2HKoH1nCxsKRM6QIZVtZ__2ssOiOcxlAs');
});
it('uses the null separator to distinguish unequal keys', () => {
const keyOne = getStorageKey('image/jpeg', 'blibblab');
const keyTwo = getStorageKey('image', '/jpegblibblab');
expect(keyOne).not.toBe(keyTwo);
});
});
describe(getStorageKeyForAssetAsync, () => {
const pathLocation = uuidv4();
fs.writeFileSync(pathLocation, Buffer.from('I am pretending to be a jpeg'));
it('returns the correct key', async () => {
const asset = {
type: 'jpg',
contentType: 'image/jpeg',
path: pathLocation,
};
expect(await getStorageKeyForAssetAsync(asset)).toBe(
'fo8Y08LktVk6qLtGbn8GRWpOUyD13ABMUnbtRCN1L7Y'
);
});
});
describe(convertAssetToUpdateInfoGroupFormatAsync, () => {
it('resolves to the correct value', async () => {
const path = uuidv4();
fs.writeFileSync(path, 'I am pretending to be a jpeg');
const type = 'jpg';
const asset = {
type,
contentType: 'image/jpeg',
path,
};
await expect(convertAssetToUpdateInfoGroupFormatAsync(asset)).resolves.toEqual({
bundleKey: `c939e759656f577c058f445bfb19182e.${type}`,
contentType: 'image/jpeg',
fileHash: 'tzD6J-OQZaHCKnL3GHWV9RbnrpyojnagiOE7r3mSkU4',
storageBucket: 'update-assets-production',
storageKey: 'fo8Y08LktVk6qLtGbn8GRWpOUyD13ABMUnbtRCN1L7Y',
});
});
});
describe(buildUpdateInfoGroupAsync, () => {
const androidBundlePath = uuidv4();
const assetPath = uuidv4();
fs.writeFileSync(androidBundlePath, 'I am a js bundle');
fs.writeFileSync(assetPath, 'I am pretending to be a jpeg');
it('returns the correct value', async () => {
await expect(
buildUpdateInfoGroupAsync({
android: {
launchAsset: {
type: 'bundle',
contentType: 'bundle/javascript',
path: androidBundlePath,
},
assets: [
{
type: 'jpg',
contentType: 'image/jpeg',
path: assetPath,
},
],
},
})
).resolves.toEqual({
android: {
assets: [
{
bundleKey: 'c939e759656f577c058f445bfb19182e.jpg',
contentType: 'image/jpeg',
fileHash: 'tzD6J-OQZaHCKnL3GHWV9RbnrpyojnagiOE7r3mSkU4',
storageBucket: 'update-assets-production',
storageKey: 'fo8Y08LktVk6qLtGbn8GRWpOUyD13ABMUnbtRCN1L7Y',
},
],
launchAsset: {
bundleKey: 'ec0dd14670aae108f99a810df9c1482c.bundle',
contentType: 'bundle/javascript',
fileHash: 'KEw79FnKTLOyVbRT1SlohSTjPe5e8FpULy2ST-I5BUg',
storageBucket: 'update-assets-production',
storageKey: 'aC9N6RZlcHoIYjIsoJd2KUcigBKy98RHvZacDyPNjCQ',
},
},
});
});
});
describe(resolveInputDirectory, () => {
it('returns the correct distRoot path', () => {
const customDirectoryName = path.resolve(uuidv4());
fs.mkdirSync(customDirectoryName, { recursive: true });
expect(resolveInputDirectory(customDirectoryName)).toBe(customDirectoryName);
});
it('throws an error if the path does not exist', () => {
const nonExistentPath = path.resolve(uuidv4());
expect(() => {
resolveInputDirectory(nonExistentPath);
}).toThrow(`${nonExistentPath} does not exist. Please create it with your desired bundler.`);
});
});
describe(collectAssets, () => {
it('builds an update info group', () => {
const fakeHash = 'md5-hash-of-jpg';
const fakeJson = { bundledAssets: [`asset_${fakeHash}.jpg`] };
const bundles = { android: 'android-bundle-code', ios: 'ios-bundle-code' };
const inputDir = uuidv4();
const userDefinedAssets = [
{
type: 'jpg',
contentType: 'image/jpeg',
path: path.resolve(`${inputDir}/assets/${fakeHash}`),
},
];
const bundleDir = path.resolve(`${inputDir}/bundles`);
const assetDir = path.resolve(`${inputDir}/assets`);
fs.mkdirSync(bundleDir, { recursive: true });
fs.mkdirSync(assetDir, { recursive: true });
Platforms.forEach(platform => {
fs.writeFileSync(
path.resolve(`${inputDir}/${platform}-index.json`),
JSON.stringify(fakeJson)
);
fs.writeFileSync(
path.resolve(`${inputDir}/bundles/${platform}-randomHash.js`),
bundles[platform]
);
});
fs.writeFileSync(path.resolve(`${inputDir}/assets/${fakeHash}`), dummyFileBuffer);
expect(collectAssets(inputDir)).toEqual({
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: path.resolve(`${inputDir}/bundles/android-randomHash.js`),
},
assets: userDefinedAssets,
},
ios: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: path.resolve(`${inputDir}/bundles/ios-randomHash.js`),
},
assets: userDefinedAssets,
},
});
});
});
describe(filterOutAssetsThatAlreadyExistAsync, () => {
it('gets a missing asset', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
return [
{
storageKey: 'blah',
status: AssetMetadataStatus.DoesNotExist,
__typename: 'AssetMetadataResult',
},
];
});
expect(
await (await filterOutAssetsThatAlreadyExistAsync([{ storageKey: 'blah' } as any])).length
).toBe(1);
});
it('ignores an asset that exists', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
return [
{
storageKey: 'blah',
status: AssetMetadataStatus.Exists,
__typename: 'AssetMetadataResult',
},
];
});
expect(
await (await filterOutAssetsThatAlreadyExistAsync([{ storageKey: 'blah' } as any])).length
).toBe(0);
});
});
describe(uploadAssetsAsync, () => {
const publishBundles = {
android: {
code: 'android bundle code',
assets: [{ files: ['md5-hash-of-file'], type: 'jpg' }],
map: 'dummy-string',
},
ios: {
code: 'ios bundle code',
assets: [{ files: ['md5-hash-of-file'], type: 'jpg' }],
map: 'dummy-string',
},
};
const androidBundlePath = uuidv4();
const iosBundlePath = uuidv4();
const dummyFilePath = uuidv4();
const userDefinedPath = uuidv4();
fs.writeFileSync(androidBundlePath, publishBundles.android.code);
fs.writeFileSync(iosBundlePath, publishBundles.ios.code);
fs.writeFileSync(dummyFilePath, dummyFileBuffer);
fs.writeFileSync(userDefinedPath, 'I am an octet stream');
const userDefinedAsset = {
type: 'bundle',
contentType: 'application/octet-stream',
path: userDefinedPath,
};
const assetsForUpdateInfoGroup = {
android: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [userDefinedAsset, { type: 'jpg', contentType: 'image/jpeg', path: dummyFilePath }],
},
ios: {
launchAsset: {
type: 'bundle',
contentType: 'application/javascript',
path: androidBundlePath,
},
assets: [userDefinedAsset, { type: 'jpg', contentType: 'image/jpeg', path: dummyFilePath }],
},
};
jest.spyOn(PublishMutation, 'getUploadURLsAsync').mockImplementation(async () => {
return { specifications: ['{}', '{}', '{}'] };
});
beforeEach(() => {
jest.useFakeTimers();
});
it('throws an error if the upload exceeds TIMEOUT_LIMIT', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status = AssetMetadataStatus.DoesNotExist;
mockdate.set(Date.now() + TIMEOUT_LIMIT + 1);
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
mockdate.set(0);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).rejects.toThrow(
'Asset upload timed out. Please try again.'
);
});
it('resolves if the assets are already uploaded', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status = AssetMetadataStatus.Exists;
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
mockdate.set(0);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toBe(undefined);
});
it('resolves if the assets are eventually uploaded', async () => {
jest.spyOn(PublishQuery, 'getAssetMetadataAsync').mockImplementation(async () => {
const status =
Date.now() === 0 ? AssetMetadataStatus.DoesNotExist : AssetMetadataStatus.Exists;
mockdate.set(Date.now() + 1);
jest.runAllTimers();
return [
{
storageKey: 'qbgckgkgfdjnNuf9dQd7FDTWUmlEEzg7l1m1sKzQaq0',
status,
__typename: 'AssetMetadataResult',
}, // userDefinedAsset
{
storageKey: 'bbjgXFSIXtjviGwkaPFY0HG4dVVIGiXHAboRFQEqVa4',
status,
__typename: 'AssetMetadataResult',
}, // android.code
{
storageKey: 'dP-nC8EJXKz42XKh_Rc9tYxiGAT-ilpkRltEi6HIKeQ',
status,
__typename: 'AssetMetadataResult',
}, // ios.code
];
});
mockdate.set(0);
await expect(uploadAssetsAsync(assetsForUpdateInfoGroup)).resolves.toBe(undefined);
});
});
+251
View File
@@ -0,0 +1,251 @@
import { Platform } from '@expo/config';
import JsonFile from '@expo/json-file';
import crypto from 'crypto';
import fs from 'fs';
import { uniqBy } from 'lodash';
import mime from 'mime';
import path from 'path';
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
import { PublishMutation } from '../graphql/mutations/PublishMutation';
import { PublishQuery } from '../graphql/queries/PublishQuery';
import { PresignedPost, uploadWithPresignedPostAsync } from '../uploads';
export const TIMEOUT_LIMIT = 60_000; // 1 minute
const STORAGE_BUCKET = getStorageBucket();
export const Platforms: PublishPlatform[] = ['android', 'ios']; // TODO-JJ allow users to specify this in app.js
function getStorageBucket(): string {
if (process.env.EXPO_STAGING || process.env.EXPO_LOCAL) {
return 'update-assets-staging';
} else {
return 'update-assets-production';
}
}
export type PublishPlatform = Extract<'android' | 'ios', Platform>;
export type RawAsset = {
type: string;
contentType: string;
path: string;
};
type CollectedAssets = {
[platform in PublishPlatform]?: {
launchAsset: RawAsset;
assets: RawAsset[];
};
};
type ManifestFragment = {
launchAsset: PartialManifestAsset;
assets: PartialManifestAsset[];
};
type UpdateInfoGroup = {
[key in PublishPlatform]: ManifestFragment;
};
export function guessContentTypeFromExtension(ext?: string): string {
return mime.getType(ext ?? '') ?? 'application/octet-stream'; // unrecognized extension
}
export function getBase64URLEncoding(buffer: Buffer): string {
const base64 = buffer.toString('base64');
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* The storage key is used to store the asset in GCS
*/
export function getStorageKey(contentType: string, contentHash: string): string {
const nullSeparator = Buffer.alloc(1);
const hash = crypto
.createHash('sha256')
.update(contentType)
.update(nullSeparator)
.update(contentHash)
.digest();
return getBase64URLEncoding(hash);
}
async function calculateFileHashAsync(filePath: string, algorithm: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const file = fs.createReadStream(filePath).on('error', reject);
const hash = file.pipe(crypto.createHash(algorithm)).on('error', reject);
hash.on('finish', () => resolve(hash.read()));
});
}
/**
* Convenience function that computes an assets storage key starting from its buffer.
*/
export async function getStorageKeyForAssetAsync(asset: RawAsset): Promise<string> {
const fileHash = getBase64URLEncoding(await calculateFileHashAsync(asset.path, 'sha256'));
return getStorageKey(asset.contentType, fileHash);
}
export async function convertAssetToUpdateInfoGroupFormatAsync(
asset: RawAsset
): Promise<PartialManifestAsset> {
const fileHash = getBase64URLEncoding(await calculateFileHashAsync(asset.path, 'sha256'));
const contentType = asset.contentType;
const storageKey = getStorageKey(contentType, fileHash);
const bundleKey = [
(await calculateFileHashAsync(asset.path, 'md5')).toString('hex'),
asset.type,
].join('.');
return {
fileHash,
contentType,
storageBucket: STORAGE_BUCKET,
storageKey,
bundleKey,
};
}
export async function buildUpdateInfoGroupAsync(assets: CollectedAssets): Promise<UpdateInfoGroup> {
let platform: PublishPlatform;
const updateInfoGroup: Partial<UpdateInfoGroup> = {};
for (platform in assets) {
updateInfoGroup[platform] = {
launchAsset: await convertAssetToUpdateInfoGroupFormatAsync(assets[platform]?.launchAsset!),
assets: await Promise.all(
(assets[platform]?.assets ?? []).map(convertAssetToUpdateInfoGroupFormatAsync)
),
};
}
return updateInfoGroup as UpdateInfoGroup;
}
export function resolveInputDirectory(customInputDirectory: string): string {
const distRoot = path.resolve(customInputDirectory);
if (!fs.existsSync(distRoot)) {
throw new Error(`${distRoot} does not exist. Please create it with your desired bundler.`);
}
return distRoot;
}
export function collectUserDefinedAssets(distRoot: string): RawAsset[] {
const assetRoot = path.join(distRoot, 'assets');
const assetPointers = Platforms.map(platform => {
const assetJsonPath = path.join(distRoot, `${platform}-index.json`);
return JsonFile.read(assetJsonPath).bundledAssets;
}).flat();
return [...new Set(assetPointers)].map(pointer => {
const [filename, ext] = new String(pointer ?? '').split('_').pop()?.split('.') ?? [];
if (!filename) {
throw new Error(
'There was an error locating all of the bundler defined assets. Please rerun the bundler and then retry publishing.'
);
}
return {
type: ext ?? '',
contentType: guessContentTypeFromExtension(ext),
path: path.join(assetRoot, filename),
};
});
}
export function collectBundles(distRoot: string): { [key: string]: RawAsset } {
const bundleRoot = path.join(distRoot, 'bundles');
const bundlePaths = Object.fromEntries(
fs.readdirSync(bundleRoot).map(name => [name.split('-')[0], path.join(bundleRoot, name)])
);
const bundleBuffers: { [key: string]: RawAsset } = {};
Platforms.forEach(platform => {
bundleBuffers[platform] = {
type: 'bundle',
contentType: 'application/javascript',
path: bundlePaths[platform],
};
});
return bundleBuffers;
}
export function collectAssets(inputDir: string): CollectedAssets {
const distRoot = resolveInputDirectory(inputDir);
const assets = collectUserDefinedAssets(distRoot);
const bundles = collectBundles(distRoot);
const assetsFinal: CollectedAssets = {};
for (const platform of Platforms) {
assetsFinal[platform] = {
launchAsset: bundles[platform],
assets,
};
}
return assetsFinal;
}
export async function filterOutAssetsThatAlreadyExistAsync(
uniqueAssetsWithStorageKey: (RawAsset & { storageKey: string })[]
): Promise<(RawAsset & { storageKey: string })[]> {
const assetMetadata = await PublishQuery.getAssetMetadataAsync(
uniqueAssetsWithStorageKey.map(asset => asset.storageKey)
);
const missingAssetKeys = assetMetadata
.filter(result => result.status !== AssetMetadataStatus.Exists)
.map(result => result.storageKey);
const missingAssets = uniqueAssetsWithStorageKey.filter(asset => {
return missingAssetKeys.includes(asset.storageKey);
});
return missingAssets;
}
export async function uploadAssetsAsync(assetsForUpdateInfoGroup: CollectedAssets): Promise<void> {
let assets: RawAsset[] = [];
let platform: keyof CollectedAssets;
for (platform in assetsForUpdateInfoGroup) {
assets = [
...assets,
assetsForUpdateInfoGroup[platform]!.launchAsset,
...assetsForUpdateInfoGroup[platform]!.assets,
];
}
const uniqueAssets = uniqBy<
RawAsset & {
storageKey: string;
}
>(
await Promise.all(
assets.map(async asset => {
return {
...asset,
storageKey: await getStorageKeyForAssetAsync(asset),
};
})
),
'storageKey'
);
let missingAssets = await filterOutAssetsThatAlreadyExistAsync(uniqueAssets);
const { specifications } = await PublishMutation.getUploadURLsAsync(
missingAssets.map(ma => ma.contentType)
);
await Promise.all(
missingAssets.map((missingAsset, i) => {
const presignedPost: PresignedPost = JSON.parse(specifications[i]);
return uploadWithPresignedPostAsync(fs.createReadStream(missingAsset.path), presignedPost);
})
);
// Wait up to TIMEOUT_LIMIT for assets to be uploaded and processed
const start = Date.now();
let timeout = 1;
while (missingAssets.length > 0) {
const timeoutPromise = new Promise(resolve => setTimeout(resolve, timeout * 1000)); // linear backoff
missingAssets = await filterOutAssetsThatAlreadyExistAsync(missingAssets);
await timeoutPromise; // await after filterOutAssetsThatAlreadyExistAsync for easy mocking with jest.runAllTimers
timeout += 1;
if (Date.now() - start > TIMEOUT_LIMIT) {
throw new Error('Asset upload timed out. Please try again.');
}
}
}
@@ -35,7 +35,7 @@ const SubmissionErrorMessages: Record<SubmissionErrorCode, string> = {
[SubmissionErrorCode.IOS_OLD_VERSION_CODE_ERROR]:
"You've already submitted this version of the app.\n" +
'Versions are identified by Build Numbers (expo.ios.buildNumber in app.json).\n' +
"If you're submitting an Expo project built with EAS Build, increment the build number in app.json and build the project again." +
"If you're submitting an Expo project built with EAS Build, increment the build number in app.json and build the project again.\n" +
`${learnMore('https://expo.fyi/bumping-ios-build-number')}.`,
[SubmissionErrorCode.IOS_UNKNOWN_ERROR]:
"We couldn't figure out what went wrong. Please see logs to learn more.",
+14 -10
View File
@@ -57,8 +57,8 @@ async function obtainS3PresignedPostAsync(
export async function uploadWithPresignedPostAsync(
stream: Readable | Buffer,
presignedPost: PresignedPost,
handleProgressEvent: ProgressHandler
) {
handleProgressEvent?: ProgressHandler
): Promise<string> {
const form = new FormData();
for (const [fieldKey, fieldValue] of Object.entries(presignedPost.fields)) {
form.append(fieldKey, fieldValue);
@@ -66,17 +66,21 @@ export async function uploadWithPresignedPostAsync(
form.append('file', stream);
const formHeaders = form.getHeaders();
let uploadPromise = got.post(presignedPost.url, { body: form, headers: { ...formHeaders } });
if (handleProgressEvent) {
uploadPromise = uploadPromise.on('uploadProgress', progress =>
handleProgressEvent({ progress })
);
try {
const response = await uploadPromise;
handleProgressEvent({ isComplete: true });
return String(response.headers.location);
} catch (error) {
handleProgressEvent({ isComplete: true, error });
throw error;
}
}
try {
const response = await uploadPromise;
handleProgressEvent({ isComplete: true });
return String(response.headers.location);
} catch (error) {
handleProgressEvent({ isComplete: true, error });
throw error;
}
const response = await uploadPromise;
return String(response.headers.location);
}
+4 -2
View File
@@ -50,10 +50,12 @@ async function isGitInstalledAsync(): Promise<boolean> {
return true;
}
async function getBranchNameAsync(): Promise<string | undefined> {
async function getBranchNameAsync(): Promise<string | null> {
try {
return (await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout;
} catch (e) {}
} catch (e) {
return null;
}
}
async function getLastCommitMessageAsync(): Promise<string | null> {
+1
View File
@@ -4,6 +4,7 @@
"importHelpers": true,
"module": "commonjs",
"esModuleInterop": true,
"noImplicitReturns": true,
"outDir": "build",
"rootDir": "src",
"strict": true,
+15
View File
@@ -3686,6 +3686,11 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.161.tgz#a21ca0777dabc6e4f44f3d07f37b765f54188b18"
integrity sha512-EP6O3Jkr7bXvZZSZYlsgt5DIjiGr0dXP1/jVEwVLTFgg0d+3lWVQkRavYVQszV7dYUwvg0B8R0MBDpcmXg7XIA==
"@types/mime@^2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
"@types/minimatch@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -9258,6 +9263,11 @@ mime@^2.4.1, mime@^2.4.4:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1"
integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==
mime@^2.4.7:
version "2.4.7"
resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.7.tgz#962aed9be0ed19c91fd7dc2ece5d7f4e89a90d74"
integrity sha512-dhNd1uA2u397uQk3Nv5LM4lm93WYDUXFn3Fu291FJerns4jyTudqhIWe4W04YLy7Uk1tm1Ore04NpjRvQp/NPA==
mimic-fn@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
@@ -9397,6 +9407,11 @@ mkdirp@^0.5.0, mkdirp@^0.5.1:
dependencies:
minimist "^1.2.5"
mockdate@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/mockdate/-/mockdate-3.0.2.tgz#a5a7bb5820da617747af424d7a4dcb22c6c03d79"
integrity sha512-ldfYSUW1ocqSHGTK6rrODUiqAFPGAg0xaHqYJ5tvj1hQyFsjuHpuWgWFTZWwDVlzougN/s2/mhDr8r5nY5xDpA==
modify-values@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"