Boring Rails Book

Articles

Building a Rails CI pipeline with GitHub Actions

Sep 23rd, 2019 11 min read

GitHub Actions is a new 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 can run arbitrary code as Jobs and you can piece together multiple Steps to achieve pretty much whatever you want.

Aside 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 CirceCI/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, at least in this current beta stage, is more akin to LEGO.

On the other hand, GitHub Actions come included in your GitHub projects. For public repositories, GitHub Actions are free (apparently without limitations). 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.

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 am also working 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 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 audits, 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.

Here is what my project’s (mostly) standard CircleCI config looked like:

# .circleci/config.yml

version: 2
jobs:
  build:
    docker:
      - image: circleci/ruby:2.6.3-node-browsers
        environment:
          RAILS_ENV: test
          PGHOST: localhost
          PGUSER: myapp

      - image: circleci/postgres:10.5
        environment:
          POSTGRES_USER: myapp
          POSTGRES_DB: myapp_test
          POSTGRES_PASSWORD: ""

    working_directory: ~/repo

    steps:
      - checkout

      # Download (and cache) dependencies
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "Gemfile.lock" }}
            - v1-dependencies-

      - run:
          name: install dependencies
          command: |
            bundle install --path vendor/bundle

      - save_cache:
          paths:
            - ./vendor/bundle
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}

      - restore_cache:
          keys:
            - rails-yarn-{{ checksum "yarn.lock" }}
            - rails-yarn-

      - run:
          name: Yarn Install
          command: |
            yarn install --cache-folder ~/.cache/yarn

      # Store yarn / webpacker cache
      - save_cache:
          key: rails-yarn-{{ checksum "yarn.lock" }}
          paths:
            - ~/.cache/yarn

      # Database setup
      - run: bin/rails db:create
      - run: bin/rails db:migrate

      # run tests, linter, etc!
      - run: bin/rubocop
      - run: bin/eslint
      - run: bin/prettier
      - run: bin/stylelint

      - run: bin/bundler-audit --update
      - run: bin/brakeman -q -w2

      - run: bin/rspec

Your configuration might look a little bit different (especially near the end of the file where you are actually running whatever project specific tests and checks you have), but conceptually this vanilla CI/CD pipeline:

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

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

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

Please note that GitHub Actions is still in beta and iterating fairly quickly, there are some rough edges. There may be slight differences in the example below, but it should be directionally correct.

# .github/workflows/verify.yml

name: Verify
on: [push, pull_request]

jobs:
  verify:
    name: Build
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:11
        env:
          POSTGRES_USER: scout
          POSTGRES_DB: scout_test
          POSTGRES_PASSWORD: ""
        ports: ["5432:5432"]
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v1
      - name: Set up Ruby
        uses: actions/setup-ruby@v1
        with:
          ruby-version: 2.6.3
      - name: Set up Node
        uses: actions/setup-node@v1
        with:
          node-version: 10.13.0
      - name: Install dependencies
        run: |
          sudo apt-get -yqq install libpq-dev chromium-chromedriver
          gem install bundler
          bundle install
          yarn install
      - name: Setup test database
        env:
          RAILS_ENV: test
          PGHOST: localhost
          PGUSER: scout
        run: |
          bin/rails db:create
          bin/rails db:migrate

      - name: Run Rubocop
        run: bin/rubocop
      - name: Run Style Lint
        run: bin/stylelint
      - name: Run Prettier
        run: bin/prettier
      - name: Run ESLint
        run: bin/eslint

      - name: Run Bundler Audit
        run: bin/bundler-audit --update
      - name: Run Brakeman
        run: bin/brakeman -q -w2

      - name: Run tests
        run: bin/rspec

A few notable differences:

  • 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 and pull_request. 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 actions/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 come from a global cache.

  • Setting up a Postgres service was very similar but there were some extra health-check options that were recommended. I have no idea how critical they are or if I was supposed to be using something similar on CircleCI. :man_shrugging:

  • I had to drop down to the dreaded sudo apt-get shell to install libpq-dev (for ActiveRecord) and chromium-chromedriver (for Capybara headless Chrome mode). Not only did this feel like a step backwards, but it’s pretty slow to install these on every build and feels wasteful. There is a big list of what pre-existing software comes on the standard GitHub virtual environments but these two items were not on the list and seem pretty standard for a Rails CI setup. Hopefully these will be added to the stock image as the beta continues.

  • The build is slow. Noticeably slower than my CircleCI build. The reason is simple to say, but harder to address: dependency caching. The performance of GitHub Actions was comparable to CircleCI for starting up the container and running the linters/tests, but installing the system binaries, gems, and JavaScript packages has to be done every build. For my case, this setup represented about 50% of the total execution time of the workflow. GitHub has acknowledged that caching is important and plans to have it released by November 2019.

  • There is no queue. On the positive side, you can queue up multiple builds without waiting and you can parallelize your workflows by using multiple Jobs. Most of the competitors do not offer these features on the free/cheap tiers and it was very nice. I ended up splitting my GitHub Actions workflow into 3 parallel Jobs: one to run Ruby linter/static analysis, one for JS/CSS linting, and one for RSpec. While each job has to do the same environment setup, we save a few minutes running the final test steps in parallel.

Areas for Improvement

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.

Perhaps this recipe could be improved: I tried to get really sneaky and use the CircleCI community Docker images when running my workflow (to avoid some of the environment setup), but ran into both Linux permissions issues and did not see a huge performance boost.

Maybe there is an approach where you build a Docker container that has your app dependencies compiled and built already and you run that as your run-time environment. I don’t currently have that setup for my project and don’t see us doing that anytime soon, but it might help.

Overall Impressions

GitHub Actions is going to be 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.

But the key phrase there is: going to be. The lack of dependency caching is a pretty big hurdle to overcome if you have even a moderately-sized application and are already set up on CirceCI (or another comparable platform). Rails tests suites are notoriously slow and, while things are constantly getting better at a framework level, I’m not sure dev teams will be willing to pay an extra 20-50% cost on build times to transition.

If I was starting a new project, I would still stick with CircleCI, but once GitHub Actions smooths out the issues detailed above, I would absolutely migrate over and start using it as my default CI service.

If you are not as concerned with build times or are dealing with lots of queuing/waiting for builds (and can’t throw money at the problem), GitHub Actions is already a reasonable option and you should have no trouble switching over immediately.

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