appraisal2 3.1.4 → 3.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: bbe46d02bf329c467f1a1174498ac52b428836db70f3b481d9895ab72eae831f
4
- data.tar.gz: 478f076b882e497da7a709180c9a27bf70c9c90a0e34d3dad12a4281b49cd520
3
+ metadata.gz: 3ea29429e7d6b15deb35e182590258883e32ef6fecfe015c4d39d932fd4bcf96
4
+ data.tar.gz: 11bafabfd834fd35254e0777feaa1180be8c6f08b6ea89e92e3615cfefc2b392
5
5
  SHA512:
6
- metadata.gz: bad0a5d468f7d046063b315d70f74f43e751c21a4b2316d599d6904f1e3f77b783f2bafd7d4cef9946fcb3e5757a359b1b6af431b9e99681042064d82d13d8c0
7
- data.tar.gz: 2ee5da066917eaf2862328952292e79b76483befd7fcad754945dec312fa988e77813dd93e2ad7812a27489239e95da13be1159f7e068468c0c699c560398550
6
+ metadata.gz: 3289da2711ffa8539b1a34aba8346209e077d3fd86cf1bdb1b726bea7093bc8ae5e2f4bc62c8aa68e03783266f6c77bdb743a2dc550a4fb14fb3744714943957
7
+ data.tar.gz: bf38ac3a50e473895fb994b5e0e2abbcc437e18b1ec9a2f46acb232bfefce945eddbc5201c7cb1ef2ac739ed556ff3e1b1c1da739c32b3525f23bfff01c58a98
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,53 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [3.2.0] - 2026-07-18
34
+
35
+ - TAG: [v3.2.0][3.2.0t]
36
+ - COVERAGE: 91.00% -- 910/1000 lines in 29 files
37
+ - BRANCH COVERAGE: 77.54% -- 183/236 branches in 29 files
38
+ - 42.33% documented
39
+
40
+ ### Added
41
+
42
+ - `appraisal generate`, `install`, `update`, `generate-install`, and
43
+ `generate-update` can now process appraisals in parallel with
44
+ `--appraisal-jobs` / `-n`, or `APPRAISAL_JOBS`. Appraisal-level
45
+ parallelism defaults to 2 workers; use `-n 1` to run serially. Bundler older
46
+ than 2.1 falls back to serial processing.
47
+
48
+ ### Changed
49
+
50
+ - Full dependency updates now run `bundle update --all` to avoid Bundler's
51
+ deprecated implicit full-update form.
52
+ - Acceptance specs now opt into expensive dummy-gem and bundle fixture setup
53
+ through metadata, and reuse a per-worker cached default fixture stage.
54
+ - Generated CI workflows now cache `.rspec_status` and pass it to
55
+ `kettle-test` so `turbo_tests2` can balance workers with historical example
56
+ timings.
57
+ - `generate-install` and `generate-update` now reuse the parsed Appraisals set
58
+ for their generate and install/update phases.
59
+
60
+ - kettle-jem-template-20260716-001 - Shim gemspec manifests now include
61
+ `LICENSE.md` instead of nonexistent `LICENSE.txt`.
62
+ - kettle-jem-template-20260716-002 - Generated gemspec manifests now ship fewer
63
+ repository-only files by default to reduce downstream distro packaging churn.
64
+
65
+ ### Fixed
66
+
67
+ - Appraisal command execution now checks whether the `bundle` executable can
68
+ boot before falling back to RubyGems spec probing and Bundler installation,
69
+ preserving TruffleRuby's engine-shipped Bundler in isolated subprocesses.
70
+ - Acceptance fixture Bundler selection now detects TruffleRuby's shipped
71
+ Bundler without reusing a newer Bundler activated by the appraised suite.
72
+ - Acceptance fixture binstubs now pin the selected Bundler version before
73
+ loading generated `bin/bundle` handoff code.
74
+ - Bundler-backed appraisal installs now set `BUNDLE_JOBS` explicitly so the
75
+ configured Appraisal job count also controls Bundler's installer worker
76
+ count in subprocesses.
77
+ - JRuby and TruffleRuby workflows now correctly skip ordinary pull request
78
+ branches unless the branch targets `jruby/`, `truffleruby/`, or `engines/`.
79
+
33
80
  ## [3.1.4] - 2026-07-01
