Skip to main content

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

  1. 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.
  2. 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-rails 0.7.0 (last released 2022-12-24), seed_migration 1.2.3 (last released 2017), amoeba 3.3.0, gon 6.4.0, clean-architecture 5.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.
  3. 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_defaults stepping: 7.2 → 8.0 → 8.1, each via a generated new_framework_defaults_X_Y.rb reviewed 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-rails remains 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.expect is additive/optional — existing require/permit code keeps working.
  • devise 4.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.
  • sidekiq8.x + sidekiq-pro 8 (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 (the redis gem 4.x and Rails cache work against 6.0 as-is), recorded here for completeness.
  • paper_trail 15.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-rails 6.1.0 → 8.0.4 (requires railties >= 7.2).
  • searchkick 5.3.1 → 6.1.2 (requires activemodel >= 7.2).
  • grape stays 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.
  • karafka stays current from the Phase 1 chain (2.5.x) — no additional bump forced by Rails 8.1 itself.
  • rails_admin 3.3.0 allows rails >= 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 its config.asset_source = :webpacker coupling 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-rails as 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 by rails_admin, used only in rails_admin's own views today. Meanwhile turbolinks 5.2.1 is still live in the main app's own layouts (app/javascript/packs/application.js:7,12, data-turbolinks-track attributes in both layout ERBs). Two competing systems already coexist in the same dependency graph; this workstream consolidates onto one (Turbo).
  • rails_admin's config.asset_source = :webpacker must 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

  • Set becomes a core class (no longer stdlib-only); Set#to_set/Enumerable#to_set with arguments is deprecated; SortedSet is removed (moved to a separate sorted_set gem). Any code or gem monkey-patching stdlib Set is 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, irb move from default to bundled gems. ostruct matters most for this app — audit found ~24 OpenStruct usages across lib/http.rb and several app/core//app/api/ files (see Phase 1 RFC). Confirm ostruct (and logger, already a declared dependency per the lock) stay explicitly resolvable.
    • Concrete latent hazard confirmed by source audit: lib/http.rb:153,155,157 constructs OpenStruct.new(...) with no explicit require 'ostruct' anywhere in that file — once ostruct leaves Ruby's default gems in 4.0, this is a latent LoadError. Must be fixed no later than this track (add the explicit require, or replace with a plain Struct/Hash). Fix it earlier — as part of the Phase 1 wave-0 work — if lib/http.rb becomes the pigeon-http replacement (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.
  • 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-rails 0.7.0, seed_migration 1.2.3, amoeba 3.3.0, gon 6.4.0, clean-architecture 5.0.2, aliyun-sdk 0.8.0, rest-client 2.1.0, wit 7.0.1, byebug 11.1.3 (native ext — if it fails to build, the community fallback is the debug gem). 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.

  1. 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_defaults chasm — five stepping stops (7.0, 7.1, 7.2, 8.0, 8.1) collapsed into a single leap with no intermediate warning phase.
  2. ~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.1 load_defaults stepping, the Ruby-bundled-gem require fixes, 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.
  3. A combined jump concentrates 2 Ruby minors + 3 Rails minors + 5 load_defaults steps 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 dead test/ Minitest directory that contributes nothing). A failure in that combined deploy is far harder to bisect than a failure after any single stepped deploy.
  4. 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)
RiskHigh — no deprecation-warning safety net across 5 defaults stepsLower — each step independently bisectable, warnings surfaced before they become errors
Rollback granularityOne 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 windowLonger 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)

  1. Bump Gemfile to gem 'rails', '~> 8.0.5'; run bin/rails app:update and review the diff explicitly (new generators/config defaults, Propshaft-related scaffolding to consciously decline if staying on Sprockets).
  2. 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.
  3. 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.
  4. Step load_defaults 7.2 → 8.0 (generate, review, deploy, flip), same rolling-deploy discipline as Phase 1 (mixed-version-pod cache/cookie risk).
  5. 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.
  6. Bump Gemfile to gem 'rails', '~> 8.1.3'; run bin/rails app:update again; step load_defaults 8.0 → 8.1.
  7. 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.
  8. Spike rails_admin on Rails 8.1 explicitly (not blessed upstream) before relying on it in production.
  9. 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)

  1. 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.
  2. Re-run the bundled-gem require grep against the 4.0-specific list (ostruct, pstore, benchmark, logger, rdoc, win32ole, irb).
  3. Rebuild/verify every native-extension gem against Ruby 4.0's ABI.
  4. Confirm Set core-class change does not conflict with any monkey-patching in config/initializers/system.rb (which patches clean-architecture/athens internals — audit §2) or elsewhere.
  5. Bump .ruby-version, Gemfile, all Dockerfiles, and the CI image, mirroring Phase 1's Ruby-bump mechanics.
  6. Full validation + staging bake + canary, same discipline as every prior step.

Validation

  • Full bundle exec rspec suite 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-cron schedules, 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_admin functional 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.rb columns 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

  1. Does the team's Sidekiq Pro license/registry access cover Sidekiq Pro 8, or does it require a new subscription tier?
  2. 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.
  3. 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?
  4. Do the dry-rails/seed_migration spikes (Track 2b) turn up a hard blocker requiring replacement, or do they pass?
  5. 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.
  6. If aegis is 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 #call with the right arity, which is Sidekiq-API-version-agnostic, but whether Sidekiq.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.