CI for Ruby on Rails: GitHub Actions

CI for Ruby on Rails: GitHub Actions vs. CircleCI

This is part of a three part series where I will walk you through setting up your CI suite with GitHub Actions, CircleCI, and then comparing which you may want to use if you are setting up continuous integration for your Rails app.

Part 1: GitHub Actions

1. Set the name for your action

name: Run Tests & Linters

2. Set what events should trigger the action to run

name: Run Tests & Linters

on:
  pull_request:
    branches:
      - '*'
  push:
    branches:
      - master

What this says is that this action will run anytime a pull_request is updated on any branch, and also on pushes to master.

3. Create your job, and choose what to run the action on

jobs:
  build:
    runs-on: ubuntu-latest

This tells our action we want to run the action on Ubuntu, and use the latest version GitHub has available, which is Ubuntu 18.04.

4. Define services

For a typical Rails app, you are probably using Redis for caching a tools like Sidekiq, and you also probably have a database. Defining services in your action allows us to use additional containers to run these types of tools.

services:
  postgres: # The name of the service
    image: postgres:11 # A docker image
    env: # Environment variables you want to use inside the service
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
    ports: ['5432:5432'] # The port that you can access the service on
    options:
      >- # Options for the service, in this case we want to make sure that the Postgres container passes a health check
      --health-cmd pg_isready
      --health-interval 10s
      --health-timeout 5s
      --health-retries 5
  redis: # The name of the service
    image: redis # A docker image
    ports: ['6379:6379'] # The ports that you can access the service on
    options: --entrypoint redis-server # Options for the service

5. Setup dependencies and checkout the branch

Here is where it got tricky for me. If you search for using GitHub actions with Rails, you will probably see something like this:

- uses: actions/checkout@v1
- name: Setup Ruby
  uses: actions/setup-ruby@v1
  with:
    ruby-version: 2.6.x
- uses: borales/actions-yarn@v2.0.0
  with:
    cmd: install

This particular example is from my friend Chris Oliver, who runs Go Rails (check it out!!).

This solution would have been great except:

  • The latest Ruby version available from GitHub is Ruby 2.6.3
  • The latest Node version available from GitHub is Node 12.13.1

At CodeFund, we are using Ruby 2.6.5 (about to bump to 2.7) and Node 13.0.1. There are a few solutions that have been proposed for this problem, like installing the version of Ruby you want from source with ruby build or using a tool like nvm. These may work for you but they can be slow, and they wouldn’t work for a problem I would later have. Instead, I wrote my own Docker image that had everything I needed already built in. Ruby 2.6.5, Node 13.0.1, additional packages you would need for Postgres, Chrome for system tests, Bundler 2.0.2, and my generic environment variables.

I am not going to explain all of the details here, and I know I could reduce the size a bit but here is the first iteration of that image:

FROM ruby:2.6.5

LABEL "name"="Locomotive"
LABEL "version"="0.0.1"

ENV APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn
ENV BUNDLE_PATH='/bundle/vendor'
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US.UTF-8
ENV LC_ALL=C.UTF-8
ENV PG_HOST='postgres'
ENV PG_PASSWORD='postgres'
ENV PG_USERNAME='postgres'
ENV RACK_ENV='test'
ENV RAILS_ENV='test'
ENV REDIS_CACHE_URL='redis://redis:6379/0'
ENV REDIS_QUEUE_URL='redis://redis:6379/0'

RUN  curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
     echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
     curl -sL https://deb.nodesource.com/setup_13.x | bash - && \
     wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
     echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list && \
     apt-get update && \
     apt-get install -y google-chrome-stable && \
     echo "CHROME_BIN=/usr/bin/google-chrome" | tee -a /etc/environment && \
     wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - && \
     echo 'deb http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \
     apt-get -yqq install libpq-dev && \
     apt-get install -qq -y google-chrome-stable yarn nodejs postgresql postgresql-contrib

RUN gem install bundler:2.0.2

6. Use Docker container

container:
  image: andrewmcodes/locomotive:v0.0.1 # my image name
  env: # additional environment variables I want to have access to
    DEFAULT_HOST: app.codefund.io

Note: If you do not set a container, all steps will run directly on the host specified, which if you remember is Ubuntu 18.04.

As of now, our action looks like:

name: Run Tests & Linters

on:
  pull_request:
    branches:
      - '*'
  push:
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:11
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: postgres
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis
        ports: ['6379:6379']
        options: --entrypoint redis-server
    container:
      image: andrewmcodes/locomotive:v0.0.1
      env:
        DEFAULT_HOST: app.codefund.io

7. Add steps

Now it is time to run commands inside of our container. We will start by checking out the code.

steps:
  - uses: actions/checkout@v2

8. Caching

Thankfully, GitHub provides some examples for getting started with your tools of choice for caching dependencies. I recommend checking those out and the documentation.

