Detecting GitHub workflow job run status changes

The basic GitHub workflow

The below GitHub workflow (defined in the file .github/workflows/workflow_history.yaml will be the starting point with two steps:

  1. It checks out the repository (this is important to determine the status later)
  2. On manual invocation, it displays a checkbox named "Fail the job?". If this is checked, the job will fail, otherwise it will pass.

Github action invocation

---
name: 'Github Workflow History'

'on':
  workflow_dispatch:
    inputs:
      failJob:
        description: Fail the job?
        type: boolean
        required: true
        default: false

jobs:
  exampleJob:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        
      - name: Pass or fail job
        run: exit 1
        if: github.event.inputs.failJob == 'true'

For testing purposes, I ran the job twice, once with a successful outcome, once with a failure:

Workflow runs

Getting the last job status with the GitHub CLI

List previous runs

GitHub CLI is the official command line tool for Github that can perform various tasks on repositories. One of these is checking a workflow job history - this will come in handy.

To test this approach, I installed the CLI tool locally by following this instruction. In the end, we will use it from inside the workflow, of course.

This is the syntax to determine the last job status by passing the yaml file name:

gh run list --workflow workflow_history.yaml

This returns the following results:

STATUS  NAME                          WORKFLOW                 BRANCH  EVENT              ID          ELAPSED  AGE
X       Update workflow_history.yaml  Github Workflow History  main    workflow_dispatch  2481361465  13s      1d
✓       Update workflow_history.yaml  Github Workflow History  main    workflow_dispatch  2481359101  12s      1d

Get only the last run

Per default, this lists the job status sorted by date, newest first.

Luckily, we can get only the last one with the --limit option:

gh run list --workflow workflow_history.yaml --limit 1

which returns only the latest job status.

X       Update workflow_history.yaml  Github Workflow History  main    workflow_dispatch  2481361465  13s      1d

Get only the status of the last run

This is great, but we only need the status of the last run, not all the other information.

This is where awk comes in. This linux command line tool cuts a string tab separated string (if no custom separator is specified) and can then be used to retrieve a specific part.

  • awk '{print $1}' would give us the first part of the string from the beginning until the first tab
  • awk '{print $2}' would give us the part after the second tab, etc.

Since the gh run list command has the job completion status as the first part of the string (e.g. completed, in_progress, etc.), we are interested in the second which returns either failure or success:

gh run list --workflow workflow_history.yaml --limit 1 | awk '{print $2}

In our case, this returns failure since the latest job invocation returned an error.

Perfect! Or is it?

A small issue

Unfortunately, there is a problem - the status is only there on jobs that are not in_progress like here:

in_progress		Update workflow_history.yaml	Github Workflow History	main	workflow_dispatch	2489395313	15s	0m
completed	success	Update workflow_history.yaml	Github Workflow History	main	workflow_dispatch	2489383728	22s	2m
completed	failure	Update workflow_history.yaml	Github Workflow History	main	workflow_dispatch	2489367709	32s	4m

In this case, we would get Update since there is no success or failure status for jobs that are still running. So there needs to be a change to our initial CLI command:

gh run list --workflow workflow_history.yaml | grep -oh "completed.*" | head -1 | awk '{print $2}'

This command works much better because it will only take the completed jobs into account:

  1. gh run list --workflow workflow_history.yaml returns the list of jobs:

in_progress Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489473415 11s 0m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489395313 23s 14m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489383728 22s 15m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489373971 29s 17m completed failure Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489367709 32s 18m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489358495 38s 20m completed failure Update workflow_history.yaml Github Workflow History main workflow_dispatch 2481361465 13s 1d completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2481359101 12s 1d

2. 	By piping this to `grep -oh "completed.*"`, we filters out all incomplete jobs, leaving

completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489395313 23s 14m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489383728 22s 15m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489373971 29s 17m completed failure Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489367709 32s 18m completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489358495 38s 20m completed failure Update workflow_history.yaml Github Workflow History main workflow_dispatch 2481361465 13s 1d completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2481359101 12s 1d

3. With `head -1` we only keep the top line (which is the newest run):

completed success Update workflow_history.yaml Github Workflow History main workflow_dispatch 2489395313 23s 14m

4. Finally, we can retrieve the status with `awk '{print $2}'` as before:

success


# Calling GitHub CLI from the runner

Since the GitHub runners that are in charge of executing workflows have the GitHub CLI built in, we can perform this operation also from our yaml file:

  • name: Check last job status id: lastJobStatus if: always() run: | LAST_JOB_STATUS=$(gh run list --workflow workflow_history.yaml | grep -oh "completed.*" | head -1 | awk '{print $2}') THIS_JOB_STATUS="$" env: GITHUB_TOKEN: $

There are some important parts to this:

- We specify the `id: lastJobStatus` since we want to have access to the status of the last job from subsequent steps. Without an id, we cannot access any variables this job sets later on in the process.
- `if: always()` means that this step will __always__ be executed, regardless of the status of the previous one. Without this, it would be skipped if the previous step fails.
- The line ` LAST_JOB_STATUS=$(gh run list --workflow workflow_history.yaml | grep -oh "completed.*" | head -1 | awk '{print $2}')` calls the shell command we tested above and saves its result in a new variable called `LAST_COMPLETED_JOB_STATUS`.
- The job status of the current run can be retrieved with GitHub's `$` variable. This we save in `THIS_JOB_STATUS` for readability.
- In the `env:` block, we need the line `GITHUB_TOKEN: $`. It stores the GitHub token of this repository in the environment variable `GITHUB_TOKEN` which is required for GitHub CLI to work properly. This token is automatically regenerated for every workflow run by GitHub.

Now we should have the current and the last job status.

# Detecting the changed job run status

Now we are missing some logic to check if the status was actually changed. We can modify the script from above to include this:

run: | LAST_JOB_STATUS=$(gh run list --workflow workflow_history.yaml | grep -oh "completed.*" | head -1 | awk '{print $2}') THIS_JOB_STATUS="$" if [ "$LAST_JOB_STATUS" != "$THIS_JOB_STATUS" ]; then echo "status changed from $LAST_JOB_STATUS to $THIS_JOB_STATUS" echo "::set-output name=changedState::true" else echo "status is still $THIS_JOB_STATUS" echo "::set-output name=changedState::false" fi


Here we added an if condition that compares the `$LAST_JOB_STATUS` to the `$THIS_JOB_STATUS` variable. If both are different, we know that the status changed. Otherwise, the status is still like the former run.

To debug this, we echo out the result of this check (either `status changed from $LAST_JOB_STATUS to $THIS_JOB_STATUS` or `status is still $THIS_JOB_STATUS`).

Additionally, we use GitHub's mechanism to set step output variables using `echo "::set-output name=changedState::true"` or `echo "::set-output name=changedState::false"`. This way, subsequent steps can check this and know if we have a changed status.

# Subsequent steps

Finally, let's add one step afterwards that is executed __only if we have a changed state__. This uses the output variable from above as a condition.

  • name: Showcase output variable if: always() && steps.lastJobStatus.outputs.changedState == 'true' run: echo "CHANGED STATE!!!"

Here, we use the `always() && steps.lastJobStatus.outputs.changedState == 'true'` to execute this step when our custom variable from above is set to `true` (`lastJobStatus` is the id of the step that set the output variable before).

# Result

If we have a changed state like this

![Changed state](Screenshot%202022-06-13%20at%2018.30.28.png "Changed state")

we see the correct result and that the step is properly executed:

![Successful step execution](Untitled%202.png "Successful step execution")

In case the former state is the same

![Unchanged state](Screenshot%202022-06-13%20at%2018.34.23.png "Unchanged state")

we see this:

![Disabled step](Untitled%203.png "Disabled step")

This is it!

# Complete workflow

This is the complete workflow script:


name: 'Github Workflow History'

'on': workflow_dispatch: inputs: failJob: description: Fail the job? type: boolean required: true default: false

jobs: exampleJob: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3

- name: Pass or fail job run: exit 1 if: github.event.inputs.failJob == 'true'

- name: Check last job status id: lastJobStatus if: always() run: | LAST_JOB_STATUS=$(gh run list --workflow workflow_history.yaml | grep -oh "completed.*" | head -1 | awk '{print $2}') THIS_JOB_STATUS="$" if [ "$LAST_JOB_STATUS" != "$THIS_JOB_STATUS" ]; then echo "status changed from $LAST_JOB_STATUS to $THIS_JOB_STATUS" echo "::set-output name=changedState::true" echo "::set-output name=stateMessage::Test status changed from '$LAST_COMPLETED_JOB_STATUS' to '$THIS_JOB_STATUS'." else echo "status is still $THIS_JOB_STATUS" echo "::set-output name=changedState::false" echo "::set-output name=stateMessage::$THIS_JOB_STATUS" fi env: GITHUB_TOKEN: $

- name: Showcase output variable if: always() && steps.lastJobStatus.outputs.changedState == 'true' run: echo "CHANGED STATE!!!"