Triggering and waiting for an external Github Workflow

Posted on 2023-02-07


Recently I was working on a project that has a lot of different services, all of which can be deployed independently. We have a test environment that we deploy to as soon as a PR is merged, so in order to make sure that the service doesn’t break the production environment I created an end-to-end testing step that runs on the test environment (using playwright) that should run and succeed before being allowed to deploy to production.

This meant I needed to figure out how to trigger a workflow from another repo on Github and retrieve the result from that workflow before allowing a next step to occur.

So the gist of the setup is:

  • E2E repo (e.g. frontend repo) which holds the workflow we want to call. playwright.yml in my case.

  • Some other repo that should call the E2E repo’s workflow from its own workflow and wait for success

There is no real quick and convenient way of doing this with Github, but using actions/github-script I was able to create a script that does the job nicely.

Using github-script we have access to octokit to call the GitHub API to do things like starting workflows, checking runs, etc. However to do so we need to create a Personal Access Token as the usual github action GITHUB_TOKEN is not allowed to trigger workflows to prevent infinite loops.

You can do this by going to your Account settings -> developer settings -> Personal access tokens and creating a token with repo and workflow rights.

Save this token as a repository secret on the service you want to add the end-to-end testing step to by going to Repository settings -> Secrets and variables -> Actions and clicking New repository secret. Give it whatever name you want, just make sure it matches in the workflow. I named it WORKFLOW_TRIGGER_PAT.

Now in your E2E repo you’ll want to make sure the workflow you are trying to call has the following added

	on:
	  workflow_dispatch:
	    inputs:
	      service-name:
	        required: true
	        type: string
	      workflow-run-id:
	        type: string

now you don’t need the inputs, but I added the following step in my playwright.yml job to add some information to the step summary:

    steps:
    - run: |
        echo "E2E test triggered by [${{ inputs.service-name }}](https://github.com/${{ github.repository_owner }}/${{ inputs.service-name }}/actions/runs/${{ inputs.workflow-run-id }})" >> $GITHUB_STEP_SUMMARY

Now that the workflow you want to trigger is set up properly we can move on to the actual script. I did not want to inline it, as it is quite lenghty and I need to add it to multiple services, but you could argue that it is not best practice to do so. Anyway this means I put the script in the .github/scripts directory as trigger-and-wait.js:

module.exports = async ({core, github, context}) => {
    const createdDate = `>=${new Date(Date.now() - 120000).toISOString()}`
    core.info('Starting external workflow...')
    await github.rest.actions.createWorkflowDispatch({
        owner: 'USERNAME_OR_ORG',
        repo: 'E2E_REPO_NAME',
        workflow_id: 'playwright.yml',
        ref: 'main',
        inputs: {
            'service-name': context.repo.repo,
            'workflow-run-id': context.runId.toString()
        },
    })

    const currentWorkFlowId = await new Promise((resolve) => {
        const interval = setInterval(async () => {
            core.info('Checking if workflow has started...')
            const currentWorkFlows = (await github.rest.actions.listWorkflowRuns({
                owner: 'USERNAME_OR_ORG',
                repo: 'E2E_REPO_NAME',
                workflow_id: 'playwright.yml',
                branch: 'main',
                event: 'workflow_dispatch',
                created: createdDate,
            })).data.workflow_runs

            const inProgressWorkFlow = currentWorkFlows.find(workflow => workflow.status === 'in_progress')
            if (inProgressWorkFlow) {
                clearInterval(interval)
                clearTimeout(timeout)
                resolve(inProgressWorkFlow.id)
            }
        }, 5000)
        const timeout = setTimeout(() => {
            clearInterval(interval)
            clearTimeout(timeout)
            core.setFailed('Timed out trying to start workflow')
        }, 60000)
    })

    core.info(`Workflow started with id ${currentWorkFlowId}: https://github.com/USERNAME_OR_ORG/E2E_REPO_NAME/actions/runs/${currentWorkFlowId}`)

    // completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending
    await new Promise((resolve) => {
        const interval = setInterval(async () => {
            const result = await github.rest.actions.getWorkflowRun({
                owner: 'USERNAME_OR_ORG',
                repo: 'E2E_REPO_NAME',
                run_id: currentWorkFlowId,
            })

            if (result.data.status === 'in_progress') {
                core.info('Workflow is still running, waiting...')
            } else {
                core.info(`Finished workflow: ${result.data.conclusion}\nWith status: (${result.data.status})`)
                clearInterval(interval)
                clearTimeout(timeout)
                if (['failure', 'timed_out'].includes(result.data.conclusion) || ['failure', 'timed_out'].includes(result.data.status)) {
                    core.setFailed('End-to-End tests failed')
                }
                resolve({ conclusion: result.data.conclusion, status: result.data.status })
            }
        }, 30000)

        const timeout = setTimeout( () => {
            clearInterval(interval)
            core.setFailed('Timed out waiting for workflow to finish')
        }, 1000 * 60 * 60)
    })

    core.info('End-to-End step succeeded')
}

You will need to replace USERNAME_OR_ORG and E2E_REPO_NAME with the correct values.

Note this script is by no means optimized, but it works for what I need to do and has a few fail safes just in case.

It will create and dispatch the workflow, get the corresponding ID for the triggered workflow and wait for it to be anything but failure or timed_out before succeeding or failing the step.

You can add it to your repository’s CI by adding the following job to whichever workflow you want:

  end-to-end:
    runs-on: ubuntu-latest
    needs: [ deploy-to-test ]
    steps:
      - uses: actions/checkout@v3
      - name: Run end-to-end tests
        uses: actions/github-script@v6
        with:
          github-token: ${{ secrets.WORKFLOW_TRIGGER_PAT }}
          script: |
            const script = require('./.github/scripts/trigger-and-wait.js')
            await script({github, context, core})

remove/alter the needs: [ deploy-to-test ] based on your situation. In my case, this job is part of the release workflow, and triggers after we have deployed our application to the test environment. Our deploy to prod job needs the end-to-end.