Ruby on Rails CI\CD with GitHub Actions
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
andcontinuous_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. Theci_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 setPOSTGRES_DB: postgres
,POSTGRES_USER: postgres
,POSTGRES_PASSWORD: postgres
and have adatabase.yml.ci
prepared just for the CI (if you don't want to modify the originaldatabase.yml
) and right after checking out the project you would overwrite the original database.yml with this one using a terminal command such asmv 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 expandSecrets and variables
and selectActions
. Now click onNew repository secret
and fill in the text fields with the required information. The same way you create secrets GitHub Actions allows you to createVariables
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 selectActions,
from the side menu select youraction name
, and then theworkflow
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