Skip to content

Rails 6.1 to 8.1 on Ruby 3.4 - #505

Open
OlegPhenomenon wants to merge 15 commits into
masterfrom
upgrade/rails-8-1
Open

OlegPhenomenon wants to merge 15 commits into
masterfrom
upgrade/rails-8-1

Conversation

@OlegPhenomenon

Copy link
Copy Markdown
Contributor

Rails 6.1.4.1 stopped receiving security fixes on 1 October 2024. The obvious target, 7.2, stopped being one too: that series ended its security-only window on 9 August 2026, which is where the open Snyk pull request #502 would have taken us. 8.1 gets bug fixes until 10 October 2026 and security fixes until 10 October 2027, so this goes straight there.

.ruby-version needed no change - master already says 3.4.9, above the 3.2 that Rails 8.1 requires. What did need changing is the CI matrix, which was pinned to 3.0.3 while the servers ran 3.4.9. That is how a Rails version the application cannot boot on could have passed CI, and it is the same gap that produced the last three failed deploys.

What is in here

Each step is its own commit and can be reverted on its own.

  1. zeitwerk - config.autoloader = :classic has no meaning since Rails 7.0. The one file that was not compatible was the bounce concern: sitting under app/models/concerns/contact_request/, which is an autoload root of its own, it had to define ContactRequest::Bounceable and actually defined Concerns::ContactRequest::Bounceable. It is now a plain Bounceable. rails zeitwerk:check runs in CI from now on.
  2. Rails 8.1 itself, plus the removal of mimemagic (Active Storage replaced it with marcel back in 6.0 and nothing here references it) and the unpinning of simplecov (the 0.17.1 pin existed for Code Climate, coverage goes to qlty now).
  3. minitest-mock - minitest 6 moved mocks and stubs into a gem of their own.
  4. Lockfile pinned to the ruby platform, so native extensions keep being compiled from source. The servers cannot load the published Linux binaries.
  5. Three changed APIs: to_s(:iso8601) became to_fs, show_exceptions takes a symbol, Net::SMTPFatalError takes a mandatory argument.
  6. parse_json_times written out as false - see below.
  7. Framework defaults moved from 6.0 to 8.1, with the two things that broke fixed: a dead test-only rewrite of a readonly attribute, and the main page redirect that now has to declare it leaves our host.

Two things worth a second look

config.active_support.parse_json_times said true and did nothing. On 6.1 the ActiveSupport railtie only applies settings the module already responds to, and it did not respond to this one that early in boot. So every date inside the whois json column has always reached the views and the json API as a plain string - which is why the suite is full of assertions on ISO strings and why the views call .to_datetime on those values. Rails 8 does apply the setting, and turning it on would change the date format of the public whois output. That is a product decision, not something an upgrade should do on the side, so the line now says false with the trap written next to it.

update_request_secret was dead code. It called update(secret:) whenever the environment was test, and attr_readonly had always dropped that on the way to the database. Since the 7.1 defaults such an assignment raises instead of being ignored, so the line started taking the whole confirmation flow down with it. Removed; the two model tests that asserted the silent no-op now assert the exception.

Verification

CI is green: 162 runs, 423 assertions, 0 failures, 0 errors on Ruby 3.4.9, with zeitwerk:check passing and the system tests rendering the pages through sprockets and sassc.

What CI cannot answer is the deploy itself. pg carries force_ruby_platform: true so it is compiled from source instead of pulling the binary linked against glibc 2.29 that the servers cannot load; the option needs bundler >= 2.3.18, which the servers only get now, with Ruby 3.4. Asset precompilation and Passenger against Rack 3 are the other two things only a real deploy can confirm.

Note on #503

fix/contact-request-registry-sync holds pg at 1.5.9 and pins Ruby back to 3.0.3, because at the time bundler on the servers was too old to understand force_ruby_platform. Merging both will conflict on Gemfile, Gemfile.lock and renovate.json. This branch is the state to keep in those files.

