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