GitHub Actions, Automation, and Unit Testing

Introduction

As a DevOps engineer, I have been enjoying this field ever since I built the vAuto Mobile CICD solution on the Jenkins CICD platform. Now, I not only use but also ❤️ GitHub Actions. It is fundamentally an automation platform, and CICD is just a small subset of its capabilities, which is why it excels at CICD!

Automation Required

Planning for automation needs to be second nature for a good DevOps engineer; it should be a way of life 🙂. Indeed, all major cloud providers emphasize the need for automation. Let’s quickly list their key points (in no particular order):

  • Manual recovery is more expensive and time-consuming
  • Manual, human-driven processes have a high error rate in comparison
  • Automation is an enabler for operational excellence
  • Machines are designed for repeatability
  • Automation leads to faster deployments
  • It improves reliability
  • It results in cost savings
  • It is a critical component of cloud environments (enabler)
  • It serves as an entry point for automated recovery

Observations

There are a few simple observations and techniques that can be applied to GitHub Actions and, by extension, GitHub workflows to leverage the platform’s automation abilities. The most important observation is that GitHub Actions supports an interface (the workflow, see workflow_dispatch) and an implementation (the scripts, code snippets, and other actions that form a job’s steps). However, the GitHub Actions platform does not enforce this division. You are free to add as many lines of code as you want into a single script step within a workflow or action, and GitHub Actions will faithfully execute the step, readability notwithstanding.

This approach mixes implementation details with the interface and places harsh constraints on testing your Actions - you have to run your action to test it. The outcome we actually want is to test the code to confirm the action will work.

Example

To take advantage of the interface/implementation boundary, we turn to the world of functional programming to leverage functions. Let’s walk through an example action that will utilize this approach so that the benefits are apparent.

Setup and Requirements

For this example, a custom composite action will be created with one example step. The action is hosted in its own GitHub repository for clarity; you can do this in a shared actions repository, but it requires more logical work. The action’s steps are bash script steps. Using languages such as Python works just as well.

Action Root Directory Structure

traditional approach

All but the ‘bash_functions.sh’ are standard files for a GitHub Action hosted in its own repository. Now we can write our own functions in the bash file and consume them in the action’s steps.

Example code

traditional approach

Thanks to a handy environment variable provided by GitHub, we can source the bash functions file in the action step with this one line:

traditional approach

Example Usage

traditional approach

That’s it for setup. Going forward, you write your functionality in the bash functions file and consume the functions in the action’s steps. The action step(s) will have much better readability since you should just be calling functions with a little parameter prep work.

Conclusion

The pattern is simple to implement with a composite action and bash files. The action’s code is cleaner and easier to read. The core of the action’s functionality can now be unit tested automatically, and you have a unit test workflow to safeguard the action’s development going forward.

I really enjoy the simplification this pattern brings and the sharp, well-defined boundary it enables between implementation (bash functions) and the interface (the action). Unit testing becomes a breeze and fits right into the normal CICD cycle.

Thank you for reading, and good luck with your automation journey.