First some Core Principles
For those of us in the DevOps field, the DRY (Don’t Repeat Yourself) principle lives close to heart. It endorses:
- Automation: If you have to do it more than once, automate it.
- Code Reusability: Your code should be structured in a way that it can be reused as much as possible.
- Minimisation of Duplication: The more duplication we have, the higher the cost of maintenance as well as higher chance of inconsistencies across configurations.
This resonates with DevOps environments, where code is often shared between teams and reused across projects.
What are Reusable Workflows?
Reusable Workflows allow us to build template repositories (for example a Docker Image Build & Publish template) that we can use across our projects.
Note: Reusable Workflows do not need to be in a separate repository, since they can live in the project repository. With this said, I do not recommend project based templates since they end up being difficult to manage due to the tight coupling with the project. In my experience, scenarios like these lead to issues with maintainability more often than not.
Before we cover how we create this “template repositories”, lets take a look at some of the limitations that we have:
- When using Nested Reusable Workflows we are currently limited to connecting up to four levels of workflows.
- We are limited to a maximum of 20 unique reusable workflows being called from a single workflow file. This includes all nested reusable workflows called starting from your top level caller workflow file.
- ENV Variables cannot be used across Reusable Workflows, we have to use
$GITHUB_OUTPUT
. In term since workflows are called directly within a job, and not a job step, we cannot use$GITHUB_ENV
to pass values to job steps in the caller workflow. - In order to reuse variables in multiple workflows, we need to set them as environment, repository, or organization levels and reference them using the vars context.
How do we create them?
Reusable Workflows are similar to any other GitHub workflow file. They are YAML-formatted files, who live under the .github/workflows
directory of a repository.
The key point for a workflow to be reusable is the trigger point. In GitHub workflows this is the on:
value, we must include the workflow_call
trigger like so:
on:
workflow_call:
For Example
Now lets take a look at how all of it connects:
Caller repository workflow:
In our caller repository we first need to create the workflow that will call the template.
# main.yml
name: Call Docker build & publish
on:
push:
tags: ["v*.*.*"] # this will trigger the workflow on a new version tag
jobs:
build-and-publish:
uses: <username or organization>/<repository>/.github/workflows/main.yml@main # this calls the reusable workflow
with:
app-workdir: "./app"
secrets: inherit # this is so that the called reusable workflow can use the caller workflow secrets.
Template repository workflows:
This file is the entry point in our template repository.
# main.yml
name: Template - Main
on:
workflow_call:
inputs:
app-workdir:
description: 'The location of the Dockerfile'
required: true
type: string
jobs:
build-and-publish:
uses: <username or organization>/<repository>/.github/workflows/build-and-publish.yml@main
with:
app-workdir: ${{ inputs.app-workdir }}
secrets: inherit
This file is the main worker file for our example.
# build-and-publish.yml
name: Template - Build and Publish Docker Image
on:
workflow_call:
inputs:
app-workdir:
description: 'The location of the Dockerfile'
required: true
type: string
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Repo Checkout
uses: actions/checkout@v4
- name: Get tag # this will get the tag that triggered the caller workflow
id: tag
run: echo "tag=$(echo ${GITHUB_REF#refs/*/} | sed 's/^v//')" >> $GITHUB_ENV
- name: Set Repository Name # this will get the caller repository name
id: repo
run: echo "repo=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]*//g')" >> $GITHUB_ENV
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & Push Docker Image
working-directory: ${{ inputs.app-workdir }}
run: |
docker build . --tag ghcr.io/<username or organization>/${{ env.repo }}:${{ env.tag }}
docker push ghcr.io/<username or organization>/${{ env.repo }}:${{ env.tag }}
Before we can run the Caller workflow, we need to make sure that our template repository workflows are accessible by other repositories that we own (or that the organization owns). This is done by:
- Navigating to the template repository settings.
- In the hamburger menu on the left, click on Actions, and select General.
- Scroll down till you find Access. Here we need to check the box “Accessible from repositories owned by the user
<your user>
” or if you are doing this on a organization “Accessible from repositories in the<your organization name>
organization”
With this we have set up a template that can be used across our Organization and Projects. This will allow us to easily maintain this process from a central place (for example updating the action versions in the template will update them across all occurrences where the template is used).
By understanding and effectively leveraging these workflows, we can uphold the principles of DRY and DevOps, leading to more efficient, consistent, and high-quality software delivery.