Rails Gemfile 版本管理:慣例、鎖版機制與 Breaking Change
Rails Gemfile 版本寫法慣例、Gemfile.lock 分工機制、breaking change 定義,以及 Engine 開發參考母 app lock 檔、獨立 Gem 開發寬版本範圍與 CI 矩陣測試的完整實務指南。
三種版本寫法
# 1. 不指定版本
gem "devise"
# 2. 悲觀鎖定(最常用)
gem "devise", "~> 4.9"
# 3. 精確指定
gem "devise", "= 4.9.3"
| 寫法 | 優點 | 缺點 |
|---|---|---|
| 不指定 | 永遠拿最新版,省事 | bundle update 可能帶入 breaking change |
~> 4.9(悲觀鎖定) |
允許 patch 更新(4.9.x),擋住 major/minor 升級 | 需手動追版本 |
= 4.9.3(精確) |
完全可重現 | 連 security patch 都要手動升 |
Gemfile vs Gemfile.lock 的分工
Rails 慣例是 Gemfile 不寫死版本,鎖版本的事交給 Gemfile.lock。
流程說明
你在 Gemfile 寫:
ruby
gem "devise" # 沒指定版本
跑 bundle install,Bundler 找最新版(假設 4.9.4),寫進 Gemfile.lock:
GEM
specs:
devise (4.9.4)
bcrypt (~> 3.1.7)
把 Gemfile.lock commit 進 git。
同事 clone 專案後跑 bundle install,裝到的是 4.9.4。Bundler 看到 Gemfile.lock 存在,直接照 lock 檔裝,就算 Devise 昨天出了 4.9.5 也不影響。
graph TD A[Gemfile<br/>聲明可接受範圍] -->|bundle install| B[Bundler 解析相依] B -->|寫入| C[Gemfile.lock<br/>精確記錄實際版本] C -->|commit 進 git| D[所有人裝到相同版本] E[想升版本時] -->|bundle update devise| C
分工總結
Gemfile → 聲明「我接受哪些版本」(寬鬆範圍)
Gemfile.lock → 記錄「我現在實際用哪個版本」(精確到小數點)
在 Gemfile 寫死精確版本(= 4.9.4)只是縮窄聲明範圍,但鎖版本的效果 Gemfile.lock 本來就做到了。兩件事重疊,反而變成維護負擔——每次升 patch 要改兩個地方。
語意化版本號(Semantic Versioning)
主版本.次版本.修補版本
8 . 1 . 4
主版本升 → 可能有 breaking change(要謹慎)
次版本升 → 新功能,理論上向下相容
修補版本升 → bug fix,安全,放心升
~> 8.1 的效果:允許 8.1.0、8.1.1、8.1.99,但擋住 8.2.0 和 9.0.0。
Breaking Change 是什麼
升級後,原本能跑的程式碼壞掉了。
具體例子
Devise 3.x 有個方法:
ruby
current_user.authenticated? # 回傳 true/false
Devise 4.0 把它改名:
ruby
current_user.authenticatable? # 新名字
程式碼裡有 100 個地方呼叫 authenticated?,升到 4.0 之後全部噴 NoMethodError。API 改了,舊的用法直接壞掉,不是悄悄出 bug,是直接炸。
對比 Non-Breaking Change
# 3.1.0:密碼驗證有個邊界條件 bug
# 3.1.1:修掉那個 bug,其他行為完全一樣
升 3.1.0 → 3.1.1,程式碼一行都不用改,直接受益。這就是為什麼 ~> 3.1 允許自動升 patch,但擋住 4.0。
標準 Rails Gemfile 範例
source "https://rubygems.org"
# Rails 本身用悲觀鎖定,擋住 major 升級
gem "rails", "~> 8.1"
# 資料庫 adapter 也鎖 minor
gem "pg", "~> 1.1"
# Asset / JS
gem "propshaft"
gem "importmap-rails"
# 一般套件——不寫版本,交給 Gemfile.lock
gem "devise"
gem "pundit"
gem "pagy"
gem "image_processing", "~> 1.2"
gem "dotenv-rails"
group :development, :test do
gem "debug"
gem "rspec-rails"
gem "factory_bot_rails"
end
group :development do
gem "rubocop-rails-omakase", require: false
end
版本寫法的判斷規則
| 套件類型 | 慣例寫法 | 理由 |
|---|---|---|
rails 本身 |
~> 8.1 |
major 升級有 breaking change,要主動決定 |
| 資料庫 adapter | ~> 1.1 |
跟 Rails 版本有相依性 |
| 一般應用套件 | 不寫版本 | 交給 Gemfile.lock 鎖,升版本時再決定 |
| 有已知 breaking change 的套件 | ~> x.y |
防禦性寫法 |
Engine 開發:先看 Gemfile.lock
當同仁要新開發 Rails Engine,先查母 app 的 Gemfile.lock,不要等 bundle install 衝突再調整。
Gemfile.lock 是母 app 目前實際跑的版本,是唯一的事實來源。Engine 最終要掛進這個 app,.gemspec 的版本範圍必須和 lock 檔相容。
開發流程
Step 1:查母 app 的 Gemfile.lock
# Gemfile.lock 片段
rails (8.1.2)
pg (1.5.6)
devise (4.9.4)
sidekiq (7.3.0)
Step 2:engine 的 .gemspec 對齊這些版本
# my_engine.gemspec
Gem::Specification.new do |s|
s.name = "my_engine"
s.version = "0.1.0"
# 參考 lock 檔的 rails 版本,用悲觀鎖定
s.add_dependency "rails", "~> 8.1"
s.add_dependency "devise", "~> 4.9"
end
Step 3:母 app 用本地路徑引入 engine
# 母 app 的 Gemfile
gem "my_engine", path: "../my_engine"
跑 bundle install。此時若有衝突,通常是 .gemspec 版本範圍和 lock 檔不相容,調整範圍即可。
為什麼不等衝突再調
- 衝突訊息往往不直觀,追根源需要時間
- 版本差距大時,可能要連帶升降其他 gem,影響範圍擴大
- 多個 engine 同時開發,衝突會疊加
先看 Gemfile.lock 只要 5 分鐘,能省掉後面可能好幾小時的除錯。
開發獨立 Gem:沒有母 App 可以參考
開發獨立 gem(不是掛進特定 app)時,沒有 Gemfile.lock 可以參考,也不應該有。
.gemspec 是唯一的聲明
# my_gem.gemspec
Gem::Specification.new do |s|
s.name = "my_gem"
s.version = "0.1.0"
# 範圍要夠寬,讓使用者的 app 能相容
s.add_dependency "rails", ">= 7.0", "< 9.0"
# 開發時才需要,不影響使用者
s.add_development_dependency "rspec"
s.add_development_dependency "rubocop"
end
為什麼不能寫 ~> 8.1? gem 被裝進別人的 app,如果鎖 ~> 8.1,用 Rails 7.x 的人就無法安裝。範圍越窄,潛在使用者越少。
Gemfile.lock 不 commit
獨立 gem 的 Gemfile.lock 是本地開發用的暫存檔,加進 .gitignore:
# .gitignore
Gemfile.lock
Ruby 社群的強制慣例。gem 被裝進各種不同的 app,每個 app 的相依樹不同,commit 本地的 lock 檔沒有意義,反而造成困惑。
多版本相容性測試
用 CI 矩陣對每個支援的 Rails 版本跑一次測試:
# .github/workflows/test.yml
strategy:
matrix:
rails: ["7.1", "7.2", "8.0", "8.1"]
Engine vs 獨立 Gem 對比
| Rails Engine(掛進母 app) | 獨立 Gem | |
|---|---|---|
| 版本參考來源 | 母 app 的 Gemfile.lock |
無,自己決定支援範圍 |
.gemspec 範圍 |
對齊 lock 檔,範圍較窄 | 盡量寬,支援多版本 |
Gemfile.lock |
commit 進 git | 加進 .gitignore |
| 測試策略 | 跑過母 app 的測試套件即可 | CI 矩陣,多版本並行測試 |