xref: /expo/tools/src/commands/WorkflowDispatch.ts (revision 28f207e7)
1import { Command } from '@expo/commander';
2import chalk from 'chalk';
3import inquirer from 'inquirer';
4import open from 'open';
5
6import Git from '../Git';
7import {
8  getWorkflowsAsync,
9  dispatchWorkflowEventAsync,
10  getLatestDispatchedWorkflowRunAsync,
11  Workflow,
12  getJobsForWorkflowRunAsync,
13} from '../GitHubActions';
14import logger from '../Logger';
15import { deepCloneObject, retryAsync } from '../Utils';
16
17type CommandOptions = {
18  ref?: string;
19  open?: boolean;
20};
21
22// Object containing configs for custom workflows.
23// Custom workflows extends common workflows by providing specific inputs.
24const CUSTOM_WORKFLOWS = {
25  'ad-hoc-client-shell-app-ios-upload': {
26    name: 'Ad-hoc iOS Client Shell App (with Upload to S3)',
27    baseWorkflowSlug: 'ad-hoc-client-shell-app-ios',
28    inputs: {
29      upload: 'upload',
30    },
31  },
32  'client-android-release': {
33    name: 'Android Client Release',
34    baseWorkflowSlug: 'client-android',
35    inputs: {
36      releaseGooglePlay: 'release-google-play',
37    },
38  },
39  'client-android-simulator': {
40    name: 'Android Client Simulator Release',
41    baseWorkflowSlug: 'client-android',
42    inputs: {
43      releaseAPK: 'release-apk',
44    },
45  },
46  'client-ios-simulator': {
47    name: 'iOS Client Simulator Release',
48    baseWorkflowSlug: 'client-ios',
49    inputs: {
50      releaseSimulator: 'release-simulator',
51    },
52  },
53  'shell-app-ios-upload': {
54    name: 'iOS Shell App (with newest SDK version and Upload to S3)',
55    baseWorkflowSlug: 'shell-app-ios',
56    inputs: {
57      upload: 'upload',
58      versioned: 'true',
59    },
60  },
61  'sdk-all': {
62    name: 'SDK All',
63    baseWorkflowSlug: 'sdk',
64    inputs: {
65      checkAll: 'check-all',
66    },
67  },
68};
69
70export default (program: Command) => {
71  program
72    .command('workflow-dispatch [workflowSlug]')
73    .alias('dispatch', 'wd')
74    .option(
75      '-r, --ref <ref>',
76      'The reference of the workflow run. The reference can be a branch, tag, or a commit SHA.'
77    )
78    .option(
79      '--no-open',
80      "Whether not to automatically open a page with workflow's job run containing the one that has just been triggered.",
81      false
82    )
83    .description(
84      `Dispatches an event that triggers a workflow on GitHub Actions. Requires ${chalk.magenta(
85        'GITHUB_TOKEN'
86      )} env variable to be set.`
87    )
88    .asyncAction(main);
89};
90
91/**
92 * Main action of the command.
93 */
94async function main(workflowSlug: string | undefined, options: CommandOptions) {
95  if (!process.env.GITHUB_TOKEN) {
96    throw new Error('Environment variable `GITHUB_TOKEN` must be set.');
97  }
98
99  const workflows = await getAllWorkflowsAsync();
100  const workflow = await findWorkflowAsync(workflows, workflowSlug);
101  const ref = options.ref || (await Git.getCurrentBranchNameAsync());
102
103  if (!workflow) {
104    throw new Error(`Unable to find workflow with slug \`${workflowSlug}\`.`);
105  }
106
107  // We need a confirmation to trigger a custom workflow.
108  if (!process.env.CI && workflow.inputs && !(await confirmTriggeringWorkflowAsync(workflow))) {
109    logger.warn(
110      `\n⚠️  Triggering custom workflow ${chalk.green(workflow.slug)} has been canceled.`
111    );
112    return;
113  }
114
115  // Get previously dispatched workflow run.
116  const previousWorkflowRun = await getLatestDispatchedWorkflowRunAsync(workflow.id);
117
118  // Dispatch `workflow_dispatch` event.
119  await dispatchWorkflowEventAsync(workflow.id, ref, workflow.inputs);
120
121  logger.success('�� Successfully dispatched workflow event ');
122
123  // Let's wait a little bit for the new workflow run to start and appear in the API response.
124  logger.info('⏳ Waiting for the new workflow run to start...');
125  const newWorkflowRun = await retryAsync(2000, 10, async () => {
126    const run = await getLatestDispatchedWorkflowRunAsync(workflow.id);
127
128    // Compare the result with previous workflow run.
129    return previousWorkflowRun?.id !== run?.id ? run : undefined;
130  });
131
132  // Get a list of jobs for the new workflow run.
133  const jobs = newWorkflowRun && (await getJobsForWorkflowRunAsync(newWorkflowRun.id));
134
135  // If the job exists, open it in web browser or print the link.
136  if (jobs?.[0]) {
137    const url = jobs[0].html_url;
138
139    if (options.open && !process.env.CI) {
140      await open(url);
141    }
142    logger.log(`�� You can open ${chalk.magenta(url)} to track the new workflow run.`);
143  } else {
144    logger.warn(`⚠️  Cannot find any triggered jobs for ${chalk.green(workflow.slug)} workflow`);
145  }
146}
147
148/**
149 * Resolves to an array of workflows containing workflows fetched from the API
150 * concatenated with custom workflows that declares some specific inputs.
151 */
152async function getAllWorkflowsAsync(): Promise<Workflow[]> {
153  // Fetch workflows from GitHub Actions API.
154  const commonWorkflows = await getWorkflowsAsync();
155
156  // Map custom workflow configs to workflows.
157  const customWorkflows = Object.entries(CUSTOM_WORKFLOWS)
158    .map(([customWorkflowSlug, workflowConfig]) => {
159      const baseWorkflow = commonWorkflows.find(
160        (workflow) => workflow.slug === workflowConfig.baseWorkflowSlug
161      );
162
163      return baseWorkflow
164        ? {
165            ...deepCloneObject(baseWorkflow),
166            name: workflowConfig.name,
167            slug: customWorkflowSlug,
168            baseSlug: workflowConfig.baseWorkflowSlug,
169            inputs: workflowConfig.inputs,
170          }
171        : null;
172    })
173    .filter(Boolean) as Workflow[];
174
175  const allWorkflows = ([] as Workflow[]).concat(commonWorkflows, customWorkflows);
176  return allWorkflows.sort((a, b) => a.name.localeCompare(b.name));
177}
178
179/**
180 * Finds workflow ID based on given name or config filename.
181 */
182async function findWorkflowAsync(
183  workflows: Workflow[],
184  workflowSlug: string | undefined
185): Promise<Workflow | null> {
186  if (!workflowSlug) {
187    if (process.env.CI) {
188      throw new Error('Command requires `workflowName` argument when run on the CI.');
189    }
190    return await promptWorkflowAsync(workflows);
191  }
192  return workflows.find((workflow) => workflow.slug === workflowSlug) ?? null;
193}
194
195/**
196 * Prompts for the workflow to trigger.
197 */
198async function promptWorkflowAsync(workflows: Workflow[]): Promise<Workflow> {
199  const { workflow } = await inquirer.prompt([
200    {
201      type: 'list',
202      name: 'workflow',
203      message: 'Which workflow do you want to dispatch?',
204      choices: workflows.map((workflow) => {
205        return {
206          name: `${chalk.yellow(workflow.name)} (${chalk.green.italic(workflow.slug)})`,
207          value: workflow,
208        };
209      }),
210      pageSize: workflows.length,
211    },
212  ]);
213  return workflow;
214}
215
216/**
217 * Requires the user to confirm dispatching an event that trigger given workflow.
218 */
219async function confirmTriggeringWorkflowAsync(workflow: Workflow): Promise<boolean> {
220  logger.info(
221    `\n�� I'll trigger ${chalk.green(workflow.baseSlug)} workflow extended by the following input:`
222  );
223  logger.log(workflow.inputs, '\n');
224
225  const { confirm } = await inquirer.prompt([
226    {
227      type: 'confirm',
228      name: 'confirm',
229      message: 'Please type `y` and press enter if you want to continue',
230      default: false,
231    },
232  ]);
233  return confirm;
234}
235