Back to all articles

Elixir CI: Testing, Publishing, and Containerization with GitHub Actions

18 August 2024 10 min read
1 reading now

This article is about setting up a CI pipeline for an Elixir application using GitHub Actions. We’ll cover installing dependencies, running tests, and checking code formatting, as well as publishing hex packages and building Docker images as part of the pipeline.

Introduction

Continuous Integration (CI) is a software development practice in which developers frequently integrate code changes into a shared repository. This allows problems to be identified and fixed early in the development process. Even if you are not working on a large project, setting up a basic CI pipeline that performs a few simple checks will ensure that your codebase is in good shape. In this article, we will explore how to set up a CI pipeline for an Elixir application using GitHub Actions.

The Workflow

The following steps explain how to set up a basic CI pipeline for an Elixir application using GitHub Actions. This serves as a starting point for automating the testing process and can be expanded to include additional checks and tasks. We assume that you already have an Elixir project set up and are familiar with Git and GitHub.

Setting Up GitHub Actions

First, we need to create a .github/workflows directory in the root of our project. Inside this directory, we will create a YAML file that defines the CI pipeline. The file can have any name, but for this example, we will name it ci.yml.

name: CI

on:
  push:

jobs:
  test:
    name: Test app
    runs-on: ubuntu-latest
    env:
      MIX_ENV: test

We start by defining the name of the workflow and the event that triggers it. In this case, the workflow will run whenever a push event occurs because we specified on: push. We then define a job named test that will run on the latest version of Ubuntu. We also set the MIX_ENV environment variable to test to ensure that the tests are run in the test environment.

This workflow will serve as the basis for our CI pipeline. We will add more steps as we go along.

Preparing the Elixir Environment

We need to set up the Elixir environment before we can run tests. This includes installing Elixir and Erlang. We can use the erlef/setup-beam action to set up the BEAM environment.

steps:
  - uses: actions/checkout@v4

  - uses: erlef/setup-beam@v1
    id: beam
    with:
      version-file: .tool-versions
      version-type: strict

First, we check out the code using the actions/checkout action. This action clones the repository into the runner, allowing us to access the codebase. Then we use the erlef/setup-beam action to set up the BEAM environment. The action needs to know the version of Elixir and Erlang to install. We could specify the versions directly in the action, but since we use asdf to manage our Elixir and Erlang versions, we already have a .tool-versions file in our project that specifies the versions. We can pass this file to the action under the version-file key. This allows us to keep the versions in sync with our local development environment and we don’t have to update the workflow file every time we change the versions.

The .tool-versions file looks like this:

elixir 1.17.1
erlang 27.0

Optimizing Build Times with Caching

To speed up the build process, we can cache the dependencies and build artifacts. These files will be generated in the next steps, but we need to put the caching step in front of them to ensure that the cache is restored before the dependencies are installed and the code is compiled. With the caching in place, we don’t have to reinstall the dependencies and recompile the code every time the workflow runs. We can use the actions/cache action to add caching to our workflow.

steps:
  - name: Restore the deps and _build cache
    uses: actions/cache@v4
    id: restore-cache
    env:
      OTP_VERSION: ${{ steps.beam.outputs.otp-version }}
      ELIXIR_VERSION: ${{ steps.beam.outputs.elixir-version }}
      MIX_LOCK_HASH: ${{ hashFiles('**/mix.lock') }}
    with:
      path: |
        deps
        _build
      key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-mixlockhash-${{ env.MIX_LOCK_HASH }}

We cache the deps and _build directories. We define some environment variables that we use to construct the cache key. The cache key is important because it determines when the cache will be restored. The cache key in the above example is based on the operating system, Elixir and Erlang versions, the MIX_ENV environment variable, and the hash of the mix.lock file. This ensures that the cache is only restored if the versions and dependencies have not changed.

We can access the Elixir and OTP versions from the previous step using the expressions ${{ steps.beam.outputs.otp-version }} and ${{ steps.beam.outputs.elixir-version }} (where beam is the action step ID). The hash is calculated using the hashFiles function.

Installing and Compiling Dependencies

