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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +17 -6
- data/bin/rails-preflight +3 -1
- data/database/deprecations.yml +3 -2
- data/lib/rails_preflight/config_analyzer.rb +58 -1
- data/lib/rails_preflight/deprecation_analyzer.rb +15 -7
- data/lib/rails_preflight/dual_boot_analyzer.rb +79 -0
- data/lib/rails_preflight/json_report.rb +1 -1
- data/lib/rails_preflight/markdown_report.rb +2 -1
- data/lib/rails_preflight/report_generator.rb +1 -0
- data/lib/rails_preflight/summary_calculator.rb +6 -1
- data/lib/rails_preflight/upgrade_analyzer.rb +23 -12
- data/lib/rails_preflight/version.rb +1 -1
- data/lib/rails_preflight.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5116d66e0950679bf5a2c02c21220a866955d6e309c611899e23413572e3139c
|
|
4
|
+
data.tar.gz: 5c55311682aaff5b5865fb81d7e461cf7a42713c782a3f3165c406a4d5f692b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
data/database/deprecations.yml
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
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} (
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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("."))
|
data/lib/rails_preflight.rb
CHANGED
|
@@ -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.
|
|
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
|