From d6e98c92e71bb547955c980b971467c59bda48f2 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Mon, 31 Aug 2026 15:09:58 -0400 Subject: [PATCH 1/3] [eas-cli] Support protected update channels --- .../eas-cli/src/channel/__tests__/fixtures.ts | 3 + .../src/channel/__tests__/protection-test.ts | 54 ++++++++ .../src/channel/__tests__/queries-test.ts | 26 ++++ packages/eas-cli/src/channel/protection.ts | 63 +++++++++ packages/eas-cli/src/channel/queries.ts | 7 +- .../channel/__tests__/protection.test.ts | 127 ++++++++++++++++++ .../eas-cli/src/commands/channel/protect.ts | 69 ++++++++++ .../eas-cli/src/commands/channel/unprotect.ts | 80 +++++++++++ packages/eas-cli/src/graphql/generated.ts | 32 +++-- .../src/graphql/queries/ChannelQuery.ts | 2 + .../queries/__tests__/ChannelQuery-test.ts | 69 ++++++++++ .../graphql/types/UpdateChannelBasicInfo.ts | 1 + ...meAndCreateAndLinkIfNotExistsAsync-test.ts | 2 + 13 files changed, 525 insertions(+), 10 deletions(-) create mode 100644 packages/eas-cli/src/channel/__tests__/protection-test.ts create mode 100644 packages/eas-cli/src/channel/__tests__/queries-test.ts create mode 100644 packages/eas-cli/src/channel/protection.ts create mode 100644 packages/eas-cli/src/commands/channel/__tests__/protection.test.ts create mode 100644 packages/eas-cli/src/commands/channel/protect.ts create mode 100644 packages/eas-cli/src/commands/channel/unprotect.ts create mode 100644 packages/eas-cli/src/graphql/queries/__tests__/ChannelQuery-test.ts diff --git a/packages/eas-cli/src/channel/__tests__/fixtures.ts b/packages/eas-cli/src/channel/__tests__/fixtures.ts index 2f2a2d8938..5860e96a5f 100644 --- a/packages/eas-cli/src/channel/__tests__/fixtures.ts +++ b/packages/eas-cli/src/channel/__tests__/fixtures.ts @@ -133,6 +133,7 @@ export const testChannelObject: UpdateChannelObject = { '{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.15,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}', updateBranches: [testUpdateBranch1, testUpdateBranch2], isPaused: false, + isProtected: false, __typename: 'UpdateChannel', }; @@ -141,6 +142,7 @@ export const testBasicChannelInfo: UpdateChannelBasicInfoFragment = { name: 'production', branchMapping: '{"data":[{"branchId":"754bf17f-efc0-46ab-8a59-a03f20e53e9b","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"6941a8dd-5c0a-48bc-8876-f49c88ed419f","branchMappingLogic":"true"}],"version":0}', + isProtected: false, __typename: 'UpdateChannel', }; @@ -149,5 +151,6 @@ export const testBasicChannelInfo2: UpdateChannelBasicInfoFragment = { name: 'staging', branchMapping: '{"data":[{"branchId":"d7d68e32-d9c9-4a8d-8d1b-21e53100a5e8","branchMappingLogic":{"operand":0.1,"clientKey":"rolloutToken","branchMappingOperator":"hash_lt"}},{"branchId":"f9f708c2-0c91-4360-b2a4-0b61834aef4a","branchMappingLogic":"true"}],"version":0}', + isProtected: false, __typename: 'UpdateChannel', }; diff --git a/packages/eas-cli/src/channel/__tests__/protection-test.ts b/packages/eas-cli/src/channel/__tests__/protection-test.ts new file mode 100644 index 0000000000..376b2224f0 --- /dev/null +++ b/packages/eas-cli/src/channel/__tests__/protection-test.ts @@ -0,0 +1,54 @@ +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { protectUpdateChannelAsync, unprotectUpdateChannelAsync } from '../protection'; + +function makeGraphqlClient(data: unknown): { + graphqlClient: ExpoGraphqlClient; + mutation: jest.Mock; +} { + const mutation = jest.fn().mockReturnValue({ + toPromise: jest.fn().mockResolvedValue({ data }), + }); + return { graphqlClient: { mutation } as unknown as ExpoGraphqlClient, mutation }; +} + +describe(protectUpdateChannelAsync.name, () => { + it('protects a channel by ID and returns the server state', async () => { + const channel = { + id: 'channel-id', + name: 'production', + branchMapping: '{"version":0,"data":[]}', + isProtected: true, + }; + const { graphqlClient, mutation } = makeGraphqlClient({ + updateChannel: { protectUpdateChannel: channel }, + }); + + await expect( + protectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' }) + ).resolves.toEqual(channel); + + expect(mutation.mock.calls[0][0].loc.source.body).toContain('protectUpdateChannel'); + expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' }); + }); +}); + +describe(unprotectUpdateChannelAsync.name, () => { + it('unprotects a channel by ID and returns the server state', async () => { + const channel = { + id: 'channel-id', + name: 'production', + branchMapping: '{"version":0,"data":[]}', + isProtected: false, + }; + const { graphqlClient, mutation } = makeGraphqlClient({ + updateChannel: { unprotectUpdateChannel: channel }, + }); + + await expect( + unprotectUpdateChannelAsync(graphqlClient, { channelId: 'channel-id' }) + ).resolves.toEqual(channel); + + expect(mutation.mock.calls[0][0].loc.source.body).toContain('unprotectUpdateChannel'); + expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' }); + }); +}); diff --git a/packages/eas-cli/src/channel/__tests__/queries-test.ts b/packages/eas-cli/src/channel/__tests__/queries-test.ts new file mode 100644 index 0000000000..13470683ac --- /dev/null +++ b/packages/eas-cli/src/channel/__tests__/queries-test.ts @@ -0,0 +1,26 @@ +import { renderChannelHeaderContent } from '../queries'; +import Log from '../../log'; + +jest.mock('../../log'); + +describe(renderChannelHeaderContent.name, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + [true, 'Protected'], + [false, 'Unprotected'], + ])('renders protection state when isProtected is %s', (isProtected, expected) => { + renderChannelHeaderContent({ + channelName: 'production', + channelId: 'channel-id', + isPaused: false, + isProtected, + }); + + const output = jest.mocked(Log.log).mock.calls.flat().join('\n'); + expect(output).toContain('Protection'); + expect(output).toContain(expected); + }); +}); diff --git a/packages/eas-cli/src/channel/protection.ts b/packages/eas-cli/src/channel/protection.ts new file mode 100644 index 0000000000..05f86e081a --- /dev/null +++ b/packages/eas-cli/src/channel/protection.ts @@ -0,0 +1,63 @@ +import { print } from 'graphql'; +import gql from 'graphql-tag'; + +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { withErrorHandlingAsync } from '../graphql/client'; +import { + ProtectUpdateChannelMutation, + ProtectUpdateChannelMutationVariables, + UnprotectUpdateChannelMutation, + UnprotectUpdateChannelMutationVariables, + UpdateChannelBasicInfoFragment, +} from '../graphql/generated'; +import { UpdateChannelBasicInfoFragmentNode } from '../graphql/types/UpdateChannelBasicInfo'; + +export async function protectUpdateChannelAsync( + graphqlClient: ExpoGraphqlClient, + { channelId }: ProtectUpdateChannelMutationVariables +): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation ProtectUpdateChannel($channelId: ID!) { + updateChannel { + protectUpdateChannel(channelId: $channelId) { + id + ...UpdateChannelBasicInfoFragment + } + } + } + ${print(UpdateChannelBasicInfoFragmentNode)} + `, + { channelId } + ) + .toPromise() + ); + return data.updateChannel.protectUpdateChannel; +} + +export async function unprotectUpdateChannelAsync( + graphqlClient: ExpoGraphqlClient, + { channelId }: UnprotectUpdateChannelMutationVariables +): Promise { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation( + gql` + mutation UnprotectUpdateChannel($channelId: ID!) { + updateChannel { + unprotectUpdateChannel(channelId: $channelId) { + id + ...UpdateChannelBasicInfoFragment + } + } + } + ${print(UpdateChannelBasicInfoFragmentNode)} + `, + { channelId } + ) + .toPromise() + ); + return data.updateChannel.unprotectUpdateChannel; +} diff --git a/packages/eas-cli/src/channel/queries.ts b/packages/eas-cli/src/channel/queries.ts index 7c00627dba..4a2449e067 100644 --- a/packages/eas-cli/src/channel/queries.ts +++ b/packages/eas-cli/src/channel/queries.ts @@ -125,6 +125,7 @@ export async function listAndRenderBranchesAndUpdatesOnChannelAsync( channelName: channel.name, channelId: channel.id, isPaused: channel.isPaused, + isProtected: channel.isProtected, }); if (paginatedQueryOptions.nonInteractive) { @@ -186,6 +187,7 @@ function renderPageOfChannels( channelName: channel.name, channelId: channel.id, isPaused: channel.isPaused, + isProtected: channel.isProtected, }); Log.addNewLineIfNone(); logChannelDetails(channel); @@ -212,14 +214,16 @@ function renderPageOfBranchesOnChannel( } } -function renderChannelHeaderContent({ +export function renderChannelHeaderContent({ channelName, channelId, isPaused, + isProtected, }: { channelName: string; channelId: string; isPaused: boolean; + isProtected: boolean; }): void { Log.addNewLineIfNone(); Log.log(chalk.bold('Channel:')); @@ -228,6 +232,7 @@ function renderChannelHeaderContent({ { label: 'Name', value: channelName }, { label: 'ID', value: channelId }, { label: 'Status', value: isPaused ? 'Paused' : 'Active' }, + { label: 'Protection', value: isProtected ? 'Protected' : 'Unprotected' }, ]) ); Log.addNewLineIfNone(); diff --git a/packages/eas-cli/src/commands/channel/__tests__/protection.test.ts b/packages/eas-cli/src/commands/channel/__tests__/protection.test.ts new file mode 100644 index 0000000000..b5cd1c2e61 --- /dev/null +++ b/packages/eas-cli/src/commands/channel/__tests__/protection.test.ts @@ -0,0 +1,127 @@ +import { getMockOclifConfig } from '../../../__tests__/commands/utils'; +import { + protectUpdateChannelAsync, + unprotectUpdateChannelAsync, +} from '../../../channel/protection'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { ChannelQuery } from '../../../graphql/queries/ChannelQuery'; +import Log from '../../../log'; +import { toggleConfirmAsync } from '../../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; +import ChannelProtect from '../protect'; +import ChannelUnprotect from '../unprotect'; + +jest.mock('../../../channel/protection'); +jest.mock('../../../graphql/queries/ChannelQuery'); +jest.mock('../../../log'); +jest.mock('../../../prompts'); +jest.mock('../../../utils/json'); + +const graphqlClient = {} as ExpoGraphqlClient; +const channel = { + id: 'channel-id', + name: 'production', + branchMapping: '{"version":0,"data":[]}', + isProtected: true, +}; + +function setContext(command: ChannelProtect | ChannelUnprotect): void { + // @ts-expect-error getContextAsync is protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue({ + projectId: 'project-id', + loggedIn: { graphqlClient }, + }); +} + +describe(ChannelProtect, () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel); + jest.mocked(protectUpdateChannelAsync).mockResolvedValue(channel); + }); + + it('protects a named channel', async () => { + const command = new ChannelProtect(['production'], getMockOclifConfig()); + setContext(command); + + await command.runAsync(); + + expect(protectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, { + channelId: 'channel-id', + }); + expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('production')); + }); + + it('prints the server response as JSON', async () => { + const command = new ChannelProtect(['production', '--json'], getMockOclifConfig()); + setContext(command); + + await command.runAsync(); + + expect(enableJsonOutput).toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith(channel); + expect(Log.withTick).not.toHaveBeenCalled(); + }); + + it('requires a channel name in non-interactive mode', async () => { + const command = new ChannelProtect(['--non-interactive'], getMockOclifConfig()); + + await expect(command.runAsync()).rejects.toThrow( + 'Channel name must be set when running in non-interactive mode' + ); + expect(protectUpdateChannelAsync).not.toHaveBeenCalled(); + }); +}); + +describe(ChannelUnprotect, () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(ChannelQuery.viewUpdateChannelBasicInfoAsync).mockResolvedValue(channel); + jest.mocked(unprotectUpdateChannelAsync).mockResolvedValue({ ...channel, isProtected: false }); + jest.mocked(toggleConfirmAsync).mockResolvedValue(true); + }); + + it('asks for confirmation before removing protection', async () => { + const command = new ChannelUnprotect(['production'], getMockOclifConfig()); + setContext(command); + + await command.runAsync(); + + expect(toggleConfirmAsync).toHaveBeenCalledWith({ + message: expect.stringContaining('production'), + }); + expect(unprotectUpdateChannelAsync).toHaveBeenCalledWith(graphqlClient, { + channelId: 'channel-id', + }); + }); + + it('does not mutate the channel when confirmation is declined', async () => { + jest.mocked(toggleConfirmAsync).mockResolvedValue(false); + const command = new ChannelUnprotect(['production'], getMockOclifConfig()); + setContext(command); + + await command.runAsync(); + + expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled(); + expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('production')); + }); + + it('does not prompt in non-interactive mode', async () => { + const command = new ChannelUnprotect(['production', '--non-interactive'], getMockOclifConfig()); + setContext(command); + + await command.runAsync(); + + expect(toggleConfirmAsync).not.toHaveBeenCalled(); + expect(unprotectUpdateChannelAsync).toHaveBeenCalled(); + }); + + it('requires a channel name in non-interactive mode', async () => { + const command = new ChannelUnprotect(['--non-interactive'], getMockOclifConfig()); + + await expect(command.runAsync()).rejects.toThrow( + 'Channel name must be set when running in non-interactive mode' + ); + expect(unprotectUpdateChannelAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/channel/protect.ts b/packages/eas-cli/src/commands/channel/protect.ts new file mode 100644 index 0000000000..333d5106a7 --- /dev/null +++ b/packages/eas-cli/src/commands/channel/protect.ts @@ -0,0 +1,69 @@ +import { Args } from '@oclif/core'; +import chalk from 'chalk'; + +import { protectUpdateChannelAsync } from '../../channel/protection'; +import { selectChannelOnAppAsync } from '../../channel/queries'; +import EasCommand from '../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../commandUtils/flags'; +import { ChannelQuery } from '../../graphql/queries/ChannelQuery'; +import Log from '../../log'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +export default class ChannelProtect extends EasCommand { + static override description = 'protect a channel'; + + static override args = { + name: Args.string({ + required: false, + description: 'Name of the channel to protect', + }), + }; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { args, flags } = await this.parse(ChannelProtect); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (!args.name && nonInteractive) { + throw new Error('Channel name must be set when running in non-interactive mode'); + } + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ChannelProtect, { nonInteractive }); + if (json) { + enableJsonOutput(); + } + + const existingChannel = args.name + ? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, { + appId: projectId, + channelName: args.name, + }) + : await selectChannelOnAppAsync(graphqlClient, { + projectId, + selectionPromptTitle: 'Select a channel to protect', + paginatedQueryOptions: { json, nonInteractive, offset: 0 }, + }); + + const channel = await protectUpdateChannelAsync(graphqlClient, { + channelId: existingChannel.id, + }); + + if (json) { + printJsonOnlyOutput(channel); + } else { + Log.withTick(chalk`Channel {bold ${channel.name}} is now protected.`); + } + } +} diff --git a/packages/eas-cli/src/commands/channel/unprotect.ts b/packages/eas-cli/src/commands/channel/unprotect.ts new file mode 100644 index 0000000000..5e949b9c6a --- /dev/null +++ b/packages/eas-cli/src/commands/channel/unprotect.ts @@ -0,0 +1,80 @@ +import { Args } from '@oclif/core'; +import chalk from 'chalk'; + +import { unprotectUpdateChannelAsync } from '../../channel/protection'; +import { selectChannelOnAppAsync } from '../../channel/queries'; +import EasCommand from '../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../commandUtils/flags'; +import { ChannelQuery } from '../../graphql/queries/ChannelQuery'; +import Log from '../../log'; +import { toggleConfirmAsync } from '../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; + +export default class ChannelUnprotect extends EasCommand { + static override description = 'remove protection from a channel'; + + static override args = { + name: Args.string({ + required: false, + description: 'Name of the channel to unprotect', + }), + }; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectId, + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { args, flags } = await this.parse(ChannelUnprotect); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (!args.name && nonInteractive) { + throw new Error('Channel name must be set when running in non-interactive mode'); + } + const { + projectId, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(ChannelUnprotect, { nonInteractive }); + if (json) { + enableJsonOutput(); + } + + const existingChannel = args.name + ? await ChannelQuery.viewUpdateChannelBasicInfoAsync(graphqlClient, { + appId: projectId, + channelName: args.name, + }) + : await selectChannelOnAppAsync(graphqlClient, { + projectId, + selectionPromptTitle: 'Select a channel to unprotect', + paginatedQueryOptions: { json, nonInteractive, offset: 0 }, + }); + + if (!nonInteractive) { + const confirmed = await toggleConfirmAsync({ + message: chalk`Remove protection from channel {bold ${existingChannel.name}}?`, + }); + if (!confirmed) { + Log.log(chalk`Canceled removing protection from channel {bold ${existingChannel.name}}.`); + return; + } + } + + const channel = await unprotectUpdateChannelAsync(graphqlClient, { + channelId: existingChannel.id, + }); + + if (json) { + printJsonOnlyOutput(channel); + } else { + Log.withTick(chalk`Channel {bold ${channel.name}} is no longer protected.`); + } + } +} diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index f17ab5a7b6..c4c447b61b 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -13816,6 +13816,20 @@ export type ScheduleChannelDeletionMutationVariables = Exact<{ export type ScheduleChannelDeletionMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', scheduleUpdateChannelDeletion: { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any } } }; +export type ProtectUpdateChannelMutationVariables = Exact<{ + channelId: Scalars['ID']['input']; +}>; + + +export type ProtectUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', protectUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } } }; + +export type UnprotectUpdateChannelMutationVariables = Exact<{ + channelId: Scalars['ID']['input']; +}>; + + +export type UnprotectUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', unprotectUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } } }; + export type CreateUpdateChannelOnAppMutationVariables = Exact<{ appId: Scalars['ID']['input']; name: Scalars['String']['input']; @@ -13823,7 +13837,7 @@ export type CreateUpdateChannelOnAppMutationVariables = Exact<{ }>; -export type CreateUpdateChannelOnAppMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', createUpdateChannelForApp: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } } }; +export type CreateUpdateChannelOnAppMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', createUpdateChannelForApp: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } } }; export type GetBranchInfoQueryVariables = Exact<{ appId: Scalars['String']['input']; @@ -13860,21 +13874,21 @@ export type UpdateChannelBranchMappingMutationVariables = Exact<{ }>; -export type UpdateChannelBranchMappingMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', editUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } } }; +export type UpdateChannelBranchMappingMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', editUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } } }; export type PauseUpdateChannelMutationVariables = Exact<{ channelId: Scalars['ID']['input']; }>; -export type PauseUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', pauseUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } } }; +export type PauseUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', pauseUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } } }; export type ResumeUpdateChannelMutationVariables = Exact<{ channelId: Scalars['ID']['input']; }>; -export type ResumeUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', resumeUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } } }; +export type ResumeUpdateChannelMutation = { __typename?: 'RootMutation', updateChannel: { __typename?: 'UpdateChannelMutation', resumeUpdateChannel: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } } }; export type AppInfoQueryVariables = Exact<{ appId: Scalars['String']['input']; @@ -15222,7 +15236,7 @@ export type ViewUpdateChannelBasicInfoOnAppQueryVariables = Exact<{ }>; -export type ViewUpdateChannelBasicInfoOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } | null } } }; +export type ViewUpdateChannelBasicInfoOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } | null } } }; export type ViewUpdateChannelOnAppQueryVariables = Exact<{ appId: Scalars['String']['input']; @@ -15231,7 +15245,7 @@ export type ViewUpdateChannelOnAppQueryVariables = Exact<{ }>; -export type ViewUpdateChannelOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, isPaused: boolean, name: string, updatedAt: any, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array; -export type ViewUpdateChannelsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannels: Array<{ __typename?: 'UpdateChannel', id: string, isPaused: boolean, name: string, updatedAt: any, createdAt: any, branchMapping: string, updateBranches: Array<{ __typename?: 'UpdateBranch', id: string, name: string, updateGroups: Array; -export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } }; +export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } }; export type ConvexTeamConnectionsByAccountIdQueryVariables = Exact<{ accountId: Scalars['String']['input']; @@ -15885,7 +15899,7 @@ export type UpdateBranchFragment = { __typename?: 'UpdateBranch', id: string, na export type UpdateBranchBasicInfoFragment = { __typename?: 'UpdateBranch', id: string, name: string }; -export type UpdateChannelBasicInfoFragment = { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string }; +export type UpdateChannelBasicInfoFragment = { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string, isProtected: boolean }; export type WebhookFragment = { __typename?: 'Webhook', id: string, event: WebhookType, url: string, createdAt: any, updatedAt: any }; diff --git a/packages/eas-cli/src/graphql/queries/ChannelQuery.ts b/packages/eas-cli/src/graphql/queries/ChannelQuery.ts index 6630f7b84f..960c0535ab 100644 --- a/packages/eas-cli/src/graphql/queries/ChannelQuery.ts +++ b/packages/eas-cli/src/graphql/queries/ChannelQuery.ts @@ -98,6 +98,7 @@ export const ChannelQuery = { updateChannelByName(name: $channelName) { id isPaused + isProtected name updatedAt createdAt @@ -144,6 +145,7 @@ export const ChannelQuery = { updateChannels(offset: $offset, limit: $limit) { id isPaused + isProtected name updatedAt createdAt diff --git a/packages/eas-cli/src/graphql/queries/__tests__/ChannelQuery-test.ts b/packages/eas-cli/src/graphql/queries/__tests__/ChannelQuery-test.ts new file mode 100644 index 0000000000..4e9ce3a8d2 --- /dev/null +++ b/packages/eas-cli/src/graphql/queries/__tests__/ChannelQuery-test.ts @@ -0,0 +1,69 @@ +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { ChannelQuery } from '../ChannelQuery'; + +function makeGraphqlClient(data: unknown): { + graphqlClient: ExpoGraphqlClient; + query: jest.Mock; +} { + const query = jest.fn().mockReturnValue({ + toPromise: jest.fn().mockResolvedValue({ data }), + }); + return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query }; +} + +describe(ChannelQuery.viewUpdateChannelAsync.name, () => { + it('requests and returns channel protection state', async () => { + const channel = { + id: 'channel-id', + name: 'production', + isPaused: false, + isProtected: true, + updatedAt: '2026-08-31T00:00:00.000Z', + createdAt: '2026-08-31T00:00:00.000Z', + branchMapping: '{"version":0,"data":[]}', + updateBranches: [], + }; + const { graphqlClient, query } = makeGraphqlClient({ + app: { byId: { id: 'app-id', updateChannelByName: channel } }, + }); + + await expect( + ChannelQuery.viewUpdateChannelAsync(graphqlClient, { + appId: 'app-id', + channelName: 'production', + }) + ).resolves.toEqual(channel); + + expect(query.mock.calls[0][0].loc.source.body).toContain('isProtected'); + }); +}); + +describe(ChannelQuery.viewUpdateChannelsOnAppAsync.name, () => { + it('requests and returns protection state for every channel', async () => { + const channels = [ + { + id: 'channel-id', + name: 'production', + isPaused: false, + isProtected: true, + updatedAt: '2026-08-31T00:00:00.000Z', + createdAt: '2026-08-31T00:00:00.000Z', + branchMapping: '{"version":0,"data":[]}', + updateBranches: [], + }, + ]; + const { graphqlClient, query } = makeGraphqlClient({ + app: { byId: { id: 'app-id', updateChannels: channels } }, + }); + + await expect( + ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, { + appId: 'app-id', + limit: 10, + offset: 0, + }) + ).resolves.toEqual(channels); + + expect(query.mock.calls[0][0].loc.source.body).toContain('isProtected'); + }); +}); diff --git a/packages/eas-cli/src/graphql/types/UpdateChannelBasicInfo.ts b/packages/eas-cli/src/graphql/types/UpdateChannelBasicInfo.ts index e6681fc3e2..144b0c1174 100644 --- a/packages/eas-cli/src/graphql/types/UpdateChannelBasicInfo.ts +++ b/packages/eas-cli/src/graphql/types/UpdateChannelBasicInfo.ts @@ -5,5 +5,6 @@ export const UpdateChannelBasicInfoFragmentNode = gql` id name branchMapping + isProtected } `; diff --git a/packages/eas-cli/src/update/__tests__/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync-test.ts b/packages/eas-cli/src/update/__tests__/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync-test.ts index ab6685affe..ed4d343e4b 100644 --- a/packages/eas-cli/src/update/__tests__/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync-test.ts +++ b/packages/eas-cli/src/update/__tests__/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync-test.ts @@ -64,6 +64,7 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => { name: 'test-channel-name', branchMapping: '{"data":[{"branchId":"test-branch-id","branchMappingLogic":"true"}],"version":0}', + isProtected: false, }, }, }); @@ -130,6 +131,7 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => { name: 'test-channel-name', branchMapping: '{"data":[{"branchId":"test-branch-id","branchMappingLogic":"true"}],"version":0}', + isProtected: false, }, }, }); From 8551561c6ade059d26a766d609b966e895c6455c Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Mon, 31 Aug 2026 18:03:11 -0400 Subject: [PATCH 2/3] [eas-cli] Add protected channels changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 423d34f2ac..89b2744bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [eas-cli] Add commands to protect and unprotect EAS Update channels, and show protection in channel list and view output. ([#4319](https://github.com/expo/eas-cli/pull/4319) by [@sjkim-expo](https://github.com/sjkim-expo)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores From 26a2f178ed34a256eda190dbe075656bf35feb74 Mon Sep 17 00:00:00 2001 From: SJ Kim Date: Mon, 31 Aug 2026 18:48:04 -0400 Subject: [PATCH 3/3] [eas-cli] Handle missing protected channels --- .../src/channel/__tests__/protection-test.ts | 20 +++++++++++++++++++ packages/eas-cli/src/channel/protection.ts | 12 +++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/channel/__tests__/protection-test.ts b/packages/eas-cli/src/channel/__tests__/protection-test.ts index 376b2224f0..a308a955a8 100644 --- a/packages/eas-cli/src/channel/__tests__/protection-test.ts +++ b/packages/eas-cli/src/channel/__tests__/protection-test.ts @@ -30,6 +30,16 @@ describe(protectUpdateChannelAsync.name, () => { expect(mutation.mock.calls[0][0].loc.source.body).toContain('protectUpdateChannel'); expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' }); }); + + it('throws a clear error when the channel is not returned', async () => { + const { graphqlClient } = makeGraphqlClient({ + updateChannel: { protectUpdateChannel: null }, + }); + + await expect( + protectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' }) + ).rejects.toThrow('Could not find a channel with id: missing-channel-id'); + }); }); describe(unprotectUpdateChannelAsync.name, () => { @@ -51,4 +61,14 @@ describe(unprotectUpdateChannelAsync.name, () => { expect(mutation.mock.calls[0][0].loc.source.body).toContain('unprotectUpdateChannel'); expect(mutation.mock.calls[0][1]).toEqual({ channelId: 'channel-id' }); }); + + it('throws a clear error when the channel is not returned', async () => { + const { graphqlClient } = makeGraphqlClient({ + updateChannel: { unprotectUpdateChannel: null }, + }); + + await expect( + unprotectUpdateChannelAsync(graphqlClient, { channelId: 'missing-channel-id' }) + ).rejects.toThrow('Could not find a channel with id: missing-channel-id'); + }); }); diff --git a/packages/eas-cli/src/channel/protection.ts b/packages/eas-cli/src/channel/protection.ts index 05f86e081a..48d4612a3e 100644 --- a/packages/eas-cli/src/channel/protection.ts +++ b/packages/eas-cli/src/channel/protection.ts @@ -34,7 +34,11 @@ export async function protectUpdateChannelAsync( ) .toPromise() ); - return data.updateChannel.protectUpdateChannel; + const channel = data.updateChannel.protectUpdateChannel; + if (!channel) { + throw new Error(`Could not find a channel with id: ${channelId}`); + } + return channel; } export async function unprotectUpdateChannelAsync( @@ -59,5 +63,9 @@ export async function unprotectUpdateChannelAsync( ) .toPromise() ); - return data.updateChannel.unprotectUpdateChannel; + const channel = data.updateChannel.unprotectUpdateChannel; + if (!channel) { + throw new Error(`Could not find a channel with id: ${channelId}`); + } + return channel; }