Ruby on Rails CI\CD with GitHub Actions

Filipe Martins
Runtime Revolution
Published in
10 min readNov 6, 2023

--

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) practices 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 GitHub Actions to streamline your Ruby on Rails development workflow in 4 core steps.

Step 1

Create the .github/workflows directory.

Navigate to the root of your Ruby on Rails repository and create the .github directory and inside of it create another one named workflows. This workflows folder will be the place where you will define your CI/CD workflow configurations.

Step 2

Create a new continuous_integration.yml and continuous_delivery.yml files inside of this directory.

Here we are choosing to have two files to separate the workflows, the continuous_integration.yml for Continuous Integration, and the continuous_delivery.yml for Continuous Delivery.

The best part of having multiple files in GitHub Actions is that it allows you to separate logic, and if you want to split even more your CI\CD logic nothing stops you from creating even more workflow files. Or if you prefer to mix everything together you can just have one file (which I don’t advise).

Before we go into Step 3 and start defining our continuous_integration.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.

With this in mind and now focusing on Continuous Integration we want to specify an autonomous process to validate our work branch before we merge it into the production branch.

And for Continuous Delivery as soon we get the production branch updated we want to deliver it, since at this point it should be already validated and tested.

Step 3

Defining the Continuous Integration file.

On the continuous_integration.yml we want to handle the following topics:

A — When should it run;

B — Environment to run;

C — Setting up the project;

D — Validate the project.

Step 3.A — When should it run

To answer this question we have access to the on syntax which allows us to specify the trigger of our process. If you need to specify a more complex rule you can find more information at https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on

In this example and based on a simpler git strategy we want to trigger the CI on all branches that occur changes except the production branch named main.

name: Validate Project

on:
push:
branches-ignore:
- main

A note for the previous snippet, the name syntax is the name that will appear on the GitHub Actions section.

Step 3.B — Environment to run

Since we are working on a Ruby on Rails application the most common environment to use is Linux. But you may be working on a computer with ARM architecture inside, if so, make sure your Gemfile.lock does have the x86_64-linux platform as well.

If the platform is missing in your Gemfile.lock you can add it using the following command and then commit and push it to the repository:

bundle lock --add-platform x86_64-linux

For the continuous_integration.yml, file we just need to specify on the job level the environment using the syntax runs-on. If you want to read more about this syntax you can visit https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on

# ...

jobs:
ci_validation:
runs-on: ubuntu-latest

Two notes for the previous snippet, jobs is a root syntax that holds a group of actions together. The ci_validation is a custom job identifier and must start -with a letter or _ and contain only alphanumeric characters, -, or _.

Step 3.C — Setting up the project

In this guide we will use an official Docker image from PostgresSQL to show how to use third-party services in some steps of your job.

The same way there is an official Docker image for PostgresSQL there are official Docker images for other types of databases, such as MongoDB, MySQL, MariaDB, just to mention a few.

But nothing stops you to provide a custom Docker image with the services you may need.

Then depending on your service you probably need to configure the required service environment variables on the syntax services . This services syntax is what allow us to host a container for a specific service, in this case the PostgresSQL, and the required environment variables which are POSTGRES_DB, POSTGRES_USE, andPOSTGRES_PASSWORD.

# ...

jobs:
api_validation:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:alpine
ports:
- 5432:5432
env:
POSTGRES_DB: ${{secrets.POSTGRES_DB}}
POSTGRES_USER: ${{secrets.POSTGRES_USER}}
POSTGRES_PASSWORD: ${{secrets.POSTGRES_PASSWORD}}
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

