Bundle drift detection and correction (#2101)

Gemfile.saas evals Gemfile, so shared gems should have identical versions
in both lockfiles. This adds bin/bundle-drift to detect and fix drift:

* `bin/bundle-drift check` compares shared gem versions
* `bin/bundle-drift correct` seeds Gemfile.lock from Gemfile.saas.lock
  and re-locks, letting Bundler prune SaaS-only gems while preserving
  shared versions

Adds drift check to bin/ci and GitHub CI. Corrects existing drift.
This commit is contained in:
Jeremy Daer
2025-12-11 21:32:34 -08:00
committed by GitHub
parent 2e33262960
commit 586015c3f9
4 changed files with 165 additions and 43 deletions
+14
View File
@@ -7,6 +7,20 @@ permissions:
contents: read contents: read
jobs: jobs:
gemfile-drift:
name: Gemfile drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Check for lockfile drift
run: bin/bundle-drift check
security: security:
name: Security name: Security
runs-on: ubuntu-latest runs-on: ubuntu-latest
+22 -43
View File
@@ -117,8 +117,8 @@ GEM
specs: specs:
action_text-trix (2.1.15) action_text-trix (2.1.15)
railties railties
addressable (2.8.8) addressable (2.8.7)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3) ast (2.4.3)
autotuner (1.1.0) autotuner (1.1.0)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
@@ -151,8 +151,8 @@ GEM
brakeman (7.1.1) brakeman (7.1.1)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.2)
bundler (>= 1.2.0) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
capybara (3.40.0) capybara (3.40.0)
addressable addressable
@@ -172,7 +172,7 @@ GEM
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
date (3.5.1) date (3.5.0)
debug (1.11.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
@@ -185,15 +185,11 @@ GEM
tzinfo tzinfo
faker (3.5.2) faker (3.5.2)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin) ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86-linux-gnu)
ffi (1.17.2-x86-linux-musl)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl) ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1) fugit (1.12.1)
@@ -223,7 +219,7 @@ GEM
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.18.0) json (2.17.1)
jwt (3.1.2) jwt (3.1.2)
base64 base64
kamal (2.9.0) kamal (2.9.0)
@@ -260,7 +256,6 @@ GEM
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2) minitest (5.26.2)
mission_control-jobs (1.1.0) mission_control-jobs (1.1.0)
actioncable (>= 7.1) actioncable (>= 7.1)
@@ -272,7 +267,7 @@ GEM
railties (>= 7.1) railties (>= 7.1)
stimulus-rails stimulus-rails
turbo-rails turbo-rails
mittens (0.3.1) mittens (0.3.0)
mocha (2.8.2) mocha (2.8.2)
ruby2_keywords (>= 0.0.5) ruby2_keywords (>= 0.0.5)
msgpack (1.8.0) msgpack (1.8.0)
@@ -293,9 +288,6 @@ GEM
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl) nokogiri (1.18.10-aarch64-linux-musl)
@@ -306,8 +298,6 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin) nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu) nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl) nokogiri (1.18.10-x86_64-linux-musl)
@@ -329,10 +319,10 @@ GEM
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.3.0) psych (5.2.6)
date date
stringio stringio
public_suffix (7.0.0) public_suffix (6.0.2)
puma (7.1.0) puma (7.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
@@ -345,7 +335,7 @@ GEM
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.2.1)
rack (>= 3) rack (>= 3)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
@@ -356,7 +346,7 @@ GEM
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.3.1)
rdoc (6.17.0) rdoc (6.16.1)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@@ -366,10 +356,10 @@ GEM
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.4) rexml (3.4.4)
rouge (4.6.1) rouge (4.6.1)
rqrcode (3.1.1) rqrcode (3.1.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.1) rqrcode_core (2.0.0)
rubocop (1.81.7) rubocop (1.81.7)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
@@ -388,7 +378,7 @@ GEM
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.1) rubocop-rails (2.34.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
@@ -427,18 +417,13 @@ GEM
fugit (~> 1.11) fugit (~> 1.11)
railties (>= 7.1) railties (>= 7.1)
thor (>= 1.3.1) thor (>= 1.3.1)
sqlite3 (2.8.1) sqlite3 (2.8.0-aarch64-linux-gnu)
mini_portile2 (~> 2.8.0) sqlite3 (2.8.0-aarch64-linux-musl)
sqlite3 (2.8.1-aarch64-linux-gnu) sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.1-aarch64-linux-musl) sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.1-arm-linux-gnu) sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.1-arm-linux-musl) sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.1-arm64-darwin) sqlite3 (2.8.0-x86_64-linux-musl)
sqlite3 (2.8.1-x86-linux-gnu)
sqlite3 (2.8.1-x86-linux-musl)
sqlite3 (2.8.1-x86_64-darwin)
sqlite3 (2.8.1-x86_64-linux-gnu)
sqlite3 (2.8.1-x86_64-linux-musl)
sshkit (1.24.0) sshkit (1.24.0)
base64 base64
logger logger
@@ -453,9 +438,8 @@ GEM
thruster (0.1.16) thruster (0.1.16)
thruster (0.1.16-aarch64-linux) thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin) thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-darwin)
thruster (0.1.16-x86_64-linux) thruster (0.1.16-x86_64-linux)
timeout (0.5.0) timeout (0.4.4)
trilogy (2.9.0) trilogy (2.9.0)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.20) turbo-rails (2.0.20)
@@ -497,11 +481,6 @@ PLATFORMS
arm-linux-gnu arm-linux-gnu
arm-linux-musl arm-linux-musl
arm64-darwin arm64-darwin
arm64-linux
ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux x86_64-linux
x86_64-linux-gnu x86_64-linux-gnu
x86_64-linux-musl x86_64-linux-musl
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env ruby
# Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies.
# Since Gemfile.saas evals Gemfile, shared gems should have identical versions.
#
# Usage:
# bin/bundle-drift [check] # check for drift (default subcommand)
# bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative)
require "bundler"
require "fileutils"
GEMFILE_LOCK = "Gemfile.lock"
GEMFILE_SAAS_LOCK = "Gemfile.saas.lock"
class GemfileDriftChecker
def initialize
@oss_lockfile = parse_lockfile(GEMFILE_LOCK)
@saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK)
end
def check
find_drift.tap do
report it
end
end
private
def parse_lockfile(path)
Bundler::LockfileParser.new(File.read(path))
end
def find_drift
oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile)
shared_gems = oss_specs.keys & saas_specs.keys
shared_gems.filter_map do |name|
oss_version, saas_version = oss_specs[name], saas_specs[name]
if oss_version != saas_version
{ name: name, oss: oss_version, saas: saas_version }
end
end.sort_by { |d| d[:name] }
end
def specs_hash(lockfile)
lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] }
end
def report(drift)
if drift.empty?
puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync"
else
puts "✗ Gemfile lock files have drifted!\n\n"
name_width = [ drift.map { |d| d[:name].length }.max, "Gem".length ].max
oss_width = [ drift.map { |d| d[:oss].length }.max, "Gemfile.lock".length ].max
saas_width = [ drift.map { |d| d[:saas].length }.max, "Gemfile.saas.lock".length ].max
puts " #{"Gem".ljust(name_width)} #{"Gemfile.lock".ljust(oss_width)} Gemfile.saas.lock"
puts " #{"-" * name_width} #{"-" * oss_width} #{"-" * saas_width}"
drift.each do |d|
puts " #{d[:name].ljust(name_width)} #{d[:oss].ljust(oss_width)} #{d[:saas]}"
end
puts "\nRun 'bin/bundle-drift correct' to restore alignment."
end
end
end
class GemfileDriftCorrector
def correct
drift = GemfileDriftChecker.new.check
return puts "\nNothing to correct." if drift.empty?
puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n"
# Save original for diff
original_content = File.read(GEMFILE_LOCK)
# Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints
FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK)
# Re-lock: Bundler prunes SaaS-only gems while preserving shared versions
puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)"
unless system("BUNDLE_GEMFILE=Gemfile bundle lock")
File.write(GEMFILE_LOCK, original_content)
abort("Failed to lock Gemfile. Restored original.")
end
puts "\n▸ Verifying alignment"
new_drift = GemfileDriftChecker.new.check
if new_drift.empty?
puts "\n✓ Lock files are now in sync"
show_diff(original_content, File.read(GEMFILE_LOCK))
else
puts "\n✗ Lock files still have drift after correction."
puts " Bundler couldn't resolve to matching versions."
puts " Restoring original Gemfile.lock."
File.write(GEMFILE_LOCK, original_content)
exit 1
end
end
private
def show_diff(original, corrected)
require "tempfile"
Tempfile.create("gemfile-lock-original") do |f|
f.write(original)
f.flush
diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null`
unless diff.empty?
puts "\nChanges made to Gemfile.lock:"
puts diff
end
end
end
end
case command = ARGV[0] || "check"
when "check"
exit 1 unless GemfileDriftChecker.new.check.empty?
when "correct"
GemfileDriftCorrector.new.correct
else
abort "Usage: bin/bundle-drift [check|correct]"
end
+1
View File
@@ -11,6 +11,7 @@ CI.run do
step "Style: Ruby", "bin/rubocop" step "Style: Ruby", "bin/rubocop"
step "Gemfile: Drift check", "bin/bundle-drift check"
step "Security: Gem audit", "bin/bundler-audit check --update" step "Security: Gem audit", "bin/bundler-audit check --update"
step "Security: Importmap audit", "bin/importmap audit" step "Security: Importmap audit", "bin/importmap audit"
step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"