Skip to content

Upgrade to Ruby 4 #1300

Description

@philayres

Relies on #1015


Plan

Context

  • Depends on Upgrade to Rails 8 #1015 (Rails 8 -> 8.1 upgrade), currently in progress on branch rails-8.1-upgrade-1325 (PR Upgrade to Rails 8.1.3.1 #1338, targets Rails 8.1.3.1, not yet merged to develop).
  • Ruby 4.0 was released 2025-12-25; latest patch as of now is 4.0.6 (2026-07-14). Ruby 4.0 bundles RubyGems/Bundler 4.x and minitest 6.0.0.
  • Current repo state (branch rails-8.1-upgrade-1325):
    • .ruby-version = 3.4.7
    • Gemfile: no explicit ruby '...' pin; rails '~> 8.1.0'; minitest '~> 5.1' (comment: "minitest 6 is incompatible with rails 7.2.x" - stale, app is now on Rails 8.1)
    • Gemfile.lock: BUNDLED WITH 2.6.9; minitest resolved to 5.27.0
    • No GitHub Actions/CI workflows, no Dockerfile in this repo
    • app-scripts/release_and_build.sh reads .ruby-version dynamically (no hardcoded version pin) - safe
    • config/boot.rb has a Ruby-3.4-specific bootsnap workaround for parallel test workers (frozen array issue) - re-verify need on Ruby 4
    • config/application.rb sets config.yjit = true (Ruby 3.3+) - should keep working
    • Native extension gems to watch: nokogiri, pg, bcrypt, bootsnap, debug, msgpack, ruby_parser, sassc-embedded

Ruby 4.0 breaking/notable changes relevant to this app

(researched via ruby-lang.org release notes + rubyreferences.github.io/rubychanges/4.0.html)

  • Set reimplemented as a core C class (was autoloaded stdlib). No custom Set subclasses or @hash-ivar reliance found in this codebase (grep clean). Low risk.
  • Set#to_set/Enumerable#to_set with extra arguments deprecated - no usages found in app/lib/app-scripts (grep clean).
  • cgi stdlib mostly removed; only cgi/escape remains (CGI.escape(HTML)/unescape(HTML)/escapeURIComponent/escapeElement). App uses CGI.escapeHTML in app/models/master.rb only - compatible, but must verify it's actually loaded (no explicit require 'cgi' found; likely pulled in transitively via Rack/ActionDispatch - verify at boot).
  • Several stdlibs moved from default gems to bundled gems (must be explicit Gemfile deps if directly used): benchmark, fiddle, irb, logger, ostruct, pstore, rdoc, readline, reline, win32ole. No direct usage found in app code (grep clean) - but transitive gem dependencies (aws-sdk, redcap, delayed_job, etc.) may need these; must verify via bundle install + boot + full test run rather than static analysis alone.
  • sorted_set fully removed (was already deprecated) - not used (grep clean).
  • *nil splat semantics changed (no longer calls .to_a) - unlikely to affect app code; no fragile patterns found.
  • Net::HTTP no longer auto-sets Content-Type: application/x-www-form-urlencoded on POST/PUT bodies when unset - no direct Net::HTTP POST usage with implicit content-type found in app/lib/app-scripts (grep clean); AWS SDK / other gems' internal usage is out of our control, monitor via test failures.
  • Backtrace formatting changes (<internal:> frames removed, ArgumentError shows class name + code snippet) - could affect any spec/test that asserts on exact backtrace/error-message strings.
  • RubyGems/Bundler 4 bundled with Ruby 4.0 - current Gemfile.lock BUNDLED WITH is 2.6.9; needs bump.