We are now ready to install the dependencies and compile the code.

steps:
  - name: Install mix dependencies
    if: steps.restore-cache.outputs.cache-hit != 'true'
    run: mix deps.get

  - name: Compile dependencies
    if: steps.restore-cache.outputs.cache-hit != 'true'
    run: mix deps.compile

  - name: Compile
    run: mix compile --warnings-as-errors --force

We define three steps to install the mix dependencies, compile the dependencies and compile the code. We use an if condition in the first two steps to check if the cache has been restored. If the cache has not been restored, we install the mix dependencies and compile the dependencies. The step of compiling our codebase has to be done every time because the code may have changed. We use the --warnings-as-errors flag to treat warnings as errors as we don’t want to allow warnings in our codebase.

Running Checks and Tests

The elixir environment is set up, the dependencies are installed, and the code is compiled. Now we can run our checks and tests. This is the most important part of the CI pipeline because it ensures that the code behaves as expected.

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

  - name: Check unused deps
    run: mix deps.unlock --check-unused

  - name: Credo
    run: mix credo

  - name: Run Tests
    run: mix test

We define four steps to check code formatting, unused dependencies, run Credo and test the application. This is pretty basic, but it’s a good place to start. You can add additional checks and tasks here, such as running dialyzer or checking code coverage.

Full Pipeline

In the previous sections, we built a basic CI pipeline that installs dependencies, compiles code and runs checks and tests. It also includes caching to speed up the workflow. Here is the complete pipeline, which you can copy and paste as a starting point for your Elixir project.

name: CI

on:
  push:

jobs:
  test:
    name: Test app
    runs-on: ubuntu-latest
    env:
      MIX_ENV: test

    steps:
      - uses: actions/checkout@v4

      - uses: erlef/setup-beam@v1
        id: beam
        with:
          version-file: .tool-versions
          version-type: strict

      - name: Restore the deps and _build cache
        uses: actions/cache@v4
        id: restore-cache
        env:
          OTP_VERSION: ${{ steps.beam.outputs.otp-version }}
          ELIXIR_VERSION: ${{ steps.beam.outputs.elixir-version }}
          MIX_LOCK_HASH: ${{ hashFiles('**/mix.lock') }}
        with:
          path: |
            deps
            _build
          key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-mixlockhash-${{ env.MIX_LOCK_HASH }}

      - name: Install mix dependencies
        if: steps.restore-cache.outputs.cache-hit != 'true'
        run: mix deps.get

      - name: Compile dependencies
        if: steps.restore-cache.outputs.cache-hit != 'true'
        run: mix deps.compile

      - name: Compile
        run: mix compile --warnings-as-errors --force

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

      - name: Check unused deps
        run: mix deps.unlock --check-unused

      - name: Credo
        run: mix credo

      - name: Run Tests
        run: mix test

Bonus: Publishing Hex Packages

When you develop a library, you may want to publish it to the Hex package manager. We can automate the publishing process by adding a step to the CI pipeline that publishes the package to Hex.

Add Workflow Trigger

You probably don’t want to publish a new version of your package every time you push a commit. We can add a trigger that will also run the workflow when a new release is created. We can use the release event for this with the types parameter set to [published].

on:
  push:
  release:
    types: [published]

We will check for the workflow trigger event later on to determine if the package should be published.

Add Hex Secret

To publish a package to Hex, we need to authenticate to the Hex package manager in our workflow. We create a secret in the GitHub repository that contains an Hex authorization key. The secret can be accessed in the workflow file and used to authenticate to Hex.

You can can create a Hex authorization key by running the mix hex.user key generate command or by visiting the Hex Keys Settings page. Your key should have write access to the package you want to release.

Add the generated key to your GitHub repository as a repository secret. See Managing development environment secrets for your repository or organization for more information.

Add Publishing Job

We can now add a step to the workflow that publishes the package to Hex. We use the mix hex.publish command to publish the package. We need to set the HEX_API_KEY environment variable to the secret we created earlier. Change the name of the environment variable accordingly if you used a different name for the secret.

steps:
  - name: Publish package
    if: github.event_name == 'release'
    env:
      HEX_API_KEY: ${{ secrets.HEX_API_KEY }}
    run: mix hex.publish --yes

As you can see, we use an if condition to check whether the workflow was triggered by a GitHub release event. If it was, we publish the package to Hex. We set the HEX_API_KEY environment variable to the secret we created earlier. The --yes flag is used to automatically confirm the release.

You may want to create a new job in your pipeline to publish your hex package to separate testing and publishing, but note that you must set up the BEAM environment and install the dependencies before you can publish the package.

Bonus: Building and Publishing Docker Images

If you deploy your Elixir application as a Docker container, you can automate the process of building and publishing Docker images as part of your CI pipeline. In the following sections we will create a pipeline job that builds a Docker image and publishes it to the GitHub Container Registry (GHCR).

Add a new job for building and publishing Docker images

We add a new job to our pipeline that builds and publishes our Docker image. We also add two environment variables to the job that specify the name of the image and the registry where the image will be pushed. We customize the permissions for the job, setting the contents permission to read and the packages permission to write. This will allow the job to read the contents of the repository and write packages to the GitHub container registry. We also add a step to the job that checks out the repository.

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    name: Build and push Docker image
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

Log in to the Container Registry

Before we can push the Docker image to the GitHub Container Registry, we need to log in to the registry. We use the docker/login-action action to authenticate to the registry. We set the registry parameter to the URL of the registry, the username parameter to the GitHub actor, and the password parameter to the GitHub token. We defined the REGISTRY environment variable earlier. The GitHub token and GitHub actor variables are automatically provided by GitHub Actions.

steps:
  - name: Log in to the container registry
    uses: docker/login-action@v3
    with:
      registry: ${{ env.REGISTRY }}
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}

We are now authenticated to the GitHub Container Registry.

Set up Docker Buildx

Next, we set up Docker Buildx with the docker/setup-buildx-action action. We need Docker Buildx to cache the build layers and speed up the build process. We don’t need to specify any parameters for the action.

steps:
  - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v3

Lowercase the image name

If the repository name contains uppercase letters, we need to lowercase the image name as Docker does not allow uppercase in the image name.

steps:
  - name: Lowercase image name
    run: echo "IMAGE_NAME=$(echo "$IMAGE_NAME" | awk '{print tolower($0)}')" >> $GITHUB_ENV

This step uses the awk command to lowercase the image name and sets the IMAGE_NAME environment variable to the lowercase version of the image name.

Prepare Metadata for Docker

When we push the image to the GitHub container registry, we want to provide metadata such as tags and labels. We can use the docker/metadata-action action to automatically extract metadata based on Git reference and GitHub events. We will use the output of this action in the next pipeline step when building and pushing the Docker image.

steps:
  - name: Extract metadata (tags, labels) for Docker
    id: meta
    uses: docker/metadata-action@v5
    with:
      images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

Build and Push

We are now ready to build and push the Docker image. We use the docker/build-push-action action to build and push the image. We set push to true to not only build the image but also push it to the registry. We set the tags and labels parameters to the output of the previous step to add appropriate labels and tags to the image. We also set the cache-from and cache-to parameters to cache the build layers. This will speed up the build process.

steps:
  - name: Build and push
    uses: docker/build-push-action@v6
    with:
      push: true
      tags: ${{ steps.meta.outputs.tags }}
      labels: ${{ steps.meta.outputs.labels }}
      cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
      cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

We have successfully built and pushed the Docker image to the GitHub Container Registry.

The Full Pipeline

name: CI

on:
  push:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    name: Build and push Docker image
    runs-on: ubuntu-latest

    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Lowercase image name
        run: echo "IMAGE_NAME=$(echo "$IMAGE_NAME" | awk '{print tolower($0)}')" >> $GITHUB_ENV

      - name: Log in to the container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

Conclusion

In this article, we explored how to set up a CI pipeline for an Elixir application using GitHub Actions. We covered how to install dependencies, run tests and perform certain code checks, as well as how to publish hex packages and build Docker images as part of the pipeline. This serves as a starting point for automating the testing process and can be expanded to include additional checks and tasks.