34
81
 
35
82
  - TAG: [v3.1.4][3.1.4t]
@@ -429,7 +476,9 @@ Please file a bug if you notice a violation of semantic versioning.
429
476
  - code coverage tracked with Coveralls, QLTY.sh, and the kettle-soup-cover gem
430
477
  - other minor fixes and improvements
431
478
 
432
- [Unreleased]: https://github.com/appraisal-rb/appraisal2/compare/v3.1.4...HEAD
479
+ [Unreleased]: https://github.com/appraisal-rb/appraisal2/compare/v3.2.0...HEAD
480
+ [3.2.0]: https://github.com/appraisal-rb/appraisal2/compare/v3.1.4...v3.2.0
481
+ [3.2.0t]: https://github.com/appraisal-rb/appraisal2/releases/tag/v3.2.0
433
482
  [3.1.4]: https://github.com/appraisal-rb/appraisal2/compare/v3.1.3...v3.1.4
434
483
  [3.1.4t]: https://github.com/appraisal-rb/appraisal2/releases/tag/v3.1.4
435
484
  [3.1.3]: https://github.com/appraisal-rb/appraisal2/compare/v3.1.2...v3.1.3
data/README.md CHANGED
@@ -259,6 +259,21 @@ Use the command that matches the lifecycle you want:
259
259
  | `generate-install` | Yes | Yes, via install | First setup, or after intentional Appraisals changes |
260
260
  | `generate-update` | Yes | Yes, via update | Regenerate gemfiles, then update dependency locks |
261
261
 
262
+ Appraisal2 can process appraisals in parallel with `--appraisal-jobs` / `-n`,
263
+ or with `APPRAISAL_JOBS` when no CLI value is provided. `generate`,
264
+ `generate-install`, and `generate-update` use this for generation. `install`,
265
+ `update`, `generate-install`, and `generate-update` use it to fan out dependency
266
+ resolution across appraisals. External commands run across all appraisals, such
267
+ as `bundle exec appraisal rake test`, use `APPRAISAL_JOBS`. Appraisal-level
268
+ parallelism defaults to 2 workers; use `-n 1` to run serially. When Appraisal2
269
+ is running under Bundler older than 2.1, Appraisal2 falls back to serial
270
+ processing because those Bundler versions do not provide the modern environment
271
+ helpers used to isolate parallel Bundler subprocesses.
272
+
273
+ Install commands keep `--jobs` / `-j` for the selected gem manager. With
274
+ Bundler, `-j` controls `bundle install --jobs`, while `-n` controls how many
275
+ appraisal gemfiles Appraisal2 processes at the same time.
276
+
262
277
  The deprecated rake task `rake appraisal:install` now delegates to `appraisal generate-install`, preserving its historical generate-and-install behavior while the CLI commands remain explicit.
263
278
 
264
279
  ### Command Options
@@ -270,12 +285,27 @@ Built-in dependency commands support the following options:
270
285
  | Option | Description |
271
286
  |--------|-------------|
272
287
  | `--gem-manager`, `-g` | Gem manager to use: `bundler` (default) or `ore`; applies to `install`, `update`, `generate-install`, and `generate-update` |
273
- | `--jobs`, `-j` | Install gems in parallel using the given number of workers; applies to `install` and `generate-install` |
288
+ | `--appraisal-jobs`, `-n` | Process appraisals in parallel for `generate`, `install`, `update`, `generate-install`, and `generate-update` (default: 2; use `-n 1` for serial execution; Bundler < 2.1 falls back to serial processing) |
289
+ | `--jobs`, `-j` | Pass a parallel job count to the selected gem manager; applies to `install` and `generate-install` |
274
290
  | `--retry` | Retry network and git requests that have failed; applies to `install` and `generate-install` (default: 1) |
275
291
  | `--without` | A space-separated list of groups to skip during installation; applies to `install` and `generate-install` |
276
292
  | `--full-index` | Run bundle install with the full-index argument; applies to `install` and `generate-install` |
