CI Setup with GitHub Actions for Ruby
Continuous integration, or CI, gives Ruby teams fast feedback before code reaches the main branch. GitHub Actions makes that setup easy to keep close to the repository, which means the workflow is visible, reviewable, and simple to update when the project changes.
A good CI setup does more than run tests. It helps catch dependency problems, keeps linting in the same place as the code, and gives contributors a clear signal about whether a pull request is ready to merge.
Intro context
This guide shows a CI setup for a Ruby project that stays small enough to understand and flexible enough to grow. The examples focus on the jobs most teams need first: running tests, checking style, reusing dependencies, and validating database work for Rails apps.
CI setup notes
A useful CI setup does not try to solve every release problem on day one. It starts with a simple test job, then adds checks that protect the project as it grows. That keeps the workflow readable for new contributors and makes it easier to spot which part of the pipeline needs attention when something fails.
The first workflow block is intentionally small because a compact starter file is easier to adopt in a real project. Once that baseline works, you can add extra checks one at a time instead of asking a new contributor to understand every CI branch at once.
Why GitHub actions?
# The smallest possible CI workflow looks like this:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { ruby-version: '3.3', bundler-cache: true }
- run: bundle exec rake
GitHub Actions works well for Ruby repositories because it keeps automation adjacent to the source and gives the team one place to examine each workflow run. You get:
- Free minutes for public and private repositories
- Matrix builds to test across multiple Ruby versions
- Caching to speed up your workflows
- Native integration with GitHub’s ecosystem
Those features matter most when the team wants quick feedback on every pull request without adding another external service to maintain. A workflow file can also act as a shared checklist for the project, which makes future changes easier to review.
Basic CI workflow
Create .github/workflows/ci.yml in your repository:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install dependencies
run: bundle install
- name: Run tests
run: bundle exec rake
This workflow runs on every push and pull request to main, so failures are visible before the branch gets ahead of the review queue. Keeping the install, test, and setup steps separate also makes it easier to tell which part of the job failed.
Testing multiple Ruby versions
Use a matrix strategy to test across Ruby versions:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Install dependencies
run: bundle install
- name: Run tests
run: bundle exec rake
Each Ruby version runs as a separate job, which is useful when you need compatibility across supported interpreters. Set fail-fast: false when you want every version to finish even if one fails, because that gives you a fuller picture of whether the problem is isolated or widespread.
Adding linting
Include RuboCop in your CI pipeline:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Install RuboCop
run: bundle add rubocop --skip-install && bundle install
- name: Run RuboCop
run: bundle exec rubocop
Run linting in parallel with tests:
This separation keeps the test job and the style job visible on their own, which makes it easier to tell whether a failure belongs to code behavior or code shape. It also keeps the workflow file easier to scan when new checks are added later. Running linting separately also means you can fail the build on style violations without blocking the test results. The test job finishes independently, so the team can see whether the code works even if the style needs a small revision.
jobs:
test:
# ... test steps
lint:
# ... lint steps
ci:
needs: [test, lint]
Running linting separately keeps the workflow easier to scan. A failed test run should not hide a style failure, and a style failure should not block the team from seeing whether the tests still pass. Splitting the jobs also makes it simpler to expand the workflow later if you add security checks or formatting tools.
That small separation also mirrors how GitHub Actions presents jobs in the web UI. Each job can fail or pass on its own, which makes the workflow easier to debug because you can jump directly to the logs that matter instead of searching through one large combined step list.
How do you cache dependencies for faster builds?
Speed up your workflows with caching:
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
cache-version: ${{ hashFiles('Gemfile.lock') }}
The bundler-cache: true option automatically caches gems between runs.
When the lockfile changes, the cache should change with it. That is why the example includes cache-version based on Gemfile.lock, which keeps the workflow from reusing an old bundle after dependencies have changed.
Running database tests
For Rails projects, set up a database service:
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test
RAILS_ENV: test
This pattern keeps database setup inside the workflow instead of hiding it in the app code. The service container gives the test job its own database instance, and the environment variables keep the connection details explicit, which is easier to debug when the test suite starts failing.
Complete example
Here is a production-ready workflow for a Rails application. It combines the earlier pieces into a single file so you can copy it as a starting point, then trim or extend it based on the needs of your own project.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
matrix:
ruby-version: ['3.1', '3.2', '3.3']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true
- name: Install dependencies
run: bundle install
- name: Set up database
run: bin/rails db:test:prepare
- name: Run tests
run: bundle exec rake
The lint job can stay in the same file, but keeping it in a separate block makes the structure easier to follow when you review the workflow later. It also mirrors the way GitHub Actions displays jobs on the run page, where each job has its own status and logs.
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Run RuboCop
run: bundle exec rubocop
Best practices
- Use latest actions versions:
ruby/setup-ruby@v1andactions/checkout@v4 - Cache Bundler: always enable
bundler-cache: true - Test multiple Ruby versions: use a matrix strategy for compatibility
- Separate concerns: run linting and tests as separate jobs
- Fail-fast carefully: use
fail-fast: falsewhen you want full feedback
Forward link
Once this workflow is in place, the next practical step is usually branch protection. Require the CI checks before merge, then add a deployment job or release step only after the test job is stable. The official GitHub Actions docs and the ruby/setup-ruby repository are both good references when you need to expand beyond this starter setup.
This CI setup is deliberately small, but it is not minimal in value. It gives the team one clear place to add quality checks, and it leaves room for later steps such as build artifacts, deploy previews, or release automation without forcing a redesign of the whole file.
When you keep the workflow readable, the CI setup becomes part of the project documentation instead of a hidden maintenance burden. That is often what makes the difference between a workflow people trust and one they stop looking at after the first few runs.
A CI setup that stays readable months later is a CI setup that teams actually trust and maintain over the life of the project. A clear CI setup is easier to extend, easier to explain in code review, and much easier to debug when a test or lint job starts failing in a branch that looked safe before the merge.
How do you maintain the CI workflow over time?
# Schedule periodic CI runs to catch dependency drift:
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC