Modernization
Enterprise AI
Rails upgrades with RuboCop, dual boot, and Droids
November 14, 2026 - 6 minute read
Modernization
Enterprise AI
November 14, 2026 - 6 minute read
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 Rails upgrade guide 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.
RuboCop. RuboCop is a static analyzer and formatter for Ruby. Its autocorrect modes 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 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 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 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.
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.
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 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.
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
endThe 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.
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.
Commands in AGENTS.md. 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 runs after each file edit. When it exits with code 2, Droid receives its stderr as feedback and fixes the file before moving on.
#!/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
fiRegister it in .factory/hooks.json under PostToolUse with the matcher Create|Edit|ApplyPatch.
A skill for the upgrade procedure. A skill 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 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 <Cop> --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, 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.
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.
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.
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.
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.
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.
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.
Java modernization with OpenRewrite works as a loop. Recipes make the mechanical changes, Droids repair what the recipes leave and write new ones, and the compiler, tests, and a clean dry run verify every step. How to set it up with Factory.
Plan legacy modernization with private AI around behavior preservation, internal dependencies, and reviewable milestones. Keep code and validation inside the approved boundary.
COBOL modernization works when every step is checked by something deterministic. Compile with GnuCOBOL, inventory with a real parser, capture golden files from batch inputs, and let Droids handle the residue that translation tools leave behind.
Start building