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