Using Dhall To Manage GitHub Actions Workflows
In my previous post, I talked about how we use Github Actions to automate our workflows. As promised, I will show how we use Dhall to manage our GitHub Actions files.
Don’t Repeat Yourself
“Don’t Repeat Yourself” (DRY) is a programming principle that reduces repetition in code. As we created more actions in a growing number of repositories, we noticed that there were a lot of repeating steps, jobs, and even whole workflows. Github Actions has features to make workflows DRY such as composite actions and reusable workflows. However, these are useful only in certain situations:
Composite actions are useful for running a sequence of steps e.g. Docker setup, login, then fetch an image.
Reusable workflows are useful for running whole jobs e.g. building and testing a Ruby on Rails repository
Github Actions does not have an easy way to reuse steps or small groups of steps. Dhall gave us that flexibility.
Life before Dhall
Every time we create a new repository, workflow files are also added. The easiest thing to do is copy a workflow file from another repository and edit it. Since the workflow has to be specific to the repository, some steps have to be modified. Eventually, this leads to repetitiveness and consistency problems. The workflows look similar but not the same. We also encountered template format errors such as misaligned tabs and missing required keys.
As the number of repositories grows, so do the workflows. We needed a more reliable way of creating new workflows and maintaining old ones.
Enter the Dhall configuration language
Dhall is a programmable configuration language. It creates JSON and YAML files with the benefits of programming principles such as types, let expressions, imports, and functions. I won’t go into detail about all features of Dhall. The documentation provides an overview and some tutorials. In this post, I will just look at some of its features that help us manage our YAML files.
github-actions-dhall
We did not re-invent the wheel with Dhall and GitHub Actions. There’s already an open-source project for it in GitHub. We started with that and contributed along the way whenever we thought it was useful.
Let’s start with a simple Dhall file to write a workflow that builds Node.js. We want to produce the GitHub Actions workflow below:
name: Node.js CI
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: "actions/checkout@v2"
- name: Use Node.js
uses: "actions/setup-node@v2"
with:
node-version: '21'
- name: Install Dependencies
run: npm install
- name: Run Build
run: npm run build
The Dhall file to produce the YAML above:
let GitHubActions = https://raw.githubusercontent.com/regadas/github-actions-dhall/HEAD/package.dhall
let checkoutStep = GitHubActions.Step::{
, name = Some "Checkout"
, uses = Some "actions/checkout@v2"
}
let buildJob = GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
Step,
GitHubActions.Step::{
, name = Some "Use Node.js"
, uses = Some "actions/setup-node@v2"
, `with` = Some (toMap {
, node-version = "21"
})
},
GitHubActions.Step::{
, name = Some "Install Dependencies"
, run = Some "npm install"
},
GitHubActions.Step::{
, name = Some "Run Build"
, run = Some "npm run build"
}
]
}
let workflow = GitHubActions.Workflow::{
, name = "Node.js CI"
, on = GitHubActions.On::{
, push = Some GitHubActions.Push::{
, branches = Some ["main"]
}
, pull_request = Some GitHubActions.PullRequest::{
, branches = Some ["main"]
}
}
, jobs = toMap { buildJob }
}
in workflow
In this file, we define several blocks using the let
expression. First, we import github-actions-dhall
.
github-actions-dhall
has defined types for a step, job and workflow. We use Dhall’s
record completion feature
so we only fill in values that are not default. You can check the repository for the
default values.
To produce a YAML file from Dhall, run the dhall-to-yaml
executable:
dhall-to-yaml --file workflow1.dhall > workflow1.yaml
You’ll notice that Dhall produces a slightly different YAML file:
jobs:
buildJob:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: "actions/checkout@v2"
- name: Use Node.js
uses: "actions/setup-node@v2"
with:
node-version: '21'
- name: Install Dependencies
run: npm install
- name: Run Build
run: npm run build
name: Node.js CI
on:
pull_request:
branches:
- main
push:
branches:
- main
Dhall orders the keys alphabetically. This takes some getting used to but this will work just the same. YAML doesn’t care about the order of keys.
The Dhall file is still longer than the YAML file, right? Let’s make the workflow a bit more complex by
creating two jobs: one that builds node 21, and one that builds node 20. From the workflow file, we notice
that only one step needs to change, actions/setup-node@v2
. We need to change this to use node-version 20.
In a YAML file, this means we copy the whole job then replace the value for that step only. In Dhall, I can
refactor the common steps and reference the corresponding let
expressions in the jobs that need it:
let GitHubActions = https://raw.githubusercontent.com/regadas/github-actions-dhall/HEAD/package.dhall
let checkoutStep = GitHubActions.Step::{
, name = Some "Checkout"
, uses = Some "actions/checkout@v2"
}
let npmInstallStep =
GitHubActions.Step::{
, name = Some "Install Dependencies"
, run = Some "npm install"
}
let npmRunBuildStep =
GitHubActions.Step::{
, name = Some "Run Build"
, run = Some "npm run build"
}
let buildNode21Job = GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
checkoutStep,
GitHubActions.Step::{
, name = Some "Use Node.js"
, uses = Some "actions/setup-node@v2"
, `with` = Some (toMap {
, node-version = "21"
})
},
npmInstallStep,
npmRunBuildStep
]
}
let buildNode20Job = GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
checkoutStep,
GitHubActions.Step::{
, name = Some "Use Node.js"
, uses = Some "actions/setup-node@v2"
, `with` = Some (toMap {
, node-version = "20"
})
},
npmInstallStep,
npmRunBuildStep
]
}
let workflow = GitHubActions.Workflow::{
, name = "Node.js CI"
, on = GitHubActions.On::{
, push = Some GitHubActions.Push::{
, branches = Some ["main"]
}
, pull_request = Some GitHubActions.PullRequest::{
, branches = Some ["main"]
}
}
, jobs = toMap { buildNode21Job, buildNode20Job }
}
in workflow
The Dhall file is getting bigger. We can utilise the import feature to break this file into multiple smaller files.
Create a steps.dhall
file and let’s put all our step definitions there:
let GitHubActions = https://raw.githubusercontent.com/regadas/github-actions-dhall/HEAD/package.dhall
let checkoutStep = GitHubActions.Step::{
, name = Some "Checkout"
, uses = Some "actions/checkout@v2"
}
let npmInstallStep =
GitHubActions.Step::{
, name = Some "Install Dependencies"
, run = Some "npm install"
}
let npmRunBuildStep =
GitHubActions.Step::{
, name = Some "Run Build"
, run = Some "npm run build"
}
in { checkoutStep, npmInstallStep, npmRunBuildStep }
The variables inside in { ... }
are exported. We will then import the steps.dhall
file in our original Dhall file:
let GitHubActions = https://raw.githubusercontent.com/regadas/github-actions-dhall/HEAD/package.dhall
let Steps = ./steps.dhall
let buildNode21Job = GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
Steps.checkoutStep,
GitHubActions.Step::{
, name = Some "Use Node.js"
, uses = Some "actions/setup-node@v2"
, `with` = Some (toMap {
, node-version = "21"
})
},
Steps.npmInstallStep,
Steps.npmRunBuildStep
]
}
let buildNode20Job = GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
Steps.checkoutStep,
GitHubActions.Step::{
, name = Some "Use Node.js"
, uses = Some "actions/setup-node@v2"
, `with` = Some (toMap {
, node-version = "20"
})
},
Steps.npmInstallStep,
Steps.npmRunBuildStep
]
}
let workflow = GitHubActions.Workflow::{
, name = "Node.js CI"
, on = GitHubActions.On::{
, push = Some GitHubActions.Push::{
, branches = Some ["main"]
}
, pull_request = Some GitHubActions.PullRequest::{
, branches = Some ["main"]
}
}
, jobs = toMap { buildNode21Job, buildNode20Job }
}
in workflow
We can do the same exercise for the job expressions to make it even shorter. For now, let’s keep it this way.
Now how about the Use node.js
step? They look similar and only differ in the node version. In Dhall, we
can write functions to handle this. We create a function useNodeVersionStep
and pass a nodeVersion
argument
of type Text
and it outputs a GitHubActions.Step.Type
. Then we call this function where we want to add a
step, then pass the node version that we’d like to use:
let GitHubActions = https://raw.githubusercontent.com/regadas/github-actions-dhall/HEAD/package.dhall
let Steps = ./steps.dhall
let useNodeVersionStep
: Text → GitHubActions.Step.Type
= λ(nodeVersion : Text) →
GitHubActions.Step::{
, name = Some "Use Node.js"
, uses = Some "actions/setup-node@v2"
, `with` = Some (toMap {
, node-version = nodeVersion
})
}
let buildNode21Job =
GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
Steps.checkoutStep,
(useNodeVersionStep "21"),
Steps.npmInstallStep,
Steps.npmRunBuildStep
]
}
let buildNode20Job = GitHubActions.Job::{
, runs-on = GitHubActions.RunsOn.Type.`ubuntu-latest`
, steps = [
Steps.checkoutStep,
(useNodeVersionStep "20"),
Steps.npmInstallStep,
Steps.npmRunBuildStep
]
}
let workflow = GitHubActions.Workflow::{
, name = "Node.js CI"
, on = GitHubActions.On::{
, push = Some GitHubActions.Push::{
, branches = Some ["main"]
}
, pull_request = Some GitHubActions.PullRequest::{
, branches = Some ["main"]
}
}
, jobs = toMap { buildNode21Job, buildNode20Job }
}
in workflow
The last three Dhall files that we made produce the same workflow YAML files:
jobs:
buildNode20Job:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: "actions/checkout@v2"
- name: Use Node.js
uses: "actions/setup-node@v2"
with:
node-version: '20'
- name: Install Dependencies
run: npm install
- name: Run Build
run: npm run build
buildNode21Job:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: "actions/checkout@v2"
- name: Use Node.js
uses: "actions/setup-node@v2"
with:
node-version: '21'
- name: Install Dependencies
run: npm install
- name: Run Build
run: npm run build
name: Node.js CI
on:
pull_request:
branches:
- main
push:
branches:
- main
How we use Dhall
Now we have a way to refactor common steps, jobs, and also create functions. We decided to put all our workflows in one repository and refactored them to make it more reusable. We have a folder structure like below:
dhall
- contains all Dhall filesdeps
- contains dhall dependencies such as github-actions-dhalllibs
- contains common Dhall definitions like functions and typessteps
- contains reusable steps or groups of stepsjobs
- contains reusable jobscomposite
- composite actions Dhall files with one directory per composite actionworkflows
- workflow Dhall files with one directory for each repository managed
composite
- the composite action YAML files with one directory for each composite actionworkflows
- the workflow YAML files with one directory for each repository managed
Generating the YAML files from the Dhall files is straightforward. We opted to use a simple Bash script
instead of sophisticated build tools such as make
since we don’t need to track dependencies.
#!/usr/bin/env bash
set -eou pipefail
echo -n "Cleanup files before generating..."
rm -rf composite/
rm -rf workflows/
echo "Done!"
echo -n "Generating composite actions..."
for COMPOSITE in $(ls dhall/composite)
do
COMPOSITE_NAME=${COMPOSITE%.dhall}
mkdir -p composite/$COMPOSITE_NAME
dhall-to-yaml --file dhall/composite/$COMPOSITE >> composite/$COMPOSITE_NAME/action.yml
done
echo "Done!"
for REPOSITORY in $(ls dhall/workflows)
do
REPOSITORY_NAME=${REPOSITORY%.dhall}
echo -n "Generating workflows for $REPOSITORY_NAME..."
mkdir -p workflows/$REPOSITORY_NAME
for WORKFLOW in $(ls -p dhall/workflows/$REPOSITORY/ | grep -v /$)
do
dhall-to-yaml --file dhall/workflows/$REPOSITORY_NAME/$WORKFLOW >> workflows/$REPOSITORY_NAME/$WORKFLOW_NAME.yml
done
echo "Done!"
done
This will generate all our workflow files from the Dhall files. We then propagate these workflow files to their respective repositories using what else? GitHub Actions of course! That merits another post in the future.
How Dhall helped us
Dhall has provided us with flexibility and consistency in managing our GitHub Actions workflows.
- Creating and updating workflows became less trivial because of importing and functions. Reusability of code means we are maintaining fewer lines of code.
- It has helped us make fewer mistakes because of its type-checking. Something we’ve done by accident before in YAML
is to delete a line and end up with completely broken YAML. In the example below, if you only delete the line with
c:
you accidentally putd:
intoa:
. This is an easy mistake to make whena:
andc:
are both multiple pages long.
a:
b: "b"
c:
d: "d"
- No more YAML formatting errors since the files are autogenerated. We commit YAML files alongside Dhall files, and mark
them as generated with
.gitattributes
. This lets us fearlessly refactor because it will show no diffs in the YAML files.
If your GitHub Actions files are getting unwieldy, I suggest you start looking into Dhall to make your life easier!