Neiro | Functional programming, software architecture
19 Jun 2018

Elixir continious integration with CircleCI

Elixir programming language has gained popularity and now it is supported at many platforms, including plenty of CI services. In this article we will see how we can achieve seamless and (almost) dead simple continious integration by using CircleCI in our Elixir projects.

1 CircleCI 2.0

CircleCI is one of the most popular and user-friendly continious integration solutions. It supports many programming languages and tools, including Elixir and Erlang/OTP.

CircleCI is entirely free when it comes to open-source GitHub repositories, but it also provides free 1500 minutes a month for any private repos.

Starting version 2.0 CircleCI can create jobs based on any images from DockerHub. This feature makes possible to build any programming language or platform that can be placed in Docker image.

Imagine you have a standard Elixir Phoenix / Ecto application. You need to run it on the latest versions of Elixir and Erlang/OTP and run the tests on PostgreSQL database.

Let’s start by creating a CircleCI configuration file in .circleci/config.yml:

  version: 2  # use CircleCI 2.0 instead of CircleCI Classic
  jobs:  # basic units of work in a run
    build:  # runs not using Workflows must have a `build` job as entry point
      parallelism: 1  # run only one instance of this job in parallel
      docker:  # run the steps with Docker
        - image: circleci/elixir:1.6 # ...with this image as the primary container; this is where all `steps` will run
          environment:  # environment variables for primary container
            MIX_ENV: test
            SHELL: /bin/bash
        - image: mdillon/postgis:9.6-alpine  # database image
          environment:  # environment variables for database
            POSTGRES_DB: app_test

      steps:  # commands that comprise the `build` job
        - checkout  # check out source code to working directory

        - run: mix local.hex --force  # install Hex locally (without prompt)
        - run: mix local.rebar --force  # fetch a copy of rebar (without prompt)

As you can see here, we are declaring the build continious integration job. Basically we will use the Elixir 1.6 with PostgreSQL 9.6 to run tests on the app_test database. After that we will checkout source code base to fetch our recent changes into the build. mix local tasks are also necessary in order to use any of Mix tasks later.

2 Running tests and code quality

All of us want to run all common continious integration steps such as:

  • Fetch dependencies and compile application
  • Run code quality tools and checks (you can read more about it here)
  • Execute all tests to make sure that our build is successful and infallible
  • Run heavy and bulky static analysis tools

Also we want to make our builds as fast as possible, so we definitely need caching. Let’s continue with our config and implement the steps above:

{% raw %}

        - restore_cache:  # restores saved mix cache
            keys:  # list of cache keys, in decreasing specificity
              - v1-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }}
              - v1-mix-cache-{{ .Branch }}
              - v1-mix-cache
        - restore_cache:  # restores saved build cache
            keys:
              - v1-build-cache-{{ .Branch }}
              - v1-build-cache
        - restore_cache:  # restores saved plt cache
            keys:
              - dialyzer-cache

        - run: mix do deps.get, compile # get updated dependencies & compile them

        - save_cache:  # generate and store cache so `restore_cache` works
            key: v1-mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }}
            paths: "deps"
        - save_cache:  # make another less specific cache
            key: v1-mix-cache-{{ .Branch }}
            paths: "deps"
        - save_cache:  # you should really save one more cache just in case
            key: v1-mix-cache
            paths: "deps"
        - save_cache: # don't forget to save a *build* cache, too
            key: v1-build-cache-{{ .Branch }}
            paths: "_build"
        - save_cache: # and one more build cache for good measure
            key: v1-build-cache
            paths: "_build"

        - run: mix do format --check-formatted, credo --strict, security
        - run: mix do xref deprecated --include-siblings, xref unreachable --include-siblings, xref graph --format stats

        - run:  # special utility that stalls main process until DB is ready
            name: Wait for DB
            command: dockerize -wait tcp://localhost:5432 -timeout 1m

        - run: mix do ecto.migrations, ecto.load
        - run: mix test  # run all tests in project

        - run: mix dialyzer --halt-exit-status
        - save_cache:
            key: dialyzer-cache
            paths: "_build/test/dialyxir*.plt"

        - store_test_results:  # upload test results for display in Test Summary
            path: _build/test/lib/app/results.xml

{% endraw %}

Now you can start new builds by signing up into CircleCI as it will run the configuration and steps from your config. Every commit in any branch will run the build job and you will know if something is wrong with your code.

3 Deploying

However, having only one build job is not enough even for the simplest CI process. Most often we need to make a staging/production release by using Distillery.

Let’s continue filling up our configuration file by adding a new deploy job:

    deploy:
      docker:
        - image: circleci/elixir:1.6
          environment:  # environment variables for primary container
            SHELL: /bin/bash
            MIX_ENV: staging
      steps:
        - checkout  # check out source code to working directory

        - run: mix local.hex --force  # install Hex locally (without prompt)
        - run: mix local.rebar --force  # fetch a copy of rebar (without prompt)

        - run: mix do deps.get, compile # get updated dependencies & compile them

        # set MIX_ENV to prod or staging value according to the source branch
        - run:
            name: Update MIX_ENV environment variable
            command: |
              echo "export MIX_ENV=$(if [ '$CIRCLE_BRANCH' '==' 'master' ]; then echo 'prod'; else echo 'staging'; fi)" >> $BASH_ENV
              source $BASH_ENV

        - run: cd deps/argon2_elixir && make clean && make && cd -
        - run: MIX_ENV=staging mix release --env $MIX_ENV

        - run: tar -zcvf $CIRCLE_SHA1.tar.gz bin appspec.yml VERSION _build/$MIX_ENV/rel/app/releases/$(cat VERSION)/app.tar.gz

This will be enough to create a separate deploy job that will run on a separate Docker image. However, we will need to run it only on develop and master branches in order to upload staging/production releases accordingly. We can achieve this by using CircleCI workflow and providing a simple configuration at the bottom of our config file:

  workflows:
    version: 2
    build-and-deploy:
      jobs:
        - build
        - deploy:
            requires:
              - build
            filters:
              branches:
                only:
                  - develop
                  - master

After that you are free to upload the built release to any server or any platform you want. You can use Edeliver, Ansible, Chef, Docker - it’s up to you.

4 Conclusion

As you can see above, it’s not so hard to build and deploy Elixir applications with CircleCI 2.0. This platform is flexible and fast enough to make your continious integration bright and shiny.

If you want to discover even more on the topic then let’s read CircleCI 2.0 documentation and Elixir Language Guide.

Happy hacking, everyone!

Tags: elixir quality ci