277
293
  | `--path` | Install gems in the specified directory; applies to `install` and `generate-install` |
278
294
 
295
+ ```bash
296
+ # Process two appraisals at a time, using the default Appraisal worker count
297
+ bundle exec appraisal generate-install
298
+
299
+ # Process four appraisals at a time
300
+ bundle exec appraisal generate-install -n 4
301
+
302
+ # Process two appraisals at a time, and give each Bundler install four jobs
303
+ bundle exec appraisal generate-install -n 2 -j 4
304
+
305
+ # Run Appraisal serially when parallel appraisal processing is not desired
306
+ bundle exec appraisal generate-install -n 1
307
+ ```
308
+
279
309
  ### Using Commands with Named Appraisals
280
310
 
281
311
  #### Using Appraisal's built-in commands with named appraisals
@@ -752,7 +782,7 @@ Thanks for RTFM. ☺️
752
782
  [📌gitmoji]: https://gitmoji.dev
753
783
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
754
784
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
755
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.916-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
785
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.000-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
756
786
  [🔐security]: https://github.com/appraisal-rb/appraisal2/blob/main/SECURITY.md
757
787
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
758
788
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -94,7 +94,7 @@ module Appraisal
94
94
  end
95
95
 
96
96
  def gemfile_path
97
- gemfile_root.mkdir unless gemfile_root.exist?
97
+ FileUtils.mkdir_p(gemfile_root)
98
98
 
99
99
  gemfile_root.join(gemfile_name).to_s
100
100
  end
data/lib/appraisal/cli.rb CHANGED
@@ -2,17 +2,27 @@
2
2
 
3
3
  require "thor"
4
4
  require "fileutils"
5
+ require "thread"
5
6
 
6
7
  module Appraisal
7
8
  class CLI < Thor
9
+ MINIMUM_PARALLEL_BUNDLER_VERSION = Gem::Version.new("2.1.0")
10
+
8
11
  default_task :install
9
12
  map ["-v", "--version"] => "version"
13
+ map "generate-install" => "generate_install"
14
+ map "generate-update" => "generate_update"
10
15
 
11
16
  class_option "gem-manager",
12
17
  :aliases => "-g",
13
18
  :type => :string,
14
19
  :default => "bundler",
15
20
  :desc => "Gem manager to use for install/update (bundler or ore)"
21
+ class_option "appraisal-jobs",
22
+ :aliases => "-n",
23
+ :type => :numeric,
24
+ :banner => "SIZE",
25
+ :desc => "Process appraisals in parallel using the given number of workers."
16
26
 
17
27
  class << self
18
28
  # Override help command to print out usage
@@ -59,7 +69,7 @@ module Appraisal
59
69
  :type => :numeric,
60
70
  :default => 1,
61
71
  :banner => "SIZE",
62
- :desc => "Install gems in parallel using the given number of workers."
72
+ :desc => "Pass the given parallel job count to the selected gem manager."
63
73
  method_option "retry",
64
74
  :type => :numeric,
65
75
  :default => 1,
@@ -78,11 +88,7 @@ module Appraisal
78
88
  "Bundler will remember this option."
79
89
 
80
90
  def install
81
- install_options = options.to_h
82
- AppraisalFile.each do |appraisal|
83
- appraisal.install(install_options)
84
- appraisal.relativize
85
- end
91
+ install_appraisals(appraisals, install_options, appraisal_jobs(options))
86
92
  end
87
93
 
88
94
  desc "generate-install", "Generate gemfiles, then resolve and install dependencies for each appraisal"
@@ -91,7 +97,7 @@ module Appraisal
91
97
  :type => :numeric,
92
98
  :default => 1,
93
99
  :banner => "SIZE",
94
- :desc => "Install gems in parallel using the given number of workers."
100
+ :desc => "Pass the given parallel job count to the selected gem manager."
95
101
  method_option "retry",
96
102
  :type => :numeric,
97
103
  :default => 1,
@@ -109,15 +115,14 @@ module Appraisal
109
115
  :desc => "Install gems in the specified directory. " \
110
116
  "Bundler will remember this option."
111
117
  def generate_install
