Upgrade the backend to Ruby 3.4 and Rails 8.1 - #870
Open
suttondemlong wants to merge 14 commits into
Open
Conversation
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
marked this pull request as draft
August 10, 2026 21:07
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
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>
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
aa90aa3bactive_model_serializersto 0.9.861aa1540Rails.application.secrets-> ENV87da3b0840af40a6enum0a385faa:testqueue adapterdcb335dc827dd7a3rails_12factor50ee9b18fee2f38f2c82d0d9load_defaults 8.138879a572ae5fafdf34a09c5Things worth a second opinion
annotateis replaced withannotaterb. annotate capsactiverecord < 8.0and has had no release since 2022. Left alone, bundlerresolves 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_responsesis now false, a Rails 8.1 default. JSON responsesstop escaping
<,>and&. Both forms parse to the same string, soclients calling
JSON.parsecannot tell, and this backend renders no HTML.Rails has deprecated setting it back.
rails_12factorwas overriding thelogger that
production.rbconfigures, so lines now pick up the standardseverity and timestamp prefix.
make stop && docker compose down -v && make start && make seed.Verification
puma -C config/puma.rbGET /with the expected JSONsidekiq -C config/sidekiq.ymldb/structure.sqlSeveral 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
(see
50ee9b18), but the real round trip against Facebook has not beenexercised, and omniauth-facebook 11 moves the Graph API from unversioned to
v24.0. Worth one manual check on staging.
Verified on 15.18 and 7.0.14; CI covers the rest.
SECRET_KEY_BASE,BASE_URL,SMTP_EMAIL_FROMandTOMORROW_IO_KEYare 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 byts_rank_cd(...) DESCwith no tiebreaker, so tied rows come back in arbitrary order. With
MAX_ROWS = 2that means the same search can return a different set offoods, and it makes
spec/models/food_spec.rbflaky — seen once in roughlyeight full runs.
htmlSafein the Ember client (
frontend/app/mixins/body-formatable.js:17), so markupin 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