Using GitHub Actions to Create a Simple Test and Release Pipeline for a Phoenix App

Kate Studwell
11 min readOct 18, 2019

--

GitHub Actions has gone through some exciting changes recently! Now in public beta, the new and improved Actions is empowering developers to automate their workflow in new and exciting ways, all on GitHub.

To figure out what all the buzz was about, I decided to build a simple automation workflow. I’ve used Jenkins before, but didn’t find it to be super intuitive, and the ability to define workflows with some simple YAML — and have the workflows run directly on GitHub — was attractive to me.

My first workflow was a somewhat simple test and release workflow. When I push my code up to GitHub, I want to be able to release it immediately, with no additional work required. Naturally, I also want some level of confidence that I won’t be releasing broken code, so I’ll make sure the code is tested first.

Before we dive into some examples, a quick caveat: this post is not intended to be a full tutorial on how to use Actions, and I won’t define all the steps in the process. If that’s what you’re looking for, I’d recommend checking out my colleague Jeff Rafter’s fantastic article. Also, the workflow outlined in this post is admittedly simplified, but there are some interesting concepts worth covering, so please enjoy!

With that out of the way, let’s set some goals. I want to:

  • deploy my code automatically when I push to GitHub, without having to do any extra work
  • feel confident that when I deploy my code, it’s been tested, and no broken builds are pushed to production
  • ensure that any steps necessary to deploying (like running migrations) happen automatically
  • be notified if any part of the process fails

For the purposes of this example, I decided to create a very simple Phoenix app, and deploy it to Heroku. While Heroku is not necessarily the best hosting provider for Phoenix apps, there is very helpful documentation on how to create and host a Phoenix app on Heroku, and setting up a database on Heroku is easy and simplified my set up steps considerably. Also, since this is just a simple demo app, I don’t need 100% uptime and free hosting fits the bill.

Initial Setup

First, I created an app and ensured I could deploy it before getting started with the GitHub Actions piece. To do this, I:

$ git init
$ git add .
$ git commit -m "Initial commit"
# specify the elixir buildpack as the base, as phoenix will depend on it
# give the app a name. I used the same name as my GitHub repo; we'll see why later.
$ heroku create my-awesome-app-name --buildpack hashnuke/elixir

There are additional instructions for deploying a Phoenix app to Heroku here.

Note that when I pushed the app to Heroku using the flow described in the above article, I’m prompted to log in:

$: git push heroku master
Username for 'https://git.heroku.com': username@example.com
Password for 'https://username@example.com@git.heroku.com':

This works well for my current flow of pushing from my local machine, but I’ll want to revisit this when I start deploying using GitHub Actions.

Great! The app is up and running, and deployed to Heroku.

Yay! Theoretically, I can already do my test and release flow, which looks something like:

  1. Write some code
  2. Ensure tests pass: mix test
  3. Commit the code: git add . && git commit -m “My Awesome commit”
  4. Push to GitHub: git push
  5. Deploy the code: git push heroku master

Buuut, what if I’m feeling lazy. Like, really lazy? Or, what if I’m not confident that other members on my team will remember to run tests before pushing to GitHub, or remember to deploy after pushing? I’m going to keep just steps 1, 3, and 4!

Introducing Workflows!

GitHub Actions allows you to define Actions and Workflows. Actions are a set of scripts that perform behavior defined by the author, where the Workflows respond to specific triggers, and tie Actions together and specify dependencies, operating systems, and any additional steps.

A GitHub repo may contain multiple workflows that respond to different triggers. In my case, there’s one trigger I’m interested in — pushing code, specifically to the master branch. So, for the simplified purposes of this app, I’m just going to define one workflow.

First, I created the workflow directory and file. All code for GitHub actions should live in the repository’s .github directory, so I started there:

$ mkdir .github/workflows
$ touch .github/workflows/test_and_release.yml

Note that all workflow files are now in YAML format. Previous iterations of Actions were in HCL format, so GitHub has provided handy guides for converting to the new syntax.

I started by giving the workflow a name. I chose Application Test and Release Workflow to be as explicit as possible.

name: Application Test and Release Workflow

Next, I want to specify what actions will trigger this workflow. This is done using the keyword on:. I want to specifically trigger this workflow when pushing to the master branch:

