Boring Rails Book

Articles

Building a Rails CI pipeline with GitHub Actions

Feb 8th, 2020 12 min read

GitHub Actions is an automation platform that you run directly from inside a GitHub repository.

Using GitHub Actions, you build workflows that are triggered by any kind of event. These workflows run arbitrary code as Jobs and you can piece together multiple Steps to achieve pretty much whatever you want.

Aside from automatically posting GIFs on every pull request, the most obvious use-case for this new platform is to build a testing CI/CD pipeline.

Why bother? I’m already using CircleCI / Travis / Semaphore…

Good question. All of these CI/CD platforms are, more or less, equivalent.

Dedicated CI services like CircleCI are battle-tested for the use-case of running linters, executing tests, reporting results, and kicking off deploys. GitHub Actions is more akin to LEGO – it is a generic platform for running arbitrary workflows and automation.

On the other hand, GitHub Actions come included in your GitHub projects. For public repositories, GitHub Actions are free (without limitations as of this writing). For private repositories, you get 2000 free build minutes per month per repository – and if you have an existing GitHub company plan, you’re getting upwards of 10k build minutes included at no extra cost. (Full billing/usage limits here)

All of the competing platforms have free tiers and, really, the biggest benefit to a project is to have ANY kind of CI/CD tooling set up. The differences between vendors are pretty minor.

I’m trying to keep my Rails applications as boring as possible. There is enough complexity in building products and solving customer problems that I don’t need to create more work for myself and my team with an exotic ops setup.

I also work at a custom software consultancy: I help other companies ship products and tools so it is appealing to have one less external service to juggle.

Setting up CircleCI isn’t hard, but it’s one more account to register, one more place to put a corporate credit card, one more thing that my customer might ask “hey, what is that for again?”.

Additionally, I like that we can keep our CI pipeline close to the code. One of the biggest benefits in the current wave of CI/CD tooling is the shift to Infrastructure-as-Code: you configure your build using a flat file and commit that with your project. This is miles better than fumbling around a poorly maintained Jenkins instance and changes to your pipeline can be reviewed and merged like the rest of the codebase.

GitHub Actions pushes this to the extreme. Now you have one single spot for a project’s source code, issue tracker, project management (GitHub Projects), code reviews, security alerts, and now: CI/CD testing. One account, one service, one place to look.

Migrating from CircleCI to GitHub Actions for a common Rails setup

If you’re like most people, you probably set up your CircleCI configuration on the first week of the project based on a thoughtbot blog post and haven’t touched it since. Me too.

But no worries, it is straight-forward to move from the CircleCI 2.0 configuration format to the GitHub Actions syntax.

Your configuration might be a little bit different, but conceptually a vanilla CI/CD pipeline for a Rails app:

  • Checks out the latest version of the code
  • Sets up a base image with Ruby, Node, and some browser testing stuff
  • Sets up a PostgreSQL database service
  • Installs dependencies (bundler, yarn, npm) and cache the results to speed up builds when they don’t change
  • Sets up the test database
  • Runs any linters/checkers (rubocop, eslint, brakeman, etc)
  • Runs the tests

Those steps run in order and if any of them fail, the build fails. You get a red :x: on your commit in GitHub, you can’t merge your PR, someone yells at you in Slack, you know the drill.

To get a build running on GitHub Actions, we simply translate those high-level actions into the matching workflow syntax.

One change I found useful to make was to take advantage of GitHub Actions allowing for parallel Jobs to run as part of a single workflow. This feature allows us to run linters and the tests as separate jobs that both must pass for the overall build to pass.

Most of the competitors do not offer much parallelization on the free/cheap tiers. While each job has to setup the basic environment, we can save a few minutes by running the final steps at the same time. (Note: this approach will use less minutes of wall clock time, but you will use more total build minutes).

Here is my current GitHub Actions workflow configuration:

# Put this in the file: .github/workflows/verify.yml

name: Verify
on: [push]

