Running Dialyzer for Elixir Projects in GitHub Actions
By: Trevor Brown
Tags: Dialyzer, Type Checking, Github Actions, Elixir

In this blog post I’ll show you how to set up GitHub Actions to perform type analysis on an Elixir project with Dialyzer. I’ll also share optimal settings for Dialyzer PLT caching.

Running automated tests for every commit or pull request has become common practice and is a good way to validate the correctness of your Erlang or Elixir software. Type checking with Dialyzer is another way to ensure the quality of your code changes.

I have previously written about how to run Dialyzer in a Jenkins build and here I’ll share how to do the same with a GitHub workflow.

Adding Dialyzer

You need to have dialyxir installed as a dev dependency for your project for it to be available in the GitHub workflow. If you don't already have dialyxir installed, add the following to your project's mix.exs file:

defp deps do

[

...

{:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}

]

end

Then run mix deps.get .

Creating a GitHub Workflow

On many of our Elixir projects we have a dedicated Dialyzer workflow that is triggered when pull requests are opened, updated, reopened, or when a new commit is pushed to the main branch. Below is a GitHub workflow for running Dialyzer:

name: Dialyzer

on:

pull_request:

types:

- opened

- synchronize

- reopened

push:

branches:

- main

jobs:

dialyzer:

name: Run Dialyzer for type checking

runs-on: ubuntu-latest

steps:

# Checkout the Elixir project's source code

- uses: actions/checkout@v2

# Use the erlef/setup-elixir action (https://github.com/erlef/setup-beam)

# to setup Erlang and Elixir

- name: Run Dialyzer

uses: erlef/setup-elixir@v1

with:

otp-version: '23.2.3'

elixir-version: '1.11.3'

# Install the dependencies and run dialyzer

- run: mix deps.get

- run: mix dialyzer

Caching PLT Files

The above will work fine but each run will take a substantial amount of time. On my projects it typically takes 10–12 minutes. Most of this time is spent building up the PLT file with type data from the applications that comprise Erlang and Elixir as well as your project’s dependencies. Caching the PLT files (the PLT file itself and the .hash file for it) between runs will dramatically speed up the workflow.

Caching files is possible with the actions/cache action. While it’s not the simplest build artifact caching system I’ve seen, it’s relatively easy to set up once you understand how it works. It helps to first understand how cache keys and scopes work.

Cache Keys

Every time files are saved in a cache they are saved under a unique cache key. If a cached file changes, so should the key for it. An easy way to generate a unique cache key for PLT files is to take a hash of the mix.lock file and use it as part of the key. Then when the dependencies change the key also changes. (It may also be helpful to include the Elixir and Erlang versions in the cache key so when they change the key will change as well).

Cache Scopes

The cache is scoped to the key and branch. The default branch cache is available to other branches. — https://github.com/actions/cache#cache-scopes

Workflows will not be able to access caches from any other branch except the default branch (typically main or master ). Scoping is important because an existing cache for one branch may not be useful to another branch.

Restore Keys

Restore keys are used to locate the cache to use when there is a cache miss on the exact key defined in the workflow. Restore keys are a list of strings that are either exact or partial key matches.

The cache action first searches for cache hits for key and restore-keys in the branch containing the workflow run. If there are no hits in the current branch, the cache action searches for key and restore-keys in the parent branch and upstream branches. — https://docs.github.com/en/actions/guides/caching-dependencies-to-speed-up-workflows#matching-a-cache-key

Typically restore-keys is a list of patterns that are progressively more broad.

Since PLT files contain analysis for Erlang and Elixir libraries, as well as your project’s dependencies, changes to either results in Dialyzer adding or removing data from the PLT file. Thus, when storing PLT files, we want the key to indicate what dependencies they contain. The easiest way to do this is to generate a hash of the mix.lock file and use it as part of the cache key. That way on later runs we can restore to a PLT file that contains the exact same dependencies that the current commit of the code contains. If we don't find an exact match we can fall back to another PLT cache on the same branch. Here are the GitHub action steps for this:

# Step for generating the mix file Hash used in the cache key

- name: Set mix file hash

id: set_vars

run: |

# hashFiles is a GitHub Actions function for generating a hash

mix_hash="${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}"

echo "::set-output name=mix_hash::$mix_hash"

# Step to setup caching and cache restoration

- name: Cache PLT files

id: cache-plt

uses: actions/cache@v2

with:

# Cache PLT and PLT hash files after the job has finished

path: |

_build/dev/*.plt

_build/dev/*.plt.hash

# Key to use when restoring the cache and storing the results of this dialyzer run

key: plt-cache-${{ steps.set_vars.outputs.mix_hash }}

# Key patterns to fall back to if we don't find an exact match for `key`

restore-keys: |

plt-cache-

Here is how the resulting cache restore process works:

Look for a matching PLT cache key (remember it contains the mix.lock hash) on the current branch (e.g. a feature branch) If not found, fall back to any PLT cache on the current branch If not found, fall back to a matching PLT cache key on the main branch If not found, fall back to any PLT cache on the main branch If nothing is found, run dialyzer without an existing PLT file

Saving to the cache is a much simpler process — after every run the PLT files are saved to a cache identified by the key value.

Putting It All Together

The completed Dialyzer workflow with caching looks like this:

name: Dialyzer

on:

pull_request:

types:

- opened

- synchronize

- reopened

push:

branches:

- main

jobs:

dialyzer:

name: Run Dialyzer for type checking

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v2

- name: Set mix file hash

id: set_vars

run: |

mix_hash="${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}"

echo "::set-output name=mix_hash::$mix_hash"

- name: Cache PLT files

id: cache-plt

uses: actions/cache@v2

with:

path: |

_build/dev/*.plt

_build/dev/*.plt.hash

key: plt-cache-${{ steps.set_vars.outputs.mix_hash }}

restore-keys: |

plt-cache-

- name: Run Dialyzer

uses: erlef/setup-elixir@v1

with:

otp-version: '23.2.3'

elixir-version: '1.11.3'

- run: mix deps.get

- run: mix dialyzer

Conclusion

I have shown how to create a simple and effective caching strategy designed to reduce time-consuming re-builds of PLT files. With this approach Dialyzer only takes about 2 minutes to run when there is a cache, making it viable to run on every commit. GitHub Actions provides sufficient tooling for running Dialyzer in a workflow and for effective caching and restoration of PLT files between workflow runs. Understanding how the cache action works can be challenging at first, but once understood it can be used to create almost any caching mechanism you need.

References