on:
push:
branches:
- master

Note that it’s possible to define multiple branches that would trigger this workflow. It’s also possible to trigger the workflow for any push just by specifying on: push. See the documentation here for full details on valid triggers.

Now it’s time to define some jobs! I’ll start with the test job and ensure that works before moving on to deploying the code.

The next keyword will be jobs, and there can be multiple jobs per workflow. The first job will be named test, as that’s what the job will do.

Next up, I’ll need to specify what type of system the job will run on. ubuntu will work for this case, I’ll use the latest version: runs-on: ubuntu-latest.

Next, because this is a Phoenix app that runs tests against a test PosgreSQL database, the app needs access to a DB in this environment. This can be done by defining a services key, with the DB image and some health check options. If you’re familiar with docker-compose, this may look familiar to you:

services:
db:
image: postgres:11
ports: [‘5432:5432’]
options: >-
— health-cmd pg_isready
— health-interval 10s
— health-timeout 5s

The steps for the workflow are where the meat of the action happens. The steps specify which actions and bash commands to run and any run options. The actions can either be actions defined in the .github/actions directory, or actions hosted on GitHub.

In this case, I want to first start with the checkout action, -uses: actions.checkout@v1.0.0, which will checkout the repo into the $GITHUB_WORKSPACE so the workflow can access it.

Next, I want to run actions/setup-elixir@v1.0.0, which will set up the Elixir environment so tests can be executed with mix. Check out the repo documentation for instructions for Phoenix setup. Most importantly, I’ll specify the Elixir and Erlang versions to use.

Now, it’s time for the fun part — running the tests. Defining which bash commands to run is easy — just prefix with a run: keyword, similar to a Dockerfile. In this case, the first step is installing dependencies, and then running the tests:

- run: mix deps.get
- run: mix test

If any of the above steps fail, the entire workflow will fail, and I’ll get notified via an email.

Here’s what the full test job looks like:

Most of this code is lifted directly from the setup-elixir action, so check that out for more details!

Time to test the action out on GitHub:

git add .
git commit -m "Add initial test workflow"
git push origin master

It works! Thanks to @jclem for great documentation in the setup-elixir Action.

Deploying from GitHub Actions

If all the tests pass successfully, I want to deploy the app to Heroku. Similar to the test step, another job needs to be defined. Just like with test, running the job on ubuntu-latest will work well.

In order to deploy to Heroku, the workflow will need access to both git and the heroku CLI tool. The great news is that that the virtual environment provided by Actions provides includes both these tools. Check out the documentation for the full list of virtual environments and supported software.

This is where things get a bit more complex. As we know from an earlier step, I’ll get prompted to log in to Heroku when running git push heroku master. This makes sense as I don’t want an unauthenticated or unauthorized user to be able to push to the app, but I’ll need to figure out how to pass the credentials to Heroku when deploying from the Action since I won’t be able to interact with the login prompts when the tasks run on GitHub.

Luckily, tooling exists for this. GitHub allows storing secrets on a per-repo basis, and any credentials can be passed to the workflow by defining environment variables. Environment variables can be defined per workflow, per job, or within the job steps. Read more about secrets and environment variables here.

In my first pass, I attempted defining the HEROKU_API_KEY environment variable, which I was hoping Heroku would be able to read and use to authenticate the request. To do this, I added the secret to my repository and passed it to my task like so:

env:
HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }}

This pulls the value from the secret I defined in the repository, and sets it as the value of the environment variable HEROKU_API_KEY in the container running my task.

Next, I attempted to deploy the task to Heroku from the workflow using the standard git push heroku master. This didn’t go so well —

Trying to push my repository to Heroku doesn’t work, as the virtual environment doesn’t have a remote set up named heroku. While running the actions/checkout step will checkout the code, it won’t set up the Heroku remotes. To get around this, I can add this as a step:

- run: heroku git:remote -a my_app_name

Now, we can try pushing our code up again:

Heroku is unable to authenticate the workflow user, so it can’t push the code! Some very helpful StackOverflow articles helped me discover that defining the credentials in a .netrc file would signal to Heroku to use those credentials instead of prompting for a login.