jobs:
  linters:
    name: Linters
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
      - name: Ruby gem cache
        uses: actions/cache@v1
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-gems-
      - name: Install gems
        run: |
          bundle config path vendor/bundle
          bundle install --jobs 4 --retry 3

      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: 10.13.0
      - name: Find yarn cache location
        id: yarn-cache
        run: echo "::set-output name=dir::$(yarn cache dir)"
      - name: JS package cache
        uses: actions/cache@v1
        with:
          path: ${{ steps.yarn-cache.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-
      - name: Install packages
        run: |
          yarn install --pure-lockfile

      - name: Run linters
        run: |
          bin/rubocop --parallel
          bin/stylelint
          bin/prettier
          bin/eslint
      - name: Run security checks
        run: |
          bin/bundler-audit --update
          bin/brakeman -q -w2

  tests:
    name: Tests
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:11
        env:
          POSTGRES_USER: myapp
          POSTGRES_DB: myapp_test
          POSTGRES_PASSWORD: ""
        ports: ["5432:5432"]

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
      - name: Ruby gem cache
        uses: actions/cache@v1
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-gems-
      - name: Install gems
        run: |
          bundle config path vendor/bundle
          bundle install --jobs 4 --retry 3

      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: 10.13.0
      - name: Find yarn cache location
        id: yarn-cache
        run: echo "::set-output name=dir::$(yarn cache dir)"
      - name: JS package cache
        uses: actions/cache@v1
        with:
          path: ${{ steps.yarn-cache.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-
      - name: Install packages
        run: |
          yarn install --pure-lockfile

      - name: Setup test database
        env:
          RAILS_ENV: test
          PGHOST: localhost
          PGUSER: myapp
        run: |
          bin/rails db:setup

      - name: Run tests
        run: bin/rspec

A few notes:

  • Since GitHub Actions is general purpose automation platform (and not solely for CI/CD), you need to tell the action when to run: in this case, we want it to run on every every push. You can configure this further to run on only certain branches or files if you wish.

  • Instead of using a platform-provided Docker image with a bunch of common environment tooling setup (e.g. circleci/ruby:2.6.3-node-browsers), in GitHub Actions you are advised to compose other first-party “setup” actions that will link in binaries. In this case, we use ruby/setup-ruby and actions/setup-node to include the specific versions of ruby and node that we want to use. These steps are very quick as they are essentially linking into pre-built language binaries.

  • You may notice that I am using ruby/setup-ruby (a community action) instead of the first-party GitHub actions/setup-ruby. The official action has lagged behind in both features and release versions when it comes to Ruby. During the beta of GitHub Actions, we had to completely stop using Actions because you could not easily pick the specific minor-release version of Ruby and the official action was, in some cases, months behind on supporting the latest Ruby versions (include security releases). The ruby/setup-ruby action works great – you can pick any version and any flavor (jruby, truffleruby, etc) and it will pull in a pre-built binary in under 5 seconds.

  • Many articles for setting up a PostgreSQL service (including the official example) include extra health-check options to make sure the database is started up before proceeding. In my experience, waiting for the health check was taking 15-60 extra seconds. Since we have to install gems and do other setup before we try to connect to the database, I removed them to shave down the run time and I have not had any problems. :man_shrugging: At the very least, consider changing the health interval settings from the example to something like: --health-interval 10ms --health-timeout 500ms --health-retries 15. This reduced my “Initializing containers” step from ~30 seconds to ~10 seconds on EVERY BUILD.

  • Experiment with the alpine Postgres Docker images. These images use a stripped down Linux system that is smaller than the base Docker image. In my testing, the alpine images were about 33% faster to download and spin up. The trade-off is that you may not be running the exact same environment as your production database, but for projects that aren’t doing anything fancy with Postgres, it seems worth the trade off to me. To use these images, replace e.g. postgres:11 with postgres:11-alpine in your workflow. You can find a full list of all the official Postgres images on Dockerhub.

  • I also experimented with mounting the PostgreSQL service to something called tmpfs. This bit of Docker configuration was way over my head, but I’ve read that it can have substantial speed ups for tests that use the database in a container. My rspec test suite seem to run a bit slower on Github Actions than CircleCI (~15-30 seconds slower). CircleCI publishes their own Docker images with a -ram suffix that use a RAM volume and may increase your test suite performance. Neither the tmpfs mounting nor the CircleCI -ram images seemed to make a noticeable difference for my project, but your mileage may vary. :woman_shrugging:

  • During the beta, GitHub Action builds were slow. Noticeably slower than my CircleCI build. The reason was dependency caching. Caching was released after GitHub Actions was fully launched and works great: if your gems or packages have not changed since the last build run, you can use the cached versions to save a bunch of time. You can find a list of example caching steps for all of the popular package managers here.

  • You should check the various tools you use for options to speed up the run-time in a CI environment. For example, yarn has a --pure-lockfile option that tells it not to try to create a lockfile (since you already checked one in…) and rubocop has a --parallel flag to save a bit of time. When it comes to CI builds, saving a few seconds here and there adds up quickly.

Overall Impressions

I spent one evening setting up the workflow with the goal of getting it to feature parity with our existing CircleCI setup and I was able to achieve that.

That said, I am not a DevOps expert – I’m just trying to get a solid pipeline set up so I can focus on more important things.

GitHub Actions is a compelling option that is worthy of becoming the community default. It makes too much sense to have GitHub be the one-stop place for your repository, issue tracker, and build server. One less external service means less mental overheard and a more “boring” setup that lets us focus on shipping quickly and solving customer problems, not doing integration work.

If I was starting a new project, I would absolutely start using GitHub Actions as my default CI service.

Feb 2020 Update

This article has been updated: the original draft was written during the GitHub Actions beta period and many of the areas for improvement were fixed!

Was this article valuable? Subscribe to the low-volume, high-signal newsletter. No spam. All killer, no filler.

Get notified when new Boring Rails content is posted