112
- invoke(:generate, [], {})
113
- invoke(:install, [], options.to_h)
118
+ appraisal_list = appraisals
119
+ generate_appraisals(appraisal_list, appraisal_jobs(options))
120
+ install_appraisals(appraisal_list, install_options, appraisal_jobs(options))
114
121
  end
115
122
 
116
123
  desc "generate", "Generate a gemfile for each appraisal"
117
124
  def generate
118
- AppraisalFile.each do |appraisal|
119
- appraisal.write_gemfile
120
- end
125
+ generate_appraisals(appraisals, appraisal_jobs(options))
121
126
  end
122
127
 
123
128
  desc "clean", "Remove all generated gemfiles and lockfiles from gemfiles folder"
@@ -127,17 +132,14 @@ module Appraisal
127
132
 
128
133
  desc "update [LIST_OF_GEMS]", "Update dependencies for each generated appraisal gemfile"
129
134
  def update(*gems)
130
- gem_manager = options["gem-manager"] || options[:gem_manager]
131
- update_options = gem_manager ? {:gem_manager => gem_manager} : {}
132
- AppraisalFile.each do |appraisal|
133
- appraisal.update(gems, update_options)
134
- end
135
+ update_appraisals(appraisals, gems, update_options, appraisal_jobs(options))
135
136
  end
136
137
 
137
138
  desc "generate-update [LIST_OF_GEMS]", "Generate gemfiles, then update dependencies for each appraisal"
138
139
  def generate_update(*gems)
139
- invoke(:generate, [], {})
140
- update(*gems)
140
+ appraisal_list = appraisals
141
+ generate_appraisals(appraisal_list, appraisal_jobs(options))
142
+ update_appraisals(appraisal_list, gems, update_options, appraisal_jobs(options))
141
143
  end
142
144
 
143
145
  desc "list", "List the names of the defined appraisals"
@@ -152,7 +154,99 @@ module Appraisal
152
154
 
153
155
  private
154
156
 
157
+ def appraisals
158
+ AppraisalFile.new.appraisals
159
+ end
160
+
161
+ def generate_appraisals(appraisal_list, jobs)
162
+ each_appraisal(appraisal_list, jobs) do |appraisal|
163
+ appraisal.write_gemfile
164
+ end
165
+ end
166
+
167
+ def install_appraisals(appraisal_list, install_options, jobs)
168
+ each_appraisal(appraisal_list, jobs) do |appraisal|
169
+ appraisal.install(install_options.dup)
170
+ appraisal.relativize
171
+ end
172
+ end
173
+
174
+ def update_appraisals(appraisal_list, gems, update_options, jobs)
175
+ each_appraisal(appraisal_list, jobs) do |appraisal|
176
+ appraisal.update(gems, update_options.dup)
177
+ end
178
+ end
179
+
180
+ def each_appraisal(appraisal_list, jobs)
181
+ jobs = worker_count(jobs, appraisal_list.length)
182
+ return appraisal_list.each { |appraisal| yield(appraisal) } if jobs <= 1
183
+
184
+ queue = Queue.new
185
+ errors = []
186
+ mutex = Mutex.new
187
+
188
+ appraisal_list.each { |appraisal| queue << appraisal }
189
+ jobs.times { queue << nil }
190
+
191
+ # rubocop:disable ThreadSafety/NewThread
192
+ threads = Array.new(jobs) do
193
+ Thread.new do
194
+ while (appraisal = queue.pop)
195
+ begin
196
+ yield(appraisal)
197
+ rescue Exception => error # rubocop:disable Lint/RescueException
198
+ mutex.synchronize { errors << error }
199
+ end
200
+ end
201
+ end
202
+ end
203
+ # rubocop:enable ThreadSafety/NewThread
204
+ threads.each(&:join)
205
+
206
+ raise errors.first unless errors.empty?
207
+ end
208
+
209
+ def worker_count(jobs, item_count)
210
+ jobs = jobs.to_i
211
+ jobs = 1 if jobs < 1
212
+ jobs = 1 unless parallel_appraisals_supported?
213
+ return jobs if item_count.zero?
214
+
215
+ [jobs, item_count].min
216
+ end
217
+
218
+ def parallel_appraisals_supported?
219
+ # Parallel install/update workers depend on Appraisal::Command being able
220
+ # to build independent Bundler subprocess environments. Bundler 2.1 is the
221
+ # floor for the modern original_env/unbundled_env API split that replaced
222
+ # clean_env, so older Bundler versions keep the historical serial path.
223
+ bundler_version = Gem::Specification.find_all_by_name("bundler").map(&:version).max
224
+ return false unless bundler_version
225
+
226
+ bundler_version >= MINIMUM_PARALLEL_BUNDLER_VERSION
227
+ end
228
+
229
+ def appraisal_jobs(command_options)
230
+ command_options["appraisal-jobs"] || command_options[:appraisal_jobs] || ENV["APPRAISAL_JOBS"] || 2
231
+ end
232
+
233
+ def install_options
234
+ options.to_h.reject { |key, _value| key.to_s == "appraisal-jobs" }
235
+ end
236
+
237
+ def update_options
238
+ gem_manager = options["gem-manager"] || options[:gem_manager]
239
+ gem_manager ? {:gem_manager => gem_manager} : {}
240
+ end
241
+
155
242
  def method_missing(name, *args)
