Engineering November 20, 2023 10 min read

Ruby on Rails CI\CD with GitLab

Ruby on Rails CI\CD with GitLab

In today’s fast-paced software development landscape and here at Runtime Revolution, delivering high-quality applications with speed and efficiency is crucial.

Continuous Integration (CI) and Continuous Delivery (CD) play a vital role in achieving this goal. By automating the testing, building, and deployment processes, you can ensure that your Ruby on Rails application is always in a releasable state.

In this blog post, we’ll explore how to implement CI/CD using GitLab to streamline your Ruby on Rails development workflow in 5 core steps.

Step 1

Create the .gitlab-ci.yml

Navigate to the root of your Ruby on Rails application repository and create a new file named.gitlab-ci.yml. This will be the file that will trigger the GitLab runner jobs and will have both logic for CI and CD.

Step 2

Identify all the stages

For our CI\CD structure we want to perform 5 tasks. We want to setup our project in GitLab, we want to run our tests, we want to check the code styling, we want to check our test coverage, and we want to deploy our project.

All those 5 tasks could be matched one-to-one as a stage, but since our project structure doesn’t require any dependency between tests and code styling we can join both into a single stage.

This results in the following stages:

  • Project setup (setup)
  • Project validation (validation)
  • Test coverage (coverage)
  • Project deploy (deploy)

This allow us to start defining our .gitlab-ci.yml

Code
stages:
  - setup
  - validation
  - coverage
  - deploy

# ...

Notice, this will define the order on wich the the stages will run sequentially. The concurrency can only occur with jobs if we have more than one defined for a stage.

Step 3

Setting up our project

To setup our project and make our file simpler and cleaner the first thing we will do is define a hidden job. An hidden job starts with the character . and doesn’t run but can be reused on other jobs.

This allow us to define in one place and not rewrite in each job:

  1. The common image for each job;
  2. The common rules to run or not the job;
  3. The common scripts;
  4. And the common cache we will use to share resources.

Step 3.1 Container image

In GitLab you can specify any public or private image that isn’t hosted on a private network. This allow us to pick an official ruby image hosted in dockerhub with the required version ready to run our project.

Some notes about these images, depending on the tags slim-bullseye, slim-bookworm, slim, bullseye, and bookworm, it will have some packages already installed or not, and whether their size will be greater or smaller. For example, comparing the image ruby:3.2.2-slim and ruby:3.2.2 you can see that the slim version is only 74.23MB and the regular is 365.67MB, and comparing their packages the slim version, for example, doesn't have the git package, which would require us to add extra commands in our .yml to install it if we want to use it.

With this is mind let’s start defining our hidden job:

Code
# ...

.base_setup:
  image: ruby:3.2.2

# ...

Step 3.2 Rules

Before we continue defining our .gitlab-ci.yml let's discuss Git branching strategies. Git branching strategies are rules that developers follow to stipulate how they interact with a shared codebase. This is necessary as it helps keep repositories organized to avoid errors and conflicts when merging work.

If you are not aware there are already some strategies defined by the top companies such as:

We could discuss all of them, but let’s keep it simple. Despite all their rules one thing they share in common: you never work directly on the production branch, you perform changes on a specific branch up-to-date with production.

In our case the production branch is named main, so all work developers will do will occur in every other branch. We don’t need to run our CI validation in the production branch because it will always be updated from a merge of another branch that was successfully validated before.

With this in mind let’s add a common rule to our hidden job that will be only reused for CI jobs:

Code
# ...

.base_setup:
  # ...
  rules:
    - if: $CI_COMMIT_BRANCH != "main"

# ...

One note, the $CI_COMMIT_BRANCH is a repository variable auto generated by GitLab that you have access.

Step 3.3 Scripts

For our CI we will define where it is the default location for our project dependencies. As said before to reduce the number of times we would need to write this command is each CI job let’s do it in this hidden job inside of the before-scripts to guarantee when our jobs runs this command was already performed.

Code
# ...

