Elixir
Build the Ultimate Elixir CI with Github Actions
Learn how to configure the ultimate Continuous Integration (CI) for Elixir using Github Actions. Optimized for speed and consistency using granular caching, formatting, linting and type-checking.
Setting up a Continuous Integration (CI) is a part of every mature project (or at least it should be!). CI helps ensure that the code you push doesn't lead you to quip, “works on my machine", because it will work on all anticipated target machines.
Importantly, CI can catch errors before they reach production. It also prevents consistency issues with regards to code formatting and linting, helping the team maintain a singlular approach to writing Elixir code. Lastly, we can use it to run checks that some people either forget to run or avoid running locally when they are time consuming or not part of a developer's tools / habits.
When Github introduced it's Github Actions I was happy to see Elixir included as one of the default templates. Now it is a lot easier to set up a CI solution with consolidated billing from your pre-existing Github account. This is a game changer for consultants. It is now much easier to convince clients to allow us to set up a CI, because we do not need to ask them to set up an account for us. Unfortunately, the default Elixir template does not do much other than run your tests.
In this post I will show you a configuration that will allow you to:
- Cache dependencies, which will significantly cut the time needed to run the workflow
- Lint your code using Credo
- Check the code's formatting using Mix Format
- Use and optimize Dialyzer/Dialyxir to check the type safety of your code and avoid potential run-time errors
- Run ExUnit tests
You can find the full configuration in this Github Gist.
Creating a Basic Elixir Workflow
Using Github's UI, we will start with the default Elixir CI provided by Github.
Start by navigating to your repository -> click the Actions
tab -> click New Workflow
. Github will suggest an Elixir Workflow, click Set up this workflow
The default workflow will look something like this:
name: Elixir CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup elixir
uses: actions/setup-elixir@v1
with:
elixir-version: 1.9.4 # Define the elixir version [required]
otp-version: 22.2 # Define the OTP version [required]
- name: Install Dependencies
run: mix deps.get
- name: Run Tests
run: mix test
Defining Environment Variables
Github Actions supports setting hard-coded environment variables that will be used throughout every step run by the workflow.
For our simple Elixir project we just want to set MIX_ENV
to test
. Let's add the env
section to the top level below the on
section, and specify our MIX_ENV
:
# ...
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
MIX_ENV: test
# ...
By setting the MIX_ENV
globally for the workflow you can avoid some repetitive builds, since Elixir caches build artifacts and dependencies by environment.
You can also store secrets in Github and those will be encrypted at rest, and will be decrypted only during the execution of the workflow. For example:
env:
MIX_ENV: test
SUPER_SECRET: ${{ secrets.SuperSecret }}
For this to work, you will need to define the value of the secrets in project's settings page. For the purpose of this tutorial we will not use any secrets, but it is good to be aware that those exist. You can read more about creating and storing encrypted secrets here.
Using a Build Matrix
Build Matrices allow us to test across multiple operating systems, platforms and language versions in the same workflow. This is particularly important for libraries such as Hex Packages.
The other benefit of a Build Matrix is granular caching. Since caches use a key for a lookup, we can construct the key from the Build Matrix variables which are hydrated at each run.
For our purposes we will only specify the versions of Elixir and Erlang we need, and replace the versions used in the Setup Elixir
step with those from the Build Matrix:
#...
strategy:
matrix:
elixir: [1.10.2]
otp: [22.2.8]
steps:
- uses: actions/checkout@v2
- name: Setup elixir
uses: actions/setup-elixir@v1
with:
elixir-version: ${{ matrix.elixir }} # Define the elixir version [required]
otp-version: ${{ matrix.otp }} # Define the OTP version [required]
# ...
Setting Up Mix Dependency Cache
It is crucial to make the CI run time as short as possible. Every second a developer is waiting on CI to finish running contributes to the high cost of context switching.
To speed things up we can cache the Hex dependencies for our project. Let's add a new step after the Setup elixir
step:
# ...
- name: Retrieve Mix Dependencies Cache
uses: actions/cache@v1
id: mix-cache # id to use in retrieve action
with:
path: deps
key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
# ...
We will again be using a built-in action provided by Github called cache
. We will pass it an ID that we will use later when retrieving the cache as well as a key comprised of the OS, OTP and Elixir matrix combination, as well as a hash of our mix.lock
file. This ensures a cache miss when the dependencies have changed in our mix.lock
file and segregates the cache based on the Build Matrix to prevent version conflicts.
Next up we will install dependencies if the cache was not hit:
# ...
- name: Install Mix Dependencies
if: steps.mix-cache.outputs.cache-hit != 'true'
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get
# ...
The important bit here is the if: steps.mix-cache.outputs.cache-hit != 'true'
which will prevent running the commands if there is a matching cache entry.
Check Formatting
Elixir has a formatter built into the language. To use it in CI simply add another step after Install Mix Dependencies
:
#...
- name: Check Formatting
run: mix format --check-formatted
#...
This command will return an error status code if at least one of the files in the project was not properly formatted and halt the rest of the checks.
It is imperative that you sort the steps by their run time. This will allow the CI to fail fast which shortens the iteration cycles.
Lint with Credo
Credo is my preferred linting tool for Elixir, but because it often takes longer to compile, I chose to place it after the formatting step.
After the Check Formatting
step add the following:
# ...
- name: Run Credo
run: mix credo --strict
# ...
I chose to add the --strict
since it will also show refactoring opportunities.
Running Dialyzer Checks With PLT Cache
Dialyzer is an invaluable tool that catches potential run-time errors in development, where they are cheap to correct, before they are pushed to production. However, a common deterrent for using Dialyzer is the amount of time it takes to run the check. We can resolve that by caching the PLT.
Dialyzer stores a Persistent Lookup Table (PLT) of your application's dependencies, this takes a long time to build, but makes up for it in time saved on incremental checks. The good news is there is no need to re-run the PLT build unless your dependencies have changed (which shouldn't happen too often).
Here's how we can cache the PLT and only rebuild it if our dependencies have changed. In your project's mix.exs
, you will need to tell Dialyzer where the PLT is located:
# ...
def project do
[
# ...
dialyzer: dialyzer(),
# ...
]
end
defp dialyzer do
[
plt_core_path: "priv/plts",
plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
]
end
# ...
Then in the Github workflow, below the Run Credo
step add the following:
# ...
- name: Retrieve PLT Cache
uses: actions/cache@v1
id: plt-cache
with:
path: priv/plts
key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
- name: Create PLTs
if: steps.plt-cache.outputs.cache-hit != 'true'
run: |
mkdir -p priv/plts
mix dialyzer --plt
- name: Run dialyzer
run: mix dialyzer --no-check --halt-exit-status
As in the dependencies cache, we utilize the Build Matrix and the hash of the mix.lock
file. If the dependencies have changed, we will run mix dialyzer --plt
which will only generate the PLT. Then in the Run Dialyzer
we run mix dialyzer
with the --no-check
flag which skips the check to see if the PLT needs to be updated.
Using this technique I was able to cut build times for a small-ish project from ~765 seconds (or 12m 45s) to ~105 seconds (or 1m 45s). That’s ~7x faster!
Conclusion
In this post we walked through how use Github Actions to build an optimized Elixir CI. We started with the default template provided by Github and were able to cut build times and add additional checks to ensure coding style and formatting guidelines are consistent across our team, which reduces bike-shedding.
There's more we can do to optimize the CI, but hopefully this example makes for a better starting template than the one supplied by Github Actions.
At Hashrocket, we love Elixir, Phoenix and LiveView and have years of experience delivering robust, well-tested Elixir applications to production. Reach out if you need help with your Elixir project!
Photo by Jason Yuen on Unsplash