LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 1 views

The Rails upgrade path out of a legacy app

The advice you will get first is to bump Ruby, because a modern Ruby is a prerequisite for a modern Rails and it feels like the independent half of the job. On a legacy app it is the one move that guarantees you cannot run anything. Bump Ruby to 4.0.5 on a Rails 6.1 app and the process does not reach your code, does not reach your initializers, and does not reach a single test: it dies inside active_support/logger_thread_safe_level.rb, eleven frames into a require chain that starts at rails/command.rb:3.

Everything below was run on this laptop: Ruby 4.0.5 and Ruby 2.7.8 side by side under rvm, macOS arm64-darwin25, Apple M2 Max. Every version number is read out of an installed gemspec or off rubygems.org, and every error string is pasted from the terminal.

Where the supported floor actually is

Ask rubygems.org when each series last shipped and the answer is unambiguous about which ones are maintained. This is curl -s https://rubygems.org/api/v1/versions/rails.json grouped by minor:

4.2   first 4.2.0      2014-12-20   last 4.2.11.3   2020-05-15   18 releases
5.0   first 5.0.0      2016-06-30   last 5.0.7.2    2019-03-13   11 releases
5.1   first 5.1.0      2017-04-27   last 5.1.7      2019-03-28   10 releases
5.2   first 5.2.0      2018-04-09   last 5.2.8.1    2022-07-12   22 releases
6.0   first 6.0.0      2019-08-16   last 6.0.6.1    2023-01-17   26 releases
6.1   first 6.1.0      2020-12-09   last 6.1.7.10   2024-10-23   30 releases
7.0   first 7.0.0      2021-12-15   last 7.0.10     2025-10-28   28 releases
7.1   first 7.1.0      2023-10-05   last 7.1.6      2025-10-28   15 releases
7.2   first 7.2.0      2024-08-09   last 7.2.4      2026-09-24   11 releases
8.0   first 8.0.0      2024-11-07   last 8.0.5.1    2026-07-29   10 releases
8.1   first 8.1.0      2025-10-22   last 8.1.4      2026-09-24   7 releases

7.0 and 7.1 both stopped on the same day, 2025-10-28, six days after 8.1.0 shipped. That is the shape of the end of a series: one last coordinated patch, then nothing. Three series have a 2026 release, so the honest floor today is 7.2, and 7.2.4 arrived two days before this page was written.

If you are on 5.2 the number that matters is 1537, the days between 5.2.8.1 and the day this page was published. Nothing disclosed in that window was fixed for you.

The gemspec declares a floor and Bundler checks nothing else

Here is the fact the whole upgrade order turns on. Every rails gemspec carries a required_ruby_version that is a lower bound, read here straight off the installed specs:

5.2.8.1    >= 2.2.2
6.0.6.1    >= 2.5.0
6.1.7.10   >= 2.5.0
7.0.8.7    >= 2.7.0
7.1.5.2    >= 2.7.0
7.2.2.2    >= 3.1.0
8.0.3      >= 3.2.0
8.1.3.1    >= 3.2.0

There is no upper bound anywhere in that list, and there never has been. Which means Bundler has nothing to refuse with. This Gemfile is a lie that resolves:

source "https://rubygems.org"
ruby "4.0.5"
gem "rails", "7.0.8.7"
$ bundle lock
Writing lockfile to .../lockcheck/Gemfile.lock
Fetching gem metadata from https://rubygems.org/...........
Resolving dependencies...
$ echo $?
0

Exit 0, and a lockfile on disk with RUBY VERSION / ruby 4.0.5 and BUNDLED WITH / 4.0.10 in it. The first thing that touches Rails in that bundle:

$ bundle exec ruby -e 'require "rails"'
.../activesupport-7.0.8.7/lib/active_support/logger_thread_safe_level.rb:12:in '<module:LoggerThreadSafeLevel>': uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)

    Logger::Severity.constants.each do |severity|

So the CI job that runs bundle install and nothing else stays green on a combination that cannot execute one line. The declared floor tells you where Rails refuses to install. Nothing tells you where it stops working, and finding that out is a thing you run, not a thing you look up.

Two failure modes, and they are not the same problem

I loaded active_support under Ruby 4.0.5 at each version, one subprocess per version, with ruby -e "gem 'activesupport','<v>'; require 'active_support'". Three of them fail with the identical NameError above: 6.0.6.1, 6.1.7.10, 7.0.8.7. 7.1.5.2, 7.2.3 and 8.1.3.1 load clean.

The cause is one line, and you can diff it. logger_thread_safe_level.rb on 7.0.8.7 opens with:

require "active_support/concern"
require "active_support/core_ext/module/attribute_accessors"
require "concurrent"
require "fiber"

and on 7.1.5.2 with:

require "active_support/concern"
require "logger"

Active Support used Logger::Severity at module-body level and relied on somebody else having required logger already. Ruby stopped doing that for it. On this machine Gem::Specification.find_all_by_name("logger").any?(&:default_gem?) prints false, version 1.7.0 is installed as an ordinary gem, and bare Logger raises NameError: uninitialized constant Logger.

Rails 5.2.8.1 is a different animal and the difference is worse. It loads. require "rails/all" succeeds, ActiveSupport::VERSION::STRING prints 5.2.8.1, and you would conclude you had got away with it. Then:

$ bundle exec ruby -e 'require "logger"; require "active_support/all"; require "securerandom"
  ActiveSupport::MessageEncryptor.new(SecureRandom.bytes(32)).encrypt_and_sign("hello")'
.../activesupport-5.2.8.1/lib/active_support/messages/metadata.rb:17:in 'ActiveSupport::Messages::Metadata.wrap': wrong number of arguments (given 2, expected 1) (ArgumentError)

    caller: .../activesupport-5.2.8.1/lib/active_support/message_encryptor.rb:175
    |         encrypted_data = cipher.update(Messages::Metadata.wrap(@serializer.dump(value), metadata_options))
                                                               ^^^^^
    callee: .../activesupport-5.2.8.1/lib/active_support/messages/metadata.rb:17
    |         def wrap(message, expires_at: nil, expires_in: nil, purpose: nil)

No options were passed. metadata_options is an empty hash and Ruby 3.0 stopped turning a trailing hash into keywords, so the call is two positional arguments to a method that takes one. Every encrypted cookie, every credentials read and the whole of encrypt_and_sign are broken on that app while the process is up and the logs are clean. MessageVerifier#generate on the same version works, which is why a suite that only exercises signed cookies goes green. I checked 6.0.6.1, 7.0.8.7 and 7.1.5.2 on the same probe and all three encrypt fine, so 5.2 is the only series in the list with this one.

That is the argument against treating "the app boots" as the gate for an upgrade step. On 6.1 you get a stack trace on line one. On 5.2 you get a running application with its cookie encryption removed.

Rails 7.1 is the rung you hand off on

Two constraints, pointing at each other. Ruby 2.7.8 is the newest Ruby your Rails 5.2 app can plausibly already be on, and it is the last one that resolves anything below 7.2:

$ bundle lock     # Gemfile: gem "rails", "7.2.2.2", under ruby 2.7.8p225
Resolving dependencies...
Bundler found conflicting requirements for the Ruby  version:
  In Gemfile:
    Ruby 

    rails (= 7.2.2.2) was resolved to 7.2.2.2, which depends on
      Ruby  (>= 3.1.0)
$ echo $?
6

The doubled space in Ruby version is Bundler 2.1.4's, not a paste error. And from the other side, 7.1.5.2 is the oldest Rails that loads on Ruby 4.0.5 with nothing added. So:

$ /Users/mehdifarsi/.rvm/rubies/ruby-2.7.8/bin/ruby -v
ruby 2.7.8p225 (2023-03-30 revision 1f4d455848) [arm64-darwin25]
$ bundle install && bundle exec ruby -e 'require "rails/all"; require "securerandom"
  puts "RUBY #{RUBY_VERSION} #{RUBY_PLATFORM}"; puts "BOOTED rails #{Rails::VERSION::STRING}"
  e = ActiveSupport::MessageEncryptor.new(SecureRandom.bytes(32))
  puts "encrypt_and_sign OK: #{e.encrypt_and_sign("hi")[0,10]}..."'
Bundle complete! 1 Gemfile dependency, 67 gems now installed.
RUBY 2.7.8 arm64-darwin25
BOOTED rails 7.1.5.2
encrypt_and_sign OK: VWpSS0J6eE...

Rails 7.1 is the only series in the whole range that runs on the old Ruby and on the new one. That makes the order of the work fixed rather than a preference: get to Rails 7.1 on the Ruby you already have, move Ruby there, then continue on Rails. The Ruby jump is a single commit that changes no application code, landed at the one point where both sides of it are known to work.

The cost of doing it this way is that you spend the 5.2 to 7.1 stretch on an unsupported Ruby, which means every native gem you need has to still build against it, and the ones that dropped 2.7 are now pinned at old versions in your lockfile for the duration. That is real and it is still cheaper than the alternative, where you cannot boot at all and therefore cannot bisect.