Decisions

  • Target Ruby version: 4.0.6 (latest patch).
  • Bundler/RubyGems: upgrade to 4.x as part of this issue (not deferred).
  • minitest '~> 5.1' pin: investigate and remove if Rails 8.1 + minitest 6 works; update/remove stale comment either way.
  • Deploy repo sync (fphs-app-deploy / fphs-rails-app, EB platform Ruby 4.0 availability): out of scope for this issue - those are forks that will be brought into line separately, soon after.
  • Branch strategy: stack the new branch on the current branch rails-8.1-upgrade-1325 now (coordinated stacked branches, as done for Rails 8 upgrade prep: fix otp_enc_key, advance load_defaults to 7.2 #1299 -> Upgrade Rails to 8.0.5 and Puma to 8.0.2 - fixes #1015 #1305). Rebase onto develop later once the Rails 8.1 PR chain merges, before opening the PR.

Steps

Phase 0 - Branch setup

  1. Tag start-ruby4-upgrade-1300 on current branch tip.
  2. Create branch ruby4-upgrade-1300 off rails-8.1-upgrade-1325.

Phase 1 - Ruby/Bundler version bump (depends on Phase 0)

  1. Update .ruby-version to 4.0.6.
  2. Ensure local Ruby 4.0.6 is installed.
  3. Upgrade Bundler to 4.x, regenerate Gemfile.lock BUNDLED WITH line.
  4. Re-run bundle install to rebuild native extension gems (nokogiri, pg, bcrypt, bootsnap, debug, msgpack, ruby_parser, sassc-embedded, ffi if present) against Ruby 4.0.6. Watch for build failures - these gems may need version bumps if their published gem versions don't yet support Ruby 4.0 ABI.

Phase 2 - Dependency/Gemfile adjustments (depends on Phase 1)

  1. Investigate the gem 'minitest', '~> 5.1' pin: loosen/remove, bundle update minitest, run full RSpec suite. If green with minitest 6.x, remove the pin and its stale comment; otherwise keep pin but update the comment to reflect the real reason.
  2. After bundle install, check for LoadError on any of: benchmark, fiddle, irb, logger, ostruct, pstore, rdoc, readline, reline during boot/test run - add explicit gem entries only if something breaks.
  3. Update THIRD-PARTY-LIBRARIES.md gem list/licenses if new explicit gems were added, or if bundler version changes what's reported.

Phase 3 - Ruby 4.0 code-level compatibility checks (can run in parallel with Phase 2 once Ruby 4.0.6 is installed)

  1. Verify CGI.escapeHTML in app/models/master.rb still resolves correctly at runtime; add explicit require 'cgi' only if it fails to autoload.
  2. Re-test the Ruby-3.4 bootsnap workaround in config/boot.rb (parallel test worker frozen-array issue) under Ruby 4.0.6 - remove if no longer needed, or keep with an updated comment.
  3. Confirm YJIT (config.yjit = true) still boots and functions correctly under Ruby 4.0.6.
  4. Grep specs for brittle backtrace/error-message string assertions that could break due to Ruby 4.0's backtrace formatting changes - fix any that break during Phase 4 test run.

Phase 4 - Full validation (depends on Phases 1-3)

  1. Boot the app and check for deprecation warnings/initializer errors.
  2. Run the full RSpec suite via app-scripts/parallel_test.sh (or app-scripts/headless_rspec.sh for system specs) - fix any failures attributable to Ruby 4.0 changes.
  3. Run brakeman and bundler-audit against the updated lockfile.
  4. Manually spot-check high-risk areas already flagged during the Rails 8/8.1 work: dynamic model migrations, 2FA/AR encryption, Delayed Job timing, caching.

Phase 5 - PR

  1. Do NOT rebase onto develop yet (the Rails 8.1 chain - Rails 8 upgrade prep: fix otp_enc_key, advance load_defaults to 7.2 #1299/Upgrade Rails to 8.0.5 and Puma to 8.0.2 - fixes #1015 #1305/Upgrade to Rails 8.1.3.1 #1338 - hasn't merged). Push ruby4-upgrade-1300 and open a PR with base = the Rails 8.1 upgrade branch (mirroring the Rails 8 upgrade prep: fix otp_enc_key, advance load_defaults to 7.2 #1299 -> Upgrade Rails to 8.0.5 and Puma to 8.0.2 - fixes #1015 #1305 stacked pattern), referencing "stacked on rails-8.1-upgrade-1325 (Upgrade to Rails 8 #1015/Upgrade to Rails 8.1 (checkpoint after Rails 8.0 in #1015) #1325/Upgrade to Rails 8.1.3.1 #1338), fixes Upgrade to Ruby 4 #1300". Document the merge order dependency clearly in the PR description.

Relevant files

  • .ruby-version - bump to 4.0.6
  • Gemfile - minitest pin investigation, possible new explicit gem entries
  • Gemfile.lock - regenerate BUNDLED WITH (Bundler 4.x) and all resolved gem versions
  • config/boot.rb - re-verify/remove Ruby 3.4 bootsnap workaround
  • config/application.rb - verify YJIT config still applies
  • app/models/master.rb - verify CGI.escapeHTML still resolves
  • THIRD-PARTY-LIBRARIES.md - update gem list if new explicit deps added
  • app-scripts/release_and_build.sh - no hardcoded version, but re-verify it still validates .ruby-version correctly during a release dry run

Verification

  1. bundle install completes cleanly on Ruby 4.0.6 with no native-extension build failures.
  2. app-scripts/parallel_test.sh full suite green.
  3. app-scripts/headless_rspec.sh for representative system specs green.
  4. App boots with no deprecation/initializer errors.
  5. brakeman and bundler-audit clean (or no new findings vs. current baseline).
  6. Manual boot-console check: CGI.escapeHTML('<a>'), Set.new([1,2]).to_a, confirm no LoadError for bundled-gem-now stdlibs.

Further considerations

  1. If any native extension gem (nokogiri/pg/bcrypt/etc.) doesn't yet publish a Ruby-4.0-compatible precompiled binary, that gem's version may need bumping ahead of/alongside the Ruby bump - discover this empirically in Phase 1 step 6 rather than pre-guessing.
  2. The minitest pin comment is stale (references Rails 7.2.x) regardless of outcome - it must be corrected either way (Phase 2 step 7).
  3. Prior to making any changes, time a defined set of controller, request and model specs, then repeat after the upgrade is complete. Compare the changes to look for performance regressions (or improvements!)

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions