Keeping Dependencies Updated — bundle outdated, Dependabot, and When to Hold
There are two failure modes and teams pick one without deciding. Either nobody updates anything until a CVE forces a jump across four major versions in a week, or Dependabot opens eleven PRs every Monday and everyone merges them without reading, which works right up until the morning it doesn’t. The middle path needs about twenty minutes a week and a policy you can state in one paragraph.
Know What You Have
Example:
bundle outdated
Output:
Gem Current Latest Requested Groups
nokogiri 1.15.4 1.16.2 ~> 1.15 default
puma 6.4.0 6.4.2 default
rails 7.1.2 7.2.1 ~> 7.1 default
rspec-rails 6.0.3 6.1.1 ~> 6.0 development, test
The useful flags:
bundle outdated --strict # only versions your Gemfile constraints allow
bundle outdated --patch # patch-level updates only
bundle outdated --minor # minor and patch
bundle outdated --groups # group by Gemfile group
--strict is the one that tells you what you can actually update today without editing the Gemfile. Everything else in the list is a decision, not a chore.
Security first, always
gem install bundler-audit
bundle audit check --update
Output:
Name: rack
Version: 3.0.8
CVE: CVE-2025-XXXXX
Criticality: High
URL: https://github.com/rack/rack/security/advisories/...
Title: Denial of service in multipart parsing
Solution: upgrade to >= 3.0.11
This runs in seconds and belongs in CI on every build. It’s the one dependency check that isn’t optional — everything else is maintenance, this is exposure.
The Update Cadence That Works
Weekly, batched, low-risk: patch updates for everything, applied together, verified by the test suite.
bundle update --patch --conservative
bundle exec rspec
--conservative prevents Bundler from also bumping the dependencies of the gems you’re updating, which keeps the diff small and the blast radius understandable.
Monthly, individually: minor versions, one gem or one related group per PR.
bundle update rspec-rails rspec-core rspec-expectations rspec-mocks
Quarterly or on demand: major versions, each as its own project with a read of the upgrade guide.
The reason to batch patches and separate majors is bisection. When a batched patch update breaks something, git bisect over eleven gems is unpleasant; but patch releases rarely break things, so the risk is low. Major versions break things routinely, which is why they get their own PR with their own revert.
Reading a Diff Before Merging
The single habit that separates safe updating from gambling. For any gem you’re not familiar with, read what changed.
Example:
# What actually changed between versions
gem contents rack --version 3.0.8 > /tmp/old.txt
# Better: diff the released gems
gem install diffend # or:
git clone https://github.com/rack/rack /tmp/rack
git -C /tmp/rack diff v3.0.8..v3.0.11 --stat
For most updates the changelog is enough:
bundle exec gem changelog rack # if the gem publishes one via metadata
What you’re scanning for: behaviour changes not marked as breaking, new dependencies pulled in, and anything touching serialization, authentication, or SQL generation. Twenty seconds per gem, and it catches the “patch release that changed a default” case that semver alone won’t warn you about.
Configuring Dependabot Properly
The default configuration produces noise. A tuned one produces a manageable stream.
Example:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: weekly
day: monday
time: "09:00"
timezone: "Asia/Kathmandu"
open-pull-requests-limit: 5
versioning-strategy: increase-if-necessary
groups:
rspec:
patterns: ["rspec*"]
rubocop:
patterns: ["rubocop*"]
patch-updates:
update-types: ["patch"]
ignore:
- dependency-name: "rails"
update-types: ["version-update:semver-major"]
- dependency-name: "pg"
update-types: ["version-update:semver-major"]
labels: ["dependencies"]
Three settings carry most of the value:
groupsturns eleven PRs into three. Grouping all patch updates into one PR is the biggest single reduction in noise.ignorefor majors on framework-level gems, because those need a planned upgrade rather than a surprise PR.open-pull-requests-limitstops the backlog from becoming a wall people stop looking at.
Add security-only updates on a faster schedule if your ecosystem supports it — those you do want immediately.
When to Hold
Not updating is sometimes the right call, and it should be a recorded decision rather than neglect.
- The gem is unmaintained and the new version is a community fork. Verify the fork’s provenance before switching; a hostile takeover of an abandoned gem is a real supply-chain attack pattern.
- You’re mid-migration on something the gem touches. Two moving parts at once makes any failure ambiguous.
- The new major requires a Ruby you’re not on. Sequence it: Ruby first, then the gem.
- A transitive dependency conflict.
bundle update xfailing with a resolution error usually means something else is pinning.bundle exec gem dependency <gem> --reversefinds who. - Release is less than a week old and the gem is critical. Letting a release sit for a few days lets someone else find the regression. This isn’t paranoia — it’s why staged rollouts exist.
Record the hold where the next person will see it:
# Gemfile
# Pinned: 2.4 changes the default timezone handling for parsed dates.
# Migration tracked in PROJ-882. Revisit after Q3.
gem "chronic", "~> 2.3.0"
A pin with no comment becomes permanent. A pin with a reason and a ticket becomes work.
The Lockfile Is the Contract
Gemfile.lock pins exact versions for every gem including transitive ones. Two rules:
Commit it for applications, always. Not committing it means your production install can differ from what you tested.
Don’t commit it for gems you publish — a library should resolve against whatever the consuming application has.
And check the lockfile diff on every dependency PR:
git diff Gemfile.lock
An update to one gem that changes forty lines of the lockfile pulled in something you didn’t ask for. That’s not necessarily wrong, but it should be noticed.
Pro-Tip: Add
bundle auditandbundle outdated --strictto CI as separate jobs with different failure semantics.bundle auditshould fail the build — a known CVE in a running application is not a warning.bundle outdatedshould never fail the build; run it on a schedule and have it open an issue or post to a channel instead. Mixing them is why teams end up with a permanently-red pipeline that everyone learns to ignore, and once people are ignoring the dependency job, the security check stops working too. The distinction is simple: block on exposure, report on staleness.
Major Upgrades Deserve a Plan
For something like Rails, treat it as a project rather than a PR:
- Read the upgrade guide end to end before touching anything
- Update to the latest patch of the current major first
- Run with deprecation warnings as errors and fix them all —
config.active_support.deprecation = :raise - Bump the major, run
rails app:update, and review every conflict individually - Ship behind whatever staged rollout you have, and watch error rates for a week
Step three is the one people skip and the one that does most of the work. Deprecation warnings in version N are removals in version N+1; clearing them before the bump turns a hundred failures into ten.
Conclusion
The working policy is short: bundle audit in CI as a blocking check, weekly batched patch updates verified by the suite, monthly individual minors, majors as planned projects with the deprecation pass done first. Group Dependabot’s output so Monday produces three PRs instead of eleven, ignore major bumps on framework gems so they don’t arrive unannounced, and comment every pin with a reason and a ticket. The goal isn’t to be on the latest version of everything — it’s to never be more than one manageable step away from it.
FAQs
Q1: Should I run bundle update with no arguments?
Rarely. It updates everything to the newest versions your constraints allow, producing an enormous lockfile diff that’s impossible to review or bisect. Use --patch, --conservative, or name specific gems.
Q2: What’s the difference between bundle update --conservative and --strict?
--conservative avoids updating the dependencies of the gems you named. --strict refuses to go beyond your Gemfile’s version constraints. They’re often used together.
Q3: How do I find which gem is holding back an update?
bundle exec gem dependency <gem> --reverse lists what depends on it. bundle update <gem> will also print a resolution error naming the conflicting constraint.
Q4: Is Dependabot enough on its own?
For keeping current, mostly. It doesn’t replace bundle audit for advisories in transitive dependencies, and it can’t tell you that a patch release changed a default. Treat it as a scheduler, not a reviewer.
Q5: What about gems with no releases in three years?
Not automatically a problem — a small, complete gem may simply be finished. Check open issues and whether it still works on your Ruby version. If it’s both unmaintained and load-bearing, budget time to vendor or replace it before you’re forced to.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