But, the question was, how should I create this file? I need it to be accessible to git so GitHub has access to it, but I obviously don’t want to be committing my credentials, and the file itself can’t evaluate the environment variables. I decided for simplicity’s sake to build this file on the fly, as suggested in the Stack Overflow comment. If I were defining my own action, I might have opted to do this there, but because the workflow is simple enough, I defined this as one of the workflow steps:

- run: |+
cat >~/.netrc <<EOF
machine api.heroku.com
login $HEROKU_EMAIL
password $HEROKU_API_KEY
machine git.heroku.com
login $HEROKU_EMAIL
password $HEROKU_API_KEY
EOF

This will read both the HEROKU_EMAIL and the HEROKU_API_KEY environment variables that I’ve set, and put them in the .netrc file so the container has access to those credentials.

Best of all, GitHub Actions will scrub these variables from the log for me. So, while it’s able to read the variables and store them in the file, later steps that print out the contents of the file will filter those values:

Sensitive Env vars like login and password are scrubbed from the logs

Now that the remote and credentials are set, the git push heroku master command should work. And it does! Yay!

After getting it working, I realized I didn’t necessarily want to hard-code my Heroku app name in my workflow. Luckily, GitHub Actions sets a number of environment variables for use in your action, and also luckily, my app on Heroku has the same name as my GitHub repository, so I was able to read this from the environment. I was able to use basename to scrub out my username to get just the repo name. The new command looks like the following:

- run: heroku git:remote -a $(basename $GITHUB_REPOSITORY)

With this new workflow, when I push code to my master branch, I’ll be able to run tests and deploy the code to Heroku, without having to do anything extra. I can even add another step to the process to run any migrations after pushing.

There’s one final thing to consider — by default, the jobs in the workflow will run in parallel. This makes sense, and is a great feature when our top priority is speed. In my case, however, I want to make sure that I only deploy if all the tests pass. The good news is there’s a way to define dependencies using the needs: keyword. Here, I can specify that one job depends on another, and it will only run if all steps in the previous job succeed. Hooray!

Let’s take a look at the definition for the final release job:

This job will:

1. Set the environment for the job, using the Heroku credentials

2. Define the OS to use

3. Wait until the test job has completed successfully before executing

4. Checks out the code from GitHub

5. Adds the Heroku credentials to a .netrc file

6. Sets the Heroku app as git remote repo, as this is a clean environment

7. Finally, pushes the code up to Heroku

Success!

Here’s what the output from the workflow looks like when it runs:

So, with that, I’ve defined a workflow that lets me deploy my app with confidence. The final workflow file is relatively simple (just over 50 lines), but as you can see, this simplifies the process of testing and deploying, and opens the doors to automate even more of the workflow.

Some Gotchas

As simple as the final workflow seems, I did run into some gotchas when setting this up the first time.

1. When I first attempted to run the setup-elixir action, I got a notification that the task could not run because the migration directory was not set up. While my Phoenix app did indeed have a migration directory, there were no migration files yet as I was attempting to deploy a boilerplate app. I got around this by defining an empty migration, but with more time, I would have dug into why this having a migration file was required to run mix test in the GitHub Actions environment, but not locally.

2. Figuring out the best way to run the Heroku deploy commands was a bit tricky. I initially looked into the pre-built action on actions/heroku. But, this action still uses the HCL syntax, and seemed to only support deploying using Heroku’s container deploy process. For such a simple app, I didn’t want to have to have to containerize the app and the DB. So, I decided to roll my own workflow.

3. Determining the best way to authenticate with Heroku was a bit tricky, but landing on the .netrc file file created at runtime seemed to work well.

4. I originally wanted my workflow to do all the Heroku setup for me, including ensuring that the correct buildpacks were set on the app. But, including the buildpack setup step in the workflow would error on subsequent runs — Heroku expects that you’ll only set a buildpack once, so I had to take this out of the workflow.

Although I ran into a couple snags while setting up the workflow, getting my hands dirty was a great way to get familiar with the syntax and the log output from the Actions made debugging much easier.

If you’d like to check out the full workflow in action, check out the example repo!

--

--

Kate Studwell

aerial dancer, nc native, developer, and brunch, language, and travel enthusiast