Compare commits

...
Author SHA1 Message Date
Will Schurman 0e1e29d119 [rfc][eas-cli] Add codepush commands 2024-08-12 13:51:27 -07:00
21 changed files with 5911 additions and 698 deletions

No files matched your search

+3606 -305
View File
File diff suppressed because it is too large. Load diff
+98
View File
@@ -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 -40
View File
@@ -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}`);
}
}
+34 -246
View File
@@ -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');
File diff suppressed because it is too large. Load diff
+285 -1
View File
@@ -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';