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