The very first continuous integration (CI) workflow I had to configure was using Jenkins back in 2018. It was an eye-opening interaction in my early career as a DevOps Engineer coming off from a more traditional Ops role. Looking back, getting used to the good ol’ Jenkinsfile was no easy task. There were infinite configurations directives and plugins you could use for whatever task you wanted to integrate into your pipeline. All the hacking, tweaking, and the spam of commits to make things finally work was an experience I will never forget.
But, with time, better tools appeared on my radar, and that’s where GitLab CI jumped in and revolutionized the way I configured CI workflows forever. It was simpler, more elegant, and easier to use. No longer needing to write a bunch of Groovy code felt like a breath of fresh air. I made a fan of myself with directives such as extends, services, environment, cache, anchors, among others. I also fell in love with GitLab CI runners and the resiliency of their self-hosting deployment option, and let’s not leave behind the state-of-the-art and my favorite feature, ReviewApps. It was so much fun!
In the present, I keep exploring CI tools and services and most of them include all the features I initially discovered with GitLabCI (which is great!). CircleCI and GitHub Actions are the top two I use in my day-to-day and the ones I prefer to work with.
In this post, I am going to write about my experience with GitHub Actions and its reusability capabilities. By no means I am an expert on the topic, this is only a way for me to share the best practices I have learned and how they have helped me optimize my workflow’s code.
Workflow Syntax
I am a weird learner, reading the official documentation (for me) is the fastest way to get started with a new tool or technology. and also the best approach to properly learning it. By “properly” I mean understanding the majority of its capabilities and how far can you get with it. Tutorials help in some ways, but they are limited in how much they show you and get outdated quickly.
So, I present to you, my GitHub Actions Bible. I don’t write a single line of code for GitHub Actions without a tab in my browser with this documentation page. A reference just for fun: this is my GitLab CI Bible. As you can probably infer, I believe the workflow syntax is the most important part when exploring a new CI/CD tool.
So let’s see what our bible says about reusability!
Reusability
The top relevant result by searching the word “reusable” in the documentation website is the “Reusing workflows” page. After a little bit more exploring and poking around, you may also find the “Creating a composite action” page. And… that’s it. There are no other immediate references to how you can apply reusability to your workflows. I know, it’s a bit disappointing, especially if you are coming from the mature GitLab CI or CircleCI workflow syntax. Don’t get me wrong, both callable workflows and composite actions are great features, but those being the only options leaves a huge stain in my technical nerdy head when talking or discussing constructively about tooling in general.
Composite Actions
Composite actions have become my default choice when thinking about reusability. To show case why, let’s talk about something simple: deployments to application environments.
In a TBD workflow, deployments to a stable environment ideally are triggered by a git tag pushed or release created. To choose the proper environment to deploy, you will need to know which git ref triggered the deployment. The Bible tells us that you can extract workflow information by accessing the contexts it exposes, and we can find the git ref under github.ref and github.ref_name context variables. We also know that our workflows support conditionals and triggers. Great!
Let’s imagine we want the following deployment trigger map:
- On push to branch main, deploy to stage
- On a tag with suffix
-uat
, deploy to uat - On a tag with suffix
-production
, deploy to production
The first step is to use on
:
on:
push:
branches:
- main
tags:
- *-uat
- *-production
Now, we know the workflow will be triggered in the events that we need. Next
step is to deploy to the environment according to the event that triggered the
workflow. We have github.ref_name
, so let’s use that:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
[...]
- name: Deploy to stage
if: contains(github.ref_name, 'main')
run: ./deploy --environment stage
- name: Deploy to uat
if: endsWith(github.ref_name, '-uat')
run: ./deploy --environment uat
- name: Deploy to production
if: endsWith(github.ref_name, '-production')
run: ./deploy --environment production
You may have noticed the usage of contains()
and endsWith()
.
They are called expressions.
As you can see repeating the same deployment command and just changing the
environment string is not fun. At this point, I may also add that you could’ve
started by creating individual files and splitting the on:
config so that
you end up with three files:
- deploy-stage.yml
- deploy-uat.yml
- deploy-production.yml
That way, you would’ve had more simplicity by not having to use conditionals, but you are still repeating yourself and doing the same thing over three different places (think about when you have to update the deploy command, you will need to update three different locations).
In Engineering, every technical decision is about tradeoffs, in this case, we are prioritizing reusability over simplicity, but that does not mean that if you prefer simplicity your approach is wrong. Choose the strategy that works best for you at the moment, and if the time comes, you can decide to keep it or change it - it’s up to you.
Now, let’s add a composite action to reuse the deployment task:
name: 'Deployment'
description: 'Deploy to target environment'
runs:
using: 'composite'
steps:
- name: Determine current environment using the current git ref
shell: bash -leo pipefail {0}
run: |
set_env() { echo "$1" >> $GITHUB_ENV; export ENVIRONMENT=$1 }
DEFAULT_BRANCH='main'
CURRENT_REF=${GITHUB_REF_NAME}
echo "Identified default branch : ${DEFAULT_BRANCH}"
echo "Current git reference : ${CURRENT_REF}"
if [[ "$CURRENT_REF" =~ .*-production$ ]]; then
set_env "ENVIRONMENT=production"
elif [[ "$CURRENT_REF" =~ .*-uat$ ]]; then
set_env "ENVIRONMENT=uat"
elif [[ "$CURRENT_REF" =~ "$DEFAULT_BRANCH" ]]; then
set_env "ENVIRONMENT=stage"
else
echo "Error: Could not determine target environment."
exit 1
fi
- name: Deploy to target environment
run: ./deploy --environment ${{ env.ENVIRONMENT }}
Now, this looks more verbose! In this composite action we introduced a shell script that determines which environment will be the target, depending on the current git ref! Let’s break it down:
set_env()
is a simple shell function that will append the first argument ($1
) to the environment variable GITHUB_ENV. TheGITHUB_ENV
variable is a path to a file that the CI runner uses, so we are borrowing it so we can maintain the state of the env var we want to add between steps.DEFAULT_BRANCH
is a constant with our trunk branch/ref as value.CURRENT_REF
is also a constant with the current ref name as value.- The if conditional block compares the
CURRENT_REF
with all the regular expressions we want to evaluate (*-production
,*-uat
andmain
), and then pushes the stringENVIRONMENT=<env>
toGITHUB_ENV
to save it. Now, the env varENVIRONMENT
will contain the target environment we want. - Finally, we run the deployment task now using the context env
(
${{ env. }}
) to access our recently saved variable ENVIRONMENT.
There is another way of setting the environment without the usage of the $GITHUB_ENV trick, which is to use outputs. I chose the simple shell script because it is tool-agnostic and you can use it outside of GitHub Actions simply changing
GITHUB_REF
to whatever variable contains the current git ref.
Now, from the main workflow file, we will have:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to target environment
uses: ./.github/actions/deploy
And that’s it! Now you can re-use the deployment task whenever you want without worrying about choosing the correct environment or calling the deployment commands.
Mindset
Of course, the example in this post is oversimplified, and workflows are commonly more complex than that. The goal is essentially showing you that even though we don’t have the GitLab CI anchors or the CircleCI commands, we can still look for ways to implement reusability and avoid repeating code here and there. I din’t cover reusable workflows, since I rarely use them; but they are also an option you can explore to achieve the same (or even better) results.
In the future, hopefully, GitHub Actions will be improved and new interesting features will be included.
For now, that is my approach to reusability!