243
+ case name.to_s
244
+ when "generate-install"
245
+ return generate_install
246
+ when "generate-update"
247
+ return generate_update(*args)
248
+ end
249
+
156
250
  matching_appraisal = AppraisalFile.new.appraisals.detect do |appraisal|
157
251
  appraisal.name == name.to_s
158
252
  end
@@ -213,7 +307,7 @@ module Appraisal
213
307
  Command.new(actual_args, :gemfile => matching_appraisal.gemfile_path).run
214
308
  end
215
309
  else
216
- AppraisalFile.each do |appraisal|
310
+ each_appraisal(AppraisalFile.new.appraisals, appraisal_jobs(options)) do |appraisal|
217
311
  Command.new(ARGV, :gemfile => appraisal.gemfile_path).run
218
312
  end
219
313
  end
@@ -240,6 +334,17 @@ module Appraisal
240
334
  options[:jobs] = Regexp.last_match(1).to_i
241
335
  when /^-j(\d+)$/
242
336
  options[:jobs] = Regexp.last_match(1).to_i
337
+ when /^-j$/
338
+ options[:jobs] = args[index + 1].to_i
339
+ skip_next = true
340
+ when /^--appraisal-jobs=\d+$/
341
+ nil
342
+ when /^--appraisal-jobs$/
343
+ skip_next = true
344
+ when /^-n\d+$/
345
+ nil
346
+ when /^-n$/
347
+ skip_next = true
243
348
  when /^--retry=(\d+)$/
244
349
  options[:retry] = Regexp.last_match(1).to_i
245
350
  when /^--without=(.+)$/
@@ -271,6 +376,10 @@ module Appraisal
271
376
  # Next arg should be the value
272
377
  options[:gem_manager] = args[index + 1]
273
378
  skip_next = true
379
+ when /^--appraisal-jobs$/
380
+ skip_next = true
381
+ when /^-n$/
382
+ skip_next = true
274
383
  when /^-/
275
384
  # Other options are not gems, but we don't handle them all here
276
385
  # For now, just skip them to be safe if they start with -
@@ -8,7 +8,9 @@ module Appraisal
8
8
  attr_reader :command, :env, :gemfile
9
9
 
10
10
  # BUNDLE_* environment variables that must be preserved for proper bundler operation
11
- # and test isolation. These are preserved when using with_bundler_env to ensure:
11
+ # and test isolation. These are preserved after starting from Bundler's
12
+ # unbundled environment so Appraisal can run a fresh Bundler subprocess
13
+ # without losing the target appraisal and isolation settings:
12
14
  # - Bundler version switching works (BUNDLE_GEMFILE)
13
15
  # - Test isolation is maintained (BUNDLE_APP_CONFIG, etc.)
14
16
  # - User settings are respected (BUNDLE_PATH, BUNDLE_USER_CACHE, etc.)
@@ -30,6 +32,7 @@ module Appraisal
30
32
 