Preparation for Rails 7+: config.autoloader = :classic has no effect there,
the option and the classic autoloader itself were removed in 7.0. Doing the
switch here, still on 6.1, keeps autoloading failures apart from framework
upgrade failures.

The only file that was not zeitwerk compatible is the bounce concern.
app/models/concerns is an autoload root of its own, so a file sitting at
concerns/contact_request/bounceable.rb had to define ContactRequest::Bounceable,
while it actually defined Concerns::ContactRequest::Bounceable - an extra
namespace level that only the classic autoloader tolerated. Moved to
concerns/bounceable.rb as a plain top level Bounceable, which is where a
single concern belongs anyway; reopening the ContactRequest class from inside
a second autoload root would have worked too, but it buys a namespace we have
no other use for at the price of an explicit-namespace load order.

rails zeitwerk:check runs in CI from now on, so the next mismatch is a failed
build instead of a failed deploy.
Rails 6.1 stopped receiving security fixes on 1 October 2024. The obvious
target, 7.2, is not a target any more either: that series ended its
security-only window on 9 August 2026, twelve days ago, which is what the
open Snyk pull request would have upgraded us to. 8.1 is the only series
with a future - bug fixes until 10 October 2026, security fixes until
10 October 2027 - so this goes straight there.

Ruby needs no change: .ruby-version already says 3.4.9, which is both above
the 3.2 that Rails 8.1 requires and the version the guides recommend for it.
CI, however, was still pinned to 3.0.3 in the matrix, which is how a Rails
version the app cannot even boot on could have passed. It now reads
.ruby-version like the servers do.

Along the way:

  * lib/tasks/db.rake called ActiveRecord::Base.clear_all_connections!, which
    was a delegation to the connection handler that 6.1 still had and 7.1
    removed. Nothing covers that task in tests - it migrates both production
    databases - so it would have failed silently until a deploy ran it.

  * mimemagic is gone. Active Storage depended on it up to Rails 5.2 and has
    used marcel since 6.0; nothing in this application references it, and
    nothing in the lockfile depends on it either.

  * simplecov is unpinned. The 0.17.1 pin carried the comment "CC last
    supported v0.17", but coverage has been going to qlty for a while now.

Ruby 3.4 also brings bundler 2.6 to the servers, which finally makes
force_ruby_platform understood there, so pg can stay on 1.6.x and be compiled
from source instead of being held back to 1.5.x.
minitest 6 extracted mocks and stubs into a gem of their own, and both
test_helper and the mailer test use them.
The lockfile has always listed "ruby" as its only platform, which is what
makes native extensions be compiled from source - the servers cannot use the
published Linux binaries of pg anyway. Regenerating it inside a container had
replaced that with the container's own platform.
  * Date#to_s(:iso8601) - the formatting to_s was removed in Rails 7.0 in
    favour of to_fs.
  * config.action_dispatch.show_exceptions takes a symbol since 7.1; the
    boolean it used to hold stopped meaning anything, so integration tests
    got rendered error pages instead of raised exceptions.
  * Net::SMTPFatalError now takes a mandatory argument, so raising the bare
    class fails with ArgumentError before the code under test is reached.
config.active_support.parse_json_times said true and did nothing: on 6.1 the
railtie only applies settings ActiveSupport already responds to, and it did
not respond to this one that early, which is why a suite full of assertions
on ISO strings has been green for years and why the views call .to_datetime
on values that would already be times otherwise.

Rails 8 does apply the setting, which turns every date in the whois payload
into a TimeWithZone and changes how it is rendered on the public pages and
in the json API. Written out as false so the upgrade keeps the output it
had, with the trap spelled out for whoever reads the history and decides
the true was lost by accident.

Test merges are stringified for the same reason: symbol keys merged onto the
string-keyed json hash produced literally duplicate keys in the encoded
document, which json 3.0 will refuse outright.
The application was still initialising with the 6.0 defaults, so everything
Rails changed its mind about between 6.0 and 8.1 was switched off. The 5.2
defaults file goes with it: every option in it was commented out, so it was
a list of decisions nobody ever made.
update_request_secret ran ContactRequest#update(secret:) whenever the
environment was test, which attr_readonly has always dropped on the way to
the database - the reload right after it read the original secret back. Since
the 7.1 defaults an assignment to a readonly attribute raises instead of
being ignored, so the dead line started taking the confirmation flow down
with it.

