Dead simple cross-platform GitHub Actions for Haskell

May 7, 2020

I have been looking for the perfect Continuous Integration (CI) for my Haskell projects for a while and found Github Actions to be absolute gold for that. I have been going back and forth between different CI platforms (Travis, AppVeyor, CircleCI), until recently I finally figured out the settings that include everything I need.

So, in this short blog post, I would like to present the easiest way to set up the GitHub Actions CI for Haskell projects I come up with.

Motivation

In one of my previous blog posts I’ve described Dead simple Travis CI settings for Haskell projects. There I’ve also explained why it’s important to support both build tools and multiple GHC versions on CI. However, it’s tricky to define Travis CI config with a matrix for multiple GHC versions, multiple operating systems and multiple build tools at the same time. This is where GitHub Actions come to play!

The resulting GitHub Actions configuration has the following features:

  • It is fast
  • It is short
  • Works on Linux, macOS and Windows
  • Supports multiple GHC versions
  • Builds your project with both cabal and stack
  • Contains only single copy-pasteable file: the file doesn’t have any project-specific properties or variables
  • Can be enabled automatically: no need to visit some third-party site to start CI, just push a single file to your repo
  • It is free for open-source projects

The Config

To not beat around the bush and just get to the point, below is the full config:

name: CI

# Trigger the workflow on push or pull request, but only for the master branch
on:
  pull_request:
  push:
    branches: [master]

jobs:
  cabal:
    name: ${{ matrix.os }} / ghc ${{ matrix.ghc }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macOS-latest, windows-latest]
        cabal: ["3.2"]
        ghc:
          - "8.6.5"
          - "8.8.3"
          - "8.10.1"
        exclude:
          - os: macOS-latest
            ghc: 8.8.3
          - os: macOS-latest
            ghc: 8.6.5
          - os: windows-latest
            ghc: 8.8.3
          - os: windows-latest
            ghc: 8.6.5

    steps:
    - uses: actions/checkout@v2
      if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/master'

    - uses: actions/setup-haskell@v1.1.1
      id: setup-haskell-cabal
      name: Setup Haskell
      with:
        ghc-version: ${{ matrix.ghc }}
        cabal-version: ${{ matrix.cabal }}

    - name: Freeze
      run: |
        cabal freeze

    - uses: actions/cache@v1
      name: Cache ~/.cabal/store
      with:
        path: ${{ steps.setup-haskell-cabal.outputs.cabal-store }}
        key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }}

    - name: Build
      run: |
        cabal configure --enable-tests --enable-benchmarks --test-show-details=direct
        cabal build all

    - name: Test
      run: |
        cabal test all

  stack:
    name: stack / ghc ${{ matrix.ghc }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        stack: ["2.3.1"]
        ghc: ["8.8.3"]

    steps:
    - uses: actions/checkout@v2
      if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/master'

    - uses: actions/setup-haskell@v1.1
      name: Setup Haskell Stack
      with:
        ghc-version: ${{ matrix.ghc }}
        stack-version: ${{ matrix.stack }}

    - uses: actions/cache@v1
      name: Cache ~/.stack
      with:
        path: ~/.stack
        key: ${{ runner.os }}-${{ matrix.ghc }}-stack

    - name: Build
      run: |
        stack build --system-ghc --test --bench --no-run-tests --no-run-benchmarks

    - name: Test
      run: |
        stack test --system-ghc

Enabling such CI for your projects is completely effortless. All you need to do is create a file named .github/workflows/ci.yml with the above content and push it to your repository. That’s all! GitHub takes care of everything else for you.

The above config is implemented as a workflow template, and can be enabled in one click in all Kowainik repositories:

Suggested workflow

Once all build errors are fixed, you can enjoy your well-deserved green CI 💚

All CI checks pass

Badge

After you enable GitHub Actions CI for your project, you can also add a cute badge to your README.md. Copy the following line and replace userName and repoName with your own settings:

[![GitHub CI](https://github.com/userName/repoName/workflows/CI/badge.svg)](https://github.com/userName/repoName/actions)

And it will look like this:

GitHub CI

Configuration explanation

If you want to understand why the configuration looks like it looks, below is a short explanation:

  1. The configuration is powered by two GitHub Actions: setup-haskell and cache. The setup-haskell action is responsible for installing GHC, cabal and stack on different operating systems and providing some convenient utilities. The cache action is responsible for caching your built artefacts as you might guess.
  2. It builds your project using cabal with different GHC versions on Ubuntu. The GitHub Actions virtual environment comes with GHC, Cabal and Stack already pre-installed in there, so the CI is faster on Linux than on Windows or macOS at the moment.
  3. Cache for cabal builds is based on the cabal freeze files. The cache GitHub Action doesn’t upload newer cache if the cache with such key already exists. This can be problematic when you starting developing a package and adding new dependencies. To improve the situation, the cache key is based on all project dependencies. Using this approach means that once you change dependencies, your project and all dependencies will be rebuilt from scratch. But when they are built, the whole cache will be reused next time.
  4. Your project builds on macOS and Windows only using the latest GHC version. It usually won’t give you a lot to build with multiple GHC versions on all three platforms. So instead of having a N x M matrix, it’s enough to have a N + M - 1 matrix. Though, you can easily change this behaviour by removing relevant exclude sections.
  5. The CI also configures the testing environment properly and runs your tests automatically with each build.
  6. The config is extensible and easily customizable. You can change step names and their commands, add new steps. You can even add releases in an easy way with such a system!