.base_setup:
  # ...
  before_script:
    - bundle config set --local path './vendor/ruby'

# ...

Step 3.4 Cache

By defining a common cache we will prevent the process of downloading our project dependencies (if they have already been downloaded before) every time we run our CI process, and in every job that we need to run our Ruby on Rails application commands.

Code
# ...

.base_setup:
  # ...
  cache:
    key:
        files:
          - Gemfile.lock
    paths:
      - ./vendor/ruby

# ...

Some notes. Using the Gemfile.lock as the key for the cache will guarantee the invalidation of it if the dependencies have changed.

Troubleshooting, if you are facing problems with the location of your dependencies you can temporarily add the following command bundle config path to print where the dependencies have been saved.

Step 4

Defining the Continuous Integration phase

In this phase, we want to handle the following topics per job:

A — Setting up dependencies;

B — Run our tests;

C — Check our code styling;

D — Check our test coverage;

Step 4.A — Setting up dependencies

The first job we want to first run is the setting up of our Ruby on Rails dependencies. In this step we will assign it to the setup stage and we will extend from the already defined hidden job .base_setup. GitLab will restore an existing cache if valid, and will update the cache at the end of the step if need.

Code
# ...

setup_dependencies:
  stage: setup
  extends:
    - .base_setup
  script:
    - bundle install

# ...

Step 4.B — Run our tests

Our project depend onPostgresSQL to store users data. Since we are using a image without it we need to provide a PostgresSQL service in order for our tests to run. For that we will take advantage of the services property.

With the services property we can indicate any other image that will run in another container and will be available to the main one of the job.

Depending on the service, you need to specify some environment variables. For this PostgresSQL service they are POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD.

Code
# ...

run_tests:
  stage: validation
  extends:
    - .base_setup
  services:
    - postgres
  variables:
    POSTGRES_DB: postgres
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: postgres
    POSTGRES_DB_HOST: postgres
  script:
    - bundle exec rspec

# ...

Some notes from the previous snippet, if you have already configured CI\CD variables you don’t need to rewrite them at the job level.

Since we are running inside of a container we can’t use the .localhost or ip to access the PostgresSQL container, we need to have our Ruby on Rails .database.yml prepared for this. The way we have in our project is trough the POSTGRES_DB_HOST environment variable you can see in the snippet. But if you don’t want to modify that file in your project you can for example have a different database.yml.ci that you would overwrite with the terminal command mv database.yml.ci database.yml before the bundle exec rspec.

Here’s a snippet for our .database.yml

Code
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

test:
  <<: *default
  database: <%= ENV.fetch("POSTGRES_DB") { "postgres" } %>
  username: <%= ENV.fetch("POSTGRES_USER") { "postgres" } %>
  password: <%= ENV.fetch("POSTGRES_PASSWORD") { "postgres" } %>
  host: <%= ENV.fetch("POSTGRES_DB_HOST") { "localhost" } %>

# ...

Step 4.C — Check our code styling

At the same time the tests run, we can check our code styling. To do that we will use the same stage validation.

For the actual check we will use the gem rubocop. Rubocop is a static code analyser and code formatter. Out of the box, it will enforce many of the guidelines outlined in the community Ruby Style Guide. If you need to install please visit: https://github.com/rubocop/rubocop

Code
# ...

check_style:
  stage: validation
  extends:
    - .base_setup
  script:
    - bundle exec rubocop

# ...

Step 4.D — Check our test coverage

To check our test coverage we will use two additional gems in our project, some terminal commands, and the artifacts feature from GitLab.

The main gem to archive a calculation of test coverage we will use is named simplecov. If you need to install please visit: https://github.com/simplecov-ruby/simplecov

The second gem we will use, to simplify the parsing\reading of the result values, is named simplecov-json. This gem allows us to configure the main simplecov gem with another formatter output. For more information about this gem please visit: https://github.com/vicentllongo/simplecov-json

With both gems added to your project, you can update your spec/spec_helper.rb with the following code at the beginning of the file:

Code
# frozen_string_literal: truerequire 'simplecov'
require 'simplecov-json'module SimpleCov
 module Formatter
   class MergedFormatter
     def format(result)
       SimpleCov::Formatter::HTMLFormatter.new.format(result)
       SimpleCov::Formatter::JSONFormatter.new.format(result)
     end
   end
 end
end
SimpleCov.formatter = SimpleCov::Formatter::MergedFormatter
​
SimpleCov.start
​
# ...

We could simply replace the default formatter with the simplecov-json but this demonstrates how can you have both working.

Now if you run bundle exec rspecit will appear a new folder on the root of your project named coverage. In this folder, we will find among the index.html page a coverage.json file we can easily parse.

But as you may wondering, we don’t want to run our tests twice for this, and we don’t want to mix coverage with tests in the previous defined job.

So, we need a way to pass the generated coverage files into this new job. For that we will update the run_tests job with the indication that the folder .coverage should be saved for output as an artifact.

Code
# ...

run_tests:
  stage: validation
  # ...
  script:
    - bundle exec rspec
  artifacts:
    paths:
      - coverage/coverage.json

# ...

Also, by doing this we will have the ability to download those files if we want to take a look inside for the details.

Now getting back to the code styling check, all we need to do is to use some terminal commands to parse the generated .json file and perform the logic if the job should pass or not.

Code
# ...

tests_coverage:
  stage: coverage
  script:
    - >
      if ! command -v jq &> /dev/null; then
        apt-get update && apt-get install -y jq
      fi
    - covered_percent=$(cat coverage/coverage.json | jq -r '.metrics.covered_percent')
    - re='^[+-]?[0-9]+([.||,][0-9]+)?$'
    - >
      if ! [[ $covered_percent =~ $re ]]; then 
        echo "WARNING :: Couldn't get coverage from artifact."
        exit 0
      fi
    - required_coverage=$MINIMUM_COVERAGE
    - >
      if [ $covered_percent -le $required_coverage ]; then
        echo "Coverage ($covered_percent%) is below the required threshold of $required_coverage%."
        exit 1
      else
        echo "Coverage ($covered_percent%) passed the required threshold of $required_coverage%."
      fi

# ...

Some notes for this step. At the beginning of the script, we are manually downloading the package jq which will make it much easier the parsing of the coverage.json file. We do this because the image we are using ruby:3.2.2. doesn’t have it.

The check of the coverage doesn’t mark the step failed if was not possible to retrieve the current coverage percentage. Which could be caused due to a failure already reported by the tests. If you think it should be a reason to fail coverage change the result if the exit to 1.

We use regex to confirm the value retrieve from the .json file is indeed a number. And we are using a CI\CD variable to be able to dynamically update the minimum coverage requirement.

Step 5

Defining the Continuous Delivery phase

In this guide, we will use Heroku to deploy our Ruby on Rails application.

Since all validations occur in all other branches we just need to deploy, making this the simpler job we need to define.

We can use the same ruby image we are using on the other jobs, since inside it already have the git package we need.

The rule for the deploy will be any change in the production branch main.

And we just need to define the required values, API Key and App Name, for the Heroku remote through the CI\CD variables $HEROKU_API_KEY and $HEROKU_API_APP_NAME.

Code
# ...

heroku_deploy:
  stage: deploy
  image: ruby:3.2.2
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - git remote add heroku https://heroku:[email protected]/$HEROKU_API_APP_NAME.git
    - git push --force heroku master

Conclusion

In this blog post, we explored the concepts of Continuous Integration and Continuous Delivery and learned how to implement them using GitLab for a Ruby on Rails application. By automating the testing and deployment processes, you can ensure your application is always in a reliable state and ready for release. GitLab’ flexibility and integration with your existing repositories make it an ideal choice for streamlining your development workflow.

Remember, CI/CD is not just a one-time setup; it’s an ongoing process. As your application evolves, keep iterating and refining your workflows to accommodate new requirements and improve overall efficiency. Happy coding!

Final file

.gitlab-ci.yml