Heading image for post: Build the Ultimate Elixir CI with Github Actions

Elixir

Build the Ultimate Elixir CI with Github Actions

Profile picture of Dorian Karter

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

demo

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