GitHub Actions CI Pipeline For An Elixir / Phoenix App

Here is a complete action that can be put into .github/workflows/ci.yml so that tests and other checks (formatter, Credo, Dialyzer) are ran automatically on each push and each pull request:

name: CI

on: [push, pull_request]

env:
  MIX_ENV: test

jobs:
  build-and-test:
    name: Build and Tests
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-20.04]
        elixir: [1.11.2]
        otp: [23.3.4]

    services:
      postgres:
        image: postgres:12.6
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: kiz_test
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

    steps:
    - name: Check Out Repository
      uses: actions/checkout@v2

    - name: Prepare Application
      run: cp config/test.secret.ci.exs config/test.secret.exs

    - name: Setup Elixir
      uses: actions/setup-elixir@v1
      with:
        elixir-version: ${{ matrix.elixir }}
        otp-version: ${{ matrix.otp }}
        experimental-otp: true

    - name: Restore Dependencies, Build & Dialyzer PLTs Cache
      uses: actions/cache@v2
      with:
        path: | 
          deps
          _build
          priv/plts
        key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }}
        restore-keys: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}

    - name: Install Dependencies
      run: mix deps.get

    - name: Setup DB
      run: mix ecto.setup

    - name: Run Tests
      run: mix test

    - name: Check Formatting
      run: mix format --check-formatted

    - name: Review Code With Credo
      run: mix credo

    - name: Typecheck With Dialyzer
      run: mix dialyzer

Few things to note with that setup.

  • We use build matrix, but only set one value for each of OS, Elixir and OTP versions, this way it runs normally (as if without the matrix) but we can enable it by adding a farther version of anything, e.g. os: [ubuntu-18.04, ubuntu-20.04] would make it run on both systems.
  • Specifying the full OTP version is necessary for Dialyzer to work properly, otherwise minor or bugfix version numbers will change and Dialyzer run would fail. If not using Dialyzer, setting major OTP version should be enough.
  • The flag experimental-otp is needed for Ubuntu 20.04 support at the time of writing, otherwise the “OpenSSL might not be installed on this system.” would occur. Soon it should become default so the flag will not be needed, here’s the relevant issue.
  • Postgres is a service which means it will spawn a separate container so we map ports like that 5432:5432 – that means we map the port 5432 (which is the default Postgres port) inside the container to the same port outside of it so that the app can connect to the DB using the standard port.
  • We use config system with secret configs and keep CI config in the config/test.secret.ci.exs in project repository (this file gets DB settings only and the password is “postgres”, no big secret), new apps initially made with Elixir 1.10 and later should switch to using runtime config, this way all the config options can simply be listed as env variables at the beginning of the action (where MIX_ENV is set)
  • We cache deps and _build directories as well as priv/plts, first two will enable incremental builds which will significantly speed up consecutive runs, last one is only needed for Dialyzer step and can be skipped otherwise.
  • As cache key we include OS as well as both Elixir and OTP versions, this way adding them to the Matrix or upgrading them will bust the cache and enforce new build so we get no weird behavior.
  • If you want to use Dialyzer then it must be installed and configured to use “priv/plts” as core path as well as “priv/plts/dialyzer.plt” as plt file (last one should not be necessary but without it I got no cache hits). Installing Dialyzer for Elixir means adding something like that to deps: {:dialyxir, “~> 1.0”, only: [:dev, :test], runtime: false} and configuring it requires adding dialyzer key to project function in mix.exs, example below.
def project do
    [
      ...
      dialyzer: [        
        plt_core_path: "priv/plts",
        plt_file: {:no_warn, "priv/plts/dialyzer.plt"}
      ],
      ...
    ]
  end

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.