import { Command } from '@expo/commander'; import chalk from 'chalk'; import inquirer from 'inquirer'; import open from 'open'; import Git from '../Git'; import { getWorkflowsAsync, dispatchWorkflowEventAsync, getLatestDispatchedWorkflowRunAsync, Workflow, getJobsForWorkflowRunAsync, } from '../GitHubActions'; import logger from '../Logger'; import { deepCloneObject, retryAsync } from '../Utils'; type CommandOptions = { ref?: string; open?: boolean; }; // Object containing configs for custom workflows. // Custom workflows extends common workflows by providing specific inputs. const CUSTOM_WORKFLOWS = { 'client-android-eas-release': { name: 'Android Expo Go Release', baseWorkflowSlug: 'client-android-eas', inputs: { buildType: 'versioned-client', }, }, 'sdk-all': { name: 'SDK All', baseWorkflowSlug: 'sdk', inputs: { checkAll: 'check-all', }, }, }; export default (program: Command) => { program .command('workflow-dispatch [workflowSlug]') .alias('dispatch', 'wd') .option( '-r, --ref ', 'The reference of the workflow run. The reference can be a branch, tag, or a commit SHA.' ) .option( '--no-open', "Whether not to automatically open a page with workflow's job run containing the one that has just been triggered.", false ) .description( `Dispatches an event that triggers a workflow on GitHub Actions. Requires ${chalk.magenta( 'GITHUB_TOKEN' )} env variable to be set.` ) .asyncAction(main); }; /** * Main action of the command. */ async function main(workflowSlug: string | undefined, options: CommandOptions) { if (!process.env.GITHUB_TOKEN) { throw new Error('Environment variable `GITHUB_TOKEN` must be set.'); } const workflows = await getAllWorkflowsAsync(); const workflow = await findWorkflowAsync(workflows, workflowSlug); const ref = options.ref || (await Git.getCurrentBranchNameAsync()); if (!workflow) { throw new Error(`Unable to find workflow with slug \`${workflowSlug}\`.`); } // We need a confirmation to trigger a custom workflow. if (!process.env.CI && workflow.inputs && !(await confirmTriggeringWorkflowAsync(workflow))) { logger.warn( `\n⚠️ Triggering custom workflow ${chalk.green(workflow.slug)} has been canceled.` ); return; } // Get previously dispatched workflow run. const previousWorkflowRun = await getLatestDispatchedWorkflowRunAsync(workflow.id); // Dispatch `workflow_dispatch` event. await dispatchWorkflowEventAsync(workflow.id, ref, workflow.inputs); logger.success('🎉 Successfully dispatched workflow event '); // Let's wait a little bit for the new workflow run to start and appear in the API response. logger.info('⏳ Waiting for the new workflow run to start...'); const newWorkflowRun = await retryAsync(2000, 10, async () => { const run = await getLatestDispatchedWorkflowRunAsync(workflow.id); // Compare the result with previous workflow run. return previousWorkflowRun?.id !== run?.id ? run : undefined; }); // Get a list of jobs for the new workflow run. const jobs = newWorkflowRun && (await getJobsForWorkflowRunAsync(newWorkflowRun.id)); // If the job exists, open it in web browser or print the link. if (jobs?.[0]) { const url = jobs[0].html_url; if (url) { if (options.open && !process.env.CI) { await open(url); } logger.log(`🧭 You can open ${chalk.magenta(url)} to track the new workflow run.`); } else { logger.warn(`⚠️ Cannot get URL for job: `, jobs[0]); } } else { logger.warn(`⚠️ Cannot find any triggered jobs for ${chalk.green(workflow.slug)} workflow`); } } /** * Resolves to an array of workflows containing workflows fetched from the API * concatenated with custom workflows that declares some specific inputs. */ async function getAllWorkflowsAsync(): Promise { // Fetch workflows from GitHub Actions API. const commonWorkflows = await getWorkflowsAsync(); // Map custom workflow configs to workflows. const customWorkflows = Object.entries(CUSTOM_WORKFLOWS) .map(([customWorkflowSlug, workflowConfig]) => { const baseWorkflow = commonWorkflows.find( (workflow) => workflow.slug === workflowConfig.baseWorkflowSlug ); return baseWorkflow ? { ...deepCloneObject(baseWorkflow), name: workflowConfig.name, slug: customWorkflowSlug, baseSlug: workflowConfig.baseWorkflowSlug, inputs: workflowConfig.inputs, } : null; }) .filter(Boolean) as Workflow[]; const allWorkflows = ([] as Workflow[]).concat(commonWorkflows, customWorkflows); return allWorkflows.sort((a, b) => a.name.localeCompare(b.name)); } /** * Finds workflow ID based on given name or config filename. */ async function findWorkflowAsync( workflows: Workflow[], workflowSlug: string | undefined ): Promise { if (!workflowSlug) { if (process.env.CI) { throw new Error('Command requires `workflowName` argument when run on the CI.'); } return await promptWorkflowAsync(workflows); } return workflows.find((workflow) => workflow.slug === workflowSlug) ?? null; } /** * Prompts for the workflow to trigger. */ async function promptWorkflowAsync(workflows: Workflow[]): Promise { const { workflow } = await inquirer.prompt([ { type: 'list', name: 'workflow', message: 'Which workflow do you want to dispatch?', choices: workflows.map((workflow) => { return { name: `${chalk.yellow(workflow.name)} (${chalk.green.italic(workflow.slug)})`, value: workflow, }; }), pageSize: workflows.length, }, ]); return workflow; } /** * Requires the user to confirm dispatching an event that trigger given workflow. */ async function confirmTriggeringWorkflowAsync(workflow: Workflow): Promise { logger.info( `\n👉 I'll trigger ${chalk.green(workflow.baseSlug)} workflow extended by the following input:` ); logger.log(workflow.inputs, '\n'); const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: 'Please type `y` and press enter if you want to continue', default: false, }, ]); return confirm; }