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