31
33
  PRESERVED_RUNTIME_VARS = [
32
34
  "PATH",
35
+ "GEM_HOME",
33
36
  "GEM_PATH"
34
37
  ].freeze
35
38
 
@@ -44,101 +47,112 @@ module Appraisal
44
47
  run_env = test_environment.merge(env)
45
48
 
46
49
  if @skip_bundle_exec
47
- execute(run_env)
50
+ execute(ENV.to_h, run_env)
48
51
  else
49
- # For bundler version switching to work reliably, we need to preserve
50
- # the appraisal Gemfile while avoiding the active parent Bundler process.
51
- # However, we still need to isolate from the parent's bundler state
52
- # to avoid conflicts.
53
- #
54
- # Solution: Use a selective environment approach instead of with_original_env,
55
- # which strips all BUNDLE_* variables and breaks version switching.
56
- with_bundler_env do
57
- apply_run_env(run_env)
58
- ensure_bundler_is_available
59
- ensure_locked_bundler_is_available
60
- execute(run_env)
61
- end
52
+ clean_env = bundler_env(run_env)
53
+ ensure_bundler_is_available(clean_env)
54
+ ensure_locked_bundler_is_available(clean_env)
55
+ execute(clean_env, run_env)
62
56
  end
63
57
  end
64
58
 
65
59
  private
66
60
 
67
- # Provide a clean environment while preserving Bundler's version switching
68
- # inputs and test isolation settings.
61
+ # Build an Appraisal subprocess environment from Bundler's explicit env APIs.
69
62
  #
70
- # The current Ruby process has bundler activated, which adds bundler/setup to RUBYOPT.
71
- # When we run a subprocess, we need to remove that activation so the subprocess bundler
72
- # can start fresh. However, we keep all test isolation variables intact.
73
- def with_bundler_env
74
- backup_env = ENV.to_h
75
-
76
- begin
77
- clean_env = if Bundler.respond_to?(:original_env)
78
- Bundler.original_env.to_h
79
- else
80
- backup_env.to_h
81
- end
82
-
83
- # Avoid leaking a global BUNDLE_LOCKFILE into subprocesses.
84
- clean_env.delete("BUNDLE_LOCKFILE")
85
-
86
- preserved_vars = PRESERVED_BUNDLE_VARS + PRESERVED_RUNTIME_VARS
87
-
88
- preserved_vars.each do |var|
89
- clean_env[var] = backup_env[var] if backup_env[var]
90
- end
91
-
92
- # Remove bundler/setup from RUBYOPT so subprocess doesn't auto-load bundler
93
- if backup_env["RUBYOPT"]
94
- rubyopt = backup_env["RUBYOPT"].split(" ")
95
- rubyopt.reject! { |opt| opt == "-rbundler/setup" || opt.include?("bundler/setup") }
96
- clean_env["RUBYOPT"] = rubyopt.join(" ") unless rubyopt.empty?
97
- end
98
-
99
- # Remove Bundler activation markers from the subprocess environment.
100
- # BUNDLE_BIN_PATH pins the already-active Bundler executable, so keeping
101
- # it would bypass the BUNDLED WITH version selected below.
102
- clean_env.delete("BUNDLE_BIN_PATH")
103
- clean_env.delete("BUNDLER_SETUP")
104
- clean_env.delete("BUNDLER_VERSION")
105
-
106
- ENV.replace(clean_env)
107
-
108
- yield
109
- ensure
110
- ENV.replace(backup_env)
63
+ # Bundler.clean_env / Bundler.with_clean_env are removed. Bundler now exposes
64
+ # three related but distinct choices:
65
+ #
66
+ # - Bundler.original_env: the environment before the current Bundler activated.
67
+ # This is too broad for Appraisal because unrelated original BUNDLE_* values
68
+ # can leak into the appraisal subprocess.
69
+ # - Bundler.unbundled_env: original_env with Bundler activation removed. This
70
+ # is the right base because it removes parent Bundler state, RUBYOPT
71
+ # bundler/setup activation, and arbitrary BUNDLE_* values.
72
+ # - Bundler.with_unbundled_env: the block form of unbundled_env. Appraisal
73
+ # needs a Hash to pass to Kernel.system and must re-add selected variables,
74
+ # so the block helper is the wrong shape even though its semantics are close.
75
+ #
76
+ # Starting from unbundled_env would normally remove BUNDLE_GEMFILE,
77
+ # BUNDLE_APP_CONFIG, and related isolation knobs. Appraisal intentionally
78
+ # reintroduces only the variables it owns or must preserve, then sets
79
+ # BUNDLE_GEMFILE and the selected Bundler version for the target subprocess.
80
+ def bundler_env(run_env)
81
+ current_env = ENV.to_h
82
+ clean_env = if defined?(Bundler) && Bundler.respond_to?(:unbundled_env)
83
+ Bundler.unbundled_env.to_h
84
+ elsif defined?(Bundler) && Bundler.respond_to?(:original_env)
85
+ Bundler.original_env.to_h
86
+ else
87
+ current_env.to_h
111
88
  end
