RFC — Phase 2: Upgrade Rails 7.2.3.1 → 8.1.3 (and evaluate Ruby 3.4.10 → 4.0.5)
Bucket item: A2 · Class: 🟡 Standard · Jira Story: BOT-4622 · Epic: BOT-4620
Context
This RFC starts from Phase 1's end-state: Ruby 3.4.10 / Rails 7.2.3.1 /
config.load_defaults 7.2 (see
phase-1-ruby-3-4-rails-7-2-upgrade.md).
Rails 7.2's security support ends 2026-08-09 — Phase 1 explicitly calls 7.2 a transit station, not a resting state. That makes this phase time-sensitive: the app cannot sit on 7.2 for long after Phase 1 lands without re-entering unpatched-framework territory, the same problem this whole initiative exists to fix. Rails 8.1.3 has security support runway to 2027-10-10 — over a year — making it the actual stable landing point.
Recommendation: split Phase 2 into two independent tracks
- Track 2a (do promptly): Rails 7.2 → 8.0 → 8.1.3, on Ruby 3.4.10.
- Track 2b (optional — recommend DEFER): Ruby 3.4.10 → 4.0.5.
Why defer Track 2b
- Ruby 3.4 has runway. EOL is projected 2028-03-31 — roughly 20 months from today. There is no security-support cliff forcing an immediate Ruby jump, unlike the Rails 7.2 situation driving Track 2a.
- Ruby 4.0's ecosystem is still maturing. ZJIT is explicitly experimental in the 4.0 release
(YJIT remains the production JIT). More importantly, this app carries a long tail of
unmaintained gems that are exactly where Ruby 4.0 breakage surfaces:
dry-rails0.7.0 (last released 2022-12-24),seed_migration1.2.3 (last released 2017),amoeba3.3.0,gon6.4.0,clean-architecture5.0.2 (abandoned since 2021, and its own next major adds a Sorbet dependency we don't want). None of these have a stated Ruby 4.0 compatibility position. - Rails 7.2 will never receive Ruby 4.0 fixes — it is security-only and past its bug-fix window. Ruby 4.0 compatibility work landed only in Rails 8.0.x/8.1.x. So Rails must move to 8.1 before Ruby 4.0 is viable regardless of sequencing preference — there is no order where Ruby 4.0 arrives before Rails 8.1 without running an unsupported combination.
This directly answers the stakeholder question "should we stick with 3.4.10?" — yes, for now. Revisit Ruby 4.0 as its own bucket story around 2027-Q1, once the ecosystem (and this app's unmaintained gems specifically) have had more time to prove out 4.0 compatibility.
Rails 8.0/8.1 impact for an existing app (Track 2a)
- 7.2 deprecation warnings become hard errors in 8.0+. This is the core reason Phase 1 must precede this phase rather than being skipped (see the direct-jump assessment below).
load_defaultsstepping: 7.2 → 8.0 → 8.1, each via a generatednew_framework_defaults_X_Y.rbreviewed and applied as its own deploy, same discipline as Phase 1's 7.0/7.1 stepping. The official release notes do not centrally publish the full defaults diff for 8.0/8.1 — rely on the generated file, not a changelog summary.- Solid Queue/Cache/Cable, Propshaft, Kamal 2 are new-app defaults, not forced migrations.
sprockets-railsremains supported (no upper Rails-version cap on its Active Support dependency) — the app can keep Sprockets through 8.x. Sidekiq/Redis remain fully supported alternatives to Solid Queue; no action required here beyond the asset-pipeline workstream below, which is separate from the Propshaft-vs-Sprockets question. params.expectis additive/optional — existingrequire/permitcode keeps working.devise4.9.3 → 5.0.4: 4.9.x has route-registration failures on Rails 8.1; 5.0.4 (2026-01-23) adds explicit Rails 8/8.1 support.sidekiq→ 8.x +sidekiq-pro8 (license/registry access unconfirmed — see Open questions) + Redis server ≥ 7.0: prerequisite already met — the Redis server backing Sidekiq is 7.x (stakeholder-confirmed 2026-07-13). The app's separate cache/app Redis stays at 6.0; this does not need to change for any step in either phase (theredisgem 4.x and Rails cache work against 6.0 as-is), recorded here for completeness.paper_trail15.1.0 → 17.x (tracks the Rails 8.0/8.1 line; exact per-release compat matrix not verified in detail).acts_as_paranoid(already at 0.10.3 from Phase 1) → 0.11.0, which raises the AR/AS ceiling to< 8.2(covers 8.1, not a hypothetical future 8.2).rspec-rails6.1.0 → 8.0.4 (requiresrailties >= 7.2).searchkick5.3.1 → 6.1.2 (requiresactivemodel >= 7.2).grapestays on the 2.x line (2.4.0+ from Phase 1's wave-0) — resolves fine on Rails 8.1; defer the 2.x→3.x jump (API-breaking) until after this phase.karafkastays current from the Phase 1 chain (2.5.x) — no additional bump forced by Rails 8.1 itself.rails_admin3.3.0 allowsrails >= 6.0, < 9(covers 8.1 on paper), but Rails 8.1 is not explicitly blessed anywhere in the project's own release notes (the project moves slowly) — treat as a verification spike, not an assumption, and note itsconfig.asset_source = :webpackercoupling must migrate in lockstep with the asset-pipeline workstream below.
Asset-pipeline workstream
This is the FE-touching, highest-effort item of Track 2a and should start during or right after Phase 1, not be squeezed into this phase's tail:
- Webpacker 5 → shakapacker (official successor) or an assessment of
jsbundling-railsas an alternative. Webpacker has been unmaintained since 2021; Phase 1 only added a CI boot gate, it did not migrate anything. - Turbolinks → turbo-rails. The audit found
turbo-rails(2.0.16) already present in the dependency graph — pulled in transitively byrails_admin, used only in rails_admin's own views today. Meanwhileturbolinks5.2.1 is still live in the main app's own layouts (app/javascript/packs/application.js:7,12,data-turbolinks-trackattributes in both layout ERBs). Two competing systems already coexist in the same dependency graph; this workstream consolidates onto one (Turbo). rails_admin'sconfig.asset_source = :webpackermust be updated together with whichever successor is chosen, or its admin UI breaks independently of the main app.- The Docker build stage's Node 16 base image is itself EOL and should be bumped as part of, or alongside, the webpacker → shakapacker migration rather than left on an unsupported Node line once the JS toolchain is already being touched.
Track 2b (Ruby 4.0.5) impact — for when it is picked up
Setbecomes a core class (no longer stdlib-only);Set#to_set/Enumerable#to_setwith arguments is deprecated;SortedSetis removed (moved to a separatesorted_setgem). Any code or gem monkey-patching stdlibSetis at risk.- Frozen string literals are NOT flipped by default in 4.0 — chilled-string warnings remain opt-in, same posture as Phase 1's 3.4 assessment; no forced behavior change here either.
- Further default→bundled gem migration:
ostruct,pstore,benchmark,logger,rdoc,win32ole,irbmove from default to bundled gems.ostructmatters most for this app — audit found ~24OpenStructusages acrosslib/http.rband severalapp/core//app/api/files (see Phase 1 RFC). Confirmostruct(andlogger, already a declared dependency per the lock) stay explicitly resolvable.- Concrete latent hazard confirmed by source audit:
lib/http.rb:153,155,157constructsOpenStruct.new(...)with no explicitrequire 'ostruct'anywhere in that file — onceostructleaves Ruby's default gems in 4.0, this is a latentLoadError. Must be fixed no later than this track (add the explicitrequire, or replace with a plainStruct/Hash). Fix it earlier — as part of the Phase 1 wave-0 work — iflib/http.rbbecomes thepigeon-httpreplacement (see the Phase 1 RFC's decided pigeon-http fallback): the replacement would otherwise inherit this pre-existing hazard immediately, well before Track 2b is reached.
- Concrete latent hazard confirmed by source audit:
- Native-extension recompile surface:
pg,nokogiri,google-protobuf,ffi,msgpack,sassc,karafka-rdkafka,bcrypt_pbkdf,ed25519, and the Datadog native libraries (libdatadog,libddwaf) all need a rebuild/re-verify against Ruby 4.0's ABI. - Round 2 of bundled-gem migration compounds with Phase 1's round 1 — re-run the same
require-grep discipline (Phase 1 RFC's Ruby language-impact section) against the 4.0 list specifically. - Spike list of blocker-unknown gems (no stated Ruby 4.0 position, several already unmaintained):
dry-rails0.7.0,seed_migration1.2.3,amoeba3.3.0,gon6.4.0,clean-architecture5.0.2,aliyun-sdk0.8.0,rest-client2.1.0,wit7.0.1,byebug11.1.3 (native ext — if it fails to build, the community fallback is thedebuggem). Each needs an isolated compatibility spike before Track 2b lands, not a blanket "probably fine."
Direct-jump assessment — can we skip Phase 1?
Verdict: technically possible, not recommended, and the apparent savings are mostly illusory.
- Official Rails guidance is to move one minor version at a time, explicitly to make use of
each version's deprecation warnings. Rails 7.2's deprecation warnings become hard errors in
8.0+. Skipping 7.2 removes the only safety net across what is otherwise a 6.1→8.1
load_defaultschasm — five stepping stops (7.0, 7.1, 7.2, 8.0, 8.1) collapsed into a single leap with no intermediate warning phase. - ~90% of Phase 1's work is unavoidable regardless of whether 7.2 is skipped: the
acts_as_paranoid/ddtrace/karafka/etc. gem-unblocking wave, the 46-declaration enum modernization, the 7.0/7.1load_defaultsstepping, the Ruby-bundled-gemrequirefixes, and the CI coverage gaps (asset-compile, Karafka boot) all still have to happen before Rails 8.x can run at all. Skipping Phase 1 as a deploy saves only the intermediate 7.2 production stop — not the underlying engineering work, which is identical either way. - A combined jump concentrates 2 Ruby minors + 3 Rails minors + 5
load_defaultssteps into one big-bang deploy, against a test suite with known coverage gaps (audit §5/§6: zero asset-compile CI coverage, zero Karafka CI coverage, and a deadtest/Minitest directory that contributes nothing). A failure in that combined deploy is far harder to bisect than a failure after any single stepped deploy. - The real optimization is the opposite of skipping: keep Rails 7.2 as a short-lived checkpoint — full CI green, a staging bake, and a brief production soak — then proceed immediately into 8.0/8.1, i.e. compress the gap between phases rather than delete the intermediate step.
| Big-bang (7.1→8.1 direct) | Phased with compressed gap (this RFC's recommendation) | |
|---|---|---|
| Risk | High — no deprecation-warning safety net across 5 defaults steps | Lower — each step independently bisectable, warnings surfaced before they become errors |
| Rollback granularity | One giant revert, likely incomplete (mixed cache/cookie formats already changed) | Per-step revert, each isolated |
| Total engineering effort | ~Same (gem unblocking, enum fixes, CI gaps are unavoidable either way) | ~Same, plus a short 7.2 checkpoint |
| Security-exposure window | Longer per attempt (bigger blast radius means slower, more cautious rollout) | Shorter overall (7.2 checkpoint is brief by design, not a resting stop) |
Execution plan
Track 2a — Rails 7.2 → 8.0 → 8.1.3 (on Ruby 3.4.10)
- Bump
Gemfiletogem 'rails', '~> 8.0.5'; runbin/rails app:updateand review the diff explicitly (new generators/config defaults, Propshaft-related scaffolding to consciously decline if staying on Sprockets). - Bump the blocking gems identified above (
acts_as_paranoid→ 0.11.0,devise→ 5.0.4,sidekiq→ 8.x + Pro 8,paper_trail→ 17.x,rspec-rails→ 8.0.4,searchkick→ 6.1.2) as their own small deploys, same wave-0 discipline as Phase 1. - The Redis server ≥ 7.0 prerequisite is already met (Sidekiq's Redis is 7.x, stakeholder-confirmed 2026-07-13) — re-confirm at execution time (exact patch version and any managed-service constraints) rather than treating this as an infra upgrade to schedule.
- Step
load_defaults7.2 → 8.0 (generate, review, deploy, flip), same rolling-deploy discipline as Phase 1 (mixed-version-pod cache/cookie risk). - 8.0 checkpoint: full rspec + boot smoke + staging soak on Rails 8.0.5 before proceeding to 8.1 — this is the "compressed gap" checkpoint from the direct-jump assessment, not a resting stop.
- Bump
Gemfiletogem 'rails', '~> 8.1.3'; runbin/rails app:updateagain; stepload_defaults8.0 → 8.1. - Run the asset-pipeline workstream (shakapacker/turbo-rails migration) in parallel with or immediately after step 6, since it is independent of the pure Rails version bump but blocks a clean Propshaft-free Sprockets story on 8.1.
- Spike
rails_adminon Rails 8.1 explicitly (not blessed upstream) before relying on it in production. - Full validation (below), staging bake, canary rollout across both deployment targets (AWS + Alicloud).
Track 2b — Ruby 3.4.10 → 4.0.5 (separate, deferred step-list — do not schedule with Track 2a)
- Spike the blocker-unknown gem list (
dry-rails,seed_migration,amoeba,gon,clean-architecture,aliyun-sdk,rest-client,wit,byebug) against Ruby 4.0 in isolation, well before committing a date. - Re-run the bundled-gem
requiregrep against the 4.0-specific list (ostruct,pstore,benchmark,logger,rdoc,win32ole,irb). - Rebuild/verify every native-extension gem against Ruby 4.0's ABI.
- Confirm
Setcore-class change does not conflict with any monkey-patching inconfig/initializers/system.rb(which patchesclean-architecture/athensinternals — audit §2) or elsewhere. - Bump
.ruby-version,Gemfile, all Dockerfiles, and the CI image, mirroring Phase 1's Ruby-bump mechanics. - Full validation + staging bake + canary, same discipline as every prior step.
Validation
- Full
bundle exec rspecsuite green at each Rails minor (8.0 checkpoint, then 8.1). - Boot smoke per process type (Puma, Sidekiq, Karafka, console, cron, migration job) at each step.
- Staging soak: Karafka consumer,
sidekiq-cronschedules, Datadog traces, and (for Track 2a) confirmed Redis 7.0 connectivity ahead of the Sidekiq 8 bump. - Asset-pipeline parity check once the shakapacker/turbo-rails migration lands — visual and
functional regression pass on both the main app layouts and
rails_admin. rails_adminfunctional spike result recorded before relying on it past this phase.
Risks & rollback
- Each Track 2a step (8.0 bump, defaults steps, 8.1 bump, individual gem bumps) is independently revertible, same per-step discipline as Phase 1.
schema.rbcolumns are dumped alphabetically as of 8.1 — expect a one-time large, purely cosmetic schema diff; do not conflate it with a real migration risk, but review it explicitly so a real change isn't hidden inside the noise.- The asset-pipeline migration (shakapacker/turbo-rails) is the least reversible piece — treat it as its own change with its own rollback plan (keep the webpacker/turbolinks code path available behind a flag or branch until the new pipeline is proven in production), separate from the Rails version bumps.
- Track 2b carries its own independent risk profile (native-ext ABI breakage, unmaintained-gem long tail) and should never be bundled into the same deploy window as Track 2a.
Open questions
- Does the team's Sidekiq Pro license/registry access cover Sidekiq Pro 8, or does it require a new subscription tier?
What Redis server version is actually running in production today, and who owns scheduling its upgrade to ≥ 7.0?Resolved 2026-07-13 (stakeholder-confirmed): the Redis server backing Sidekiq is 7.x — the ≥ 7.0 prerequisite is already met, no scheduled infra upgrade needed for this. The app's separate cache/app Redis is 6.0, unaffected and not a blocker for either phase. Note (informational, not actioned here): Redis 6.0 is itself an EOL line (6.2 is the maintained 6.x release) — out of scope for Phase 1/Phase 2, but a candidate for its own separate bucket story.- What is the outcome of the
rails_admin-on-8.1 verification spike — does it need a fork/patch, or does it work as-is? - Do the
dry-rails/seed_migrationspikes (Track 2b) turn up a hard blocker requiring replacement, or do they pass? - Does Track 2b (Ruby 4.0.5) land within this fiscal year, or is it explicitly pushed to the 2027-Q1 revisit mentioned above? This should be answered when Track 2a is scheduled, not left open indefinitely.
- If
aegisis still attached at this phase (not detached per the Phase 1 fallback): does its Sidekiq client/server middleware-chain registration API still work unchanged at Sidekiq 8 (Track 2a's sidekiq bump)? Source-audited as a narrowed residual — aegis's patch classes only need#callwith the right arity, which is Sidekiq-API-version-agnostic, but whetherSidekiq.configure_client/configure_server's block-style chain registration itself survives the Sidekiq 8 major bump was not verified against Sidekiq's own source or changelog.