# Rails upgrades with RuboCop, dual boot, and Droids

> A Ruby and Rails upgrade goes one minor version at a time. RuboCop, bin/rails app:update, dual boot, and Synvert handle the mechanical changes, deprecation warnings become the work list, and Factory Droids fix the residue under the same deterministic checks.

URL: https://factory.com/articles/ruby-rails-upgrade-rubocop
Published: 2026-11-14
Updated: 2026-11-14

A Rails upgrade is a sequence of small, checkable steps. Move to the latest patch release, clear the deprecation warnings, bump one minor version, run the update task, fix what broke, and repeat. Most of those steps are mechanical, and the Ruby ecosystem has deterministic tools for them. Factory Droids handle the residue that no tool fixes, and the same tools verify the result.

The parts are familiar to most Rails teams: RuboCop with rubocop-rails, `bin/rails app:update`, dual booting with next_rails, and Synvert snippets. Each one is covered below, along with the work they leave for Droids.

## The upgrade path Rails documents

The [Rails upgrade guide](https://guides.rubyonrails.org/upgrading_ruby_on_rails.html) gives a clear order. Upgrade Ruby and Rails separately, Ruby first. Move one minor Rails version at a time so deprecation warnings point at the next breaking change. Rails 8.0 and 8.1 require Ruby 3.2.0 or newer, and Rails 7.2 requires Ruby 3.1.0.

Each step follows the same procedure. Change the Rails version in the `Gemfile` and run `bundle update rails`. Run `bin/rails app:update`, which interactively updates configuration files and creates `config/initializers/new_framework_defaults_X_Y.rb`. The application keeps the previous version's defaults until each setting in that file is enabled and `config.load_defaults` is raised.

That last detail matters for planning. The framework defaults file is a list of independent behavior changes that can be enabled and deployed one at a time.

## Deterministic tools for Ruby and Rails

**RuboCop.** RuboCop is a static analyzer and formatter for Ruby. Its [autocorrect modes](https://docs.rubocop.org/rubocop/usage/autocorrect.html) separate safe corrections (`-a`), which should not change semantics, from all corrections (`-A`), which may. A safe pass can run across the whole codebase as one commit. Unsafe corrections deserve a cop-by-cop review with tests. RuboCop reads `TargetRubyVersion` from `.rubocop.yml` or from files such as `.ruby-version` and `Gemfile.lock`, so raising the Ruby version enables cops that suggest newer syntax.

**rubocop-rails.** The [rubocop-rails extension](https://docs.rubocop.org/rubocop-rails/usage.html) adds cops for Rails conventions and loads through `plugins: rubocop-rails` in RuboCop 1.72 and later. Its `TargetRailsVersion` setting, or the Rails version in `Gemfile.lock` when unset, decides which version-specific cops apply.

**Dual boot with next_rails.** The [next_rails gem](https://github.com/fastruby/next_rails) sets up dual booting. `next_rails --init` creates `Gemfile.next` and `Gemfile.next.lock`, and `BUNDLE_GEMFILE=Gemfile.next bundle exec rspec` runs the suite against the next Rails version while production stays on the current one. The gem also ships `bundle_report compatibility --rails-version=8.1` for finding gems that block the upgrade, and a deprecation tracker for RSpec and Minitest.

**Synvert.** [Synvert](https://synvert.net/ruby/docs/) rewrites Ruby source with AST-based snippets written in a Ruby DSL. Its official snippet repository includes Rails upgrade snippets such as `rails/upgrade_7_1_to_7_2`, run with `synvert-ruby --run`. A snippet can be guarded with `if_gem` so it only applies when a gem version matches.

Type checkers are optional gates. Teams that already use Sorbet or Steep can add `srb tc` or `steep check` to the verification list.

## Deprecation warnings as the work list

Rails emits a deprecation warning before it removes or changes behavior. On the current version, every warning is a pending task for the next one. Turning those warnings into a tracked list makes the upgrade measurable.

The next_rails deprecation tracker records warnings from a test run into a JSON file with `DEPRECATION_TRACKER=save`, and `DEPRECATION_TRACKER=compare` fails the run when the set of warnings changes. That gives CI a ratchet, because new deprecations cannot slip in while the team removes old ones.

Rails adds a second ratchet. The `config.active_support.disallowed_deprecation_warnings` setting marks specific deprecations as disallowed, and `config.active_support.disallowed_deprecation` sets what happens when one fires, such as raising in test. Once a deprecation is cleared, adding it to the disallowed list keeps it cleared.

The resulting list is ideal input for a Droid. Each entry names a message and a location, and the fix is verified by rerunning the tracker.

## Where Droids take over

The deterministic tools leave a specific kind of work behind. Deprecations with no autocorrect need code changes that depend on context. A gem that changed its API across a major version needs its call sites rewritten. Tests that pass on `Gemfile` and fail on `Gemfile.next` need diagnosis. Each line in the framework defaults file needs someone to read the change, decide whether the application depends on the old behavior, and enable it with a test. Unsafe RuboCop corrections need a decision per cop.

Droids work through that list under the same checks. They read the warning or failure, change the code, and rerun RuboCop and the relevant specs on both Gemfiles.

**Gem blockers first.** On many applications the slowest part of a Rails bump is the dependency graph. Run `bundle_report compatibility` for the target version before touching application code, and treat each incompatible gem as its own small change. Droids can read a gem's changelog, update the pin in `Gemfile.next`, rewrite the call sites that changed, and run both suites. Gems that are abandoned need a human decision about a replacement, and that decision belongs in the plan before the mechanical work starts.

Ruby upgrades follow the same pattern on a smaller scale. Raising `TargetRubyVersion` lets RuboCop's safe corrections adopt newer syntax, and the residue is usually keyword argument changes, frozen string behavior, and gems with native extensions that need rebuilding.
When the same fix repeats across many files, the better output is a rule. A custom RuboCop cop uses a `def_node_matcher` pattern to find code, `extend AutoCorrector` to rewrite it, and RSpec helpers from the [RuboCop development guide](https://docs.rubocop.org/rubocop/development.html) to test it. A Droid can write the cop and its specs, run them, and then run the cop across the codebase as a deterministic pass.

```ruby title="spec/rubocop/cop/acme/legacy_client_fetch_spec.rb"
RSpec.describe RuboCop::Cop::Acme::LegacyClientFetch, :config do
  it 'replaces LegacyClient.fetch with ApiClient.get' do
    expect_offense(<<~RUBY)
      LegacyClient.fetch(url)
      ^^^^^^^^^^^^^^^^^^^^^^^ Use `ApiClient.get` instead of `LegacyClient.fetch`.
    RUBY

    expect_correction(<<~RUBY)
      ApiClient.get(url)
    RUBY
  end
end
```

The same applies to Synvert. A Droid can write a snippet for an in-house API migration and test it on fixture files before running it on the application.

## Running the loop with Factory

Factory has no Ruby-specific mode. Droids run RuboCop, Rails tasks, Synvert, and the test suite through the harness features they use for any language. The loop below runs once per minor Rails version.

Deterministic pass</span><br/><span style='color:#8A8380 !important'>rubocop -a, app:update, Synvert</span>"]
    B["<span style='color:#FAFAFA !important;font-weight:600'>Gate</span><br/><span style='color:#8A8380 !important'>specs on Gemfile and Gemfile.next</span>"]
    C["<span style='color:#FAFAFA !important;font-weight:600'>Droid handles residue</span><br/><span style='color:#8A8380 !important'>deprecations, failing specs, new cops</span>"]
    D["<span style='color:#FAFAFA !important;font-weight:600'>Re-verify</span><br/><span style='color:#8A8380 !important'>RuboCop clean, deprecation tracker compare</span>"]
    E["<span style='color:#FAFAFA !important;font-weight:600'>Human review</span><br/><span style='color:#8A8380 !important'>tool commits and Droid commits</span>"]
    A --> B --> C --> D --> E
`}
/>

**Commands in AGENTS.md.** [AGENTS.md](https://docs.factory.com/harness/agents-md) lists the exact commands for both dependency sets, for example `bundle exec rspec` and `BUNDLE_GEMFILE=Gemfile.next bundle exec rspec`, plus the RuboCop invocation and the rule that new code must not add deprecation warnings.

**RuboCop on every edit.** A `PostToolUse` [hook](https://docs.factory.com/harness/hooks) runs after each file edit. When it exits with code `2`, Droid receives its stderr as feedback and fixes the file before moving on.

```bash title=".factory/hooks/rubocop-check.sh"
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in *.rb|*.rake) ;; *) exit 0 ;; esac
cd "$FACTORY_PROJECT_DIR" || exit 0
if ! out=$(bundle exec rubocop --force-exclusion --format simple "$file" 2>&1); then
  echo "$out" >&2
  exit 2
fi
```

Register it in `.factory/hooks.json` under `PostToolUse` with the matcher `Create|Edit|ApplyPatch`.

**A skill for the upgrade procedure.** A [skill](https://docs.factory.com/harness/skills) at `.factory/skills/rails-upgrade/SKILL.md` encodes the guide's procedure for the team. It lists the order of steps, how to treat the framework defaults file, which Synvert snippets apply to which version, and the rule that tool output and Droid edits go in separate commits.

**Per-file fan-out.** The Droid Exec [lint autofix recipe](https://docs.factory.com/droid-exec/recipes/lint-autofix) shows a script that asks a linter for failing files, runs one `droid exec --auto low` per file, and reruns the linter on each file. The same script shape works with `bundle exec rubocop --only  --format json` for a cop that has no safe autocorrect.

**Missions for the long run.** An upgrade across several minor versions or many applications fits [Missions](https://docs.factory.com/missions/overview), which plan milestones and validate as they go. The Missions documentation lists brownfield migrations as a use case, and hooks and skills carry over into a mission.

## Verification and commands

A step is done when RuboCop reports no offenses, the suite passes on both Gemfiles, `DEPRECATION_TRACKER=compare` shows no new warnings, and rerunning the Synvert snippet produces an empty `git diff`. Review is easier when commits follow the tools. The `app:update` output goes in one commit, the safe RuboCop pass in another, and each Synvert snippet in its own. Droid edits come last, where reviewers spend their time.

These commands start the loop as of November 2026.

<div className="not-prose my-6 flex flex-col gap-3 [&_button]:!w-auto">
  <div className="grid grid-cols-1 gap-y-5 md:grid-cols-[max-content_1fr] md:gap-x-4">
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Safe autocorrect.</strong> Applies only corrections marked safe, suitable for one mechanical commit.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Update task.</strong> Updates configuration and creates the new framework defaults file after a Rails bump.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Dual boot.</strong> Creates Gemfile.next so CI can run the suite on the next Rails version.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Next-version gate.</strong> Runs the suite against the next dependency set.</span>
  </div>
</div>

## Frequently asked questions

### What is the safest order for a Rails upgrade?

The Rails upgrade guide recommends upgrading Ruby and Rails separately, Ruby first, and moving one minor Rails version at a time. At each step, move to the latest patch release, fix tests and deprecations, then move to the next minor version.

### What is the difference between rubocop -a and rubocop -A?

The -a flag applies only corrections marked safe, which should not change code semantics. The -A flag also applies unsafe corrections that may change behavior. Run -a broadly and review -A changes cop by cop with the test suite.

### What does dual booting a Rails app mean?

Dual booting runs the same application against two dependency sets, for example the current Rails version and the next one. The next_rails gem creates a Gemfile.next and Gemfile.next.lock, and setting BUNDLE_GEMFILE=Gemfile.next runs the test suite against the next version in CI.

### What do Droids do in a Rails upgrade?

Droids fix what the deterministic tools leave behind, such as deprecation warnings with no automatic fix, failing tests on the next Rails version, gems that changed their API, and configuration choices in the new framework defaults file. They can also write custom RuboCop cops or Synvert snippets with tests so repeated fixes become deterministic.

### Does Factory have a Ruby-specific upgrade mode?

No. Factory runs RuboCop, Rails tasks, Synvert, and the test suite through hooks, skills, AGENTS.md commands, Droid Exec scripts, and Missions. The Ruby tools remain the source of the mechanical changes and the checks.

## Further reading

- [Deterministic code modernization with Factory](https://factory.com/articles/deterministic-code-modernization)
- [How to run a runtime upgrade with coding agents](https://factory.com/articles/coding-agent-runtime-upgrades)
- [Safer dependency updates with coding agents](https://factory.com/articles/coding-agent-dependency-updates)
- [PHP modernization with Rector, PHPStan, and Droids](https://factory.com/articles/php-modernization-rector)
