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