Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@
contents: read

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:
name: Security
runs-on: ubuntu-latest
Expand Down
65 changes: 22 additions & 43 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ GEM
specs:
action_text-trix (2.1.15)
railties
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
Expand Down Expand Up @@ -151,8 +151,8 @@ GEM
brakeman (7.1.1)
racc
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (3.40.0)
addressable
Expand All @@ -172,7 +172,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
Expand All @@ -185,15 +185,11 @@ GEM
tzinfo
faker (3.5.2)
i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
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-musl)
fugit (1.12.1)
Expand Down Expand Up @@ -223,7 +219,7 @@ GEM
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jmespath (1.6.2)
json (2.18.0)
json (2.17.1)
jwt (3.1.2)
base64
kamal (2.9.0)
Expand Down Expand Up @@ -260,7 +256,6 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
Expand All @@ -272,7 +267,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mittens (0.3.1)
mittens (0.3.0)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
Expand All @@ -293,9 +288,6 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
Expand All @@ -306,8 +298,6 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
Expand All @@ -329,10 +319,10 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
psych (5.3.0)
psych (5.2.6)
date
stringio
public_suffix (7.0.0)
public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
Expand All @@ -345,7 +335,7 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.2.1)
rack (>= 3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
Expand All @@ -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)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.17.0)
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
Expand All @@ -366,10 +356,10 @@ GEM
io-console (~> 0.5)
rexml (3.4.4)
rouge (4.6.1)
rqrcode (3.1.1)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.1)
rqrcode_core (2.0.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand All @@ -388,7 +378,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.1)
rubocop-rails (2.34.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
Expand Down Expand Up @@ -427,18 +417,13 @@ GEM
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.8.1)
mini_portile2 (~> 2.8.0)
sqlite3 (2.8.1-aarch64-linux-gnu)
sqlite3 (2.8.1-aarch64-linux-musl)
sqlite3 (2.8.1-arm-linux-gnu)
sqlite3 (2.8.1-arm-linux-musl)
sqlite3 (2.8.1-arm64-darwin)
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)
sqlite3 (2.8.0-aarch64-linux-gnu)
sqlite3 (2.8.0-aarch64-linux-musl)
sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
Expand All @@ -453,9 +438,8 @@ GEM
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.5.0)
timeout (0.4.4)
trilogy (2.9.0)
tsort (0.2.0)
turbo-rails (2.0.20)
Expand Down Expand Up @@ -497,11 +481,6 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
arm64-linux
ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
Expand Down
128 changes: 128 additions & 0 deletions bin/bundle-drift
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions config/ci.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

step "Style: Ruby", "bin/rubocop"

step "Gemfile: Drift check", "bin/bundle-drift check"
step "Security: Gem audit", "bin/bundler-audit check --update"
step "Security: Importmap audit", "bin/importmap audit"
step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
Expand Down