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.
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!
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 showcase 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:
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. The GITHUB_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 and main), and then pushes the string `ENVIRONMENT=<env>` to GITHUB_ENV to save it. Now, the env var ENVIRONMENT 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 and I'm still having fun!
In the future, hopefully, GitHub Actions will be improved and new interesting features will be included.
For now, that is my approach and I'm still having fun!