xref: /expo/tools/src/commands/WorkflowDispatch.ts (revision bb5069cd)
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    },
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 (url) {
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 get URL for job: `, jobs[0]);
145    }
146  } else {
147    logger.warn(`⚠️  Cannot find any triggered jobs for ${chalk.green(workflow.slug)} workflow`);
148  }
149}
150
151/**
152 * Resolves to an array of workflows containing workflows fetched from the API
153 * concatenated with custom workflows that declares some specific inputs.
154 */
155async function getAllWorkflowsAsync(): Promise<Workflow[]> {
156  // Fetch workflows from GitHub Actions API.
157  const commonWorkflows = await getWorkflowsAsync();
158
159  // Map custom workflow configs to workflows.
160  const customWorkflows = Object.entries(CUSTOM_WORKFLOWS)
161    .map(([customWorkflowSlug, workflowConfig]) => {
162      const baseWorkflow = commonWorkflows.find(
163        (workflow) => workflow.slug === workflowConfig.baseWorkflowSlug
164      );
165
166      return baseWorkflow
167        ? {
168            ...deepCloneObject(baseWorkflow),
169            name: workflowConfig.name,
170            slug: customWorkflowSlug,
171            baseSlug: workflowConfig.baseWorkflowSlug,
172            inputs: workflowConfig.inputs,
173          }
174        : null;
175    })
176    .filter(Boolean) as Workflow[];
177
178  const allWorkflows = ([] as Workflow[]).concat(commonWorkflows, customWorkflows);
179  return allWorkflows.sort((a, b) => a.name.localeCompare(b.name));
180}
181
182/**
183 * Finds workflow ID based on given name or config filename.
184 */
185async function findWorkflowAsync(
186  workflows: Workflow[],
187  workflowSlug: string | undefined
188): Promise<Workflow | null> {
189  if (!workflowSlug) {
190    if (process.env.CI) {
191      throw new Error('Command requires `workflowName` argument when run on the CI.');
192    }
193    return await promptWorkflowAsync(workflows);
194  }
195  return workflows.find((workflow) => workflow.slug === workflowSlug) ?? null;
196}
197
198/**
199 * Prompts for the workflow to trigger.
200 */
201async function promptWorkflowAsync(workflows: Workflow[]): Promise<Workflow> {
202  const { workflow } = await inquirer.prompt([
203    {
204      type: 'list',
205      name: 'workflow',
206      message: 'Which workflow do you want to dispatch?',
207      choices: workflows.map((workflow) => {
208        return {
209          name: `${chalk.yellow(workflow.name)} (${chalk.green.italic(workflow.slug)})`,
210          value: workflow,
211        };
212      }),
213      pageSize: workflows.length,
214    },
215  ]);
216  return workflow;
217}
218
219/**
220 * Requires the user to confirm dispatching an event that trigger given workflow.
221 */
222async function confirmTriggeringWorkflowAsync(workflow: Workflow): Promise<boolean> {
223  logger.info(
224    `\n�� I'll trigger ${chalk.green(workflow.baseSlug)} workflow extended by the following input:`
225  );
226  logger.log(workflow.inputs, '\n');
227
228  const { confirm } = await inquirer.prompt([
229    {
230      type: 'confirm',
231      name: 'confirm',
232      message: 'Please type `y` and press enter if you want to continue',
233      default: false,
234    },
235  ]);
236  return confirm;
237}
238