Continuous Integration in a Ruby on Rails 6 application with Github Actions

Assume you have a large or mid-level organization where a repository is shared by multiple developers and they often push the code during a day. Let’s say, a developer was building a feature and was coding in a class/module that was being called by ten other places, coded by other developer(s). Let’s also assume that he has completed his feature, tested it and it’s ready for production and it is pushed to the repository and deployed. But he might not be aware of the fact that he has changed the code that was being called by several other modules, and now, let’s say if not all, 2 to 3 of the places are broken and no one knows about it until end-user hits the problem, report it, then the developers fix the code for those 2 to 3 places, this might end up causing problems in other places where it was being called, and you end up in total chaos and it also multiplies the cost of development.

This is the best scenario where Continuous Integration comes in. It is a practice in software development that is being followed in almost all modern applications because of its numerous advantages that include.

We’re always developing large scale Ruby on Rails applications where developers push the code quite often and before we actually start development, we work on an integration/deployment script so that we have a solid foundation and a check on code even on the first push. This blog will focus on the integration script for a Ruby on Rails application and the Github as the platform. So I am taking the liberty of assuming that you have.

  1. A Github account.
  2. Knowledge of Git.
  3. A Ruby on Rails application(You might also clone it from here).
  4. Knowledge of Rspec(Unit testing gem used in almost all applications).
  5. Rubocop(Code refactoring gem).

That’s enough talking, now start working. Let’s go ahead and create main.yml(you can name it anything with yml extension) in project/.github/workflows directory.

In your .github/workflows/main.yml let’s first put this code.

1
2
3
name: CI

on: [push, pull_request]

Here we are not doing much work, just telling our action that we want to run the CI when we push the code and also when a pull request is created.

Let’s now tell or action that we want to run our job on latest version of Ubuntu that Github has.

1
2
3
4
5
6
7
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

Configure Postgres and Redis Server as our Services.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    # Similar to docker-compose.yml but not the same, 🤷‍♂️
    services:
      postgres:
        image: postgres:11.6-alpine
        ports: ['5432:5432']
        # needed because the postgres container does not provide a healthcheck
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis
        ports: ['6379:6379']
        options: --entrypoint redis-server

Now it’s time to do Ruby on Rails stuff in our action and for that, I had do consult GoRails and I always use their script for that. Let’s set it up. This script is for Rails 6 application and it was a bit tricky as for now, we don’t have much in Rails 6 because many applications are running on Rails 5. So here it is.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    # Similar to docker-compose.yml but not the same, 🤷‍♂️
    services:
      postgres:
        image: postgres:11.6-alpine
        ports: ['5432:5432']
        # needed because the postgres container does not provide a healthcheck
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis
        ports: ['6379:6379']
        options: --entrypoint redis-server

    steps:
      - uses: actions/checkout@v2
      - name: Setup Ruby
        uses: actions/setup-ruby@v1
        with:
          ruby-version: 2.7.x
      - name: Install required apt packages
        run: |
          sudo apt-get -y install libpq-dev
      - name: Setup cache key and directory for gems cache
        uses: actions/cache@v1
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gem-use-ruby-${{ hashFiles('**/Gemfile.lock') }}
      - name: Read Node.js version to install from `.nvmrc`
        run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)"
        id: nvm
      - name: Install required Node.js version
        uses: actions/setup-node@v1
        with:
          node-version: "${{ steps.nvm.outputs.NVMRC }}"
      - name: Get Yarn cache directory path
        id: yarn-cache
        run: echo "::set-output name=dir::$(yarn cache dir)"
      - name: Setup cache key and directory for node_modules cache
        uses: actions/cache@v1
        with:
          path: ${{ steps.yarn-cache.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
      - name: Bundle install
        run: |
          gem install bundler:2.1.4
          bundle install --jobs 4 --retry 3
      - name: Yarn install
        run: yarn --frozen-lockfile

At this point, if you don’t have Rspec and Rubocop included in your gems, let’s add them.

1
2
3
4
5
6
group :development, :test do
  # Call 'byebug' anywhere in the code to stop execution and get a debugger console
  gem 'byebug', platforms: %i[mri mingw x64_mingw]
  gem 'rspec-rails'
  gem 'rubocop'
end

Configure Rspec here and Consult Rubocop here, they have good documentation.

Add some models and controller and basic functionality to your app, well you can always clone it here. Write tests against them and then update your integration script like below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    # Similar to docker-compose.yml but not the same, 🤷‍♂️
    services:
      postgres:
        image: postgres:11.6-alpine
        ports: ['5432:5432']
        # needed because the postgres container does not provide a healthcheck
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis
        ports: ['6379:6379']
        options: --entrypoint redis-server

    steps:
      - uses: actions/checkout@v2
      - name: Setup Ruby
        uses: actions/setup-ruby@v1
        with:
          ruby-version: 2.7.x
      - name: Install required apt packages
        run: |
          sudo apt-get -y install libpq-dev
      - name: Setup cache key and directory for gems cache
        uses: actions/cache@v1
        with:
          path: vendor/bundle
          key: ${{ runner.os }}-gem-use-ruby-${{ hashFiles('**/Gemfile.lock') }}
      - name: Read Node.js version to install from `.nvmrc`
        run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)"
        id: nvm
      - name: Install required Node.js version
        uses: actions/setup-node@v1
        with:
          node-version: "${{ steps.nvm.outputs.NVMRC }}"
      - name: Get Yarn cache directory path
        id: yarn-cache
        run: echo "::set-output name=dir::$(yarn cache dir)"
      - name: Setup cache key and directory for node_modules cache
        uses: actions/cache@v1
        with:
          path: ${{ steps.yarn-cache.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
      - name: Bundle install
        run: |
          gem install bundler:2.1.4
          bundle install --jobs 4 --retry 3
      - name: Yarn install
        run: yarn --frozen-lockfile
      - name: Rails test
        env: # Or as an environment variable
          DATABASE_URL: postgres://postgres:@localhost:5432/test
          REDIS_URL: redis://localhost:6379/0
          RAILS_ENV: test
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
        run: |
          bundle exec rake db:drop db:create db:schema:load
          bundle exec rake db:migrate db:seed
          bundle exec rspec
      - name: Rubocop
        run: |
          bundle exec rubocop

You have successfully written your integration script and probably, I have saved you from a chaos and frustration coming your way. Happy pushing!