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
andstack
- 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
on:
workflow_dispatch:
pull_request:
types: [synchronize, opened, reopened]
push:
branches: [main]
schedule:
# additionally run once per week (At 00:00 on Sunday) to maintain cache
- cron: '0 0 * * 0'
jobs:
cabal:
name: ${{ matrix.os }} / ghc ${{ matrix.ghc }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
cabal: ["3.6.2.0"]
ghc:
- "8.10.7"
- "9.0.2"
- "9.2.1"
exclude:
- os: macOS-latest
ghc: 9.0.2
- os: macOS-latest
ghc: 8.10.7
- os: windows-latest
ghc: 9.0.2
- os: windows-latest
ghc: 8.10.7
steps:
- uses: actions/checkout@v2
- uses: haskell/actions/setup@v1.2
id: setup-haskell-cabal
name: Setup Haskell
with:
ghc-version: ${{ matrix.ghc }}
cabal-version: ${{ matrix.cabal }}
- name: Configure
run: |
cabal configure --enable-tests --enable-benchmarks --enable-documentation --test-show-details=direct --write-ghc-environment-files=always
- name: Freeze
run: |
cabal freeze
- uses: actions/cache@v2
name: Cache ~/.cabal/store
with:
path: ${{ steps.setup-haskell-cabal.outputs.cabal-store }}
key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }}
- name: Install dependencies
run: |
cabal build all --only-dependencies
- name: Build
run: |
cabal build all
- name: Test
run: |
cabal test all
- name: Documentation
run: |
cabal haddock
stack:
name: stack / ghc ${{ matrix.ghc }}
runs-on: ubuntu-latest
strategy:
matrix:
stack: ["2.7.3"]
ghc: ["8.10.7"]
steps:
- uses: actions/checkout@v2
- uses: haskell/actions/setup@v1.2
name: Setup Haskell Stack
with:
ghc-version: ${{ matrix.ghc }}
stack-version: ${{ matrix.stack }}
- uses: actions/cache@v2
name: Cache ~/.stack
with:
path: ~/.stack
key: ${{ runner.os }}-${{ matrix.ghc }}-stack
- name: Install dependencies
run: |
stack build --system-ghc --test --bench --no-run-tests --no-run-benchmarks --only-dependencies
- 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.
Once all build errors are fixed, you can enjoy your well-deserved green CI 💚
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)
Configuration explanation
If you want to understand why the configuration looks like it looks, below is a short explanation:
- The configuration is powered by two GitHub Actions: haskell/actions/setup and actions/cache). The
haskell/actions/setup
action is responsible for installing GHC, cabal and stack on different operating systems and providing some convenient utilities. Theactions/cache
action is responsible for caching your built artifacts as you might guess. - 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. - Cache for
cabal
builds is based on the cabal freeze files. Thecache
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. - Your project builds on macOS and Windows only using the latest (or working) GHC version. Building with multiple GHC versions on all three platforms usually doesn’t give you much. So instead of having a
N x M
matrix, it’s enough to have aN + M - 1
matrix. Though, you can easily change this behaviour by removing relevantexclude
sections. - The CI also configures the testing environment properly and runs your tests automatically with each build.
- Dependencies are built in a separate step, so you can quickly see from the overview, which building step has failed — dependencies or your project.
- 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!
HLint
One way you can improve this CI setup is by adding an HLint check to each CI run. This can be done pretty easily. For example, here is a separate job that downloads HLint and runs it:
hlint:
name: hlint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run HLint
env:
HLINT_VERSION: "3.2.7"
run: |
curl -L https://github.com/ndmitchell/hlint/releases/download/v${HLINT_VERSION}/hlint-${HLINT_VERSION}-x86_64-linux.tar.gz --output hlint.tar.gz
tar -xvf hlint.tar.gz ./hlint-${HLINT_VERSION}/hlint src/ test/
Dependabot
The presented CI configuration specifies versions of used GitHub Actions. They can become outdated with time, and this blog post is not updated automatically to the latest versions of each mentioned GitHub Action.
Fortunately, you can use Dependabot to move the burden of updating actions versions from your shoulders to tools.
To receive pull requests with version updates for used actions, add the .github/dependabot.yml
file with the following content (assuming, that you already have labels CI
and dependencies
in your repository, but you can choose your own existing labels):
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
commit-message:
prefix: "GA"
include: "scope"
labels:
- "CI"
- "dependencies"
If you liked this blog post, consider supporting my work on GitHub Sponsors, or following me on the Internet: