mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e1e29d119 |
No files matched your search
Generated
+3606
-305
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,14 @@ import { withErrorHandlingAsync } from '../graphql/client';
|
||||
import {
|
||||
CreateUpdateChannelOnAppMutation,
|
||||
CreateUpdateChannelOnAppMutationVariables,
|
||||
CreateUpdateChannelOnAppWithNoBranchMutation,
|
||||
CreateUpdateChannelOnAppWithNoBranchMutationVariables,
|
||||
DeleteUpdateChannelMutation,
|
||||
DeleteUpdateChannelMutationVariables,
|
||||
DeleteUpdateChannelResult,
|
||||
UpdateChannelBasicInfoFragment,
|
||||
UpdateChannelBranchMappingMutation,
|
||||
UpdateChannelBranchMappingMutationVariables,
|
||||
ViewBranchesOnUpdateChannelQueryVariables,
|
||||
ViewUpdateChannelsOnAppQueryVariables,
|
||||
} from '../graphql/generated';
|
||||
@@ -209,6 +217,42 @@ function renderChannelHeaderContent({
|
||||
Log.log(chalk`{bold Branches pointed at this channel and their most recent update group:}`);
|
||||
}
|
||||
|
||||
export async function createChannelOnAppWithNoBranchAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
}: {
|
||||
appId: string;
|
||||
channelName: string;
|
||||
}
|
||||
): Promise<CreateUpdateChannelOnAppWithNoBranchMutation> {
|
||||
return await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
CreateUpdateChannelOnAppWithNoBranchMutation,
|
||||
CreateUpdateChannelOnAppWithNoBranchMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation CreateUpdateChannelOnAppWithNoBranch($appId: ID!, $name: String!) {
|
||||
updateChannel {
|
||||
createUpdateChannelForApp(appId: $appId, name: $name) {
|
||||
id
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
name: channelName,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
}
|
||||
|
||||
export async function createChannelOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
@@ -367,3 +411,57 @@ export async function createAndLinkChannelAsync(
|
||||
}
|
||||
return newChannel;
|
||||
}
|
||||
|
||||
export async function updateChannelBranchMappingAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
|
||||
gql`
|
||||
mutation UpdateChannelBranchMapping($channelId: ID!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
|
||||
id
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ channelId, branchMapping }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const channel = data.updateChannel.editUpdateChannel;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not find a channel with id: ${channelId}`);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export async function deleteChannelOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId }: DeleteUpdateChannelMutationVariables
|
||||
): Promise<DeleteUpdateChannelResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateChannelMutation, DeleteUpdateChannelMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateChannel($channelId: ID!) {
|
||||
updateChannel {
|
||||
deleteUpdateChannel(channelId: $channelId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
channelId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateChannel.deleteUpdateChannel;
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import { deleteChannelOnAppAsync, selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
DeleteUpdateChannelMutation,
|
||||
DeleteUpdateChannelMutationVariables,
|
||||
DeleteUpdateChannelResult,
|
||||
} from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import { toggleConfirmAsync } from '../../prompts';
|
||||
@@ -95,32 +86,7 @@ export default class ChannelDelete extends EasCommand {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
} else {
|
||||
Log.withTick(`️Deleted channel "${channelName}".`);
|
||||
Log.withTick(`Deleted channel "${channelName}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChannelOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId }: DeleteUpdateChannelMutationVariables
|
||||
): Promise<DeleteUpdateChannelResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteUpdateChannelMutation, DeleteUpdateChannelMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteUpdateChannel($channelId: ID!) {
|
||||
updateChannel {
|
||||
deleteUpdateChannel(channelId: $channelId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
channelId,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.updateChannel.deleteUpdateChannel;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { selectBranchOnAppAsync } from '../../branch/queries';
|
||||
import {
|
||||
@@ -9,52 +7,15 @@ import {
|
||||
hasEmptyBranchMap,
|
||||
hasStandardBranchMap,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { selectChannelOnAppAsync } from '../../channel/queries';
|
||||
import { selectChannelOnAppAsync, updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { withErrorHandlingAsync } from '../../graphql/client';
|
||||
import {
|
||||
UpdateChannelBasicInfoFragment,
|
||||
UpdateChannelBranchMappingMutation,
|
||||
UpdateChannelBranchMappingMutationVariables,
|
||||
} from '../../graphql/generated';
|
||||
import { BranchQuery } from '../../graphql/queries/BranchQuery';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import { UpdateChannelBasicInfoFragmentNode } from '../../graphql/types/UpdateChannelBasicInfo';
|
||||
import Log from '../../log';
|
||||
import { isRollout } from '../../rollout/branch-mapping';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export async function updateChannelBranchMappingAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ channelId, branchMapping }: UpdateChannelBranchMappingMutationVariables
|
||||
): Promise<UpdateChannelBasicInfoFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<UpdateChannelBranchMappingMutation, UpdateChannelBranchMappingMutationVariables>(
|
||||
gql`
|
||||
mutation UpdateChannelBranchMapping($channelId: ID!, $branchMapping: String!) {
|
||||
updateChannel {
|
||||
editUpdateChannel(channelId: $channelId, branchMapping: $branchMapping) {
|
||||
id
|
||||
...UpdateChannelBasicInfoFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateChannelBasicInfoFragmentNode)}
|
||||
`,
|
||||
{ channelId, branchMapping }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const channel = data.updateChannel.editUpdateChannel;
|
||||
if (!channel) {
|
||||
throw new Error(`Could not find a channel with id: ${channelId}`);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export default class ChannelEdit extends EasCommand {
|
||||
static override description = 'point a channel at a new branch';
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
This set of commands does its best to map appcenter codepush commands to EAS update. Generally it works by mapping codepush deployments as EAS channels. Each release on a deployment corresponds to a branch with one update group. That release can be rolled out against the existing release on the deployment. Release history doesn't work too well, but that's ok I hope.
|
||||
|
||||
deployment add - channel create
|
||||
deployment clear - channel edit, create empty branch, point to blank branch
|
||||
deployment history - branch view of current branch associated with the channel
|
||||
deployment list - channel list
|
||||
deployment remove - channel delete
|
||||
deployment rename - not possible, throw error
|
||||
|
||||
release-react: creates new branch, rolls the channel out to N % on that branch.
|
||||
--use-hermes: not supported
|
||||
--extra-hermes-flag: not supported
|
||||
--extra-bundler-option: not supported
|
||||
--target-binary-version -> not supported, inferred runtime version
|
||||
--output-dir -> same as eas update output dir
|
||||
--sourcemap-output-dir -> not supported
|
||||
--sourcemap-output -> not supported
|
||||
--xcode-target-name -> not supported, use target-binary-version
|
||||
--build-configuration-name -> not supported, use target-binary-version
|
||||
--plist-file-prefix -> not supported
|
||||
--xcode-project-file -> not supported
|
||||
--plist-file -> not supported
|
||||
--pod-file -> not supported
|
||||
--gradle-file -> not supported, use target-binary-version
|
||||
--entry-file -> not supported, same as eas update
|
||||
--development -> not supported, think this is same as eas update
|
||||
--bundle-name -> not supported
|
||||
--rollout -> set N % to N
|
||||
--disable-duplicate-release-error -> no idea
|
||||
--private-key-path -> same as eas update
|
||||
--mandatory -> not supported for now
|
||||
--disabled -> set N % to 0
|
||||
--description -> message
|
||||
--deployment-name -> channel to put the new branch on
|
||||
|
||||
promote: copies latest update on "latest branch" on channel to "latest branch" on other channel. it does this by creating a new branch with the update and pointing the channel to that new branch according to rollout or 100% if no rollout supplied.
|
||||
|
||||
rollback: sets the rollout percentage of branch on channel to 0 (i.e. points to prev branch) and republishes the latest update on the prev branch to itself
|
||||
|
||||
patch: only update the rollout percentage
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* $ appcenter codepush deployment add --help
|
||||
|
||||
Add a new deployment to an app
|
||||
|
||||
Usage: appcenter codepush deployment add [-a|--app <arg>] <new-deployment-name>
|
||||
|
||||
Options:
|
||||
new-deployment-name New CodePush deployment name
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { createChannelOnAppWithNoBranchAsync } from '../../../channel/queries';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import Log from '../../../log';
|
||||
import { getDisplayNameForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import formatFields from '../../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class CodepushDeploymentAdd extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'create a deployment';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
description: 'Name of the deployment to create',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name: deploymentName },
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(CodepushDeploymentAdd);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushDeploymentAdd, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const createChannelResult = await createChannelOnAppWithNoBranchAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
});
|
||||
|
||||
const newChannel = createChannelResult.updateChannel.createUpdateChannelForApp;
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(newChannel);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.withTick(
|
||||
`Created a new deployment on project ${chalk.bold(
|
||||
await getDisplayNameForProjectIdAsync(graphqlClient, projectId)
|
||||
)}`
|
||||
);
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Name', value: newChannel.name },
|
||||
{ label: 'ID', value: newChannel.id },
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* $ appcenter codepush deployment clear --help
|
||||
|
||||
Clear the release history associated with a deployment
|
||||
|
||||
Usage: appcenter codepush deployment clear [-a|--app <arg>] <deployment-name>
|
||||
|
||||
Options:
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
deployment-name Specifies CodePush deployment name to be cleared
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getEmptyBranchMapping } from '../../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../../channel/queries';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class CodepushDeploymentClear extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'clear a deployment';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
description: 'Name of the deployment to clear',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name: deploymentName },
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(CodepushDeploymentClear);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushDeploymentClear, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const existingChannel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
});
|
||||
|
||||
const channel = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: existingChannel.id,
|
||||
branchMapping: JSON.stringify(getEmptyBranchMapping()),
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(channel);
|
||||
} else {
|
||||
Log.withTick(chalk`Deployment {bold ${channel.name}} is now cleared.\n`);
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
chalk`Users with builds on deployment {bold ${channel.name}} will no longer have an available update.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* $ appcenter codepush deployment history --help
|
||||
|
||||
Display the release history for a CodePush deployment
|
||||
|
||||
Usage: appcenter codepush deployment history [-a|--app <arg>] <deployment-name>
|
||||
|
||||
Options:
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
deployment-name Specifies CodePush deployment name to view history
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../../log';
|
||||
import { printRollout } from '../../../rollout/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class CodepushDeploymentHistory extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'get the release history for a deployment';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
description: 'Name of the deployment',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name: deploymentName },
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(CodepushDeploymentHistory);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushDeploymentHistory, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const existingChannel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(existingChannel);
|
||||
} else {
|
||||
Log.withTick(chalk`Deployment {bold ${existingChannel.name}} info:\n`);
|
||||
Log.addNewLineIfNone();
|
||||
printRollout(existingChannel);
|
||||
Log.warn('For formatted output, add the --json flag to your command.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* $ appcenter codepush deployment list --help
|
||||
|
||||
List the deployments associated with an app
|
||||
|
||||
Usage: appcenter codepush deployment list [-s|--skipFetchingDeploymentMetrics] [-k|--displayKeys]
|
||||
[-a|--app <arg>]
|
||||
|
||||
Options:
|
||||
-s|--skipFetchingDeploymentMetrics Specifies whether to fetch deployment metrics
|
||||
-k|--displayKeys Specifies whether to display the deployment keys
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import { CHANNELS_LIMIT, listAndRenderChannelsOnAppAsync } from '../../../channel/queries';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import {
|
||||
EasPaginatedQueryFlags,
|
||||
getLimitFlagWithCustomValues,
|
||||
getPaginatedQueryOptions,
|
||||
} from '../../../commandUtils/pagination';
|
||||
import { enableJsonOutput } from '../../../utils/json';
|
||||
|
||||
export default class CodepushDeploymentList extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'list deployments';
|
||||
|
||||
static override flags = {
|
||||
...EasPaginatedQueryFlags,
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: 10, limit: CHANNELS_LIMIT }),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(CodepushDeploymentList);
|
||||
const paginatedQueryOptions = getPaginatedQueryOptions(flags);
|
||||
const { json: jsonFlag, 'non-interactive': nonInteractive } = flags;
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushDeploymentList, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
await listAndRenderChannelsOnAppAsync(graphqlClient, {
|
||||
projectId,
|
||||
paginatedQueryOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* $ appcenter codepush deployment remove --help
|
||||
|
||||
Remove CodePush deployment
|
||||
|
||||
Usage: appcenter codepush deployment remove [-a|--app <arg>] <deployment-name>
|
||||
|
||||
Options:
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
deployment-name Specifies CodePush deployment name to be removed
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import { deleteChannelOnAppAsync } from '../../../channel/queries';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class CodepushDeploymentRemove extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'Delete a deployment';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
description: 'Name of the deployment to delete',
|
||||
},
|
||||
];
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { name: deploymentName },
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(CodepushDeploymentRemove);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushDeploymentRemove, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const { id: channelId, name: channelName } = await ChannelQuery.viewUpdateChannelAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
}
|
||||
);
|
||||
|
||||
const deletionResult = await deleteChannelOnAppAsync(graphqlClient, {
|
||||
channelId,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(deletionResult);
|
||||
} else {
|
||||
Log.withTick(`Deleted deployment "${channelName}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* $ appcenter codepush patch --help
|
||||
|
||||
Update the metadata for an existing CodePush release
|
||||
|
||||
Usage: appcenter codepush patch [-r|--rollout <arg>] [-d|--description <arg>]
|
||||
[-t|--target-binary-version <arg>] [-x|--disabled] [-m|--mandatory]
|
||||
[-l|--existing-release-label <arg>] [-a|--app <arg>] <deployment-name>
|
||||
|
||||
Options:
|
||||
-r|--rollout <arg> Specifies percentage of users this release should be
|
||||
immediately available to. (The specified number must be
|
||||
an integer between 1 and 100)
|
||||
-d|--description <arg> Specifies description of the changes made to the app with
|
||||
this release
|
||||
-t|--target-binary-version <arg> Specifies binary app version(s) that specifies this
|
||||
release is targeting for. (The value must be a semver
|
||||
expression such as 1.1.0, ~1.2.3)
|
||||
-x|--disabled Specifies whether this release should be immediately
|
||||
downloadable. (Putting -x flag means disabled)
|
||||
-m|--mandatory Specifies whether this release should be considered
|
||||
mandatory. (Putting -m flag means mandatory)
|
||||
-l|--existing-release-label <arg> Specifies label of one existing release to update.
|
||||
(Defaults to the latest release within the specified
|
||||
deployment)
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
deployment-name Specifies one existing deployment name.
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import {
|
||||
BranchMapping,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
editRolloutBranchMapping,
|
||||
getRolloutInfoFromBranchMapping,
|
||||
isRolloutBranchMapping,
|
||||
} from '../../rollout/branch-mapping';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class CodepushPatch extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'update the metadata for the latest release on a deployment';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
description: 'Name of the codepush deployment to patch the latest release on',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
rollout: Flags.integer({
|
||||
char: 'r',
|
||||
description: 'Percentage of users this release should be available to',
|
||||
required: false,
|
||||
}),
|
||||
disabled: Flags.boolean({
|
||||
char: 'x',
|
||||
description: 'Specifies whether this release should be immediately downloadable',
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { rollout, disabled, json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
args: { name: deploymentName },
|
||||
} = await this.parse(CodepushPatch);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushPatch, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
});
|
||||
|
||||
// set channel rollout
|
||||
const rolloutPercent = disabled ? 0 : rollout ?? 100;
|
||||
|
||||
const existingBranchMappingString = channel.branchMapping;
|
||||
let updatedBranchMapping: BranchMapping;
|
||||
if (!existingBranchMappingString) {
|
||||
throw new Error(
|
||||
'No existing rollout on deployment. Publish an release with the `release-react` command.'
|
||||
);
|
||||
} else {
|
||||
const existingBranchMapping = getBranchMapping(existingBranchMappingString);
|
||||
if (isEmptyBranchMapping(existingBranchMapping)) {
|
||||
throw new Error(
|
||||
'No existing rollout on deployment. Publish an release with the `release-react` command.'
|
||||
);
|
||||
} else if (isAlwaysTrueBranchMapping(existingBranchMapping)) {
|
||||
throw new Error(
|
||||
'No existing rollout on deployment. Publish an release with the `release-react` command.'
|
||||
);
|
||||
} else if (isRolloutBranchMapping(existingBranchMapping)) {
|
||||
const rolloutInfo = getRolloutInfoFromBranchMapping(existingBranchMapping);
|
||||
const existingRolloutRolloutBranchId = rolloutInfo.rolledOutBranchId;
|
||||
updatedBranchMapping =
|
||||
rolloutPercent === 100
|
||||
? getAlwaysTrueBranchMapping(existingRolloutRolloutBranchId)
|
||||
: editRolloutBranchMapping(existingBranchMapping, rolloutPercent);
|
||||
} else {
|
||||
throw new Error('Unrecognized custom deployment structure');
|
||||
}
|
||||
}
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(updatedBranchMapping),
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`✅ Successfuly updated rollout on ${newChannelInfo.name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* $ appcenter codepush promote --help
|
||||
|
||||
Create a new release for the destination deployment, which includes the exact code and metadata from the latest release of the source deployment
|
||||
|
||||
Usage: appcenter codepush promote -s|--source-deployment-name <arg> -d|--destination-deployment-name <arg>
|
||||
[-t|--target-binary-version <arg>] [-r|--rollout <arg>] [--disable-duplicate-release-error]
|
||||
[-x|--disabled] [-m|--mandatory] [-l|--label <arg>] [--description <arg>] [-a|--app <arg>]
|
||||
|
||||
Options:
|
||||
-s|--source-deployment-name <arg> Specifies source deployment name
|
||||
-d|--destination-deployment-name <arg> Specifies destination deployment name
|
||||
-t|--target-binary-version <arg> Specifies binary app version(s) that specifies this
|
||||
release is targeting for. (The value must be a
|
||||
semver expression such as 1.1.0, ~1.2.3)
|
||||
-r|--rollout <arg> Specifies percentage of users this release should be
|
||||
immediately available to. (The specified number must
|
||||
be an integer between 1 and 100)
|
||||
--disable-duplicate-release-error Specifies that if the update is identical to the
|
||||
latest release on the deployment, the CLI should
|
||||
generate a warning instead of an error
|
||||
-x|--disabled Specifies whether this release should be immediately
|
||||
downloadable. (Putting -x flag means disabled)
|
||||
-m|--mandatory Specifies whether this release should be considered
|
||||
mandatory. (Putting -m flag means mandatory)
|
||||
-l|--label <arg> Allows you to pick the specified label from the
|
||||
source deployment and promote it to the destination
|
||||
deployment
|
||||
--description <arg> Specifies description of the changes made to the app
|
||||
with this release
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import { Flags } from '@oclif/core';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
|
||||
import {
|
||||
BranchMapping,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
createRolloutBranchMapping,
|
||||
getRolloutInfoFromBranchMapping,
|
||||
isRolloutBranchMapping,
|
||||
} from '../../rollout/branch-mapping';
|
||||
import { republishAsync } from '../../update/republish';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class CodepushPromote extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'republish latest release from source deployment to destination deployment';
|
||||
|
||||
static override flags = {
|
||||
sourceDeploymentName: Flags.string({
|
||||
char: 's',
|
||||
description: 'Source deployment',
|
||||
required: true,
|
||||
}),
|
||||
destinationDeploymentName: Flags.string({
|
||||
char: 'd',
|
||||
description: 'Destination deployment',
|
||||
required: true,
|
||||
}),
|
||||
rollout: Flags.integer({
|
||||
char: 'r',
|
||||
description: 'Percentage of users this release should be available to',
|
||||
required: false,
|
||||
}),
|
||||
disabled: Flags.boolean({
|
||||
char: 'x',
|
||||
description: 'Specifies whether this release should be immediately downloadable',
|
||||
required: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
sourceDeploymentName,
|
||||
destinationDeploymentName,
|
||||
rollout,
|
||||
disabled,
|
||||
'private-key-path': privateKeyPath,
|
||||
json: jsonFlag,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(CodepushPromote);
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushPromote, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!sourceDeploymentName) {
|
||||
throw new Error('Source deployment name may not be empty.');
|
||||
}
|
||||
|
||||
if (!destinationDeploymentName) {
|
||||
throw new Error('Source deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const [sourceChannel, destinationChannel] = await Promise.all([
|
||||
ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: sourceDeploymentName,
|
||||
}),
|
||||
ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: destinationDeploymentName,
|
||||
}),
|
||||
]);
|
||||
|
||||
const sourceBranchMappingString = sourceChannel.branchMapping;
|
||||
let latestBranchIdOnSourceChannel: string;
|
||||
if (!sourceBranchMappingString) {
|
||||
throw new Error(
|
||||
'No releases on source deployment. Publish an release with the `release-react` command.'
|
||||
);
|
||||
} else {
|
||||
const existingBranchMapping = getBranchMapping(sourceBranchMappingString);
|
||||
if (isEmptyBranchMapping(existingBranchMapping)) {
|
||||
throw new Error(
|
||||
'No releases on source deployment. Publish an update with the `release-react` command.'
|
||||
);
|
||||
} else if (isAlwaysTrueBranchMapping(existingBranchMapping)) {
|
||||
latestBranchIdOnSourceChannel = existingBranchMapping.data[0].branchId;
|
||||
} else if (isRolloutBranchMapping(existingBranchMapping)) {
|
||||
const rolloutInfo = getRolloutInfoFromBranchMapping(existingBranchMapping);
|
||||
latestBranchIdOnSourceChannel = rolloutInfo.rolledOutBranchId;
|
||||
} else {
|
||||
throw new Error('Unrecognized custom deployment structure');
|
||||
}
|
||||
}
|
||||
|
||||
const latestBranchOnSourceChannel = nullthrows(
|
||||
sourceChannel.updateBranches.find(b => b.id === latestBranchIdOnSourceChannel)
|
||||
);
|
||||
|
||||
// republish latest updates from source branch to new destination branch
|
||||
const createdBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: latestBranchOnSourceChannel.name, // maybe change name
|
||||
});
|
||||
|
||||
const updateGroupToRepublish = latestBranchOnSourceChannel.updateGroups[0];
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(exp, privateKeyPath ?? undefined);
|
||||
const arbitraryUpdate = updateGroupToRepublish[0];
|
||||
const { message: oldUpdateMessage, group: oldGroupId } = arbitraryUpdate;
|
||||
const newUpdateMessage = `Republish "${oldUpdateMessage!}" - group: ${oldGroupId}`;
|
||||
await republishAsync({
|
||||
graphqlClient,
|
||||
app: {
|
||||
projectId,
|
||||
exp,
|
||||
},
|
||||
updatesToPublish: updateGroupToRepublish.map(update => ({
|
||||
...update,
|
||||
groupId: update.group,
|
||||
branchId: update.branch.id,
|
||||
branchName: update.branch.name,
|
||||
})),
|
||||
codeSigningInfo,
|
||||
targetBranch: { branchId: createdBranch.id, branchName: createdBranch.name },
|
||||
updateMessage: newUpdateMessage,
|
||||
});
|
||||
|
||||
// set destination channel rollout
|
||||
const destinationRolloutPercent = disabled ? 0 : rollout ?? 100;
|
||||
|
||||
const destinationBranchMappingString = destinationChannel.branchMapping;
|
||||
let updatedBranchMapping: BranchMapping;
|
||||
if (!destinationBranchMappingString) {
|
||||
if (destinationRolloutPercent !== 100) {
|
||||
throw new Error('Cannot roll out first release on deployment'); // TODO(wschurman): this needs to be possible
|
||||
}
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(createdBranch.id);
|
||||
} else {
|
||||
const existingBranchMapping = getBranchMapping(destinationBranchMappingString);
|
||||
if (isEmptyBranchMapping(existingBranchMapping)) {
|
||||
if (destinationRolloutPercent !== 100) {
|
||||
throw new Error('Cannot roll out first release on deployment'); // TODO(wschurman): this needs to be possible
|
||||
}
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(createdBranch.id);
|
||||
} else if (isAlwaysTrueBranchMapping(existingBranchMapping)) {
|
||||
const existingDestinationBranchId = existingBranchMapping.data[0].branchId;
|
||||
updatedBranchMapping =
|
||||
destinationRolloutPercent === 100
|
||||
? getAlwaysTrueBranchMapping(existingDestinationBranchId)
|
||||
: createRolloutBranchMapping({
|
||||
defaultBranchId: existingBranchMapping.data[0].branchId,
|
||||
rolloutBranchId: createdBranch.id,
|
||||
runtimeVersion: arbitraryUpdate.runtimeVersion,
|
||||
percent: destinationRolloutPercent,
|
||||
});
|
||||
} else if (isRolloutBranchMapping(existingBranchMapping)) {
|
||||
if (destinationRolloutPercent !== 100) {
|
||||
throw new Error(
|
||||
'Cannot roll out first release on deployment that already has a roll out in progress'
|
||||
); // TODO(wschurman): handle runtime versions (only check for roll out for runtime version)
|
||||
}
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(createdBranch.id);
|
||||
} else {
|
||||
throw new Error('Unrecognized custom deployment structure');
|
||||
}
|
||||
}
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: destinationChannel.id,
|
||||
branchMapping: JSON.stringify(updatedBranchMapping),
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`✅ Successfuly updated rollout on ${newChannelInfo.name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* $ appcenter codepush release-react --help
|
||||
|
||||
Release a React Native update to an app deployment
|
||||
|
||||
Usage: appcenter codepush release-react [--use-hermes] [--extra-hermes-flag <arg>] [--extra-bundler-option <arg>]
|
||||
[-t|--target-binary-version <arg>] [-o|--output-dir <arg>] [--sourcemap-output-dir <arg>]
|
||||
[-s|--sourcemap-output <arg>] [-xt|--xcode-target-name <arg>] [-c|--build-configuration-name <arg>]
|
||||
[--plist-file-prefix <arg>] [-xp|--xcode-project-file <arg>] [-p|--plist-file <arg>] [--pod-file <arg>]
|
||||
[-g|--gradle-file <arg>] [-e|--entry-file <arg>] [--development] [-b|--bundle-name <arg>]
|
||||
[-r|--rollout <arg>] [--disable-duplicate-release-error] [-k|--private-key-path <arg>] [-m|--mandatory]
|
||||
[-x|--disabled] [--description <arg>] [-d|--deployment-name <arg>] [-a|--app <arg>]
|
||||
|
||||
Options:
|
||||
--use-hermes Enable hermes and bypass automatic checks
|
||||
--extra-hermes-flag <arg> Flag that gets passed to Hermes, JavaScript to bytecode compiler. Can
|
||||
be specified multiple times
|
||||
--extra-bundler-option <arg> Option that gets passed to react-native bundler. Can be specified
|
||||
multiple times
|
||||
-t|--target-binary-version <arg> Semver expression that specifies the binary app version(s) this
|
||||
release is targeting (e.g. 1.1.0, ~1.2.3)
|
||||
-o|--output-dir <arg> Path to where the bundle should be written. If omitted, the bundle
|
||||
will not be saved on your machine
|
||||
--sourcemap-output-dir <arg> Path to folder where the sourcemap for the resulting bundle should be
|
||||
written. Name of sourcemap file will be generated automatically. This
|
||||
argument will be ignored if "sourcemap-output" argument is provided.
|
||||
If omitted, a sourcemap will not be generated
|
||||
-s|--sourcemap-output <arg> Path to where the sourcemap for the resulting bundle should be
|
||||
written. If omitted, a sourcemap will not be generated
|
||||
-xt|--xcode-target-name <arg> Name of target (PBXNativeTarget) which specifies the binary version
|
||||
you want to target this release at (iOS only)
|
||||
-c|--build-configuration-name <arg> Name of build configuration which specifies the binary version you
|
||||
want to target this release at. For example, "Debug" or "Release" (iOS
|
||||
only)
|
||||
--plist-file-prefix <arg> Prefix to append to the file name when attempting to find your app's
|
||||
Info.plist file (iOS only)
|
||||
-xp|--xcode-project-file <arg> Path to the Xcode project or project.pbxproj file
|
||||
-p|--plist-file <arg> Path to the plist file which specifies the binary version you want to
|
||||
target this release at (iOS only)
|
||||
--pod-file <arg> Path to the cocopods config file (iOS only)
|
||||
-g|--gradle-file <arg> Path to the gradle file which specifies the binary version you want to
|
||||
target this release at (android only)
|
||||
-e|--entry-file <arg> Path to the app's entry JavaScript file. If omitted,
|
||||
"index.<platform>.js" and then "index.js" will be used (if they exist)
|
||||
--development Specifies whether to generate a dev or release build
|
||||
-b|--bundle-name <arg> Name of the generated JS bundle file. If unspecified, the standard
|
||||
bundle name will be used, depending on the specified platform:
|
||||
"main.jsbundle" (iOS), "index.android.bundle" (Android) or
|
||||
"index.windows.bundle" (Windows)
|
||||
-r|--rollout <arg> Percentage of users this release should be available to
|
||||
--disable-duplicate-release-error When this flag is set, releasing a package that is identical to the
|
||||
latest release will produce a warning instead of an error
|
||||
-k|--private-key-path <arg> Specifies the location of a RSA private key to sign the release
|
||||
with.NOTICE: use it for react native applications only, client SDK on
|
||||
other platforms will be ignoring signature verification for now!
|
||||
-m|--mandatory Specifies whether this release should be considered mandatory
|
||||
-x|--disabled Specifies whether this release should be immediately downloadable
|
||||
--description <arg> Description of the changes made to the app in this release
|
||||
-d|--deployment-name <arg> Deployment to release the update to
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { createUpdateBranchOnAppAsync } from '../../branch/queries';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
import { getUpdateGroupUrl } from '../../build/utils/url';
|
||||
import {
|
||||
BranchMapping,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
buildAndUploadAssetsAsync,
|
||||
getRuntimeVersionObjectAsync,
|
||||
isUploadedAssetCountAboveWarningThreshold,
|
||||
publishUpdateGroupsAsync,
|
||||
} from '../../project/publish';
|
||||
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
|
||||
import { createRolloutBranchMapping, isRolloutBranchMapping } from '../../rollout/branch-mapping';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import uniqBy from '../../utils/expodash/uniqBy';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { maybeWarnAboutEasOutagesAsync } from '../../utils/statuspageService';
|
||||
|
||||
export default class CodepushReleaseReact extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'Release an update to a deployment';
|
||||
|
||||
static override flags = {
|
||||
rollout: Flags.integer({
|
||||
char: 'r',
|
||||
description: 'Percentage of users this release should be available to',
|
||||
required: false,
|
||||
}),
|
||||
description: Flags.string({
|
||||
description: 'Description of the changes made to the app in this release',
|
||||
required: true,
|
||||
}),
|
||||
deploymentName: Flags.string({
|
||||
char: 'd',
|
||||
description: 'Deployment to release the update to',
|
||||
required: true,
|
||||
}),
|
||||
disabled: Flags.boolean({
|
||||
char: 'x',
|
||||
description: 'Specifies whether this release should be immediately downloadable',
|
||||
required: false,
|
||||
}),
|
||||
'clear-cache': Flags.boolean({
|
||||
description: `Clear the bundler cache before publishing`,
|
||||
default: false,
|
||||
}),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.DynamicProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.Vcs,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: {
|
||||
rollout,
|
||||
description,
|
||||
deploymentName,
|
||||
disabled,
|
||||
'clear-cache': clearCache,
|
||||
'private-key-path': privateKeyPath,
|
||||
json: jsonFlag,
|
||||
'non-interactive': nonInteractive,
|
||||
},
|
||||
} = await this.parse(CodepushReleaseReact);
|
||||
const {
|
||||
getDynamicPublicProjectConfigAsync,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
loggedIn: { graphqlClient },
|
||||
vcsClient,
|
||||
} = await this.getContextAsync(CodepushReleaseReact, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, nonInteractive);
|
||||
|
||||
const {
|
||||
exp: expPossiblyWithoutEasUpdateConfigured,
|
||||
projectId,
|
||||
projectDir,
|
||||
} = await getDynamicPublicProjectConfigAsync();
|
||||
|
||||
await maybeWarnAboutEasOutagesAsync(graphqlClient, [StatuspageServiceName.EasUpdate]);
|
||||
|
||||
await ensureEASUpdateIsConfiguredAsync({
|
||||
exp: expPossiblyWithoutEasUpdateConfigured,
|
||||
platform: RequestedPlatform.All,
|
||||
projectDir,
|
||||
projectId,
|
||||
vcsClient,
|
||||
env: undefined,
|
||||
});
|
||||
|
||||
const { exp } = await getDynamicPublicProjectConfigAsync();
|
||||
const { exp: expPrivate } = await getDynamicPrivateProjectConfigAsync();
|
||||
const codeSigningInfo = await getCodeSigningInfoAsync(expPrivate, privateKeyPath);
|
||||
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
});
|
||||
|
||||
const createdBranch = await createUpdateBranchOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
name: description, // TODO(wschurman): make this name something else maybe, maybe something with date or something
|
||||
});
|
||||
|
||||
const {
|
||||
realizedPlatforms,
|
||||
unsortedUpdateInfoGroups,
|
||||
uploadedAssetCount,
|
||||
assetLimitPerUpdateGroup,
|
||||
} = await buildAndUploadAssetsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
skipBundler: false,
|
||||
projectDir,
|
||||
inputDir: 'dist',
|
||||
exp,
|
||||
platformFlag: 'all',
|
||||
clearCache,
|
||||
});
|
||||
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
|
||||
const runtimeVersions = await getRuntimeVersionObjectAsync({
|
||||
exp,
|
||||
platforms: realizedPlatforms,
|
||||
projectDir,
|
||||
workflows: {
|
||||
...workflows,
|
||||
web: Workflow.UNKNOWN,
|
||||
},
|
||||
env: undefined,
|
||||
});
|
||||
|
||||
const runtimeVersionsArr = Array.from(new Set(runtimeVersions.map(r => r.runtimeVersion)));
|
||||
if (runtimeVersionsArr.length > 1) {
|
||||
throw new Error(
|
||||
'Using codepush requires the runtime version to be the same for all platforms'
|
||||
);
|
||||
}
|
||||
|
||||
const gitCommitHash = await vcsClient.getCommitHashAsync();
|
||||
const isGitWorkingTreeDirty = await vcsClient.hasUncommittedChangesAsync();
|
||||
|
||||
const { newUpdates } = await publishUpdateGroupsAsync({
|
||||
graphqlClient,
|
||||
branchId: createdBranch.id,
|
||||
runtimeVersions,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
unsortedUpdateInfoGroups,
|
||||
updateMessage: description,
|
||||
codeSigningInfo,
|
||||
});
|
||||
|
||||
// set channel rollout
|
||||
const rolloutPercent = disabled ? 0 : rollout ?? 100;
|
||||
const existingBranchMappingString = channel.branchMapping;
|
||||
let updatedBranchMapping: BranchMapping;
|
||||
if (!existingBranchMappingString) {
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(createdBranch.id);
|
||||
} else {
|
||||
const existingBranchMapping = getBranchMapping(existingBranchMappingString);
|
||||
if (isEmptyBranchMapping(existingBranchMapping)) {
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(createdBranch.id);
|
||||
} else if (isAlwaysTrueBranchMapping(existingBranchMapping)) {
|
||||
updatedBranchMapping =
|
||||
rolloutPercent === 100
|
||||
? getAlwaysTrueBranchMapping(createdBranch.id)
|
||||
: createRolloutBranchMapping({
|
||||
defaultBranchId: existingBranchMapping.data[0].branchId,
|
||||
rolloutBranchId: createdBranch.id,
|
||||
runtimeVersion: runtimeVersionsArr[0],
|
||||
percent: rolloutPercent,
|
||||
});
|
||||
} else if (isRolloutBranchMapping(existingBranchMapping)) {
|
||||
if (rolloutPercent !== 100) {
|
||||
throw new Error('Cannot start a rollout on a deployment when one is already in progress');
|
||||
}
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(createdBranch.id);
|
||||
} else {
|
||||
throw new Error('Unrecognized custom deployment structure');
|
||||
}
|
||||
}
|
||||
|
||||
await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(updatedBranchMapping),
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(newUpdates));
|
||||
} else {
|
||||
if (new Set(newUpdates.map(update => update.group)).size > 1) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
'👉 Since multiple runtime versions are defined, multiple update groups have been published.'
|
||||
);
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
for (const runtime of uniqBy(runtimeVersions, version => version.runtimeVersion)) {
|
||||
const newUpdatesForRuntimeVersion = newUpdates.filter(
|
||||
update => update.runtimeVersion === runtime.runtimeVersion
|
||||
);
|
||||
if (newUpdatesForRuntimeVersion.length === 0) {
|
||||
throw new Error(
|
||||
`Publish response is missing updates with runtime ${runtime.runtimeVersion}.`
|
||||
);
|
||||
}
|
||||
const platforms = newUpdatesForRuntimeVersion.map(update => update.platform);
|
||||
const newAndroidUpdate = newUpdatesForRuntimeVersion.find(
|
||||
update => update.platform === 'android'
|
||||
);
|
||||
const newIosUpdate = newUpdatesForRuntimeVersion.find(update => update.platform === 'ios');
|
||||
const updateGroupId = newUpdatesForRuntimeVersion[0].group;
|
||||
|
||||
const projectName = exp.slug;
|
||||
const accountName = (await getOwnerAccountForProjectIdAsync(graphqlClient, projectId)).name;
|
||||
const updateGroupUrl = getUpdateGroupUrl(accountName, projectName, updateGroupId);
|
||||
const updateGroupLink = link(updateGroupUrl, { dim: false });
|
||||
|
||||
Log.log(
|
||||
formatFields([
|
||||
{ label: 'Branch', value: createdBranch.name },
|
||||
{ label: 'Runtime version', value: runtime.runtimeVersion },
|
||||
{ label: 'Platform', value: platforms.join(', ') },
|
||||
{ label: 'Update group ID', value: updateGroupId },
|
||||
...(newAndroidUpdate
|
||||
? [{ label: 'Android update ID', value: newAndroidUpdate.id }]
|
||||
: []),
|
||||
...(newIosUpdate ? [{ label: 'iOS update ID', value: newIosUpdate.id }] : []),
|
||||
{ label: 'Message', value: description ?? '' },
|
||||
...(gitCommitHash
|
||||
? [
|
||||
{
|
||||
label: 'Commit',
|
||||
value: `${gitCommitHash}${isGitWorkingTreeDirty ? '*' : ''}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: 'EAS Dashboard', value: updateGroupLink },
|
||||
])
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
if (
|
||||
isUploadedAssetCountAboveWarningThreshold(uploadedAssetCount, assetLimitPerUpdateGroup)
|
||||
) {
|
||||
Log.warn(
|
||||
`This update group contains ${uploadedAssetCount} assets and is nearing the server cap of ${assetLimitPerUpdateGroup}.\n` +
|
||||
`${learnMore('https://docs.expo.dev/eas-update/optimize-assets/', {
|
||||
learnMoreMessage: 'Consider optimizing your usage of assets',
|
||||
dim: false,
|
||||
})}.`
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* $ appcenter codepush rollback --help
|
||||
|
||||
Rollback a deployment to a previous release
|
||||
|
||||
Usage: appcenter codepush rollback [--target-release <arg>] [-a|--app <arg>] <deployment-name>
|
||||
|
||||
Options:
|
||||
--target-release <arg> Specifies the release label to be rolled back
|
||||
-a|--app <arg> Specify app in the <ownerName>/<appName> format
|
||||
deployment-name Specifies deployment name to be rolled back
|
||||
|
||||
Common Options (works on all commands):
|
||||
--disable-telemetry Disable telemetry for this command
|
||||
-v|--version Display appcenter version
|
||||
--quiet Auto-confirm any prompts without waiting for input
|
||||
-h|--help Display help for current command
|
||||
--env <arg> Environment when using API token
|
||||
--token <arg> API token
|
||||
--output <arg> Output format: json
|
||||
--debug Display extra output for debugging
|
||||
*/
|
||||
|
||||
import {
|
||||
BranchMapping,
|
||||
getAlwaysTrueBranchMapping,
|
||||
getBranchMapping,
|
||||
isAlwaysTrueBranchMapping,
|
||||
isEmptyBranchMapping,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { ChannelQuery } from '../../graphql/queries/ChannelQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
getRolloutInfoFromBranchMapping,
|
||||
isRolloutBranchMapping,
|
||||
} from '../../rollout/branch-mapping';
|
||||
import { enableJsonOutput } from '../../utils/json';
|
||||
|
||||
export default class CodepushRollback extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = 'roll back a rollout on a deployment';
|
||||
|
||||
static override args = [
|
||||
{
|
||||
name: 'name',
|
||||
required: true,
|
||||
description: 'Name of the codepush deployment to roll back the current rollout on',
|
||||
},
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
flags: { json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
args: { name: deploymentName },
|
||||
} = await this.parse(CodepushRollback);
|
||||
const {
|
||||
privateProjectConfig: { projectId },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(CodepushRollback, {
|
||||
nonInteractive,
|
||||
});
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!deploymentName) {
|
||||
throw new Error('Deployment name may not be empty.');
|
||||
}
|
||||
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: deploymentName,
|
||||
});
|
||||
|
||||
// set channel rollout
|
||||
|
||||
const existingBranchMappingString = channel.branchMapping;
|
||||
let updatedBranchMapping: BranchMapping;
|
||||
if (!existingBranchMappingString) {
|
||||
throw new Error('No existing rollout on deployment.');
|
||||
} else {
|
||||
const existingBranchMapping = getBranchMapping(existingBranchMappingString);
|
||||
if (isEmptyBranchMapping(existingBranchMapping)) {
|
||||
throw new Error('No existing rollout on deployment.');
|
||||
} else if (isAlwaysTrueBranchMapping(existingBranchMapping)) {
|
||||
throw new Error('No existing rollout on deployment.');
|
||||
} else if (isRolloutBranchMapping(existingBranchMapping)) {
|
||||
const rolloutInfo = getRolloutInfoFromBranchMapping(existingBranchMapping);
|
||||
const existingRolloutDefaultBranchId = rolloutInfo.defaultBranchId;
|
||||
updatedBranchMapping = getAlwaysTrueBranchMapping(existingRolloutDefaultBranchId);
|
||||
} else {
|
||||
throw new Error('Unrecognized custom deployment structure');
|
||||
}
|
||||
}
|
||||
|
||||
const newChannelInfo = await updateChannelBranchMappingAsync(graphqlClient, {
|
||||
channelId: channel.id,
|
||||
branchMapping: JSON.stringify(updatedBranchMapping),
|
||||
});
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(`✅ Successfuly updated rollout on ${newChannelInfo.name}`);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Platform as PublishPlatform } from '@expo/config';
|
||||
import { Workflow } from '@expo/eas-build-job';
|
||||
import { Errors, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { ensureBranchExistsAsync } from '../../branch/queries';
|
||||
import { ensureRepoIsCleanAsync } from '../../build/utils/repository';
|
||||
@@ -10,47 +8,26 @@ import { getUpdateGroupUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getPaginatedQueryOptions } from '../../commandUtils/pagination';
|
||||
import fetch from '../../fetch';
|
||||
import {
|
||||
PublishUpdateGroupInput,
|
||||
StatuspageServiceName,
|
||||
UpdateInfoGroup,
|
||||
UpdatePublishMutation,
|
||||
} from '../../graphql/generated';
|
||||
import { PublishMutation } from '../../graphql/mutations/PublishMutation';
|
||||
import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import Log, { learnMore, link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { RequestedPlatform } from '../../platform';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
|
||||
import {
|
||||
ExpoCLIExportPlatformFlag,
|
||||
RawAsset,
|
||||
buildBundlesAsync,
|
||||
buildUnsortedUpdateInfoGroupAsync,
|
||||
collectAssetsAsync,
|
||||
buildAndUploadAssetsAsync,
|
||||
defaultPublishPlatforms,
|
||||
filterExportedPlatformsByFlag,
|
||||
generateEasMetadataAsync,
|
||||
getBranchNameForCommandAsync,
|
||||
getRequestedPlatform,
|
||||
getRuntimeToPlatformMappingFromRuntimeVersions,
|
||||
getRuntimeVersionObjectAsync,
|
||||
getUpdateMessageForCommandAsync,
|
||||
isUploadedAssetCountAboveWarningThreshold,
|
||||
platformDisplayNames,
|
||||
resolveInputDirectoryAsync,
|
||||
uploadAssetsAsync,
|
||||
publishUpdateGroupsAsync,
|
||||
} from '../../project/publish';
|
||||
import { resolveWorkflowPerPlatformAsync } from '../../project/workflow';
|
||||
import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure';
|
||||
import { getUpdateJsonInfosForUpdates } from '../../update/utils';
|
||||
import {
|
||||
checkManifestBodyAgainstUpdateInfoGroup,
|
||||
getCodeSigningInfoAsync,
|
||||
getManifestBodyAsync,
|
||||
signBody,
|
||||
} from '../../utils/code-signing';
|
||||
import areSetsEqual from '../../utils/expodash/areSetsEqual';
|
||||
import { getCodeSigningInfoAsync } from '../../utils/code-signing';
|
||||
import uniqBy from '../../utils/expodash/uniqBy';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -233,141 +210,27 @@ export default class UpdatePublish extends EasCommand {
|
||||
jsonFlag,
|
||||
});
|
||||
|
||||
// build bundle and upload assets for a new publish
|
||||
if (!skipBundler) {
|
||||
const bundleSpinner = ora().start('Exporting...');
|
||||
try {
|
||||
await buildBundlesAsync({ projectDir, inputDir, exp, platformFlag, clearCache });
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
} catch (e) {
|
||||
bundleSpinner.fail('Export failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const {
|
||||
distRoot,
|
||||
realizedPlatforms,
|
||||
unsortedUpdateInfoGroups,
|
||||
uploadedAssetCount,
|
||||
assetLimitPerUpdateGroup,
|
||||
} = await buildAndUploadAssetsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
skipBundler,
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp,
|
||||
platformFlag,
|
||||
clearCache,
|
||||
});
|
||||
|
||||
// After possibly bundling, assert that the input directory can be found.
|
||||
const distRoot = await resolveInputDirectoryAsync(inputDir, { skipBundler });
|
||||
|
||||
const assetSpinner = ora().start('Uploading...');
|
||||
let unsortedUpdateInfoGroups: UpdateInfoGroup = {};
|
||||
let uploadedAssetCount = 0;
|
||||
let assetLimitPerUpdateGroup = 0;
|
||||
let realizedPlatforms: PublishPlatform[] = [];
|
||||
|
||||
try {
|
||||
const collectedAssets = await collectAssetsAsync(distRoot);
|
||||
const assets = filterExportedPlatformsByFlag(collectedAssets, platformFlag);
|
||||
realizedPlatforms = Object.keys(assets) as PublishPlatform[];
|
||||
|
||||
// Timeout mechanism:
|
||||
// - Start with NO_ACTIVITY_TIMEOUT. 180 seconds is chosen because the cloud function that processes
|
||||
// uploaded assets has a timeout of 60 seconds and uploading can take some time on a slow connection.
|
||||
// - Each time one or more assets reports as ready, reset the timeout.
|
||||
// - Each time an asset upload begins, reset the timeout. This includes retries.
|
||||
// - Start upload. Internally, uploadAssetsAsync uploads them all first and then checks for successful
|
||||
// processing every (5 + n) seconds with a linear backoff of n + 1 second.
|
||||
// - At the same time as upload is started, start timeout checker which checks every 1 second to see
|
||||
// if timeout has been reached. When timeout expires, send a cancellation signal to currently running
|
||||
// upload function call to instruct it to stop uploading or checking for successful processing.
|
||||
const NO_ACTIVITY_TIMEOUT = 180 * 1000; // 180 seconds
|
||||
let lastUploadedStorageKeys = new Set<string>();
|
||||
let lastAssetUploadResults: {
|
||||
asset: RawAsset & { storageKey: string };
|
||||
finished: boolean;
|
||||
}[] = [];
|
||||
let timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT;
|
||||
const cancelationToken = { isCanceledOrFinished: false };
|
||||
|
||||
const uploadResults = await Promise.race([
|
||||
uploadAssetsAsync(
|
||||
graphqlClient,
|
||||
assets,
|
||||
projectId,
|
||||
cancelationToken,
|
||||
assetUploadResults => {
|
||||
const currentUploadedStorageKeys = new Set(
|
||||
assetUploadResults.filter(r => r.finished).map(r => r.asset.storageKey)
|
||||
);
|
||||
if (!areSetsEqual(currentUploadedStorageKeys, lastUploadedStorageKeys)) {
|
||||
timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT; // reset timeout to NO_ACTIVITY_TIMEOUT
|
||||
lastUploadedStorageKeys = currentUploadedStorageKeys;
|
||||
lastAssetUploadResults = assetUploadResults;
|
||||
}
|
||||
|
||||
const totalAssets = assetUploadResults.length;
|
||||
const missingAssetCount = assetUploadResults.filter(a => !a.finished).length;
|
||||
assetSpinner.text = `Uploading (${totalAssets - missingAssetCount}/${totalAssets})`;
|
||||
},
|
||||
() => {
|
||||
// when an upload is retried, reset the timeout as we know this will now need more time
|
||||
timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT; // reset timeout to NO_ACTIVITY_TIMEOUT
|
||||
}
|
||||
),
|
||||
(async () => {
|
||||
while (Date.now() < timeAtWhichToTimeout) {
|
||||
if (cancelationToken.isCanceledOrFinished) {
|
||||
break;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, 1000)); // wait 1 second
|
||||
}
|
||||
cancelationToken.isCanceledOrFinished = true;
|
||||
const timedOutAssets = lastAssetUploadResults
|
||||
.filter(r => !r.finished)
|
||||
.map(r => `\n- ${r.asset.originalPath ?? r.asset.path}`);
|
||||
throw new Error(`Asset processing timed out for assets: ${timedOutAssets}`);
|
||||
})(),
|
||||
]);
|
||||
|
||||
uploadedAssetCount = uploadResults.uniqueUploadedAssetCount;
|
||||
assetLimitPerUpdateGroup = uploadResults.assetLimitPerUpdateGroup;
|
||||
unsortedUpdateInfoGroups = await buildUnsortedUpdateInfoGroupAsync(assets, exp);
|
||||
|
||||
// NOTE(cedric): we assume that bundles are always uploaded, and always are part of
|
||||
// `uploadedAssetCount`, perferably we don't assume. For that, we need to refactor the
|
||||
// `uploadAssetsAsync` and be able to determine asset type from the uploaded assets.
|
||||
const uploadedBundleCount = uploadResults.launchAssetCount;
|
||||
const uploadedNormalAssetCount = Math.max(0, uploadedAssetCount - uploadedBundleCount);
|
||||
const reusedNormalAssetCount = uploadResults.uniqueAssetCount - uploadedNormalAssetCount;
|
||||
|
||||
assetSpinner.stop();
|
||||
Log.withTick(
|
||||
`Uploaded ${uploadedBundleCount} app ${uploadedBundleCount === 1 ? 'bundle' : 'bundles'}`
|
||||
);
|
||||
if (uploadedNormalAssetCount === 0) {
|
||||
Log.withTick(`Uploading assets skipped - no new assets found`);
|
||||
} else {
|
||||
let message = `Uploaded ${uploadedNormalAssetCount} ${
|
||||
uploadedNormalAssetCount === 1 ? 'asset' : 'assets'
|
||||
}`;
|
||||
if (reusedNormalAssetCount > 0) {
|
||||
message += ` (reused ${reusedNormalAssetCount} ${
|
||||
reusedNormalAssetCount === 1 ? 'asset' : 'assets'
|
||||
})`;
|
||||
}
|
||||
Log.withTick(message);
|
||||
}
|
||||
for (const uploadedAssetPath of uploadResults.uniqueUploadedAssetPaths) {
|
||||
Log.debug(chalk.dim(`- ${uploadedAssetPath}`));
|
||||
}
|
||||
|
||||
const platformString = (Object.keys(assets) as PublishPlatform[])
|
||||
.map(platform => {
|
||||
const collectedAssetForPlatform = nullthrows(assets[platform]);
|
||||
const totalAssetsForPlatform = collectedAssetForPlatform.assets.length + 1; // launch asset
|
||||
const assetString = totalAssetsForPlatform === 1 ? 'asset' : 'assets';
|
||||
return `${totalAssetsForPlatform} ${platformDisplayNames[platform]} ${assetString}`;
|
||||
})
|
||||
.join(', ');
|
||||
Log.withInfo(
|
||||
`${platformString} (maximum: ${assetLimitPerUpdateGroup} total per update). ${learnMore(
|
||||
'https://expo.fyi/eas-update-asset-limits',
|
||||
{ learnMoreMessage: 'Learn more about asset limits' }
|
||||
)}`
|
||||
);
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload');
|
||||
throw e;
|
||||
}
|
||||
const { branchId } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName,
|
||||
});
|
||||
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
|
||||
const runtimeVersions = await getRuntimeVersionObjectAsync({
|
||||
@@ -380,95 +243,20 @@ export default class UpdatePublish extends EasCommand {
|
||||
},
|
||||
env: undefined,
|
||||
});
|
||||
const runtimeToPlatformMapping =
|
||||
getRuntimeToPlatformMappingFromRuntimeVersions(runtimeVersions);
|
||||
|
||||
const { branchId } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName,
|
||||
});
|
||||
|
||||
const gitCommitHash = await vcsClient.getCommitHashAsync();
|
||||
const isGitWorkingTreeDirty = await vcsClient.hasUncommittedChangesAsync();
|
||||
|
||||
// Sort the updates into different groups based on their platform specific runtime versions
|
||||
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformMapping.map(
|
||||
({ runtimeVersion, platforms }) => {
|
||||
const localUpdateInfoGroup = Object.fromEntries(
|
||||
platforms.map(platform => [
|
||||
platform,
|
||||
unsortedUpdateInfoGroups[platform as keyof UpdateInfoGroup],
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
branchId,
|
||||
updateInfoGroup: localUpdateInfoGroup,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
};
|
||||
}
|
||||
);
|
||||
let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'];
|
||||
const publishSpinner = ora('Publishing...').start();
|
||||
try {
|
||||
newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
|
||||
|
||||
if (codeSigningInfo) {
|
||||
Log.log('🔒 Signing updates');
|
||||
|
||||
const updatesTemp = [...newUpdates];
|
||||
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
|
||||
const newUpdates = updatesTemp.splice(
|
||||
0,
|
||||
Object.keys(nullthrows(updateGroup.updateInfoGroup)).length
|
||||
);
|
||||
return {
|
||||
updateGroup,
|
||||
newUpdates,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
updateGroupsAndTheirUpdates.map(async ({ updateGroup, newUpdates }) => {
|
||||
await Promise.all(
|
||||
newUpdates.map(async newUpdate => {
|
||||
const response = await fetch(newUpdate.manifestPermalink, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'multipart/mixed' },
|
||||
});
|
||||
const manifestBody = nullthrows(await getManifestBodyAsync(response));
|
||||
|
||||
checkManifestBodyAgainstUpdateInfoGroup(
|
||||
manifestBody,
|
||||
nullthrows(
|
||||
nullthrows(updateGroup.updateInfoGroup)[
|
||||
newUpdate.platform as keyof UpdateInfoGroup
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
const manifestSignature = signBody(manifestBody, codeSigningInfo);
|
||||
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
|
||||
alg: codeSigningInfo.codeSigningMetadata.alg,
|
||||
keyid: codeSigningInfo.codeSigningMetadata.keyid,
|
||||
sig: manifestSignature,
|
||||
});
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publishSpinner.succeed('Published!');
|
||||
} catch (e) {
|
||||
publishSpinner.fail('Failed to publish updates');
|
||||
throw e;
|
||||
}
|
||||
const { newUpdates } = await publishUpdateGroupsAsync({
|
||||
graphqlClient,
|
||||
branchId,
|
||||
runtimeVersions,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
unsortedUpdateInfoGroups,
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
});
|
||||
|
||||
if (!skipBundler && emitMetadata) {
|
||||
Log.log('Generating eas-update-metadata.json');
|
||||
|
||||
+530
-66
File diff suppressed because it is too large.
Load diff
@@ -17,10 +17,17 @@ import { selectBranchOnAppAsync } from '../branch/queries';
|
||||
import { getDefaultBranchNameAsync } from '../branch/utils';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { PaginatedQueryOptions } from '../commandUtils/pagination';
|
||||
import { AssetMetadataStatus, PartialManifestAsset } from '../graphql/generated';
|
||||
import fetch from '../fetch';
|
||||
import {
|
||||
AssetMetadataStatus,
|
||||
PartialManifestAsset,
|
||||
PublishUpdateGroupInput,
|
||||
UpdatePublishMutation,
|
||||
} from '../graphql/generated';
|
||||
import { PublishMutation } from '../graphql/mutations/PublishMutation';
|
||||
import { PublishQuery } from '../graphql/queries/PublishQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { RequestedPlatform, requestedPlatformDisplayNames } from '../platform';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { getBranchNameFromChannelNameAsync } from '../update/getBranchNameFromChannelNameAsync';
|
||||
@@ -30,6 +37,12 @@ import {
|
||||
truncateString as truncateUpdateMessage,
|
||||
} from '../update/utils';
|
||||
import { PresignedPost, uploadWithPresignedPostWithRetryAsync } from '../uploads';
|
||||
import {
|
||||
CodeSigningInfo,
|
||||
checkManifestBodyAgainstUpdateInfoGroup,
|
||||
getManifestBodyAsync,
|
||||
signBody,
|
||||
} from '../utils/code-signing';
|
||||
import {
|
||||
expoCommandAsync,
|
||||
shouldUseVersionedExpoCLI,
|
||||
@@ -39,6 +52,7 @@ import {
|
||||
ExpoUpdatesCLIModuleNotFoundError,
|
||||
expoUpdatesCommandAsync,
|
||||
} from '../utils/expoUpdatesCli';
|
||||
import areSetsEqual from '../utils/expodash/areSetsEqual';
|
||||
import chunk from '../utils/expodash/chunk';
|
||||
import { truthy } from '../utils/expodash/filter';
|
||||
import uniqBy from '../utils/expodash/uniqBy';
|
||||
@@ -811,3 +825,273 @@ export const platformDisplayNames: Record<Platform, string> = {
|
||||
ios: 'iOS',
|
||||
web: 'Web',
|
||||
};
|
||||
|
||||
export async function buildAndUploadAssetsAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
skipBundler,
|
||||
projectDir,
|
||||
inputDir,
|
||||
exp,
|
||||
platformFlag,
|
||||
clearCache,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
skipBundler: boolean;
|
||||
projectDir: string;
|
||||
inputDir: string;
|
||||
exp: ExpoConfig;
|
||||
platformFlag: ExpoCLIExportPlatformFlag;
|
||||
clearCache: boolean;
|
||||
}): Promise<{
|
||||
distRoot: string;
|
||||
realizedPlatforms: Platform[];
|
||||
unsortedUpdateInfoGroups: UpdateInfoGroup;
|
||||
uploadedAssetCount: number;
|
||||
assetLimitPerUpdateGroup: number;
|
||||
}> {
|
||||
// build bundle and upload assets for a new publish
|
||||
if (!skipBundler) {
|
||||
const bundleSpinner = ora().start('Exporting...');
|
||||
try {
|
||||
await buildBundlesAsync({ projectDir, inputDir, exp, platformFlag, clearCache });
|
||||
bundleSpinner.succeed('Exported bundle(s)');
|
||||
} catch (e) {
|
||||
bundleSpinner.fail('Export failed');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// After possibly bundling, assert that the input directory can be found.
|
||||
const distRoot = await resolveInputDirectoryAsync(inputDir, { skipBundler });
|
||||
|
||||
const assetSpinner = ora().start('Uploading...');
|
||||
try {
|
||||
const collectedAssets = await collectAssetsAsync(distRoot);
|
||||
const assets = filterExportedPlatformsByFlag(collectedAssets, platformFlag);
|
||||
const realizedPlatforms = Object.keys(assets) as Platform[];
|
||||
|
||||
// Timeout mechanism:
|
||||
// - Start with NO_ACTIVITY_TIMEOUT. 180 seconds is chosen because the cloud function that processes
|
||||
// uploaded assets has a timeout of 60 seconds and uploading can take some time on a slow connection.
|
||||
// - Each time one or more assets reports as ready, reset the timeout.
|
||||
// - Each time an asset upload begins, reset the timeout. This includes retries.
|
||||
// - Start upload. Internally, uploadAssetsAsync uploads them all first and then checks for successful
|
||||
// processing every (5 + n) seconds with a linear backoff of n + 1 second.
|
||||
// - At the same time as upload is started, start timeout checker which checks every 1 second to see
|
||||
// if timeout has been reached. When timeout expires, send a cancellation signal to currently running
|
||||
// upload function call to instruct it to stop uploading or checking for successful processing.
|
||||
const NO_ACTIVITY_TIMEOUT = 180 * 1000; // 180 seconds
|
||||
let lastUploadedStorageKeys = new Set<string>();
|
||||
let lastAssetUploadResults: {
|
||||
asset: RawAsset & { storageKey: string };
|
||||
finished: boolean;
|
||||
}[] = [];
|
||||
let timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT;
|
||||
const cancelationToken = { isCanceledOrFinished: false };
|
||||
|
||||
const uploadResults = await Promise.race([
|
||||
uploadAssetsAsync(
|
||||
graphqlClient,
|
||||
assets,
|
||||
projectId,
|
||||
cancelationToken,
|
||||
assetUploadResults => {
|
||||
const currentUploadedStorageKeys = new Set(
|
||||
assetUploadResults.filter(r => r.finished).map(r => r.asset.storageKey)
|
||||
);
|
||||
if (!areSetsEqual(currentUploadedStorageKeys, lastUploadedStorageKeys)) {
|
||||
timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT; // reset timeout to NO_ACTIVITY_TIMEOUT
|
||||
lastUploadedStorageKeys = currentUploadedStorageKeys;
|
||||
lastAssetUploadResults = assetUploadResults;
|
||||
}
|
||||
|
||||
const totalAssets = assetUploadResults.length;
|
||||
const missingAssetCount = assetUploadResults.filter(a => !a.finished).length;
|
||||
assetSpinner.text = `Uploading (${totalAssets - missingAssetCount}/${totalAssets})`;
|
||||
},
|
||||
() => {
|
||||
// when an upload is retried, reset the timeout as we know this will now need more time
|
||||
timeAtWhichToTimeout = Date.now() + NO_ACTIVITY_TIMEOUT; // reset timeout to NO_ACTIVITY_TIMEOUT
|
||||
}
|
||||
),
|
||||
(async () => {
|
||||
while (Date.now() < timeAtWhichToTimeout) {
|
||||
if (cancelationToken.isCanceledOrFinished) {
|
||||
break;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, 1000)); // wait 1 second
|
||||
}
|
||||
cancelationToken.isCanceledOrFinished = true;
|
||||
const timedOutAssets = lastAssetUploadResults
|
||||
.filter(r => !r.finished)
|
||||
.map(r => `\n- ${r.asset.originalPath ?? r.asset.path}`);
|
||||
throw new Error(`Asset processing timed out for assets: ${timedOutAssets}`);
|
||||
})(),
|
||||
]);
|
||||
|
||||
const uploadedAssetCount = uploadResults.uniqueUploadedAssetCount;
|
||||
const assetLimitPerUpdateGroup = uploadResults.assetLimitPerUpdateGroup;
|
||||
const unsortedUpdateInfoGroups = await buildUnsortedUpdateInfoGroupAsync(assets, exp);
|
||||
|
||||
// NOTE(cedric): we assume that bundles are always uploaded, and always are part of
|
||||
// `uploadedAssetCount`, perferably we don't assume. For that, we need to refactor the
|
||||
// `uploadAssetsAsync` and be able to determine asset type from the uploaded assets.
|
||||
const uploadedBundleCount = uploadResults.launchAssetCount;
|
||||
const uploadedNormalAssetCount = Math.max(0, uploadedAssetCount - uploadedBundleCount);
|
||||
const reusedNormalAssetCount = uploadResults.uniqueAssetCount - uploadedNormalAssetCount;
|
||||
|
||||
assetSpinner.stop();
|
||||
Log.withTick(
|
||||
`Uploaded ${uploadedBundleCount} app ${uploadedBundleCount === 1 ? 'bundle' : 'bundles'}`
|
||||
);
|
||||
if (uploadedNormalAssetCount === 0) {
|
||||
Log.withTick(`Uploading assets skipped - no new assets found`);
|
||||
} else {
|
||||
let message = `Uploaded ${uploadedNormalAssetCount} ${
|
||||
uploadedNormalAssetCount === 1 ? 'asset' : 'assets'
|
||||
}`;
|
||||
if (reusedNormalAssetCount > 0) {
|
||||
message += ` (reused ${reusedNormalAssetCount} ${
|
||||
reusedNormalAssetCount === 1 ? 'asset' : 'assets'
|
||||
})`;
|
||||
}
|
||||
Log.withTick(message);
|
||||
}
|
||||
for (const uploadedAssetPath of uploadResults.uniqueUploadedAssetPaths) {
|
||||
Log.debug(chalk.dim(`- ${uploadedAssetPath}`));
|
||||
}
|
||||
|
||||
const platformString = (Object.keys(assets) as Platform[])
|
||||
.map(platform => {
|
||||
const collectedAssetForPlatform = nullthrows(assets[platform]);
|
||||
const totalAssetsForPlatform = collectedAssetForPlatform.assets.length + 1; // launch asset
|
||||
const assetString = totalAssetsForPlatform === 1 ? 'asset' : 'assets';
|
||||
return `${totalAssetsForPlatform} ${platformDisplayNames[platform]} ${assetString}`;
|
||||
})
|
||||
.join(', ');
|
||||
Log.withInfo(
|
||||
`${platformString} (maximum: ${assetLimitPerUpdateGroup} total per update). ${learnMore(
|
||||
'https://expo.fyi/eas-update-asset-limits',
|
||||
{ learnMoreMessage: 'Learn more about asset limits' }
|
||||
)}`
|
||||
);
|
||||
|
||||
return {
|
||||
distRoot,
|
||||
realizedPlatforms,
|
||||
unsortedUpdateInfoGroups,
|
||||
uploadedAssetCount,
|
||||
assetLimitPerUpdateGroup,
|
||||
};
|
||||
} catch (e) {
|
||||
assetSpinner.fail('Failed to upload');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishUpdateGroupsAsync({
|
||||
graphqlClient,
|
||||
branchId,
|
||||
runtimeVersions,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
unsortedUpdateInfoGroups,
|
||||
updateMessage,
|
||||
codeSigningInfo,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
branchId: string;
|
||||
runtimeVersions: { platform: string; runtimeVersion: string }[];
|
||||
gitCommitHash: string | undefined;
|
||||
isGitWorkingTreeDirty: boolean | undefined;
|
||||
unsortedUpdateInfoGroups: UpdateInfoGroup;
|
||||
updateMessage: string | undefined;
|
||||
codeSigningInfo: CodeSigningInfo | undefined;
|
||||
}): Promise<{ newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups'] }> {
|
||||
const runtimeToPlatformMapping = getRuntimeToPlatformMappingFromRuntimeVersions(runtimeVersions);
|
||||
|
||||
// Sort the updates into different groups based on their platform specific runtime versions
|
||||
const updateGroups: PublishUpdateGroupInput[] = runtimeToPlatformMapping.map(
|
||||
({ runtimeVersion, platforms }) => {
|
||||
const localUpdateInfoGroup = Object.fromEntries(
|
||||
platforms.map(platform => [
|
||||
platform,
|
||||
unsortedUpdateInfoGroups[platform as keyof UpdateInfoGroup],
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
branchId,
|
||||
updateInfoGroup: localUpdateInfoGroup,
|
||||
runtimeVersion,
|
||||
message: updateMessage,
|
||||
gitCommitHash,
|
||||
isGitWorkingTreeDirty,
|
||||
awaitingCodeSigningInfo: !!codeSigningInfo,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const publishSpinner = ora('Publishing...').start();
|
||||
try {
|
||||
const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups);
|
||||
|
||||
if (codeSigningInfo) {
|
||||
Log.log('🔒 Signing updates');
|
||||
|
||||
const updatesTemp = [...newUpdates];
|
||||
const updateGroupsAndTheirUpdates = updateGroups.map(updateGroup => {
|
||||
const newUpdates = updatesTemp.splice(
|
||||
0,
|
||||
Object.keys(nullthrows(updateGroup.updateInfoGroup)).length
|
||||
);
|
||||
return {
|
||||
updateGroup,
|
||||
newUpdates,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
updateGroupsAndTheirUpdates.map(async ({ updateGroup, newUpdates }) => {
|
||||
await Promise.all(
|
||||
newUpdates.map(async newUpdate => {
|
||||
const response = await fetch(newUpdate.manifestPermalink, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'multipart/mixed' },
|
||||
});
|
||||
const manifestBody = nullthrows(await getManifestBodyAsync(response));
|
||||
|
||||
checkManifestBodyAgainstUpdateInfoGroup(
|
||||
manifestBody,
|
||||
nullthrows(
|
||||
nullthrows(updateGroup.updateInfoGroup)[
|
||||
newUpdate.platform as keyof UpdateInfoGroup
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
const manifestSignature = signBody(manifestBody, codeSigningInfo);
|
||||
|
||||
await PublishMutation.setCodeSigningInfoAsync(graphqlClient, newUpdate.id, {
|
||||
alg: codeSigningInfo.codeSigningMetadata.alg,
|
||||
keyid: codeSigningInfo.codeSigningMetadata.keyid,
|
||||
sig: manifestSignature,
|
||||
});
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
publishSpinner.succeed('Published!');
|
||||
|
||||
return {
|
||||
newUpdates,
|
||||
};
|
||||
} catch (e) {
|
||||
publishSpinner.fail('Failed to publish updates');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
hasEmptyBranchMap,
|
||||
hasStandardBranchMap,
|
||||
} from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import { getUpdateBranch } from '../../channel/utils';
|
||||
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
import {
|
||||
UpdateBranchBasicInfoFragment,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import { ChannelQuery, UpdateChannelObject } from '../../graphql/queries/ChannelQuery';
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getAlwaysTrueBranchMapping } from '../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../commands/channel/edit';
|
||||
import { updateChannelBranchMappingAsync } from '../../channel/queries';
|
||||
import { EASUpdateAction, EASUpdateContext } from '../../eas-update/utils';
|
||||
import { UpdateChannelBasicInfoFragment } from '../../graphql/generated';
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { testBasicChannelInfo, testChannelObject } from '../../../channel/__tests__/fixtures';
|
||||
import { getAlwaysTrueBranchMapping, getBranchMapping } from '../../../channel/branch-mapping';
|
||||
import { updateChannelBranchMappingAsync } from '../../../commands/channel/edit';
|
||||
import { updateChannelBranchMappingAsync } from '../../../channel/queries';
|
||||
import { createCtxMock } from '../../../eas-update/__tests__/fixtures';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
|
||||
Reference in new issue
Block a user