112
- end
113
89
 
114
- def execute(run_env)
115
- announce
90
+ # Avoid leaking a global BUNDLE_LOCKFILE into subprocesses.
91
+ clean_env["BUNDLE_LOCKFILE"] = nil
92
+
93
+ preserved_vars = PRESERVED_BUNDLE_VARS + PRESERVED_RUNTIME_VARS
116
94
 
117
- ENV["BUNDLE_GEMFILE"] = gemfile
118
- if (bundler_version = locked_bundler_version)
119
- ENV["BUNDLER_VERSION"] = bundler_version
95
+ preserved_vars.each do |var|
96
+ clean_env[var] = current_env[var] if current_env[var]
120
97
  end
121
- ENV["APPRAISAL_INITIALIZED"] = "1"
98
+
99
+ clean_env["RUBYOPT"] = current_env["RUBYOPT"] if current_env["RUBYOPT"]
100
+
101
+ # Remove bundler/setup from RUBYOPT so subprocess doesn't auto-load bundler.
102
+ # Bundler.original_env can also contain RUBYOPT, so strip from the final
103
+ # command environment rather than only from the active process env.
104
+ if clean_env["RUBYOPT"]
105
+ rubyopt = clean_env["RUBYOPT"].split(" ")
106
+ rubyopt.reject! { |opt| opt == "-rbundler/setup" || opt.include?("bundler/setup") }
107
+ clean_env["RUBYOPT"] = rubyopt.join(" ")
108
+ clean_env["RUBYOPT"] = nil if clean_env["RUBYOPT"].empty?
109
+ end
110
+
111
+ # Remove Bundler activation markers from the subprocess environment.
112
+ # BUNDLE_BIN_PATH pins the already-active Bundler executable, so keeping
113
+ # it would bypass the BUNDLED WITH version selected below.
114
+ clean_env["BUNDLE_BIN_PATH"] = nil
115
+ clean_env["BUNDLE_VERSION"] = nil
116
+ clean_env["BUNDLER_SETUP"] = nil
117
+ clean_env["BUNDLER_VERSION"] = nil
118
+
122
119
  run_env.each_pair do |key, value|
123
- ENV[key] = value
120
+ clean_env[key] = value
124
121
  end
125
122
 
126
- exit(1) unless Kernel.system(command_as_string)
123
+ clean_env
127
124
  end
128
125
 
129
- def apply_run_env(run_env)
130
- run_env.each_pair do |key, value|
131
- ENV[key] = value
126
+ def execute(base_env, run_env)
127
+ announce
128
+
129
+ process_env = base_env.merge(run_env)
130
+ process_env["BUNDLE_GEMFILE"] = gemfile if gemfile
131
+ if (bundler_version = subprocess_bundler_version(process_env))
132
+ process_env["BUNDLE_VERSION"] = bundler_version
133
+ process_env["BUNDLER_VERSION"] = bundler_version
132
134
  end
135
+ process_env["APPRAISAL_INITIALIZED"] = "1"
136
+
137
+ exit(1) unless Kernel.system(process_env, command_as_string)
133
138
  end
134
139
 
