rubyguides

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

  1. Use latest actions versions: ruby/setup-ruby@v1 and actions/checkout@v4
  2. Cache Bundler: always enable bundler-cache: true
  3. Test multiple Ruby versions: use a matrix strategy for compatibility
  4. Separate concerns: run linting and tests as separate jobs
  5. Fail-fast carefully: use fail-fast: false when you want full feedback

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

See Also