The two model tests that asserted the silent no-op now assert the exception,
which is the same guarantee stated out loud.
The 7.1 defaults refuse cross host redirects unless they say so explicitly.
This one goes to main_page_url, which is server configuration and not
anything the visitor can influence.
The deploy got past pg and died on nokogiri, loading it through sassc-rails:

  libm.so.6: version `GLIBC_2.29' not found (required by
  nokogiri-1.19.4-x86_64-linux-gnu/lib/nokogiri/3.4/nokogiri.so)

Same story as pg: nokogiri's precompiled x86_64-linux-gnu gem requires glibc
2.29 or newer (nokogiri.org/tutorials/installing_nokogiri.html, checked via web
2026-08-27; the floor was raised in 1.18), and the deploy servers are older than
that. force_ruby_platform makes Bundler ignore the binary and compile from
source, which is what the gem's own error message recommends. nokogiri is a
Rails dependency, not one we use directly, so it has to be named in the Gemfile
just to carry the flag - and therefore also in DEPENDENCIES.

Locking PLATFORMS to "ruby" was supposed to cover this, but it only holds if the
install honours the lockfile: `bundle install` outside frozen/deployment mode
adds the machine's own platform and re-resolves, which is where the native gem
came from. Per-gem force_ruby_platform survives that, since it is a property of
the Gemfile dependency rather than of the lockfile.

Resolution is unchanged - the lockfile already held the ruby platform variant of
nokogiri. Verified on Ruby 3.4.9 / Bundler 2.5.23 in a container: after
`bundle lock --add-platform x86_64-linux`, ffi is the only gem left resolving to
a native x86_64-linux-gnu build; nokogiri and pg stay on the ruby platform.
ffi's binary needs no symbol above GLIBC_2.27, so it still loads on a 2.27/2.28
server, but it is next in line if the servers are older than that.

Also brings DEPENDENCIES back in sync with the Gemfile: it asked for pg ~> 1.5.9
while both the Gemfile and the resolved version say 1.6.3, so the lockfile could
not be satisfied as written.
The branch was force-pushed back onto the pre-rebase line, and with it came back
the state of four files as they were before the staging deploys of 2026-08-27.
CI stops on its very first step:

  gem install bundler -v 2.6.9
  ERROR: bundler-2.6.9 requires Ruby version >= 3.1.0.
         The current ruby version is 3.0.3.157.

because .ruby-version went back to 3.0.3 while Gemfile.lock still says BUNDLED
WITH 2.6.9 - and the Gemfile asks for Rails 8.1, which needs 3.2 anyway. This is
the same trap the workflow comment warned about: the runner reads .ruby-version,
so a stale number there does not fail loudly, it fails before the suite runs.

Restored, all of it verified on staging before it was lost:

  - .ruby-version back to 3.4.9, which is what the servers run
  - gem "syslog", without which the app cannot boot on Ruby 3.4 - syslog is a
    bundled gem now and Bundler hides it unless the Gemfile asks
  - uglifier gone again, from the Gemfile and from production.rb: the app ships
    no JavaScript, and uglify-js died on the staging JS runtime with an empty
    Uglifier::Error during assets:precompile
  - the workflow comment explaining why the matrix reads .ruby-version

Everything the force-push added - the contact request work, its tests, the
dependabot and renovate changes, the changelog - is left untouched.

Also untracks .repowise/sessions/sessions.db, a local index that slipped into
the commit; .gitignore already lists .repowise/, so it will not come back.

Lockfile regenerated rather than hand-edited: syslog 0.4.0 in, uglifier and
execjs out, nothing else moved. Verified on Ruby 3.4.9 in a container that
bundle lock --no-update reproduces it byte for byte, and that ffi remains the
only gem resolving to a native x86_64-linux build.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants