Ruby on Rails
Santiago Calvo • 25 MAR 2022
DevOps with Rails: setting up CI with Github Actions and Lefthook
Introduction:
One of the key DevOps practices is the process of CI/CD. This englobes three main practices: Continuous Integration, Continuous Delivery, and Continuous Deployment.
In this opportunity, we will focus on applying continuous integration using GitHub actions to a Rails project to improve the development experience for our software development team.
Continuous Integration is the practice of merging source code to a main development branch as soon as possible to keep a fluid advance into a goal while keeping the completeness of our application. This is especially helpful for Agile web development where the objective is to build a solution through quick iterations of the software product. Setting up a CI pipeline is easy, and with the power of GitHub actions, this can be done from the comfort of your own repository without putting your credit card and setting up other third party alternatives.
To ensure Ruby on Rails devs catch errors as soon as possible we will set up lefthook to avoid pushing or even committing wrong code.
Workflow
A good CI workflow includes:
Running tests and analyzers locally: CI is intended to be used along with automated unit tests. A developer should have his code pass every test before being able to integrate it. This avoids having one developer's work break another one’s code. As an additional layer, we would also run analyzers and tests in the CI environment to help and report any other major issues. This helps also to keep track of any difference in the environments a dev and the rest of the team has as well as making sure everything passed event after merging multiple branches of codes.
Compile code in CI: Deploying and compiling code periodically in a CI server will help caught compilation errors early in the development.
The setup
-
The project
Our usual tools for static analysis involve brakeman, reek, rails_best_practices and rubocop. These tools will give immediate feedback on basic code styling issues as well as warn us of bad practices and vulnerabilities present in our code base.
For testing we are very familiar with rspec so that would be our tool of choice.
- Lefthook
To help the team run the required static analysis tools we will install git hooks using Lefthook for a ruby enviroment. You can also set it up for a NodeJS environment if that is what you would prefer. You can check out how to do it here.
We will use brakeman, reek, rails_best_practices and rubocop.
You’ll probably want to write a config file for each to fit your product. You should refer to the docs for how to tailor them to your needs.
Each of these tools will be used in pre commits hooks.
We will also use Rspec for unit tests. I would recommend adding this only for pre-push hooks since tests could take several minutes to run.
We will first add lefthook to your gemfile as a dev dependency since we only want to run it on developer machines.
gem 'lefthook', '~> 0.7.7'
After that run a bundle install and we should be good to start creating our hooks on a lefthook.yml file in the root of the project. A configuration for the hooks mentioned above would look something like:
pre-commit:
parallel: true
commands:
audit:
run: brakeman --no-pager
rails_best_practices:
run: rails_best_practices
reek:
run: reek
rubocop:
files: git diff --name-only --staged
glob: "*.rb"
run: rubocop --force-exclusion {files}
pre-push:
commands:
test:
run: rspec .
After our lefthook config file has the necessary information we can install the hooks. This step should be run by every developer in their environment.
$ lefthook install
Now we can test the hooks by running:
$ lefthook install
$ lefthook run pre-commit
$ lefthook run pre-push
With these steps done, we can expect every Ruby on Rails developer to install these git hooks to automatically run any analysis necessary.
GitHub Actions
Now comes running our analysis and testing tools before integration. GitHub provides us with an easy way to configure this utilizing GitHub Actions. They are very simple to set up. If you don’t have one already you can create a .github folder inside your project’s root folder. Inside it adds a workflows folder where we will put our CI action (and others if necessary).
Here we will create our ci.yml file with the following contents.
We will first name our action. You can use whatever name you want. We will use CI for this case.
name: CI
After that we will set this action’s triggers. Github actions provides us with a lot of possible triggers. In this case we will set up triggers for all pull_requests to make sure tests pass before merging anything.
on:
pull_request:
branches:
- '*'
After that, we set up our ruby version and we can start writing our jobs.
For every job we need to specify the name, where it runs on (mining which OS), and what the steps are.
The machine we will be running the actions from will need a basic setup, so every job will need to install ruby, gems, and Node if necessary. We have created a setup action for this to avoid rewriting that part inside the .github/actions/setup/action.yml. For all actions, GitHub searches inside the action folder for the action.yml file to run.
This one is a composite action meaning that it follows steps that need to run instead of a program like Javascript and Docker actions.
name: Setup environment
description: Sets up environment for other jobs
inputs:
ruby-version:
description: 'Ruby version to use'
required: false
default: '2.5.1'
node-version:
description: 'Node version to use'
required: false
default: '16.x'
runs:
using: composite
steps:
- name: Setup Ruby and install gems
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: ${{ inputs.node-version }}
After that, we can simply run our linting code. For each job, we define steps with names and what it does. The `uses` key indicates we want to use a pre-existing action. The keyboard (as used also in the code above) indicates the inputs we are passing to that action. If there is no predefined action we can just use the `run` keyword and specify what bash commands we want to run.
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup environment
uses: ./.github/actions/setup
with:
ruby-version: ${{ env.RUBY }}
- name: Run linters
run: |
bundle exec rubocop --parallel
bundle exec reek app config lib
bundle exec rails_best_practices .
bundle exec brakeman . -z -q
Similarly, for the test we will do the basic setup with our setup action.
Afterwards we set up yarn, Node packages and then run our testing code which involves creating a database, migrating, and running all tests.
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports: ['5432:5432']
steps:
- uses: actions/checkout@v2
- name: Setup environment
uses: ./.github/actions/setup
with:
ruby-version: ${{ env.RUBY }}
- name: Get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache yarn
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: |
yarn install --frozen-lockfile
- name: Run tests
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/test
RAILS_ENV: test
PG_USER: postgres
run: |
bin/rails db:create db:migrate
bundle exec rspec
The important new things here are: ENVS define the environment variables the machine needs to run certain actions. In this case, we set up the database and environment for our Rails test server.
Also, the cache action is not entirely necessary. However, it helps with the execution time for our action. You can read more about it here.
The end result should look like this:
name: CI
on:
pull_request:
branches:
- '*'
env:
# Your ruby version
RUBY: '3.1.0'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup environment
uses: ./.github/actions/setup
with:
ruby-version: ${{ env.RUBY }}
- name: Run linters
run: |
bundle exec rake code_analysis
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports: ['5432:5432']
steps:
- uses: actions/checkout@v2
- name: Setup environment
uses: ./.github/actions/setup
with:
ruby-version: ${{ env.RUBY }}
- name: Get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Cache yarn
uses: actions/cache@v2
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install dependencies
run: |
yarn install --frozen-lockfile
- name: Run tests
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/test
RAILS_ENV: test
PG_USER: postgres
run: |
bin/rails db:create db:migrate
bundle exec rspec
Conclusion
After following these steps your Ruby on Rails project will now enforce guidelines for all your software developers allowing it to evolve in a polished manner. Depending on your team and project, it might be a good idea to also restrict merges and commits towards your main branches when the GitHub actions fail.
There are many more checks that could be added to bulletproof your project, these are just the basics. If you want to learn more about Ruby on Rails CI/CD and Dev Ops you can check out this github repository for plenty of resources.