Deploying Confidently: Haskell and Circle CI
In last week’s article, we deployed our Haskell code to the cloud using Heroku. Our solution worked, but the process was also very basic and very manual. Let’s review the steps we would take to deploy code on a real project with this approach.
- Make a pull request against master branch
- Merge code into master
- Pull master locally, run tests
- Manually run
git push heroku master
- Hope everything works fine on Heroku
This isn’t a great approach. Wherever there are manual steps in our development process, we’re likely to forget something. This will almost always come around to bite us at some point. In this article, we’ll see how we can automate our development workflow using Circle CI.
Getting Started with Circle
To follow along with this article, you should already have your project stored on Github. As soon as you have this, you can integrate with Circle easily. Go to the Circle Website and login with Github. Then go to “Add Project”. You should see all your personal repositories. Clicking your Haskell project should allow you to integrate the two services.
Now that Circle knows about our repository, it will try to build whenever we push code up to Github. But we have to tell Circle CI what to do once we’ve pushed our code! For this step, we’ll need to create a config file and store it as part of our repository. Note we’ll be using Version 2 of the Circle CI configuration. To define this configuration we first create a folder called .circleci
at the root of our repository. Then we make a YAML file called config.yaml
.
In Circle V2, we specify “workflows” for the Circle container to run through. To keep things simple, we’ll limit our actions to take place within a single workflow. We specify the workflows
section at the bottom of our config:
workflows:
version: 2
build_and_test:
jobs:
- build_project
Now at the top, we’ll again specify version 2, and then lay out a bare-bones definition of our build_project
job.
version: 2
jobs:
build_project:
machine: true
steps:
- checkout
- run: echo “Hello”
The machine
section indicates a default Circle machine image we’re using for our project. There’s no built-in Haskell machine configuration we can use, so we’re using a basic image. Then for our steps, we’ll first checkout our code, and then run a simple “echo” command. Let’s now consider how we can get this machine to get the Stack utility so we can actually go and build our code.
Installing Stack
So right now our Circle container has no Haskell tools. This means we'll need to do everything from scratch. This is a useful learning exercise. We’ll learn the minimal steps we need to take to build a Haskell project on a Linux box. Next week, we’ll see a shortcut we can use.
Luckily, the Stack tool handles most of our problems for us, but we first have to download it. So after checking our our code, we’ll run several different commands to install Stack. Here’s what they look like:
steps:
- checkout
- run: wget https://github.com/commercialhaskell/stack/releases/download/v1.6.1/stack-1.6.1-linux-x86_64.tar.gz -O /tmp/stack.tar.gz
- run: sudo mkdir /tmp/stack-download
- run: sudo tar -xzf /tmp/stack.tar.gz -C /tmp/stack-download
- run: sudo chmod +x /tmp/stack-download/stack-1.6.1-linux-x86_64/stack
- run: sudo mv /tmp/stack-download/stack-1.6.1-linux-x86_64/stack /usr/bin/stack
The wget
command downloads Stack off Github. If you’re using a different version of Stack than we are (1.6.1), you’ll need to change the version numbers of course. We’ll then create a temporary directory to unzip the actual executable to. Then we use tar
to perform the unzip step. This leaves us with the stack
executable in the appropriate folder. We’ll give this executable x
permissions, and then move it onto the machine’s path. Then we can use stack
!
Building Our Project
Now we’ve done most of the hard work! From here, we’ll just use the Stack commands to make sure our code works. We’ll start by running stack setup
. This will download whatever version of GHC our project needs. Then we’ll run the stack test
command to make sure our code compiles and passes all our test suites.
steps:
- checkout
- run: wget …
...
- run: stack setup
- run: stack test
Note that Circle expects our commands to finish with exit code 0. This means if any of them has a non-zero exit code, the build will be a “failure”. This includes our stack test
step. Thus, if we push code that fails any of our tests, we’ll see it as a build failure! This spares us the extra steps of running our tests manually and “hoping” they’ll work on the environment we deploy to.
Caching
There is a pretty big weakness in this process right now. Every Circle container we make starts from scratch. Thus we’ll have to download GHC and all the different libraries our code depends on for every build. This means you might need to wait 30-60 minutes to see if your code passes depending on the size of your project! We don’t want this. So to make things faster, we’ll tell Circle to cache this information, since it won’t change on most builds. We’ll take the following two steps:
- Only download GHC when
stack.yaml
changes (since the LTS might have changed). This involves caching the~/.stack
directory - Only re-download libraries when either
stack.yaml
or our.cabal
file changes. For this, we’ll cache the.stack-work
library.
For each of these, we’ll make an appropriate cache key. At the start of our build process, we’ll attempt to restore these directories from the cache based on particular keys. As part of each key, we’ll use a checksum of the relevant file.
steps:
- checkout
- restore-cache:
keys:
- stack-{{ checksum “stack.yaml” }}
- restore-cache:
keys:
- stack-{{checksum “stack.yaml”}}-{{checksum “project.cabal”}}
If these files change, the checksum will be different, so Circle won’t be able to restore the directories. Then our other steps will run in full, downloading all the relevant information. At the end of the process, we want to then make sure we’ve saved these directories under the same key. We do this with the save_cache
command:
steps:
…
- stack test
- save-cache:
key: stack-{{ checksum “stack.yaml” }}
paths:
- “~/.stack”
- restore-cache:
keys: stack-{{checksum “stack.yaml”}}-{{checksum “project.cabal”}}
paths:
- “.stack-work”
Now the next builds won’t take as long! There are other ways we can make our cache keys. For instance, we could use the Stack LTS as part of the key, and bump this every time we change which LTS we’re using. The downside is that there’s a little more manual work required. But this work won’t happen too often. The positive side is that we won’t need to re-download GHC when we add extra dependencies to stack.yaml
.
Deploying to Heroku
Last but not least, we’ll want to actually deploy our code to heroku every time we push to the master branch. Heroku makes it very easy for us to do this! First, go to the app dashboard for Heroku. Then find the Deploy tab. You should see an option to connect with Github. Use it to connect your repository. Then make sure you check the box that indicates Heroku should wait for CI. Now, whenever your build successfully completes, your code will get pushed to Heroku!
Conclusion
You might have noticed that there’s some redundancy with our approaches now! Our Circle CI container will build the code. Then our Heroku container will also build the code! This is very inefficient, and it can lead to deployment problems down the line. Next week, we’ll see how we can use Docker in this process. Docker fully integrates with Circle V2. It will simplify our Circle config definition. It will also spare us from needing to rebuild all our code on Heroku again!
With all these tools at your disposal, it’s time to finally build that Haskell app you always wanted to! Download our Production Checklist to learn some cool libraries you can use!
If you’ve never programmed in Haskell before, hopefully you can see that it’s not too difficult to use! Download our Haskell Beginner’s Checklist and get started!