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
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:
- The common image for each job;
- The common rules to run or not the job;
- The common scripts;
- 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:
# ...
.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:
# ...
.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.
# ...
.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.
# ...
.base_setup:
# ...
cache:
key:
files:
- Gemfile.lock
paths:
- ./vendor/ruby
# ...Some notes. Using the
Gemfile.lockas 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 pathto 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.
# ...
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.
# ...
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
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
# ...
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:
# frozen_string_literal: true
require '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-jsonbut 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 namedcoverage. In this folder, we will find among theindex.htmlpage acoverage.jsonfile 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.
# ...
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.
# ...
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
jqwhich will make it much easier the parsing of thecoverage.jsonfile. We do this because the image we are usingruby: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
exitto1.We use regex to confirm the value retrieve from the
.jsonfile 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.
# ...
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 masterConclusion
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