Dilating GitHub Actions using Dialyzer

Dialyzer is a powerful static analysis tool for Elixir. Here's how to to use it in GitHub Actions with minimal impact on build times.

by:
Andreas Kasprzok
Share

Elixir is a strong dynamic language : it checks types at run time, enabling some of its most powerful features like pattern matching and macros. The “strong” part tells us that type conversion needs to be explicit, unlike JavaScript, which aims to always do “something” with your code, and happily converts between types.

These properties make Elixir a flexible and productive language, but open the possibility for bugs such as passing the wrong type to a function during run time, crashing the process. The likelihood of such bugs increases as a project’s code base grows.

Many of these bugs can be caught by using static analysis - checking as many assumptions as possible before executing the code. Dialyzer is a great tool for this, using Elixir and Erlang’s built in type system to catch type errors and unreachable code. A welcome side effect of using dialyzer is that it encourages extensive use of the type system available in Elixir, increasing readability and maintainability.

A robust Elixir or Erlang codebase can add Dialyzer in its CI pipeline to increase confidence in code changes and prevent regressions. At Massdriver we host our code on GitHub and make extensive use of GitHub Actions for CI. In this post I’ll show you how to set up Dialyzer in a GitHub Action while avoiding some common mistakes..

Time is Money

The example GitHub Action on the dialyxir README is a good starting point:


# ...
    steps:
      - uses: actions/checkout@v2
      - name: Set up Elixir
        id: beam
        uses: erlef/setup-beam@v1
        with:
          elixir-version: "1.12.3" # Define the elixir version
          otp-version: "24.1" # Define the OTP version

      # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones
      # Cache key based on Elixir & Erlang version (also useful when running in matrix)
      - name: Restore PLT cache
        uses: actions/cache@v2
        id: plt_cache
        with:
          key: |
                        ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt
          restore-keys: |
                        ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt
          path: |
                        priv/plts

      # Create PLTs if no cache was found
      - name: Create PLTs
        if: steps.plt_cache.outputs.cache-hit != 'true'
        run: mix dialyzer --plt

      - name: Run dialyzer
        run: mix dialyzer --format github
 

This is a good start, but depending on the size of your code base and dependencies, the “Create PLTs” step which builds the Persistent Lookup Tables - the static analysis output - can take a long time. For the main Massdriver application, this step can take over 10 minutes for a complete rebuild! But if the next step, Run dialyzer, fails, the cache is not saved, so the next run will have to rebuild the PLT from scratch - again!

The Run dialyzer step failed, and the PLTs aren’t saved to the cache.

Fun with Caches

By default, the GitHub Cache action will only save the cache if all steps in the job succeed. But since actions/cache@v3 we can separate the all-in-one action into actions/cache/restore@v3 to restore the PLTs, build them if there was no cache hit, and then finally use actions/cache/save@v3 to save the PLTs even if the Run dialyzer step fails. This way, if a commit that fails mix dialyzer is pushed (which happens to me all the time), the subsequent fix will complete CI much faster.


...
      - name: Restore PLT cache
-       uses: actions/cache@v2
+       uses: actions/cache/restore@v3
        id: plt_cache
        with:
          key: |
            ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt
          restore-keys: |
            ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt
          path: |
            priv/plts

      # Create PLTs if no cache was found
      - name: Create PLTs
        if: steps.plt_cache.outputs.cache-hit != 'true'
        run: mix dialyzer --plt

+     - name: Save PLT cache
+       uses: actions/cache/save@v3
+       if: steps.plt_cache.outputs.cache-hit != 'true'
+       id: plt_cache_save
+       with:
+         key: |
+           ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt
+         path: |
+           priv/plts

      - name: Run dialyzer
        run: mix dialyzer --format github
 

We haven’t yet fixed our Dialyzer bug, but we saved the PLTs to the cache even though Run dialyzer failed!

PLTs successfully cached!

Now let’s push a fix, and see how long it takes to run Dialyzer again:

Success! The PLTs were restored from the cache, and we shaved about 10 minutes off our CI time.

A fast CI shortens feedback loops and enables developers to move faster. Every minute saved on CI is a minute saved every time a dev pushes a commit. Build time optimization is often overlooked, but can have an outsized impact on developer productivity.

Sign up to our newsletter to stay up to date