Skip to content

Upgrade the backend to Ruby 3.4 and Rails 8.1 - #870

Open
suttondemlong wants to merge 14 commits into
rubyforgood:masterfrom
suttondemlong:upgrade/rails-8-readiness
Open

Upgrade the backend to Ruby 3.4 and Rails 8.1#870
suttondemlong wants to merge 14 commits into
rubyforgood:masterfrom
suttondemlong:upgrade/rails-8-readiness

Conversation

@suttondemlong

@suttondemlong suttondemlong commented Aug 10, 2026

Copy link
Copy Markdown

Ruby 3.2 and Rails 7.1 both passed end of life before this was opened
(2026-03-31 and 2025-10-01), so the backend has not been receiving security
patches. This takes it to Ruby 3.4.10 and Rails 8.1.3.1, at full
load_defaults 8.1.

Rails 7.2 reached EOL on 2026-08-09 and 8.0 goes EOL on 2026-11-07, so 8.1
(supported to 2027-10-10) is the only target worth the effort; the intermediate
versions are skipped.

Reviewing this

Every commit stands alone and explains itself, and the first six work on Rails
7.1 as well as 8.1, so the branch can be read in order. The commit messages
carry the reasoning and the evidence — that is where the detail is.

aa90aa3b Pin active_model_serializers to 0.9.8
61aa1540 Rails.application.secrets -> ENV
87da3b08 Delete the leftover 7.1 defaults initializer
40af40a6 Positional enum
0a385faa :test queue adapter
dcb335dc Cover the OmniAuth callback endpoint
827dd7a3 Drop rails_12factor
50ee9b18 OmniAuth 1 -> 2
fee2f38f Rails 7.1 -> 8.1
2c82d0d9 load_defaults 8.1
38879a57 Ruby 3.2.3 -> 3.4.10
2ae5fafd PostgreSQL and MongoDB off EOL in dev/CI
f34a09c5 Docs

Things worth a second opinion

  • annotate is replaced with annotaterb. annotate caps
    activerecord < 8.0 and has had no release since 2022. Left alone, bundler
    resolves backwards to annotate 2.6.5 from 2015 rather than report a
    conflict. Nothing automates it here, so this only affects running it by hand
    — say the word if you would rather just drop the gem.
  • escape_json_responses is now false, a Rails 8.1 default. JSON responses
    stop escaping <, > and &. Both forms parse to the same string, so
    clients calling JSON.parse cannot tell, and this backend renders no HTML.
    Rails has deprecated setting it back.
  • Production log lines change shape. rails_12factor was overriding the
    logger that production.rb configures, so lines now pick up the standard
    severity and timestamp prefix.
  • Dev volumes need wiping after the datastore bump:
    make stop && docker compose down -v && make start && make seed.

Verification

Check Result
rspec 321 examples, 0 failures, no deprecation warnings
StandardRB / ERB lint clean
Boot development, test and production
puma -C config/puma.rb serves GET / with the expected JSON
sidekiq -C config/sidekiq.yml boots and connects to Redis
CORS preflight 200, headers intact under Rack 3
db/structure.sql loads on PostgreSQL 15.18

Several of the breakages here were invisible to the test suite — the Facebook
token exchange, Puma's config file, Sidekiq's Redis floor — which is why the
list above goes past rspec.

Not verified

  • Facebook login end to end. The riskiest part now has a regression test
    (see 50ee9b18), but the real round trip against Facebook has not been
    exercised, and omniauth-facebook 11 moves the Graph API from unversioned to
    v24.0. Worth one manual check on staging.
  • PostgreSQL 17.10 and MongoDB 8.0.28, which could not be run locally.
    Verified on 15.18 and 7.0.14; CI covers the rest.
  • Heroku config. Please confirm SECRET_KEY_BASE, BASE_URL,
    SMTP_EMAIL_FROM and TOMORROW_IO_KEY are set in both environments.

Noticed but not fixed

Two things turned up while checking the above. Both predate this branch and are
left alone as out of scope; happy to open issues for either.

  • Food.fts (backend/app/models/food.rb:49) orders by ts_rank_cd(...) DESC
    with no tiebreaker, so tied rows come back in arbitrary order. With
    MAX_ROWS = 2 that means the same search can return a different set of
    foods, and it makes spec/models/food_spec.rb flaky — seen once in roughly
    eight full runs.
  • Post and comment bodies are stored unsanitised and rendered through htmlSafe
    in the Ember client (frontend/app/mixins/body-formatable.js:17), so markup
    in a body reaches the DOM as-is. Unaffected by the JSON escaping change above:
    both forms parse to the same string, so the sink is identical either way.

🤖 Generated with Claude Code

compwron and others added 5 commits August 9, 2026 22:48
The constraint `~> 0.9` also permits 0.10, so any `bundle update` would
silently pull in active_model_serializers 0.10. That release is a rewrite
with a different JSON output format, and the Ember client consumes the 0.9
format via ActiveModelAdapter and EmbeddedRecordsMixin, so the jump would
break the API contract without any change to app code.

Moving to 0.10 means changing the serializers (24 call sites still use the
0.9-only `embed:`/`embed_in_root:`/`self.root` API) and the frontend
together. Pin to `~> 0.9.8` so that stays a deliberate decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Rails.application.secrets` is deprecated in Rails 7.1 and removed in 7.2, so
this has to go before the framework can be upgraded. Every value in
config/secrets.yml was already just an ENV read, and ENV is what the rest of
the app uses for configuration (27 call sites, including ENV["BASE_URL"] in
notifications_mailer two lines below one of the calls replaced here), so the
secrets indirection bought nothing.

secret_key_base is unaffected in production: Rails resolves ENV["SECRET_KEY_BASE"]
ahead of secrets.yml, and the production block only interpolated that variable
anyway. Development and test fall back to Rails' generated local secret.

The two literals in the `test:` block move to config/environments/test.rb.
They are test fixtures rather than developer configuration: the Tomorrow.io
key has to match the URI recorded in the WeatherRetriever VCR cassettes, and
the from-address is asserted directly in the mailer specs. Keeping them in the
environment file means the suite still runs without any .env setup, in CI and
locally. They are assigned when blank rather than when nil because env-example
ships SMTP_EMAIL_FROM with an empty value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
config/initializers/new_framework_defaults_7_1.rb is the leftover scaffold from
the upgrade to 7.1. Every line in it is uncommented, which is what the file is
for during an upgrade, but config/application.rb has since been moved to
`load_defaults 7.1` and now applies all 23 of those settings itself. Dumping
each setting with and without the file gives identical output.

It is not harmless to leave behind: it assigns
active_record.allow_deprecated_singular_associations_name, which Rails 8.1
removes, so the app fails to boot on 8.1 with the file in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rails 7 deprecated `enum name: values` and Rails 8 removed it, so the keyword
form raises ArgumentError on 8.x. The positional form has been supported since
7.0 and is equivalent here: both map to {"mb"=>0, "in"=>1} and {"f"=>0, "c"=>1}
and generate the same predicates and scopes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite relied on ActiveJob::TestHelper installing the test adapter itself.
That works on 7.1 but not on 8.x, where the adapter stays :sidekiq inside
examples, so perform_enqueued_jobs silently stops running anything and
DataExportJob's mailer expectation fails with no obvious cause.

Configuring the adapter explicitly is the documented approach and does not
depend on the helper's internals. It also stops the suite from pushing to
Redis: TrackableUsage enqueues SwitchTrackableVisibility in an after_commit
hook, so any spec touching that model was writing to a real queue. The suite
no longer opens a Redis connection at all.

Jobs invoked through Sidekiq's own perform_async do not go through Active Job
and are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@suttondemlong
suttondemlong marked this pull request as draft August 10, 2026 21:07
compwron and others added 8 commits August 10, 2026 22:08
Nothing in the suite touched OmniAuth, which is awkward timing: the omniauth
1 -> 2 bump that follows changes the request phase to POST-only with CSRF
protection, and there was no test to say whether anything broke.

The request phase turns out not to matter here. The Ember client never calls it:
it signs the user in with Facebook's JavaScript SDK via Torii and then POSTs
straight to /api/auth/facebook/callback (frontend/app/authenticators/facebook.js:19,
with `namespace: "api"` from the ajax service), where the strategy picks the
authorization code out of the `fbsr_<app_id>` cookie the SDK left behind. That
cookie path is what needs protecting, so that is what these cover.

The request spec drives the real middleware stack — Session, Warden, the Facebook
strategy — through OmniAuth's test mode, so it exercises the callback dispatch
and the on_failure proc in config/initializers/devise.rb rather than just the
controller. The controller spec covers the three branches of handle_omniauth,
including the invited-but-not-yet-accepted user, which is easy to regress
because it reads as a successful lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gem has been unmaintained since 2016 and Heroku stopped recommending it long
before that. It is two railties: one forces static file serving on, the other
replaces the logger with its own stdout logger.

Neither is wanted here. There is no backend/public directory, app/assets/{images,
javascripts,stylesheets} are all empty, and ApplicationController inherits from
ActionController::API, so nothing is served out of public/ — the only asset
reference in any view is an inline mail attachment
(app/views/checkin_reminder_mailer/remind.html.erb:8). Removing the railtie
returns config.public_file_server.enabled to what production.rb already says it
should be, which is off unless RAILS_SERVE_STATIC_FILES is set.

The logger railtie was doing real harm: it runs in a before_initialize hook,
which fires after config/environments/production.rb is evaluated, so it was
overwriting the logger that file configures. Booting production with and without
the gem:

    logger     RailsStdoutLogging::StdoutLogger  ->  ActiveSupport::BroadcastLogger
    formatter  Logger::SimpleFormatter           ->  Logger::Formatter
    static     true                              ->  false

Both loggers write to stdout, so nothing is lost, but production regains the
formatter it asks for. Heads-up for whoever watches the logs: lines pick up the
standard severity and timestamp prefix, so anything parsing them by position
should be checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OmniAuth 1.8.1 pins `rack < 3`, which blocks Rails 8, and carries CVE-2015-9284:
the request phase answers GET, so a third-party page can silently start an auth
flow. Version 2 fixes that by making the request phase POST-only with a CSRF
check. omniauth-facebook has to move with it, 3.0.0 -> 11.0.0, since v3 depends
on omniauth-oauth2 ~> 1.2, which wants omniauth 1.

The request-phase change does not affect this app. The Ember client never calls
it — it authenticates with Facebook's JavaScript SDK and POSTs to the callback
path directly — and the native app has no Facebook login at all. The unused
GET /api/auth/facebook route now falls through to passthru instead of starting a
flow, which is the point of the CVE fix.

The token exchange did need a change. omniauth-oauth2 1.9 pulls in oauth2 2.x,
which flipped the default `auth_scheme` from `:request_body` to `:basic_auth`.
That sends nothing but an Authorization header:

    POST https://graph.facebook.com/v24.0/oauth/access_token
    Authorization: Basic MTIzNDU2Nzg5MDpmYWNlYm9vay1hcHAtc2VjcmV0
    code=fb-auth-code&grant_type=authorization_code&redirect_uri=

Facebook's token endpoint takes client_id and client_secret as parameters and
ignores that header, so every Facebook login would have failed. Neither
omniauth-oauth2 nor omniauth-facebook overrides the default, so
config/initializers/devise.rb now sets it back to what oauth2 1.4.7 sent.

The new spec drives the real callback phase — a signed fbsr_<app_id> cookie in,
Facebook's endpoints stubbed — rather than OmniAuth's test mode, which returns
before any of this happens. It fails on the default auth_scheme, so it is the
regression test for the above rather than a restatement of it. The Facebook
credentials it needs are set in config/environments/test.rb alongside the other
test-only values, since env-example ships them empty.

Also bumped as dependencies of the above: oauth2 1.4.7 -> 2.0.25, faraday
1.8 -> 2.14, hashie 3.5 -> 5.1, jwt 2.3 -> 3.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rails 7.1 went out of support on 2025-10-01. 7.2 reached EOL on 2026-08-09 and
8.0 goes EOL on 2026-11-07, so 8.1 (supported to 2027-10-10) is the only target
worth the move, and the intermediate versions can be skipped.

Three gems capped the framework below 7.2 and had to move with it. These are
resolver failures, not guesses:

    mongoid 8.1.3   activemodel >= 5.1, < 7.2   -> 9.1.0
    globalize 6.3.0 activerecord >= 4.2, < 7.2  -> 7.1.3
    annotate 3.2.0  activerecord >= 3.2, < 8.0  -> replaced

annotate has had no release since 2022 and its cap has not moved. Left alone,
bundler resolves *backwards* to annotate 2.6.5 from 2015 rather than report a
conflict, which is worse than a failure because it is silent. annotaterb is the
maintained successor. Nothing in the repo automates it — there is no
auto_annotate_models.rake — so this only affects running it by hand, and the
existing schema headers are untouched. Say so if you would rather just drop it.

Two more only fail at runtime, so the resolver had nothing to say about them:

- bullet raises "Bullet does not support active_record 8.1.3.1 yet" from
  config/application.rb:20 the moment anything boots. It is unpinned, but a
  conservative resolve keeps whatever is in the lockfile, so it needed an
  explicit update: 7.2.0 -> 8.1.3.
- config/puma.rb referenced DefaultRackup, which Puma 6 removed, so the web
  process would not have started under Puma 8. The line only ever restated the
  default of config.ru and is deleted rather than replaced. on_worker_boot is
  now before_worker_boot, its name since Puma 8.

Sidekiq 8 refuses to start against Redis older than 7.0.0, so the pins in
docker-compose.yml, .tool-versions and the CI workflow move from 6.2.3 to
7.2.15, matching what Heroku Key-Value Store offers. The suite never noticed
because it runs on the :test queue adapter, but `make start` would have brought
up a worker that died on boot. Postgres and MongoDB are also long out of
support; they are not blocking, so they are left for their own change.

Verified on 8.1.3.1 with Mongoid 9.1, Rack 3.2, Devise 5, Puma 8 and Sidekiq 8:

    rspec                     321 examples, 0 failures, no deprecations
    standardrb / erb lint     clean
    boot                      development, test and production
    puma -C config/puma.rb    serves GET / with the expected JSON
    sidekiq -C sidekiq.yml    boots and connects to Redis
    CORS preflight            200, Access-Control-Allow-Origin intact
                              (rack-cors is pinned at 2.0.1, which predates
                              Rack 3, so this was worth checking)

Mongoid 9 turns on immutable_ids and map_big_decimal_to_decimal128. Neither
applies: no model declares a BigDecimal field, and the only _id in application
code is an aggregation pipeline key in app/models/reaction.rb:23, which
reaction_spec covers.

active_model_serializers stays on 0.9.8 and the JSON format with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app was running 8.1 on 7.1's defaults, which is the supported way to land an
upgrade but not somewhere to stay: it keeps every behaviour change opted out
indefinitely and leaves the next upgrade to deal with all of them at once.

Dumping all 121 framework settings before and after, nine change:

    action_controller.action_on_path_relative_redirect  :log -> :raise
    action_controller.escape_json_responses                  -> false
    action_dispatch.strict_freshness                  false -> true
    action_view.remove_hidden_field_autocomplete             -> true
    action_view.render_tracker                               -> :ruby
    active_record.postgresql_adapter_decode_dates            -> true
    active_record.raise_on_missing_required_finder_order_columns -> true
    active_record.validate_migration_timestamps              -> true
    active_support.escape_js_separators_in_json              -> false

Most are inert here. There is no redirect_to, fresh_when or stale? anywhere in
app/, config/ or lib/, and no form helpers — the only views are mailers.

escape_json_responses is the one that changes what goes over the wire:

    7.1  {"body":"hi \u003cscript\u003ealert(1)\u003c/script\u003e \u0026 done"}
    8.1  {"body":"hi <script>alert(1)</script> & done"}

This is safe for the clients we have. Both forms parse to the same string, so
anything calling JSON.parse — the Ember client through ember-data, the native
app through fetch — cannot tell the difference. The escaping only protects JSON
that is interpolated straight into an HTML document, and this backend renders no
HTML: ApplicationController is an ActionController::API. Rails has also
deprecated setting it back, so it stops having any effect in 8.2.

config.active_support.cache_format_version = 7.1 is deleted because
load_defaults 8.1 already sets exactly that — dumping the settings with and
without the line gives byte-identical output. There is no 8.x cache format, and
assigning one raises Unrecognized ActiveSupport::Cache.format_version, so the
line was only ever going to become a trap.

rspec 321 examples 0 failures, standardrb and erb lint clean, and development,
test and production all boot with no deprecation warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ruby 3.2 stopped receiving security fixes on 2026-03-31. 3.4.10 is the current
release of the newest stable series and is well within what Rails 8.1 supports
(>= 3.2). The pins live in five places — .ruby-version, .tool-versions, the
Gemfile's ruby directive and the Dockerfile — plus backend/.ruby-version, which
is a symlink to the root file and so follows on its own. The Dockerfile keeps its
bundler 2.5.6 pin; that version drives Ruby 3.4 without complaint, so the
lockfile's BUNDLED WITH does not move.

Two former default gems now have to be declared:

- csv, which app/jobs/data_export_job.rb:21 and lib/tasks/utils.rake use. It has
  been resolving only as another gem's dependency, which was already luck.
- mutex_m, which httpclient requires at auth.rb:11, and pusher depends on
  httpclient. This one is only a problem in combination: activesupport 7.1
  depended on mutex_m, so it was in the lockfile; Rails 8.1 dropped that
  dependency, and Ruby 3.4 dropped it from the default gems. Either change alone
  is harmless. Together they take the whole suite down at boot, with an error
  that names webmock rather than either culprit:

      NameError: undefined method 'do_get_block' for class 'WebMockHTTPClient'

  webmock installs an httpclient adapter when httpclient is present, and
  httpclient had failed halfway through loading. Even the newest pusher (2.1.1)
  still requires httpclient ~> 2.8, so declaring mutex_m is the way out.

Also pry-doc 1.5.0 -> 1.7.0, which otherwise prints "ruby/3.4.10 isn't supported
by this pry-doc version" on every run.

Verified on a real 3.4.10 build rather than by inspection:

    rspec                  321 examples, 0 failures, no warnings
    standardrb / erb lint  clean
    boot                   development, test and production
    rake -T                90 tasks, so utils.rake's require "csv" resolves

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PostgreSQL 12 went out of support on 2024-11-21 and MongoDB 4.4 on 2024-02-29.
Nothing in the upgrade required this — Mongoid 9's driver still speaks wire
protocol 9 — but the pins were badly out of date and only ever describe the dev
stack and CI. Production is unaffected: Heroku manages the Postgres version and
MongoDB is hosted at mongodb.com, neither of which reads these files.

Some evidence the app is not attached to the old versions: this container runs
PostgreSQL 15.18 and MongoDB 7.0.14, several majors past both pins, and the
suite is green on them. db/structure.sql was in fact dumped from PostgreSQL 13,
not 12.8, and loads cleanly on 15.18 — 24 tables, no errors — so the file is not
regenerated here and there is no pg_dump churn in this diff. The Mongo driver
knows about 8.0 explicitly; it maps it to wire version 25 in
mongo-2.25.0/lib/mongo/server/description.rb:899.

**This will not start against an existing dev volume.** Neither database will
open a data directory written by an older major version, and 4.4 -> 8.0 is
several majors in one step, which MongoDB does not support in place. The dev
data is disposable, so the fix is to throw it away and re-seed:

    make stop && docker compose down -v && make start && make seed

CI creates its databases from scratch every run and is not affected.

The CI action pin for MongoDB stays at 1.10.0. It passes mongodb-version
straight through as a Docker image tag, so it can pull 8.0.28 as it stands, and
leaving it alone keeps one less untested thing in this change.

Honest limits: 17.10 and 8.0.28 are not verified here, because this container
cannot run another database. The verification above is on 15.18 and 7.0.14. CI
runs the suite against these images on every pull request, so this is exactly
where being wrong is cheap and immediately visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README's Environment list and the two version references in CLAUDE.md still
described the stack as it was before this branch. No prose changes, only the
numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@suttondemlong suttondemlong changed the title Prepare the backend for Rails 8 Upgrade the backend to Ruby 3.4 and Rails 8.1 Aug 10, 2026
@suttondemlong
suttondemlong marked this pull request as ready for review August 10, 2026 23:01
Two things in the README stopped being true.

`bundle config set --local without 'production'` excluded a group that no longer
exists — rails_12factor was the only gem in it, so dropping the gem took the
group with it. The line is inert rather than harmful, but it writes a setting
into backend/.bundle/config that means nothing.

The datastore bump needs a note for anyone with an existing checkout. The README
mentioned `docker compose down -v` only as an optional full reset; after this
branch it is required once, because neither PostgreSQL nor MongoDB will open a
data directory written by an older major version. Without it `docker compose
--profile dev up` fails, and the error does not obviously point at the volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants