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