GitHub Actions Cache Examples Cache Documentation

NOTE: Individual caches are limited to 400MB and a repository can have up to 2GB of caches. Once the 2GB limit is reached, older caches will be evicted based on when the cache was last accessed. Caches that are not accessed within the last week will also be evicted.

- name: Get Yarn Cache
  id: yarn-cache
  run: echo "::set-output name=dir::$(yarn cache dir)"

- name: Node Modules Cache
  id: node-modules-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: Gems Cache
  id: gem-cache
  uses: actions/cache@v1
  with:
    path: vendor/bundle
    key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
    restore-keys: |
      ${{ runner.os }}-gem-

- name: Assets Cache
  id: assets-cache
  uses: actions/cache@v1
  with:
    path: public/packs-test
    key: ${{ runner.os }}-assets-${{ steps.extract_branch.outputs.branch }}
    restore-keys: |
      ${{ runner.os }}-assets-

9. Bundle, Yarn, and Precompile Assets

Next, we will want to run Bundler and Yarn to install our dependencies if they were not restored from the cache, and precompile our assets.

- name: Bundle Install
  run: bundle check || bundle install --path vendor/bundle --jobs 4 --retry 3

- name: Yarn Install
  run: yarn check || bin/rails yarn:install

- name: Compile Assets
  run: |
    if [[ ! -d public/packs-test ]]; then
      bin/rails webpacker:compile
    else
      echo "No need to compile assets."
    fi

NOTE: You may be able to skip the asset compilation, that is up to you.

10. Update some files

In order to get this to work, I had to make a couple updates to some files in my project.

1. config/database.yml

Update host for test to be: host: <%= ENV.fetch("PG_HOST", "localhost") %>

  1. Update test/application_system_test_case.rb
     require "test_helper"

     class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
       driven_by :selenium, using: :headless_chrome, screen_size: [1400,1400] do |driver_options|
        driver_options.add_argument("--disable-dev-shm-usage")
        driver_options.add_argument("--no-sandbox")
      end
    end

11. Setup Database

One last item we need to take care of prior to running the tests and linters is setting up our database.

- name: Setup DB
      run: bin/rails db:drop db:create db:structure:load --trace

12. Run Tests and Linters

Now we can finally run our tests and linters.

- name: Run Rails Tests
  run: |
    bin/rails test
    bin/rails test:system

- name: Zeitwerk Check
  run: bundle exec rails zeitwerk:check

- name: StandardRB Check
  run: bundle exec standardrb --format progress

- name: ERB Lint
  run: bundle exec erblint app/views_redesigned/**/*.html.erb

- name: Prettier-Standard Check
  run: yarn run --ignore-engines prettier-standard --check 'app/**/*.js'

At this point, your action should be complete!

Here is my completed action file:

name: Run Tests & Linters

on:
  pull_request:
    branches:
      - '*'
  push:
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:11
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: postgres
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis
        ports: ['6379:6379']
        options: --entrypoint redis-server
    container:
      image: andrewmcodes/locomotive:v0.0.1
      env:
        DEFAULT_HOST: app.codefund.io
    steps:
      - uses: actions/checkout@v1

      - name: Get Yarn Cache
        id: yarn-cache
        run: echo "::set-output name=dir::$(yarn cache dir)"

      - name: Cache Node Modules
        id: node-modules-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: Cache Gems
        id: gem-cache
        uses: actions/cache@v1
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-gem-

      - name: Cache Assets
        id: assets-cache
        uses: actions/cache@v1
        with:
          path: public/packs-test
          key: ${{ runner.os }}-assets-${{ steps.extract_branch.outputs.branch }}
          restore-keys: |
            ${{ runner.os }}-assets-

      - name: Bundle Install
        run: bundle install --path vendor/bundle --jobs 4 --retry 3

      - name: Yarn Install
        run: bin/rails yarn:install

      - name: Compile Assets
        shell: bash
        run: |
          if [[ ! -d public/packs-test ]]; then
            bundle exec rails webpacker:compile
          else
            echo "No need to compile assets."
          fi

      - name: Setup DB
        run: bin/rails db:drop db:create db:structure:load --trace

      - name: Run Rails Tests
        run: |
          bin/rails test
          bin/rails test:system

      - name: Zeitwerk Check
        run: bundle exec rails zeitwerk:check

      - name: StandardRB Check
        run: bundle exec standardrb --format progress

      - name: ERB Lint
        run: bundle exec erblint app/views_redesigned/**/*.html.erb

      - name: Prettier-Standard Check
        run: yarn run --ignore-engines prettier-standard --check 'app/**/*.js'

As you can see, setting up GitHub Actions for your CI can be quite involved and requires a lot of initial setup. Hopefully this post will help you if you are thinking of experimenting with them on your Rails app. Check back later this week for Part 2, setting up CircleCI!

This post is also available on DEV.