The previous snippet uses secrets to set the required env variables of PostgresSQL to demonstrate how to use them. But since we are on a testing level you can have them hard-coded in this .yml file, as long your values are not confidential critical. For example you could set POSTGRES_DB: postgres, POSTGRES_USER: postgres, POSTGRES_PASSWORD: postgres and have a database.yml.ci prepared just for the CI (if you don't want to modify the original database.yml) and right after checking out the project you would overwrite the original database.yml with this one using a terminal command such as mv database.yml.ci database.yml.

To define secrets for GitHub Actions open your project website on the browser and navigate to Settings. From the Settings side menu expand Secrets and variables and select Actions. Now click on New repository secret and fill in the text fields with the required information. The same way you create secrets GitHub Actions allows you to create Variables that remain visible.

Now we are ready to start defining the CI steps. Just one note for easily understanding this guide, let’s assume each - name: a new step.

The first thing to do is checkout the repository. For this we will use an action provided by GitHub Actions named checkout.

# ...

steps:
- name: Checkout Repository
uses: actions/checkout@v3

And then we set up the ruby version using the official ruby action.

# ...

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.3
bundler-cache: true
cache-version: 1

Notes for the previous snippet. This is where we set the version of Ruby. A cool thing this action can do it has the ability to download the gem dependencies and configure caching for them. This means every time you run your CI process it won’t download any dependencies if they already have been downloaded before.

Now that we have everything set up let’s do the validation of the project in this continuous_integration.yml file. Things to consider: tests, lint, and coverage.

Step 3.D — Validate the project

For tests, we create a new step and we call the rspec.

# ...

- name: Test
run: bundle exec rspec

For lint we will use 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

# ...

- name: Lint
run: bundle exec rubocop

And finally coverage. Coverage is something you may or may not want. But if you want to force your project to have a respectful amount of tests it is something you should consider. To do that we will use two additional gems, and some terminal commands.

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-json but this demonstrates how can you have both working.

Now if you run bundle exec rspec it will appear a new folder on the root of your project named coverage. In this folder we will find among a .html page with the detailed coverage a .json file we can parse.

Having the coverage.json file accessible after running the tests we can add one more step and do a manual processing.

# ...

- name: Check Coverage
run: |
covered_percent=$(cat coverage/coverage.json | jq -r '.metrics.covered_percent');

re='^[+-]?[0-9]+([.||,][0-9]+)?$';
if ! [[ $covered_percent =~ $re ]]; then
echo "Couldn't get coverage from artifact.";
exit 0 # Returning 0 doesn't invalidate this step.
fi

required_coverage=${{env.MINIMUM_COVERAGE}};

if [ $covered_percent -le $required_coverage ]; then
echo "Coverage ($covered_percent%) is below the required threshold of $required_coverage%.";
exit 1 # Returning 1 will invalidate this step and the job will be marked as failed.
else
echo "Coverage ($covered_percent%) passed the required threshold of $required_coverage%."
fi

Step 4

Defining the Continuous Delivery file.

In this section of our guide, we will define our continuous_delivery.yml to deploy our solution to Heroku. This alllows us to have full control of the deployment.

But before you take this approach, if you are using Heroku and GitHub as well, make sure you don't have the Heroku GitHub Auto Deploy configured already, which is a totally acceptable alternative to this Step 4.

On the continuous_delivery.yml we want to handle the following topics:

A — When should it run;

B — Environment to run;

C — Setting up the project;

D — Deploy the project.

Step 4.A — When should it run

For this, we will use the same syntax as before the on syntax, but now reversed.

name: Distribute to Heroku

on:
push:
branches:
- main

Remembering about Git Strategies we mentioned before and our simpler approach, we assume all work is being done on feature branches, and since they are being validated using the previously continuous_integration.yml file we can just assume as soon someone merges a feature branch tested and validated into production branch we just need to deploy it, no additional tests need to be done because they were already passed on the feature branch, that should be always up to date with the state of the production branch.

Step 4.B — Environment to run

The same as the Continuous Integration file, a Linux environment.

# ...

jobs:
ci_deploy:
runs-on: ubuntu-latest

Step 4.C — Setting up the project

Here we just need to checkout the project from the repository.

# ...

steps:
- name: Checkout Repository
uses: actions/checkout@v3

Step 4.D — Deploy the project

To deploy the project we could use the git push to Heroku with some extra fields, but let’s take advantage of an already existing action that handles this for us, it is named akhileshns/heroku-deploy.

# ...

- name: Deploy
uses: akhileshns/heroku-deploy@v3.12.14
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: ${{secrets.HEROKU_API_APP_NAME}}
heroku_email: ${{secrets.HEROKU_EMAIL}}

The same way we did for the PostgresSQL service here we use secrets to set the required values. Just one difference this time, these should never be hard-coded on your project. Always use secrets for keys.

Extra Step — Artifacts

One thing you can take advantage of is to export artifacts from your jobs. This is useful when for example you want to get access to the details of coverage (the page it generates) or any other file you want to get access to after the job ends.

In this step, we will include an additional step to export the coverage folder. To do that we will use the official GitHub Action named upload-artifact.

# ...

- name: Upload Coverage
uses: actions/upload-artifact@v3
with:
name: coverage
path: coverage

After the workflow ends you will have access to this content in a zip file you can download from the GitHub website. To do that you open your project repository website, then you select Actions, from the side menu select your action name, and then the workflow that has been completed. The artifacts should be displayed at the bottom if they were generated successfully.

Conclusion

In this blog post, we explored the concepts of Continuous Integration and Continuous Delivery and learned how to implement them using GitHub Actions in 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. GitHub Actions’ 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!

Generated files:

.github/workflows/continuous_integration.yml

name: Validate Project

on:
push:
branches-ignore:
- main

jobs:
ci_validation:
runs-on: ubuntu-latest
env:
RAILS_ENV: test

services:
postgres:
image: postgres:alpine
ports:
- 5432:5432
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

steps:
- name: Checkout Repository
uses: actions/checkout@v3

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.1.3
bundler-cache: true
cache-version: 1

- name: Test
run: bundle exec rspec

- name: Upload Coverage
uses: actions/upload-artifact@v3
with:
name: coverage
path: coverage

- name: Lint
run: bundle exec rubocop

- name: Validate Coverage
run: |
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=${{env.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

.github/workflows/continuous_delivery.yml

name: Distribute to Heroku

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3

- name: Deploy
uses: akhileshns/heroku-deploy@v3.12.14
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: ${{secrets.HEROKU_API_APP_NAME}}
heroku_email: ${{secrets.HEROKU_EMAIL}}

For more information about GitHub Actions please visit: https://docs.github.com/en/actions

--

--