diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index d097b9590..773674233 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -7,6 +7,20 @@ permissions: 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 diff --git a/Gemfile.lock b/Gemfile.lock index b0ef0a648..3a9430a61 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -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) @@ -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) @@ -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 @@ -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) @@ -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 diff --git a/bin/bundle-drift b/bin/bundle-drift new file mode 100755 index 000000000..64fd23292 --- /dev/null +++ b/bin/bundle-drift @@ -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 diff --git a/config/ci.rb b/config/ci.rb index a99248a93..6a8cdca20 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -11,6 +11,7 @@ CI.run do 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"