Github Actions for Elixir CI

Image by DALL-E

This is about setting up Github Actions to run CI checks for your Elixir and Phoenix projects on Github. Fly.io is a great place to run those Elixir applications! Check out how to get started!

A critical ingredient for modern development teams is a regularly run set of code checks. If it's up to every developer to run code tests and checks locally before pushing code, you know it will be forgotten at some point. This leaves it as a problem for someone else to cleanup later. Uncool!

We want the benefits of modern Continuous Integration (CI) workflows for our Elixir projects. This lays out a good starting point that teams can customize to suit their needs.

What Is Continuous Integration (CI)?

Continuous integration is a software development practice where developers frequently merge code changes into a central repository. Automated builds and tests are run to assert the new code's correctness before integrating the changes into the main development branch.

The goal with CI is to find and correct bugs faster, improve software quality and enable software releases to happen faster.

CI is a critical ingredient for modern development teams.

Getting Started

To get started with Github Actions in your project, let's create a "test" workflow. To do this, create this path and file in the root of your project:

.github/workflows/elixir.yaml

Let's look at a sample file. Comments are included to explain and document what we're doing and why.

name: Elixir CI

# Define workflow that runs when changes are pushed to the
# `main` branch or pushed to a PR branch that targets the `main`
# branch. Change the branch name if your project uses a
# different name for the main branch like "master" or "production".
on:
  push:
    branches: [ "main" ]  # adapt branch for project
  pull_request:
    branches: [ "main" ]  # adapt branch for project

# Sets the ENV `MIX_ENV` to `test` for running tests
env:
  MIX_ENV: test

permissions:
  contents: read

jobs:
  test:
    # Set up a Postgres DB service. By default, Phoenix applications
    # use Postgres. This creates a database for running tests.
    # Additional services can be defined here if required.
    services:
      db:
        image: postgres:12
        ports: ['5432:5432']
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    runs-on: ubuntu-latest
    name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
    strategy:
      # Specify the OTP and Elixir versions to use when building
      # and running the workflow steps.
      matrix:
        otp: ['25.0.4']       # Define the OTP version [required]
        elixir: ['1.14.1']    # Define the elixir version [required]
    steps:
    # Step: Setup Elixir + Erlang image as the base.
    - name: Set up Elixir
      uses: erlef/setup-beam@v1
      with:
        otp-version: ${{matrix.otp}}
        elixir-version: ${{matrix.elixir}}

    # Step: Check out the code.
    - name: Checkout code
      uses: actions/checkout@v3

    # Step: Define how to cache deps. Restores existing cache if present.
    - name: Cache deps
      id: cache-deps
      uses: actions/cache@v3
      env:
        cache-name: cache-elixir-deps
      with:
        path: deps
        key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
        restore-keys: |
          ${{ runner.os }}-mix-${{ env.cache-name }}-

    # Step: Define how to cache the `_build` directory. After the first run,
    # this speeds up tests runs a lot. This includes not re-compiling our
    # project's downloaded deps every run.
    - name: Cache compiled build
      id: cache-build
      uses: actions/cache@v3
      env:
        cache-name: cache-compiled-build
      with:
        path: _build
        key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
        restore-keys: |
          ${{ runner.os }}-mix-${{ env.cache-name }}-
          ${{ runner.os }}-mix-

    # Step: Download project dependencies. If unchanged, uses
    # the cached version.
    - name: Install dependencies
      run: mix deps.get

    # Step: Compile the project treating any warnings as errors.
    # Customize this step if a different behavior is desired.
    - name: Compiles without warnings
      run: mix compile --warnings-as-errors

    # Step: Check that the checked in code has already been formatted.
    # This step fails if something was found unformatted.
    # Customize this step as desired.
    - name: Check Formatting
      run: mix format --check-formatted

    # Step: Execute the tests.
    - name: Run tests
      run: mix test

When the Workflow Runs...

When code is pushed to the main branch, this workflow is run. This happens either from directly pushing to the main branch or after merging a PR into the main branch.

This workflow is also configured to run checks on PR branches that target the main branch. This is where it's most helpful. We can work on a fix or a new feature in a branch and as we work and push our code up, it automatically runs the full gamut of checks we want.

When all steps in the workflow succeed, the workflow "passes" and the automated checks say it can be merged. When a step fails, the workflow halts at that point and "fails", potentially blocking a merge.

Customizing the Workflow Steps

This workflow is a starting point for a team. Every project is unique and every team values different things. Is this missing something your team wants? Check out some additional steps that can be added to your workflow.

  • Add a step to run Credo checks.
  • Add a step to run Sobelow for security focused static analysis.
  • Add a step to run dialyxir. This runs Dialyzer static code analysis on the project. Refer to the project for tips on caching the PLT.
  • Customize the mix test command to include code coverage checks.
  • Add Node setup and caching if npm assets are part of the project's test suite.
  • Add a step to run mix_audit. This provides a mix deps.audit task to scan a project's Mix dependencies for known Elixir security vulnerabilities
  • Add a step to run mix hex.audit. This shows all Hex dependencies that have been marked as retired, which the package maintainers no longer recommended using.
  • Add a set to run mix deps.unlock --check-unused. This checks that the mix.lock file has no unused dependencies. This is useful if you want to reject contributions with extra dependencies.

Benefits of Caching

It's worth spending time tweaking your caches. Why? A lot of effort has been put into speeding up Elixir build times. If we don't cache the build artifacts, then we don't reap any of those benefits!

Of course, faster build times means you spend less money running your CI workflow. But that's not the reason to do it! Better caches mean the checks are performed faster and that means faster feedback. Faster feedback means that, as a team, you save time and can move faster. No waiting 20 minutes for the checks to complete so a PR can be merged. (Yes, I have felt that pain!)

This starting template builds in two caching steps. If node_modules factor into your project's tests, then caching there makes a lot of sense too.

Just keep in mind that the reason we cache is to reduce drag on the speed of our team.

If our caches ever cause a problem, and sometimes they can, it's good to know how to clear them. In our project, go to Actions > (Sidebar) Management > Caches. This is the list of caches saved for the project. We can use our naming format to identify which cache file is for what.

Annotated screenshot of Github cache management interface

What About Continuous Delivery (CD)?

With CI working, the next obvious question is, "Can I auto-deploy my application on Fly.io?" The answer is an emphatic, "Yes!"

It's actually pretty straightforward and there's nothing specific to Elixir about it. The process is documented well in the Continuous Deployment with Fly and GitHub Actions guide.

Discussion

In very short order we've got a slick Continuous Integration (CI) workflow running for our Elixir project! Similar approaches can be used for projects hosted on Gitlab and elsewhere.

The goal is to keep the code quality of our application high without slowing down or overly burdening the development process. With a customized CI workflow based on something like this, we get the benefits of Elixir's improved compile times while enforcing security checks, coding practices and more.

Hopefully this improves your code checks while reducing the friction for your team at the same time!

NOTE: This guide can also be found in the growing section of Elixir documentation.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It's really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!