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