135
- def ensure_bundler_is_available
136
- # Check if any version of bundler is available
137
- return if system(%(gem list --silent -i bundler))
140
+ def ensure_bundler_is_available(process_env)
141
+ rubygems_env = rubygems_command_env(process_env)
142
+
143
+ # First ask the actual Bundler executable whether it can boot in the
144
+ # environment Appraisal is about to use. In CI, especially on alternate
145
+ # Ruby engines, Bundler may be available through the active app bundle
146
+ # while a manually scrubbed RubyGems spec lookup cannot see it through the
147
+ # current GEM_HOME/GEM_PATH.
148
+ return if system(process_env, "bundle -v > /dev/null 2>&1")
149
+
150
+ # Check if any version of bundler is available through RubyGems.
151
+ return if system(rubygems_env, bundler_available_command)
138
152
 
139
153
  puts ">> Bundler not found, attempting to install..."
140
154
  # If that fails, try to install the latest stable version
141
- return if system("gem install bundler")
155
+ return if system(rubygems_env, "ruby --disable=gems -S gem install bundler")
142
156
 
143
157
  puts
144
158
  puts <<-ERROR.rstrip
@@ -150,14 +164,16 @@ manually.
150
164
  exit(1)
151
165
  end
152
166
 
153
- def ensure_locked_bundler_is_available
167
+ def ensure_locked_bundler_is_available(process_env)
154
168
  locked_version = locked_bundler_version
155
169
  return unless locked_version
156
170
 
157
- return if system(%(gem list --silent -i bundler -v "#{locked_version}"))
171
+ rubygems_env = rubygems_command_env(process_env)
172
+
173
+ return if system(rubygems_env, bundler_available_command(locked_version))
158
174
 
159
175
  puts ">> Bundler #{locked_version} not found, attempting to install..."
160
- return if system("gem install bundler -v #{Shellwords.escape(locked_version)} --no-document")
176
+ return if system(rubygems_env, "ruby --disable=gems -S gem install bundler -v #{Shellwords.escape(locked_version)} --no-document")
161
177
 
162
178
  puts
163
179
  puts <<-ERROR.rstrip
@@ -180,6 +196,37 @@ manually.
180
196
  match && match[1]
181
197
  end
182
198
 
199
+ def subprocess_bundler_version(process_env)
200
+ # Production subprocesses pin Bundler only when the appraisal lockfile
201
+ # says to. Acceptance specs may provide APPRAISAL_TEST_BUNDLER_VERSION so
202
+ # the nested subprocesses use the same isolated Bundler selected by the
203
+ # harness, without changing production behavior.
204
+ locked_bundler_version || process_env["APPRAISAL_TEST_BUNDLER_VERSION"]
205
+ end
206
+
207
+ def rubygems_command_env(process_env)
208
+ process_env.dup.tap do |env|
209
+ env["BUNDLE_APP_CONFIG"] = nil
210
+ env["BUNDLE_BIN_PATH"] = nil
211
+ env["BUNDLE_GEMFILE"] = nil
212
+ env["BUNDLE_LOCKFILE"] = nil
213
+ env["BUNDLE_VERSION"] = nil
214
+ env["BUNDLER_SETUP"] = nil
215
+ env["BUNDLER_VERSION"] = nil
216
+ env["RUBYOPT"] = nil
217
+ end
218
+ end
219
+
220
+ def bundler_available_command(version = nil)
221
+ requirement = version ? ", Gem::Requirement.new(#{version.inspect})" : ""
222
+ code = <<-RUBY.chomp
223
+ require "rubygems"
224
+ specs = Gem::Specification.find_all_by_name("bundler"#{requirement})
225
+ exit(specs.empty? ? 1 : 0)
226
+ RUBY
227
+ "ruby --disable=gems -e #{Shellwords.escape(code)}"
228
+ end
229
+
183
230
  def announce
184
231
  if gemfile
185
232
  puts ">> BUNDLE_GEMFILE=#{gemfile} #{command_as_string}"
@@ -219,7 +266,7 @@ manually.
219
266
 
220
267
  {
221
268
  "GEM_HOME" => ENV["GEM_HOME"],
222
- "GEM_PATH" => ""
269
+ "GEM_PATH" => ENV["APPRAISAL_TEST_SYSTEM_GEM_PATH"].to_s
223
270
  }
224
271
  end
225
272
  end