The go-to resource for upgrading Ruby, Rails, and your dependencies.

How to Optimize Your GitHub Actions CI Pipeline for Rails Upgrades


During the 19th century, railway companies faced a monumental challenge: they needed to upgrade their tracks from narrow gauge to standard gauge. They couldn’t shut down the entire railway network for months to rebuild it; trade and transportation had to continue. Their solution was to lay a third rail alongside the existing tracks, allowing both old narrow-gauge trains and new standard-gauge trains to run simultaneously until the transition was complete.

A major Ruby on Rails upgrade presents a remarkably similar challenge. We cannot halt feature development for months while we rewrite our application to support a new framework version. Instead, we must keep the existing application running smoothly while simultaneously preparing it for the new environment.

Our Continuous Integration (CI) pipeline is the engine that makes this parallel operation possible. When we transition codebases to modern framework versions, we rely on automated tests to catch regressions, syntax deprecations, and third-party gem incompatibilities. However, an upgrade places unprecedented stress on CI infrastructure. A test suite that takes twenty minutes on a stable branch can quickly become a severe bottleneck when distributed teams are pushing dozens of incremental commits to resolve upgrade-related failures.

To execute a Rails upgrade efficiently, we must optimize our GitHub Actions workflows to provide rapid feedback without compromising on thoroughness. A well-structured CI/CD pipeline allows your team to merge compatibility fixes continuously, ensuring the upgrade project maintains momentum. Let’s look at practical strategies for optimizing GitHub Actions specifically for the demands of a Ruby on Rails upgrade.

Dual Booting with Matrix Builds

A “big bang” upgrade approach — where you attempt to change the Rails version, update all gems, and fix all deprecations in one massive pull request — introduces significant risk. The standard, pragmatic methodology for upgrading complex Rails applications is dual booting. This technique, often facilitated by tools like next_rails, allows your application to run on both the current Rails version and the target Rails version simultaneously.

In GitHub Actions, we can leverage matrix builds to run our test suite against both environments concurrently. This ensures that any new code merged into your main branch remains compatible with both the current production environment and the future upgrade target.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        gemfile: [ Gemfile, Gemfile_next ]
    env:
      BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
      - run: bundle exec rails test

You may notice the fail-fast: false directive. By default, if one job in a GitHub Actions matrix fails, GitHub cancels all other running jobs in that matrix. By setting fail-fast: false, we ensure that a failure in the Gemfile_next build does not cancel the standard Gemfile build.

This visibility is critical. When working on upgrade compatibility, you need to see exactly which tests pass and fail under the new Rails version, but you absolutely cannot disrupt standard product development by breaking the build for the current Gemfile.

Optimizing Dependency Caching

During an upgrade, you will frequently modify dependencies and update lockfiles. If your CI pipeline downloads and installs every gem from scratch on every run, you will consume unnecessary compute minutes and delay feedback significantly.

The ruby/setup-ruby action provides built-in caching mechanisms that are highly efficient. When dual booting, it’s important to ensure that your caching strategy accounts for both dependency lockfiles. Fortunately, the action automatically hashes the lockfile specified in the BUNDLE_GEMFILE environment variable. This ensures the cache is correctly invalidated when you update a gem for either the current or the next Rails version.

Tip: While aggressive caching improves speed, it comes with the trade-off of potential cache bloat. If you notice anomalous behavior during complex dependency resolution steps, clearing the GitHub Actions cache is often a good first troubleshooting step.

Parallelizing the Test Suite

As test suites grow, execution time naturally increases. When upgrading, we need feedback in minutes, not hours. GitHub Actions allows us to split our test suite across multiple parallel jobs, a technique known as sharding.

For standard Minitest suites in modern Rails, you can configure parallelize(workers: :number_of_processors) in your test_helper.rb to utilize all available cores on a single GitHub Actions runner.

If the suite is still too slow, though, you can shard the execution across multiple independent CI jobs. To do this practically, many teams use a tool like the knapsack gem, which handles splitting the test files evenly across multiple nodes.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        ci_node_total: [4]
        ci_node_index: [0, 1, 2, 3]
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bundle exec rake knapsack:minitest
        env:
          CI_NODE_TOTAL: ${{ matrix.ci_node_total }}
          CI_NODE_INDEX: ${{ matrix.ci_node_index }}

(Note: The exact syntax for invoking parallel tests depends heavily on your specific test runner and gem configuration. The example above illustrates the concept of distributing work across a matrix of runners using Knapsack.)

Parallelization, of course, introduces its own challenges. It frequently exposes race conditions and order-dependent tests that were previously masked by sequential execution. Resolving these flaky tests is a necessary investment that yields a permanently faster CI pipeline.

Isolating Security and Linting Checks

Before running a resource-intensive test suite, your pipeline should execute fast, static analysis checks. This “fail-fast” methodology is crucial during an upgrade, where syntax errors and deprecated method calls are common.

We can create a separate GitHub Actions job that runs tools like RuboCop, Brakeman, and bundle-audit. These tools verify code structure, detect supply chain attacks or vulnerabilities in your lockfiles, and ensure baseline compliance without booting the entire Rails application.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bundle exec rubocop
      - run: bundle exec brakeman -q -w2
      - run: bundle exec bundle-audit check --update

Strictly speaking, you could run these checks in the same job as your tests. However, by making the test job depend on the successful completion of the lint job, you prevent the CI runner from wasting time executing a twenty-minute test suite on code that contains fundamental syntactical or security flaws.

Addressing Flaky Tests Proactively

A Rails upgrade frequently exposes latent bugs and flaky tests within your suite. Changes in Ruby version behavior, database query execution timing, or framework internals can cause poorly isolated tests to fail intermittently.

When a test fails randomly in GitHub Actions during an upgrade, we face a decision: investigate the root cause immediately or quarantine the test to maintain pipeline velocity. Quarantining — moving the test to a separate execution group that does not fail the build — is a pragmatic trade-off. It allows the core upgrade work to proceed while acknowledging the technical debt that must be addressed before the final validation and cutover.

However, quarantining should be a temporary measure. We must allocate engineering time to stabilize these tests. A CI pipeline that engineers do not trust is fundamentally broken, and pushing a Rails upgrade to production with an unstable test suite guarantees regressions.

Conclusion

Optimizing your GitHub Actions pipeline is a mandatory prerequisite for a smooth Ruby on Rails upgrade. By implementing dual booting, leveraging aggressive caching, parallelizing test execution, and isolating static analysis, we provide our engineering teams with the rapid, reliable feedback they need.

While these CI infrastructure optimizations require an upfront investment of time, they dramatically reduce the risk, compute costs, and overall duration of the upgrade process. They ensure that, much like the railway engineers of the 19th century, we can build the infrastructure of the future without disrupting the operations of today.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming