Skip to content

Automating Elixir Package Deployment with GitHub Actions

Tags: elixir, github-actions • Categories: Learning

Table of Contents

I enjoy Elixir, although I know it doesn’t make sense to use it for most production applications. However, I try to use it for side projects when I can, even just because it’s such a fun and beautiful language to program in.

One thing I’ve been fascinated with recently is automating the entire release process for packages using conventional commits with GitHub actions. This post walks through how to make that happen, and some other bits around Elixir project setup. Here’s the example project with all of the learnings from this post in place.

mix.exs project configuration

Add some key deps to mix.exs you’ll need for build & deploy:

  def project do
    [
      test_coverage: [tool: ExCoveralls],
      deps: deps()
    ]
  end

  defp deps do
    [
      {:excoveralls, "~> 0.17.1", only: :test},
      {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
    ]
  end

Make sure you have extra fields in your mix.exs to avoid the Missing metadata fields: description, licenses, links error.

Note that unlike many other languages keywords is not a valid option in package().

  def project do
    [
      ...
      package: package(),
      ...
    ]
  end

  defp package do
    [
      description:
        "An Elixir utility to execute SQL files on Postgres using psql CLI, supporting multi-statement SQL with mock testing capabilities",
      files: ["lib", "mix.exs", "README.md", "CHANGELOG.md"],
      maintainers: ["Michael Bianco"],
      licenses: ["MIT"],
      links: %{"GitHub" => "https://github.com/iloveitlay/postgres_executor"}
    ]
  end

Automated test, build, and release

I wanted the following features using GitHub actions:

  • Code formatting
  • Unused dependencies
  • Test coverage
  • Test
  • Postgres
  • Changelog generation
  • Build & publish to mix
  • GitHub release generation

To support this, I had to hack in mix support into the changelog action.

# base configuration pulled from:
# https://github.com/dashbitco/broadway/blob/master/.github/workflows/ci.yml

name: Build & Publish

on:
  pull_request:
  push:
    branches:
      - "*"

# by default, permissions are read-only, read + write is required for git pushes
permissions:
  contents: write

env:
  MIX_ENV: test

jobs:
  test:
    runs-on: "ubuntu-latest"

    services:
      postgres:
        # replace with postgis/postgis:, use dashes instead of dots for version
        image: postgres:15.3
        ports:
          - 5432:5432
        env:
          POSTGRES_PASSWORD: postgres

        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    name: Test Elixir ${{ matrix.elixir }}, OTP ${{ matrix.otp }}
    strategy:
      fail-fast: false
      matrix:
        os: ["ubuntu-latest"]
        elixir: ["1.15", "1.14", "1.13"]
        otp: ["26", "25"]
        exclude:
          - elixir: "1.13"
            otp: "26"
        include:
          - elixir: "1.15"
            otp: "26"
            coverage: coverage
            lint: lint
    steps:
      - uses: actions/checkout@v4
      - uses: erlef/setup-beam@v1
        with:
          otp-version: ${{matrix.otp}}
          elixir-version: ${{matrix.elixir}}

      - name: Restore Dependency Cache
        uses: actions/cache@v3
        id: cache-deps
        with:
          path: |
            deps
            _build
          key: |
            mix-${{ runner.os }}-${{matrix.elixir}}-${{matrix.otp}}-${{ hashFiles('**/mix.lock') }}
          restore-keys: |
            mix-${{ runner.os }}-${{matrix.elixir}}-${{matrix.otp}}-

      - run: mix deps.get
        if: steps.cache-deps.outputs.cache-hit != 'true'
      - run: mix deps.compile --warnings-as-errors
        if: steps.cache-deps.outputs.cache-hit != 'true'

      - name: Check for unused deps
        run: mix deps.unlock --check-unused
        if: ${{matrix.lint}}

      - run: mix format --check-formatted
        if: ${{matrix.lint}}

      - name: Check for abandonded packaged
        run: mix hex.audit
        if: ${{matrix.lint}}

      - name: Set up database
        run: |
          PGPASSWORD=postgres psql -c 'create database postgres_test;' -U postgres -h localhost -p 5432

      - run: mix test

      - run: mix coveralls.github
        if: ${{matrix.lint}}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Build Changelog
        if: ${{matrix.lint}}
        id: changelog
        # change to uses: TriPSs/conventional-changelog-action@v4 once PR is merged
        uses: iloveitaly/conventional-changelog-action@elixir-support
        with:
          github-token: ${{ secrets.github_token }}
          fallback-version: "0.1.0"
          version-file: "./mix.exs"
          output-file: "CHANGELOG.md"

      - name: Build & Publish
        run: |
          mix hex.build
          mix hex.publish --yes
        if: ${{matrix.lint && steps.changelog.outputs.skipped == 'false'}}
        env:
          MIX_ENV: dev
          # gh secret set HEX_API_KEY --app actions --body $HEX_API_KEY
          HEX_API_KEY: ${{ secrets.HEX_API_KEY }}

      - name: Github Release
        if: ${{ matrix.lint && steps.changelog.outputs.skipped == 'false' }}
        uses: softprops/action-gh-release@v1
        with:
          # output options: https://github.com/TriPSs/conventional-changelog-action#outputs
          body: ${{ steps.changelog.outputs.clean_changelog }}
          tag_name: ${{ steps.changelog.outputs.tag }}

Dependabot

For automated dependency updates add this to .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: mix
    directory: "/"
    schedule:
      interval: monthly

  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: monthly

Quickly using these actions with new projects

I hack on a bunch of open source projects. It’s a pain to have to remember the folder structure for the .github/* related bits.

One thing that makes this slightly easier for me is shell history combined with a cli alias touchp which will create a file and it’s folder path in one command. This makes it easy to type touchp .g and cycle through past yml file paths to easily generate the folder structure to copy/paste the above code into.

Readme badges

Here’s some nice badges that you can quickly use by subbing the USERNAME and REPO with your own.

[![Module Version](https://img.shields.io/hexpm/v/REPO.svg)](https://hex.pm/packages/REPO)
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/REPO/)
[![Total Download](https://img.shields.io/hexpm/dt/REPO.svg)](https://hex.pm/packages/REPO)
[![License](https://img.shields.io/hexpm/l/REPO.svg)](https://github.com/USERNAME/REPO/blob/master/LICENSE.md)
![github actions badge](https://github.com/USERNAME/REPO/actions/workflows/build_and_publish.yml/badge.svg)