rails_preflight 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e083ff064d2ee34d2b8862a68c3ea41ca63c1f39107237adec73647e8755b1ec
4
- data.tar.gz: 770f2d5ac5a2b6b5a8bd491517c735be6c3874b45fdbafa7b25f5d1035af78ab
3
+ metadata.gz: 5116d66e0950679bf5a2c02c21220a866955d6e309c611899e23413572e3139c
4
+ data.tar.gz: 5c55311682aaff5b5865fb81d7e461cf7a42713c782a3f3165c406a4d5f692b2
5
5
  SHA512:
6
- metadata.gz: 7c3486a813bd9c02965df146309c21966bc0c463905ea89c06760b74714b32e0ab5f75c2eabdaa20211e68c88f13aba0c03ddf368132d6a740ea772ccc34b9ce
7
- data.tar.gz: 9d31b9cf5a20aedfbac6452d437760d0f509a169a44837ba9d651364b9d05516d770bac23a165b4f1b327d56be8400a414fc3107ec4f14c10f51e84724f52628
6
+ metadata.gz: ec6b19914d2de83e4fcb6b96689612ab6365302a4e58a44ad406ee958c6045b6f8a96eb1b42c07ff1395e70f431d17c03ce7489cbcafaaf0f926d1c86acdd987
7
+ data.tar.gz: f5bf7047615dc677b4a4a1d454d0450e9114797476bc04e098b186a428968e7a2efc47d9ba6b88cefb89b96eb5bdcdae6c0c597f8c11093cbf12f9f34885ee6e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-30)
4
+
5
+ - Each step links to its RailsDiff and reminds you to run `bin/rails app:update`; JSON steps carry the link as `railsdiff` (#29)
6
+ - Dual boot: a tip to set it up when there's none, and a tip to add a CI job when CI doesn't run it (#30)
7
+ - A warning when `.ruby-version`, the Dockerfile and CI config disagree on Ruby (#30)
8
+ - Secrets committed in config YAML are flagged by file and key, never the value, outside the development and test sections (#31)
9
+ - A production `secret_key_base` in `config/secrets.yml` is flagged, as a blocker when the target is Rails 7.2 or later, which stops reading that file; one read from `ENV["SECRET_KEY_BASE"]` isn't (#31)
10
+ - The `STATS_DIRECTORIES` message shows its replacement as one code span (#33)
11
+
12
+ ## 0.1.2 (2026-10-13)
13
+
14
+ - Code inside Packwerk packs (`packs/<name>/app`, `lib`, `spec`, ...) is scanned like the app root, and rules scoped to `app/helpers` or `spec` apply inside packs too (#22, thanks @JuanVqz)
15
+ - `BUNDLE_GEMFILE=Gemfile.next rails-preflight` reads that Gemfile and its lockfile, so a dual-boot app can audit its next boot; one outside the app, as `bundle exec` sets it, is ignored (#23, #24, thanks @JuanVqz)
16
+ - A compiled binary under `bin/` no longer aborts the run, and scanned files are read as UTF-8 whatever the locale, so a file with non-ASCII characters no longer crashes the scan under `LANG=C` (#21, thanks @JuanVqz)
17
+
3
18
  ## 0.1.1 (2026-10-09)
4
19
 
5
20
  - rubygems.org lookups also trust the certificates RubyGems ships for rubygems.org, so they work wherever `gem install` does, even when Ruby's OpenSSL can't find the system certificates
data/README.md CHANGED
@@ -44,6 +44,8 @@ The audit analyzes your project for common Rails upgrade risk factors:
44
44
  - Docker runtime risks (EOL base image, locale, tzdata, OpenSSL mismatch)
45
45
  - Database schema risks (charset, integer IDs)
46
46
  - Missing or outdated `config.load_defaults`
47
+ - Secrets committed in config YAML, by file and key, never the value; a production `secret_key_base` in `config/secrets.yml`, which Rails 7.2 stops reading
48
+ - Dual boot (`Gemfile.next`, `next?` in the Gemfile, bootboot) and whether CI runs it, and a Ruby version that `.ruby-version`, the Dockerfile and CI config disagree on
47
49
 
48
50
  The output is a **single HTML report** designed to be:
49
51
 
@@ -97,13 +99,13 @@ It knows Rails 5.0 to 8.1 (`database/compatibility.yml`); the default target is
97
99
 
98
100
  The tool will analyze:
99
101
 
100
- - `Gemfile` and `Gemfile.lock`
102
+ - `Gemfile` and `Gemfile.lock`, or the Gemfile `BUNDLE_GEMFILE` names and its lockfile (`BUNDLE_GEMFILE=Gemfile.next` for a dual-boot app)
101
103
  - `.ruby-version`
102
104
  - `Dockerfile` (if present)
103
105
  - `db/schema.rb` (if present; an app on `db/structure.sql` gets a note that the schema checks were skipped)
104
106
  - `config/application.rb` and `config/**/*.yml`
105
- - `.rb`, `.erb`, `.haml`, `.slim` and `.rake` files under `app/`, `config/`, `db/`, `lib/`, `test/` and `spec/`
106
- - Scripts and CI config (`bin/`, `script/`, Procfiles, Makefiles, CI workflows) for removed rake tasks
107
+ - `.rb`, `.erb`, `.haml`, `.slim` and `.rake` files under `app/`, `config/`, `db/`, `lib/`, `test/` and `spec/`, at the root and in each Packwerk pack (`packs/<name>/app/`, ...)
108
+ - Scripts and CI config (`bin/`, `script/`, Procfiles, Makefiles, CI workflows) for removed rake tasks, and CI config for dual boot and its Ruby version
107
109
 
108
110
  And generate:
109
111
 
@@ -165,7 +167,7 @@ It's a checklist per step, with `file:line` and the matched line under each find
165
167
  ```markdown
166
168
  ## Step 3: 6.1 to 7.0
167
169
 
168
- Needs Ruby 2.7.0–3.2: 2.7.2 works.
170
+ Needs Ruby 2.7.0–3.2: 2.7.2 works. Run `bin/rails app:update` and compare with [RailsDiff](https://railsdiff.org/6.1.0/7.0.0).
169
171
 
170
172
  - [ ] **Blocker:** annotate 3.1.1 requires activerecord >= 3.2, < 7.0, so it doesn't install on Rails 7.0. Upgrade it to a release that allows 7.0, or replace it.
171
173
  - [ ] **Blocker:** `ActionMailer::DeliveryJob` and `ActionMailer::Parameterized::DeliveryJob` were removed in Rails 7.0. Mail is delivered by `ActionMailer::MailDeliveryJob`. ([source](https://guides.rubyonrails.org/7_0_release_notes.html#action-mailer-removals))
@@ -181,12 +183,13 @@ Needs Ruby 2.7.0–3.2: 2.7.2 works.
181
183
  rails-preflight --format json --stdout 7.2 /path/to/your/app | jq '.counts'
182
184
  ```
183
185
 
184
- The top level holds `schema`, `tool`, `app`, `current_rails`, `target_rails`, `ruby`, `offline`, `verdict` and `counts`, then the findings in `before` (before the first step), `steps` (each with `from`, `version`, its Ruby note and `findings`), `ahead` (keyed by the Rails version that removes them) and `cant_see`. There's no timestamp, so the same app gives the same output. Schema 1 may still change before 1.0; the number goes up when it does. A step from the same run, shortened ([full sample](docs/sample-report.json)):
186
+ The top level holds `schema`, `tool`, `app`, `current_rails`, `target_rails`, `ruby`, `offline`, `verdict` and `counts`, then the findings in `before` (before the first step), `steps` (each with `from`, `version`, its Ruby note, `railsdiff` and `findings`), `ahead` (keyed by the Rails version that removes them) and `cant_see`. There's no timestamp, so the same app gives the same output. Schema 1 may still change before 1.0; the number goes up when it does. A step from the same run, shortened ([full sample](docs/sample-report.json)):
185
187
 
186
188
  ```json
187
189
  {
188
190
  "from": "6.1",
189
191
  "version": "7.0",
192
+ "railsdiff": "https://railsdiff.org/6.1.0/7.0.0",
190
193
  "findings": [
191
194
  {
192
195
  "section": "Gem Compatibility",
@@ -227,7 +230,7 @@ rails-preflight --offline --format json --fail-on blockers 7.2
227
230
 
228
231
  ## Roadmap
229
232
 
230
- For a detailed list of current features and future plans, please see [ROADMAP.md](ROADMAP.md).
233
+ What's planned is in [ROADMAP.md](ROADMAP.md).
231
234
 
232
235
  ## Philosophy
233
236
 
@@ -248,6 +251,14 @@ bundle exec rake
248
251
 
249
252
  CI runs the suite on every Ruby from 2.7 to 4.0.
250
253
 
254
+ ## Support the project ❤️
255
+
256
+ RailsPreFlight is independently developed and maintained. If it helps you assess an upgrade, identify risks, or save engineering time, consider [sponsoring its development](https://github.com/sponsors/aslam).
257
+
258
+ Sponsorship helps support ongoing maintenance, compatibility updates, and improvements.
259
+
260
+ Contributions, bug reports, and sharing the project are equally appreciated.
261
+
251
262
  ## License
252
263
 
253
264
  MIT
data/bin/rails-preflight CHANGED
@@ -16,6 +16,7 @@ if %w[-h --help].include?(ARGV[0])
16
16
  puts "--format markdown|json: write rails_preflight_report.md or .json into the app instead of the HTML report."
17
17
  puts "--stdout: print the report instead of writing it (progress goes to stderr), for pipes and coding agents."
18
18
  puts "--fail-on blockers: exit 1 when anything blocks the upgrade or is already broken. --fail-on broken: only when something is already broken."
19
+ puts "BUNDLE_GEMFILE=Gemfile.next: read that Gemfile and its lockfile instead, for a dual-boot app. Ignored when it's outside APP_PATH."
19
20
  exit
20
21
  end
21
22
 
@@ -38,7 +39,8 @@ abort "Target must be a Rails version such as 7.2 (got #{target.inspect})." if t
38
39
  project_path = ARGV[0] || Dir.pwd
39
40
 
40
41
  begin
41
- summary = RailsPreflight::UpgradeAnalyzer.new(target, project_path, offline: offline, format: format, stdout: stdout).run
42
+ summary = RailsPreflight::UpgradeAnalyzer.new(target, project_path, offline: offline, format: format, stdout: stdout,
43
+ gemfile: ENV["BUNDLE_GEMFILE"]).run
42
44
  rescue RailsPreflight::Error => e
43
45
  abort e.message
44
46
  end
@@ -103,7 +103,8 @@ deprecations:
103
103
  recategorization: "Rails 7.1 -> 7.2"
104
104
  fix_effort: low
105
105
  - id: fixture_path_assignment
106
- pattern: "\\bfixture_path\\s*="
106
+ # Ruby reads a bare `fixture_path = x` as a local variable; the removed setter always has a receiver
107
+ pattern: "\\.fixture_path\\s*=(?!=)"
107
108
  message: "'fixture_path' was removed in Rails 7.2. Assign 'fixture_paths' instead, which takes an array."
108
109
  removed_in: "7.2"
109
110
  severity: error
@@ -228,7 +229,7 @@ deprecations:
228
229
  fix_effort: medium
229
230
  - id: stats_directories
230
231
  pattern: "\\bSTATS_DIRECTORIES\\b"
231
- message: "'STATS_DIRECTORIES' was removed in Rails 8.1. Register extra directories with Rails::CodeStatistics.register_directory('Services', 'app/services')."
232
+ message: "'STATS_DIRECTORIES' was removed in Rails 8.1. Register extra directories with 'Rails::CodeStatistics.register_directory(\"Services\", \"app/services\")'."
232
233
  removed_in: "8.1"
233
234
  severity: error
234
235
  confidence: "High"
@@ -1,10 +1,21 @@
1
1
  # lib/rails_preflight/config_analyzer.rb
2
+ require_relative 'deprecation_analyzer'
3
+
2
4
  module RailsPreflight
3
5
  class ConfigAnalyzer
4
- def initialize(root_path = Dir.pwd, current_rails = nil)
6
+ # `key: value` in YAML, key quoted or not, with the value up to a trailing comment.
7
+ YAML_PAIR = /^\s*-?\s*["']?([\w-]+)["']?\s*:\s*(.*?)\s*(?:\s#.*)?$/
8
+ # Values, unquoted, that hold nothing secret: ERB, aliases, anchors, flow collections, booleans, null, numbers.
9
+ NOT_LITERAL = /\A(?:<%|[*&{\[]|(?:true|false|yes|no|null|~|-?\d+(?:\.\d+)?)\z)/i
10
+ # Top-level sections whose values never reach production.
11
+ LOCAL_ENVS = %w[development test].freeze
12
+ SECRETS_REMOVED = "https://guides.rubyonrails.org/7_2_release_notes.html#railties-removals"
13
+
14
+ def initialize(root_path = Dir.pwd, current_rails = nil, target_rails: nil)
5
15
  @root_path = root_path
6
16
  # load_defaults takes a minor version ("7.1"), so compare at that level
7
17
  @current_rails = Gem::Version.new(current_rails.to_s.split(".").first(2).join(".")) if current_rails
18
+ @target_rails = Gem::Version.new(target_rails) if target_rails && Gem::Version.correct?(target_rails)
8
19
  end
9
20
 
10
21
  def run
@@ -17,6 +28,9 @@ module RailsPreflight
17
28
  result[:checks] << { message: "config/application.rb not found.", status: :warning, kind: :unknown }
18
29
  end
19
30
 
31
+ check_committed_secrets(result[:checks])
32
+ check_secrets_secret_key_base(result[:checks])
33
+
20
34
  if result[:checks].any?
21
35
  result[:status] = :warning
22
36
  else
@@ -42,5 +56,48 @@ module RailsPreflight
42
56
  checks << { message: message, status: :warning, fix_effort: :medium }
43
57
  end
44
58
  end
59
+
60
+ # Secret-named keys with a literal value, outside the development and test sections. Shows the key, never the value.
61
+ def check_committed_secrets(checks)
62
+ hits = yaml_pairs(Dir.glob(File.join(@root_path, "config", "**", "*.{yml,yml.erb}")).reject { |file| file.include?("/locales/") })
63
+ .select { |_, _, section, key, value| key.match?(DeprecationAnalyzer::SECRET_NAME) && literal?(value) && !LOCAL_ENVS.include?(section) }
64
+ return if hits.empty?
65
+
66
+ them = hits.one? ? "it" : "them"
67
+ checks << { message: "#{hits.one? ? 'A secret is' : "#{hits.size} secrets are"} committed in config YAML, where anyone with the repository can read #{them}. Rotate #{them}, and read #{them} from credentials or ENV instead.",
68
+ status: :warning, fix_effort: :medium, files: hits.map { |file, line, _, key| { file: file, line: line, snippet: "#{key}: [hidden]" } } }
69
+ end
70
+
71
+ # Rails 7.2 stops reading config/secrets.yml, so a production secret_key_base set there is lost; one read from
72
+ # ENV["SECRET_KEY_BASE"] still works, since 7.2 reads that variable itself.
73
+ def check_secrets_secret_key_base(checks)
74
+ return if @current_rails && @current_rails >= Gem::Version.new("7.2")
75
+
76
+ hit = yaml_pairs([File.join(@root_path, "config", "secrets.yml")]).find do |_, _, section, key, value|
77
+ %w[production shared].include?(section) && key == "secret_key_base" && !value.empty? && !value.match?(/ENV(?:\[|\.fetch\(\s*)["']SECRET_KEY_BASE["']/)
78
+ end
79
+ return unless hit
80
+
81
+ checks << { message: "'config/secrets.yml' sets 'secret_key_base' for production, and Rails 7.2 no longer reads 'config/secrets.yml'. Move the same value to credentials or 'SECRET_KEY_BASE' first: a new one invalidates sessions and signed cookies.",
82
+ status: @target_rails && @target_rails >= Gem::Version.new("7.2") ? :failed : :warning, removed_in: "7.2", fix_effort: :medium,
83
+ source: SECRETS_REMOVED, files: [{ file: hit[0], line: hit[1], snippet: "secret_key_base: [hidden]" }] }
84
+ end
85
+
86
+ def literal?(value)
87
+ value = value.sub(/\A(["'])(.*)\1\z/, '\2')
88
+ !value.empty? && !value.match?(NOT_LITERAL)
89
+ end
90
+
91
+ # [file, line, top-level section, key, value] for each `key: value` line in the files that exist.
92
+ def yaml_pairs(files)
93
+ files.select { |file| File.file?(file) }.sort.flat_map do |file|
94
+ section = nil
95
+ File.foreach(file, encoding: "UTF-8").with_index(1).filter_map do |line, number|
96
+ section = $1 if line =~ /^([\w-]+):/
97
+ next unless (pair = line.match(YAML_PAIR))
98
+ [file.delete_prefix(File.join(@root_path, "")), number, section, pair[1], pair[2]]
99
+ end
100
+ end
101
+ end
45
102
  end
46
103
  end
@@ -4,6 +4,8 @@ module RailsPreflight
4
4
  DATA_PATH = File.expand_path('../../database/deprecations.yml', __dir__)
5
5
  SCAN_DIRS = %w[app config db lib test spec].freeze
6
6
  SCAN_EXTS = %w[.rb .erb .rake .haml .slim].freeze
7
+ # Packwerk packs (`packs/<name>/app`, `packs/<name>/spec`, ...) hold app code outside the root's SCAN_DIRS.
8
+ PACKS_DIR = 'packs'.freeze
7
9
  # Lines that never run, by file type: [line comment, silent comment]. A silent comment hides the lines indented
8
10
  # under it too (HAML `-#`, Slim `/`); an HTML comment (HAML `/`, Slim `/!` or `/[if IE]`) is text, but the lines
9
11
  # under it still render and run. In templates `#main` is an element id, not a comment.
@@ -22,8 +24,9 @@ module RailsPreflight
22
24
  # Snippets go into a report people print and share: on a line that names a secret, values are hidden.
23
25
  SECRET_NAME = /secret|token|passw(?:or)?d|api_?key|access_?key|private_?key|credential/i
24
26
 
25
- def initialize(root_path = Dir.pwd, database_path = DATA_PATH, target_rails: nil, current_rails: nil)
27
+ def initialize(root_path = Dir.pwd, database_path = DATA_PATH, target_rails: nil, current_rails: nil, lockfile_path: nil)
26
28
  @root_path = root_path
29
+ @lockfile_path = lockfile_path || File.join(root_path, "Gemfile.lock")
27
30
  @rules = YAML.load_file(database_path)
28
31
  @target_rails = target_rails
29
32
  @current_rails = current_rails
@@ -68,7 +71,7 @@ module RailsPreflight
68
71
  # Rubocop Advisory Check
69
72
  if check_rubocop_rails
70
73
  result[:checks] << {
71
- message: "rubocop-rails is installed. Run `bundle exec rubocop -a` to auto-fix more deprecations.",
74
+ message: "rubocop-rails is installed. Run 'bundle exec rubocop -a' to auto-fix more deprecations.",
72
75
  status: :passed
73
76
  }
74
77
  else
@@ -87,7 +90,8 @@ module RailsPreflight
87
90
  found = []
88
91
  compiled = rules.map { |rule| [Regexp.new(rule['pattern']), rule] }
89
92
 
90
- files = Dir.glob(File.join(@root_path, "{#{SCAN_DIRS.join(',')}}/**/*")).select { |file| SCAN_EXTS.include?(File.extname(file)) }.to_h { |file| [file, 'code'] }
93
+ code_globs = ["", "#{PACKS_DIR}/*/"].map { |prefix| File.join(@root_path, "#{prefix}{#{SCAN_DIRS.join(',')}}/**/*") }
94
+ files = Dir.glob(code_globs).select { |file| SCAN_EXTS.include?(File.extname(file)) }.to_h { |file| [file, 'code'] }
91
95
  FILE_GLOBS.each do |kind, globs|
92
96
  Dir.glob(globs.map { |glob| File.join(@root_path, glob) }).each { |file| files[file] ||= kind }
93
97
  end
@@ -96,15 +100,19 @@ module RailsPreflight
96
100
  next if File.directory?(file)
97
101
 
98
102
  relative_path = Pathname.new(file).relative_path_from(Pathname.new(@root_path)).to_s
99
- test = relative_path.start_with?('test/', 'spec/')
103
+ # A pack is laid out like the app root, so rules see `packs/billing/app/models/x.rb` as `app/models/x.rb`.
104
+ app_path = relative_path.sub(%r{\A#{PACKS_DIR}/[^/]+/}, '')
105
+ test = app_path.start_with?('test/', 'spec/')
100
106
 
101
- under = ->(paths) { paths.any? { |path| relative_path == path || relative_path.start_with?("#{path}/") } }
107
+ under = ->(paths) { paths.any? { |path| app_path == path || app_path.start_with?("#{path}/") } }
102
108
  # A rule with `paths:` only applies under those directories or to those files.
103
109
  applicable = compiled.select do |_regex, rule|
104
110
  rule.fetch('files', ['code']).include?(kind) && (rule['paths'].nil? || under.(rule['paths']))
105
111
  end
106
112
  next if applicable.empty?
107
- content = File.read(file)
113
+ # UTF-8 whatever the locale (LANG=C reads US-ASCII); a compiled binary under bin/ (e.g. a Rust CLI) isn't text to scan.
114
+ content = File.read(file, encoding: "UTF-8")
115
+ next unless content.valid_encoding?
108
116
  # Only `x.errors` counts under `needs_receiver_in:` (a bare `errors` in a helper or view is a local), and in a
109
117
  # file that assigns `errors` or takes it as a parameter (`skip_bare_if_local:`), where the bare name is that local.
110
118
  # ponytail: file-wide, so a model with an `errors` local in one method loses bare hits in the others.
@@ -207,7 +215,7 @@ module RailsPreflight
207
215
  end
208
216
 
209
217
  def lockfile
210
- @lockfile ||= File.exist?(File.join(@root_path, "Gemfile.lock")) ? File.read(File.join(@root_path, "Gemfile.lock")) : ""
218
+ @lockfile ||= File.exist?(@lockfile_path) ? File.read(@lockfile_path) : ""
211
219
  end
212
220
 
213
221
  end
@@ -0,0 +1,79 @@
1
+ # lib/rails_preflight/dual_boot_analyzer.rb
2
+ require_relative 'docker_analyzer'
3
+
4
+ module RailsPreflight
5
+ # Before the first step: can the app boot both Rails versions, does CI test both, and do the places
6
+ # that name a Ruby agree on it.
7
+ class DualBootAnalyzer
8
+ CI_GLOBS = %w[.github/workflows/*.{yml,yaml} .circleci/config.yml .gitlab-ci.yml .travis.yml].freeze
9
+ NEXT_RAILS = "https://github.com/fastruby/next_rails"
10
+ # next_rails runs the next boot with BUNDLE_GEMFILE=Gemfile.next; bootboot with DEPENDENCIES_NEXT=1.
11
+ CI_RUNS_NEXT = /Gemfile\.next|BUNDLE_GEMFILE|DEPENDENCIES_NEXT/
12
+ # setup-ruby's `ruby-version: 3.3`, and image tags such as `ruby:3.3` or `cimg/ruby:3.3.5`. A matrix
13
+ # (`ruby-version: ${{ matrix.ruby }}`) isn't resolved, so it's never compared.
14
+ CI_RUBY = /(?:ruby-version:\s*["']?|\bruby:)(\d+\.\d+(?:\.\d+)?)/
15
+
16
+ def initialize(root_path = Dir.pwd)
17
+ @root_path = root_path
18
+ end
19
+
20
+ def run
21
+ result = { title: "Dual Boot", status: :passed, checks: [], confidence: :medium }
22
+ ci = Dir.glob(CI_GLOBS.map { |glob| File.join(@root_path, glob) }).sort.to_h { |file| [relative(file), File.read(file, encoding: "UTF-8")] }
23
+
24
+ if (setup = dual_boot)
25
+ if ci.any? && ci.values.none? { |content| content.match?(CI_RUNS_NEXT) }
26
+ result[:checks] << { message: "Dual boot is set up (#{setup}), but CI only runs the current Gemfile. Add a CI job with 'BUNDLE_GEMFILE=Gemfile.next', so each step is tested on the next Rails before it ships.",
27
+ status: :passed, kind: :tip, fix_effort: :low, source: NEXT_RAILS }
28
+ else
29
+ result[:checks] << { message: "Dual boot is set up (#{setup}).", status: :passed }
30
+ end
31
+ else
32
+ result[:checks] << { message: "No dual boot found (no 'Gemfile.next' or 'next?' in the Gemfile). Set it up first, e.g. with next_rails, so the app and its tests run on the current and the next Rails while each step is in progress.",
33
+ status: :passed, kind: :tip, fix_effort: :low, source: NEXT_RAILS }
34
+ end
35
+
36
+ check_ruby_versions(ci, result)
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def dual_boot
43
+ gemfile = File.join(@root_path, "Gemfile")
44
+ if File.exist?(File.join(@root_path, "Gemfile.next")) then "'Gemfile.next'"
45
+ elsif File.exist?(File.join(@root_path, "Gemfile_next.lock")) then "'Gemfile_next.lock'"
46
+ elsif File.exist?(gemfile) && File.read(gemfile, encoding: "UTF-8").match?(/^\s*def\s+next\?/) then "'next?' in the Gemfile"
47
+ end
48
+ end
49
+
50
+ # .ruby-version and the Dockerfile name the Ruby the app runs on, so they must agree. CI only has to test
51
+ # that Ruby somewhere: a matrix or a side workflow (docs, linting) on another Ruby is fine.
52
+ def check_ruby_versions(ci, result)
53
+ ruby_version_file = File.join(@root_path, ".ruby-version")
54
+ app = [[".ruby-version", File.exist?(ruby_version_file) && File.read(ruby_version_file)[/\d+\.\d+(?:\.\d+)?/]],
55
+ ["Dockerfile", DockerAnalyzer.new(@root_path).detect_ruby_version]].select(&:last)
56
+ return if app.empty?
57
+
58
+ ci_rubies = ci.flat_map { |file, content| content.scan(CI_RUBY).flatten.uniq.map { |version| [file, version] } }
59
+ app_ruby = app.first.last
60
+ differ = app.map(&:last).any? { |version| !same?(version, app_ruby) } ||
61
+ (ci_rubies.any? && ci_rubies.none? { |_, version| same?(version, app_ruby) })
62
+ return unless differ
63
+
64
+ result[:status] = :warning
65
+ result[:checks] << { message: "The Ruby version differs between files: #{(app + ci_rubies).map { |source, version| "#{source} #{version}" }.join(', ')}. Make them match, so CI tests the Ruby that runs in production.",
66
+ status: :warning, fix_effort: :low }
67
+ end
68
+
69
+ # 3.3 and 3.3.5 agree; 3.3 and 3.4 don't.
70
+ def same?(a, b)
71
+ a, b = [a, b].map { |version| version.split(".") }
72
+ a.first(b.size) == b.first(a.size)
73
+ end
74
+
75
+ def relative(file)
76
+ file.delete_prefix(File.join(@root_path, ""))
77
+ end
78
+ end
79
+ end
@@ -26,7 +26,7 @@ module RailsPreflight
26
26
  counts: %i[broken blockers to_fix unknowns].to_h { |key| [key, @summary.fetch(key, []).size] },
27
27
  before: @summary.fetch(:before, []).map { |entry| finding(entry) },
28
28
  steps: @summary.fetch(:steps, []).map do |step|
29
- step.slice(:from, :version, :ruby, :ruby_upgrade).merge(findings: step[:checks].map { |entry| finding(entry) })
29
+ step.slice(:from, :version, :ruby, :ruby_upgrade, :railsdiff).merge(findings: step[:checks].map { |entry| finding(entry) })
30
30
  end,
31
31
  ahead: @summary.fetch(:ahead, {}).transform_values { |entries| entries.map { |entry| finding(entry) } },
32
32
  cant_see: @summary.fetch(:cant_see, []).map { |entry| finding(entry) }
@@ -22,7 +22,8 @@ module RailsPreflight
22
22
  out << section("Before you start#{" (on Rails #{@data[:current_rails]})" if @data[:current_rails]}", before) if before.any?
23
23
  @summary.fetch(:steps, []).each.with_index(1) do |step, i|
24
24
  title = "Step #{i}: #{step[:from] ? "#{step[:from]} to #{step[:version]}" : "Rails #{step[:version]}"}"
25
- out << section(title, step[:checks], "#{step[:ruby]}.")
25
+ note = ["#{step[:ruby]}.", ("Run `bin/rails app:update` and compare with [RailsDiff](#{step[:railsdiff]})." if step[:railsdiff])]
26
+ out << section(title, step[:checks], note.compact.join(" "))
26
27
  end
27
28
  @summary.fetch(:ahead, {}).each do |version, entries|
28
29
  out << section("Ahead: removed in #{version}, not needed for #{@data[:target_rails]}", entries)
@@ -251,6 +251,7 @@ module RailsPreflight
251
251
  <article class="step" id="<%= step_id(step[:version]) %>">
252
252
  <div class="step-head"><h3><%= h(step[:from] ? "#{step[:from]} → #{step[:version]}" : "Rails #{step[:version]}") %></h3><span class="tag"><%= h(blocker_tag(step[:checks])) %></span></div>
253
253
  <div class="rubynote"><span class="k">Ruby</span><span><%= h(step[:ruby]) %></span></div>
254
+ <% if step[:railsdiff] %><div class="rubynote"><span class="k">Config</span><span>Run <code>bin/rails app:update</code> and compare with <a href="<%= h(step[:railsdiff]) %>">RailsDiff</a></span></div><% end %>
254
255
  <% if step[:checks].any? %>
255
256
  <ul class="rows"><% step[:checks].each do |entry| %><%= row_html(entry) %><% end %></ul>
256
257
  <% end %>
@@ -97,7 +97,7 @@ module RailsPreflight
97
97
  max_now = @current_max_ruby
98
98
  @hops.map do |hop|
99
99
  note, upgraded = ruby_note(hop, ruby, max_now)
100
- step = { version: hop[:version], from: from, ruby: note, ruby_upgrade: upgraded,
100
+ step = { version: hop[:version], from: from, ruby: note, ruby_upgrade: upgraded, railsdiff: railsdiff(from, hop[:version]),
101
101
  checks: in_order(in_steps.select { |entry| step_for(entry) == hop[:version] }) }
102
102
  ruby = upgraded || ruby
103
103
  from = hop[:version]
@@ -106,6 +106,11 @@ module RailsPreflight
106
106
  end
107
107
  end
108
108
 
109
+ # The generated-app diff between the two minors, which is what `bin/rails app:update` brings in.
110
+ def railsdiff(from, to)
111
+ from && "https://railsdiff.org/#{from.split('.').first(2).join('.')}.0/#{to}.0"
112
+ end
113
+
109
114
  # An API removed in one of the step versions is fixed as part of that step. A Ruby too old or too new for
110
115
  # the target is checked against the target, so it sits in the last step, next to its Ruby note.
111
116
  def step_for(entry)
@@ -16,14 +16,19 @@ module RailsPreflight
16
16
  # format: :html, :markdown or :json, written into the app as rails_preflight_report.<ext>.
17
17
  # stdout: print the report instead of writing it, with progress on stderr, for pipes and coding agents.
18
18
  # run returns the summary (nil when there's nothing to upgrade to), so the caller can set the exit code.
19
- def initialize(target_rails = nil, project_path = Dir.pwd, offline: false, format: :html, stdout: false)
19
+ def initialize(target_rails = nil, project_path = Dir.pwd, offline: false, format: :html, stdout: false, gemfile: nil)
20
20
  @target_rails = target_rails
21
21
  @offline = offline
22
22
  @format = format
23
23
  @stdout = stdout
24
24
  @out = stdout ? $stderr : $stdout
25
25
  @project_path = project_path
26
- @lockfile_path = File.join(project_path, "Gemfile.lock")
26
+ # gemfile: another Gemfile, as BUNDLE_GEMFILE names it (Gemfile.next for dual boot); its lockfile sits next to it.
27
+ # Only one inside the app counts: `bundle exec` sets BUNDLE_GEMFILE to the Gemfile of wherever it was run.
28
+ gemfile = File.expand_path(gemfile, project_path) if gemfile
29
+ inside = gemfile&.start_with?(File.join(File.expand_path(project_path), ""))
30
+ @gemfile_path = inside ? gemfile : File.expand_path("Gemfile", project_path)
31
+ @lockfile_path = "#{@gemfile_path}.lock"
27
32
  @rules = YAML.load_file(DATA_PATH)
28
33
  end
29
34
 
@@ -50,10 +55,12 @@ module RailsPreflight
50
55
  results << check_ruby_version
51
56
  results << scan_gems
52
57
  results << DockerAnalyzer.new(@project_path, checked_ruby: @eol_checked_ruby).run
58
+ results << DualBootAnalyzer.new(@project_path).run
53
59
  results << GemAnalyzer.new(@project_path, lockfile_specs, hops: upgrade_hops.map { |hop| hop[:version] }, direct: lockfile&.dependencies&.keys || [],
54
60
  last_releases: (@last_releases || {} unless @offline), target_released: target_rails_rules&.dig('released')).run
55
- results << DeprecationAnalyzer.new(@project_path, target_rails: @target_rails, current_rails: current_rails&.to_s).run
56
- results << ConfigAnalyzer.new(@project_path, current_rails).run
61
+ results << DeprecationAnalyzer.new(@project_path, target_rails: @target_rails, current_rails: current_rails&.to_s,
62
+ lockfile_path: @lockfile_path).run
63
+ results << ConfigAnalyzer.new(@project_path, current_rails, target_rails: @target_rails).run
57
64
  results << DatabaseAnalyzer.new(@project_path, database_rules).run
58
65
 
59
66
  report_data = {
@@ -100,12 +107,12 @@ module RailsPreflight
100
107
  if current_rails.nil?
101
108
  result[:status] = :warning
102
109
  result[:confidence] = :low
103
- result[:checks] << { message: "Could not read current Rails version from Gemfile.lock.", status: :warning, kind: :unknown, fix_effort: :low }
110
+ result[:checks] << { message: "Could not read current Rails version from #{lockfile_name}.", status: :warning, kind: :unknown, fix_effort: :low }
104
111
  elsif Gem::Version.new(current_rails.segments.first(2).join(".")) >= Gem::Version.new(@target_rails)
105
112
  result[:status] = :warning
106
113
  result[:checks] << { message: "Already on Rails #{current_rails}: #{@target_rails} is not an upgrade.", status: :passed }
107
114
  else
108
- result[:checks] << { message: "Rails #{current_rails} (Gemfile.lock) → #{@target_rails}", status: :passed, fix_effort: :low }
115
+ result[:checks] << { message: "Rails #{current_rails} (#{lockfile_name}) → #{@target_rails}", status: :passed, fix_effort: :low }
109
116
  end
110
117
 
111
118
  result
@@ -169,7 +176,7 @@ module RailsPreflight
169
176
  return [File.read(ruby_version_file), ".ruby-version"] if File.exist?(ruby_version_file)
170
177
 
171
178
  lock_ruby = File.exist?(@lockfile_path) && File.read(@lockfile_path)[/^RUBY VERSION\s+ruby (\S+)/, 1]
172
- return [lock_ruby, "Gemfile.lock"] if lock_ruby
179
+ return [lock_ruby, lockfile_name] if lock_ruby
173
180
 
174
181
  docker_ruby = DockerAnalyzer.new(@project_path).detect_ruby_version
175
182
  [docker_ruby, "Dockerfile"] if docker_ruby
@@ -204,7 +211,7 @@ module RailsPreflight
204
211
  result[:status] = :warning
205
212
  result[:confidence] = :medium
206
213
  result[:checks] << {
207
- message: "No Gemfile.lock found. Skipping gem compatibility checks.",
214
+ message: "No #{lockfile_name} found. Skipping gem compatibility checks.",
208
215
  status: :warning,
209
216
  kind: :unknown,
210
217
  fix_effort: :low
@@ -231,7 +238,7 @@ module RailsPreflight
231
238
  end
232
239
 
233
240
  if mixed_gems.any? && @offline
234
- message = "#{mixed_gems.size} gems come from one Gemfile.lock section that lists rubygems.org and #{mixed_remotes.join(', ')}; can't tell which are private with --offline"
241
+ message = "#{mixed_gems.size} gems come from one #{lockfile_name} section that lists rubygems.org and #{mixed_remotes.join(', ')}; can't tell which are private with --offline"
235
242
  result[:checks] << { message: message, status: :warning, kind: :unknown, names: mixed_gems, fix_effort: :unknown }
236
243
  result[:status] = :warning
237
244
  end
@@ -244,7 +251,7 @@ module RailsPreflight
244
251
  private_gems.concat(mixed_gems.select { |name| found[name] == false })
245
252
  @last_releases = dated_gems.filter_map { |name| [name, found[name]['version_created_at']] if found[name].is_a?(Hash) && found[name]['version_created_at'] }.to_h
246
253
  if mixed_gems.any?
247
- result[:checks] << { message: "Looked up #{mixed_gems.size} gems on rubygems.org, since Gemfile.lock lists them under both rubygems.org and #{mixed_remotes.join(', ')}. Pass --offline to skip.", status: :passed, fix_effort: :low }
254
+ result[:checks] << { message: "Looked up #{mixed_gems.size} gems on rubygems.org, since #{lockfile_name} lists them under both rubygems.org and #{mixed_remotes.join(', ')}. Pass --offline to skip.", status: :passed, fix_effort: :low }
248
255
  end
249
256
  # One entry per distinct error: a missing certificate fails every lookup the same way.
250
257
  inconclusive.group_by { |_, error| error }.each do |error, failed|
@@ -359,7 +366,7 @@ module RailsPreflight
359
366
  global = []
360
367
  scoped = {}
361
368
  blocks = [] # source for a `source … do` block, nil for any other block
362
- gemfile_lines(File.join(@project_path, "Gemfile")).each do |line|
369
+ gemfile_lines(@gemfile_path).each do |line|
363
370
  source = line[/^\s*source\b\s*\(?\s*(.+?)\s*\)?\s*(?:do\b.*)?$/, 1]&.delete_prefix("\"")&.delete_suffix("\"")&.delete("'")
364
371
  name = line[/^\s*gem\s*\(?\s*["']([^"']+)["']/, 1]
365
372
  if name
@@ -406,6 +413,10 @@ module RailsPreflight
406
413
  @lockfile = File.exist?(@lockfile_path) ? Bundler::LockfileParser.new(File.read(@lockfile_path)) : nil
407
414
  end
408
415
 
416
+ def lockfile_name
417
+ File.basename(@lockfile_path)
418
+ end
419
+
409
420
  def known_rails_versions
410
421
  @rules.fetch('rails_versions', {}).keys.sort_by { |v| Gem::Version.new(v) }
411
422
  end
@@ -417,7 +428,7 @@ module RailsPreflight
417
428
  # Next known minor after the current Rails (7.1.3 → 7.2), or nil when already on the newest.
418
429
  def next_rails
419
430
  unless current_rails
420
- reason = File.exist?(@lockfile_path) ? "No Rails in #{@lockfile_path}" : "No Gemfile.lock in #{@project_path}"
431
+ reason = File.exist?(@lockfile_path) ? "No Rails in #{@lockfile_path}" : "No #{lockfile_name} in #{@project_path}"
421
432
  raise Error, "#{reason}, so the current Rails version is unknown. Pass a target, e.g. `rails-preflight #{latest_known_rails}`."
422
433
  end
423
434
  current_minor = Gem::Version.new(current_rails.segments.first(2).join("."))
@@ -1,3 +1,3 @@
1
1
  module RailsPreflight
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,6 +1,7 @@
1
1
  require "rails_preflight/version"
2
2
  require "rails_preflight/upgrade_analyzer"
3
3
  require "rails_preflight/docker_analyzer"
4
+ require "rails_preflight/dual_boot_analyzer"
4
5
  require "rails_preflight/deprecation_analyzer"
5
6
  require "rails_preflight/gem_analyzer"
6
7
  require "rails_preflight/config_analyzer"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_preflight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Syed Aslam
@@ -35,6 +35,7 @@ files:
35
35
  - lib/rails_preflight/database_analyzer.rb
36
36
  - lib/rails_preflight/deprecation_analyzer.rb
37
37
  - lib/rails_preflight/docker_analyzer.rb
38
+ - lib/rails_preflight/dual_boot_analyzer.rb
38
39
  - lib/rails_preflight/gem_analyzer.rb
39
40
  - lib/rails_preflight/json_report.rb
40
41
  - lib/rails_preflight/markdown_report.rb