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