What would change my mind: a Rails series that declares an upper required_ruby_version, or an Active Support that keeps loading on Ruby releases published after it. Neither exists, so the handoff rung is arithmetic rather than taste, and it moves every time Rails raises its floor. Today it is 7.1.

If you are stuck below 7.1 on a new Ruby anyway

Sometimes the Ruby has already moved, in a base image or by somebody else's commit, and you need the old Rails to run on it long enough to get out. Four gems and one require did it for rails 7.0.8.7 on Ruby 4.0.5. I found them by adding whatever the last LoadError named and rerunning, four times:

source "https://rubygems.org"
ruby "4.0.5"
gem "rails", "7.0.8.7"
gem "sqlite3", "~> 1.4"
gem "logger"
gem "bigdecimal"
gem "mutex_m"
gem "benchmark"

The dead end was assuming that was sufficient. It is not, and this is the part worth carrying away: listing logger in the Gemfile does nothing on its own. With that exact Gemfile installed, require "rails/all" still raised the same NameError. Bundler puts the gem on the load path; it does not require it, and Bundler.require runs in config/application.rb well after require "rails/all" has already blown up. The require has to happen first, by hand:

$ bundle exec ruby -e 'require "rails/all"; puts "BOOTED"'
.../activesupport-7.0.8.7/lib/active_support/logger_thread_safe_level.rb:12: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)

$ bundle exec ruby -e 'require "logger"; require "rails/all"; puts "BOOTED #{Rails::VERSION::STRING}"'
.../railties-7.0.8.7/lib/rails/initializable.rb:3: warning: tsort was loaded from the standard library, but will no longer be part of the default gems starting from Ruby 4.1.0
You can add tsort to your Gemfile or gemspec to silence this warning.
BOOTED 7.0.8.7

In an application that line goes in config/boot.rb, above require "bundler/setup". The same Gemfile plus require got 6.0.6.1 and 6.1.7.10 to BOOTED as well, and 5.2.8.1 to BOOTED with its encryption still broken, because that one is a source bug rather than a missing require.

Note what the interpreter volunteered on the way past: tsort leaves the default gems in Ruby 4.1.0. The shim list is not stable. It grows on a schedule set by Ruby, and it grows for exactly the versions of Rails nobody is patching.

The config line that is accepted and ignored

config.autoloader = :classic is the single most common line in a Rails 6 config/application.rb that an upgrade has to deal with, and Rails 7 deals with it by pretending to.

On railties 6.1.7.10 it is a real setting. On 7.0.8.7 and 8.1.3.1, reading it raises and writing it succeeds:

=== railties 6.1.7.10 ===
  autoloader reader => :classic
  autoloader = :classic => accepted, now :classic
=== railties 7.0.8.7 ===
  autoloader reader => NoMethodError: undefined method 'autoloader' for an instance of Rails::Application::Configuration
  autoloader = :classic => accepted, now :classic
=== railties 8.1.3.1 ===
  autoloader reader => NoMethodError: undefined method 'autoloader' for an instance of Rails::Application::Configuration
  autoloader = :classic => accepted, now :classic

The write lands in railties-8.1.3.1/lib/rails/railtie/configuration.rb:99:

def method_missing(name, *args, &blk)
  if name.end_with?("=")
    key = name[0..-2].to_sym
    if actual_method?(key)
      raise NoMethodError.new("Cannot assign to `#{key}`, it is a configuration method")
    end
    @@options[key] = args.first
  elsif @@options.key?(name)
    @@options[name]
  else
    super
  end
end

Any setter that is not an actual_method? is stored in @@options and forgotten about. Which is why the reader raises before the assignment and returns :classic after it. You cannot detect this by printing the value back, because printing it back gives you exactly what you wrote.

And there is no warning to grep for either. grep -rl classic railties-7.0.8.7/lib/ matches zero files, and so does grep -rn "config.autoloader". The setting was not deprecated on the way out, it was deleted, and the line in your application file went from configuring the autoloader to configuring nothing without changing one character. Every constant your app resolved through const_missing now resolves through Zeitwerk, on the same commit as the framework bump, silently.

The practical consequence is that the 6.1 to 7.0 step has to be done as config.autoloader = :zeitwerk on 6.1 first, with bin/rails zeitwerk:check green, and only then the version bump. If you skip that, the failure you get is a NameError on a constant in production under an eager_load setting that differs from development, and nothing in the diff points at it.

How big the jump is

Two lockfiles, resolved on Ruby 2.7.8 from a Gemfile holding only rails and puma:

gems in the 5.2 lock: 49
gems in the 7.1 lock: 67
removed: 5 ['arel', 'method_source', 'sprockets', 'sprockets-rails', 'thread_safe']
added:   23 ['actionmailbox', 'actiontext', 'benchmark', 'bigdecimal', 'cgi', 'connection_pool',
             'drb', 'erb', 'io-console', 'irb', 'mutex_m', 'pp', 'prettyprint', 'prism', 'psych',
             'rack-session', 'rackup', 'rdoc', 'reline', 'securerandom', 'stringio', 'tsort',
             'zeitwerk']
version-changed: 15

Eighteen of the twenty-three additions are standard library pieces that became gems. The five that are not, actionmailbox, actiontext, connection_pool, rack-session and zeitwerk, are the actual framework change, and only one of them is load-bearing for a legacy app.

The old Ruby still builds, which I did not expect

I started this expecting the dead end to be here: an Apple Silicon Mac on macOS 26 with only OpenSSL 3 installed, and a Ruby whose final release predates both. rvm install 2.7.8 --disable-binary installed openssl@1.1 from Homebrew as a requirement, compiled, and finished:

Install of ruby-2.7.8 - #complete
EXIT=0
$ /Users/mehdifarsi/.rvm/rubies/ruby-2.7.8/bin/ruby -v
ruby 2.7.8p225 (2023-03-30 revision 1f4d455848) [arm64-darwin25]

It ships Bundler 2.1.4 and RubyGems 3.1.6, which is old enough that bundle lock writes a lockfile your 4.0.10 Bundler will want to rewrite, so keep the two out of each other's working copies.

The one thing that did bite: running the 2.7.8 binary with the 4.0.5 GEM_PATH still exported. It found activesupport 7.1.5.2 in the wrong tree and died on cannot load such file -- random/formatter (LoadError) out of securerandom-0.4.1, which is a gem built for a Ruby that 2.7 is not. Set GEM_HOME and GEM_PATH per interpreter or the error you debug will be a shadowing problem wearing a compatibility problem's clothes.

The test

All of the above is in one Minitest file rather than in my memory, which matters here because every claim is a pair of versions and there are a lot of pairs. It shells out one subprocess per version per claim, so the assertions are about what a fresh interpreter does rather than about whatever got loaded first.

$ ruby test/upgrade_path_test.rb
Run options: --seed 27980

# Running:

.............

Finished in 3.181469s, 4.0862 runs/s, 22.3167 assertions/s.

13 runs, 71 assertions, 0 failures, 0 errors, 0 skips

The one that earns its keep is the autoloader pair, because it asserts a success:

def test_7_0_and_8_1_have_no_autoloader_reader_but_accept_the_write
  %w[7.0.8.7 8.1.3.1].each do |v|
    out, ok = probe(RUBY_40, format(CONFIG, v))
    assert ok, out
    assert_includes out, "read: NoMethodError undefined method 'autoloader' for an instance of Rails::Application::Configuration"
    assert_includes out, "after write: :classic"
  end
end

assert ok is the whole point. Nothing raises, nothing warns, the exit status is 0, and the configuration line is dead.

What this page does not cover

It stops at Rails 7.1 and says nothing about 7.1 to 8.1, which is the other upgrade page: app:update, the load_defaults ladder, Propshaft and the Solid adapters are all there.

It covers the two constraints that decide the order of an upgrade and not the work inside any one step. Absent: Zeitwerk itself beyond the config line, which is a project rather than a paragraph; Webpacker and Sprockets exits; protect_from_forgery and the Rails 5 to 5.1 controller changes; update_attributes, removed in 6.1; Ruby 2.x to 3.x keyword arguments across your own code, of which the 5.2 MessageEncryptor break above is one instance out of however many your app has; multi-database and connects_to; anything below Rails 5.2, where the Ruby you need is 2.6 or older and I did not build one to check; and JRuby, where none of the interpreter measurements transfer.

I also did not run a real application through the ladder. Every measurement here is require-and-call level against installed gems and lockfile resolution, which is enough to settle the ordering question and not enough to tell you what your own initializers will do.

The boilerplate this site sells generates at Rails 8.1.3.1 on Ruby 4.0.5, so it has no upgrade path in it and is not the answer to this question. What it is useful for is the other end of the problem: if you are trying to work out what the finished state looks like, reading a Gemfile.lock and a config/application.rb that already sit where you are headed is faster than reading five sets of release notes and guessing which defaults you are supposed to have ended up with.

#rails #upgrade

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.