local_ci_plus 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 29f82722f9217011683b155e6ac979418a9b78392cd0e045a34af00e0c263f98
4
+ data.tar.gz: d987f1334cab11e82de0727352411366c5736ca75a085bec4641dc3b15402af3
5
+ SHA512:
6
+ metadata.gz: 3f01f9168e6861420e52191d50b96c38391196426b2082e046fbb78ffe50bbb30e141a1bb280c4158c59c557758a9bc1e5541134054ae2371676f3bef030648b
7
+ data.tar.gz: 9399e4a82bc56f565015f9bdfafd031b008ec1c121c35a1b52a22ca5781993050388f5a328354b742ec7bf42367187e1dd9ec50a61e469764295384900f55746
data/AGENTS.md ADDED
@@ -0,0 +1,13 @@
1
+ local_ci_plus is a Ruby gem that improves Rails local CI for both developers and agents by adding:
2
+
3
+ - Parallel step execution.
4
+ - Plain output for non-interactive (agent) environments.
5
+ - Fail-fast behavior.
6
+ - Resume/continue after a failed step.
7
+
8
+ ## Guidance for LLMs
9
+
10
+ - Prefer clear, short output suitable for non-TTY usage.
11
+ - When documenting or examples, highlight the defaults: `bin/ci` keeps working once the gem is required.
12
+ - Emphasize that `--parallel` is incompatible with `--fail-fast` and `--continue`.
13
+ - When updating docs, keep installation, generators, manual `bin/ci` patching, test, lint, and release steps in the README.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # local_ci_plus
2
+
3
+ local_ci_plus improves Rails local CI for both developers and agents with parallel execution, fail-fast,
4
+ resume, and plain output.
5
+
6
+ ## Installation
7
+
8
+ Add to your Gemfile:
9
+
10
+ ```ruby
11
+ gem "local_ci_plus"
12
+ ```
13
+
14
+ Then run:
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ `local_ci_plus` overrides `ActiveSupport::ContinuousIntegration` when it is loaded, so the default Rails `bin/ci`
23
+ continues to work without changes.
24
+
25
+ ```bash
26
+ bin/ci
27
+ ```
28
+
29
+ If your app does not already require the gem in `bin/ci`, run the installer generator:
30
+
31
+ ```bash
32
+ bin/rails generate local_ci_plus:install
33
+ ```
34
+
35
+ If you already have a `bin/ci` and want to patch it in place, run:
36
+
37
+ ```bash
38
+ bin/rails generate local_ci_plus:update
39
+ ```
40
+
41
+ If you want to edit `bin/ci` manually, add `require "local_ci_plus"` right after the boot file:
42
+
43
+ ```ruby
44
+ #!/usr/bin/env ruby
45
+ require_relative "../config/boot"
46
+ require "local_ci_plus"
47
+ require_relative "../config/ci"
48
+ ```
49
+
50
+ ### Options
51
+
52
+ ```
53
+ -f, --fail-fast Stop immediately when a step fails
54
+ -c, --continue Resume from the last failed step
55
+ -fc, -cf Combine fail-fast and continue
56
+ -p, --parallel Run all steps concurrently
57
+ --plain Disable ANSI cursor updates/colors (also used for non-TTY)
58
+ -h, --help Show this help
59
+
60
+ Compatibility:
61
+ --parallel cannot be combined with --fail-fast or --continue
62
+ ```
63
+
64
+ ## Development
65
+
66
+ Run tests:
67
+
68
+ ```bash
69
+ bundle exec ruby -Itest test/continuous_integration_test.rb
70
+ ```
71
+
72
+ Run linting:
73
+
74
+ ```bash
75
+ bundle exec standardrb
76
+ ```
77
+
78
+ ## Publishing
79
+
80
+ ### One-time setup (RubyGems)
81
+
82
+ 1. Create a RubyGems account if you do not have one.
83
+ 2. Add your API key:
84
+
85
+ ```bash
86
+ mkdir -p ~/.gem
87
+ printf "---\n:rubygems_api_key: YOUR_KEY\n" > ~/.gem/credentials
88
+ chmod 0600 ~/.gem/credentials
89
+ ```
90
+
91
+ ### Initial release
92
+
93
+ 1. Update `local_ci_plus.gemspec` with the real `authors`, `email`, `homepage`, and `license`.
94
+ 2. Build the gem:
95
+
96
+ ```bash
97
+ gem build local_ci_plus.gemspec
98
+ ```
99
+
100
+ 3. Push to RubyGems:
101
+
102
+ ```bash
103
+ gem push local_ci_plus-0.1.0.gem
104
+ ```
105
+
106
+ ### Updates
107
+
108
+ 1. Bump the version in `lib/local_ci_plus/version.rb`.
109
+ 2. Build and push:
110
+
111
+ ```bash
112
+ gem build local_ci_plus.gemspec
113
+ gem push local_ci_plus-X.Y.Z.gem
114
+ ```
@@ -0,0 +1,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Extended version of Rails' ActiveSupport::ContinuousIntegration with:
4
+ # - bin/ci -f (--fail-fast): Stop immediately when a step fails
5
+ # - bin/ci -c (--continue): Resume from the last failed step
6
+ # - bin/ci -fc: Both options combined
7
+ # - bin/ci -p (--parallel): Run all steps concurrently
8
+
9
+ require "tempfile"
10
+
11
+ module LocalCiPlus
12
+ class ContinuousIntegration
13
+ COLORS = {
14
+ banner: "\033[1;32m", # Green
15
+ title: "\033[1;35m", # Purple
16
+ subtitle: "\033[1;90m", # Medium Gray
17
+ error: "\033[1;31m", # Red
18
+ success: "\033[1;32m", # Green
19
+ skip: "\033[1;33m", # Yellow
20
+ pending: "\033[1;34m" # Blue
21
+ }
22
+
23
+ STATE_FILE = ".ci_state"
24
+
25
+ attr_reader :results
26
+
27
+ def self.run(title = "Continuous Integration", subtitle = "Running tests, style checks, and security audits", &block)
28
+ new.tap do |ci|
29
+ if ci.help?
30
+ ci.print_help
31
+ exit 0
32
+ end
33
+ ENV["CI"] = "true"
34
+ ci.validate_mode_compatibility!
35
+ ci.heading title, subtitle, padding: false
36
+ ci.show_mode_info
37
+ ci.report(title, &block)
38
+ abort unless ci.success?
39
+ end
40
+ end
41
+
42
+ def initialize
43
+ @results = []
44
+ @step_names = []
45
+ @parallel_steps = []
46
+ @skip_until = continue_mode? ? load_failed_step : nil
47
+ @skipping = !!@skip_until
48
+ end
49
+
50
+ def validate_mode_compatibility!
51
+ if parallel? && fail_fast?
52
+ abort colorize("❌ Cannot combine --parallel with --fail-fast", :error)
53
+ end
54
+ if parallel? && continue_mode?
55
+ abort colorize("❌ Cannot combine --parallel with --continue", :error)
56
+ end
57
+ end
58
+
59
+ def help?
60
+ ARGV.include?("-h") || ARGV.include?("--help")
61
+ end
62
+
63
+ def print_help
64
+ $stdout.puts <<~HELP
65
+ Usage: bin/ci [options]
66
+
67
+ Options:
68
+ -f, --fail-fast Stop immediately when a step fails
69
+ -c, --continue Resume from the last failed step
70
+ -fc, -cf Combine fail-fast and continue
71
+ -p, --parallel Run all steps concurrently
72
+ --plain Disable ANSI cursor updates/colors (also used for non-TTY)
73
+ -h, --help Show this help
74
+
75
+ Compatibility:
76
+ --parallel cannot be combined with --fail-fast or --continue
77
+ HELP
78
+ end
79
+
80
+ def step(title, *command)
81
+ @step_names << title
82
+
83
+ if @skipping
84
+ if title == @skip_until
85
+ @skipping = false
86
+ clear_state
87
+ else
88
+ heading title, "skipped (resuming from: #{@skip_until})", type: :skip
89
+ results << [true, title]
90
+ return
91
+ end
92
+ end
93
+
94
+ if parallel?
95
+ @parallel_steps << {title: title, command: command}
96
+ return
97
+ end
98
+
99
+ heading title, command.join(" "), type: :title
100
+ report(title) do
101
+ success = system(*command)
102
+ results << [success, title]
103
+
104
+ if !success && fail_fast?
105
+ save_failed_step(title)
106
+ abort colorize("\n❌ #{title} failed (fail-fast enabled)", :error)
107
+ end
108
+ end
109
+ end
110
+
111
+ def success?
112
+ results.map(&:first).all?
113
+ end
114
+
115
+ def failure(title, subtitle = nil)
116
+ heading title, subtitle, type: :error
117
+ end
118
+
119
+ def heading(heading, subtitle = nil, type: :banner, padding: true)
120
+ echo "#{"\n\n" if padding}#{heading}", type: type
121
+ echo "#{subtitle}#{"\n" if padding}", type: :subtitle if subtitle
122
+ end
123
+
124
+ def echo(text, type:)
125
+ puts colorize(text, type)
126
+ end
127
+
128
+ def show_mode_info
129
+ modes = []
130
+ modes << "fail-fast" if fail_fast?
131
+ modes << "continue from '#{@skip_until}'" if @skip_until
132
+ modes << "parallel" if parallel?
133
+ echo "Mode: #{modes.join(", ")}\n", type: :subtitle if modes.any?
134
+ end
135
+
136
+ def report(title, &block)
137
+ prev_int = Signal.trap("INT") {
138
+ interrupt_parallel! if parallel?
139
+ abort colorize("\n❌ #{title} interrupted", :error)
140
+ }
141
+ prev_term = Signal.trap("TERM") {
142
+ interrupt_parallel! if parallel?
143
+ abort colorize("\n❌ #{title} terminated", :error)
144
+ }
145
+
146
+ ci = self.class.new
147
+ ci.instance_variable_set(:@skip_until, @skip_until)
148
+ ci.instance_variable_set(:@skipping, @skipping)
149
+
150
+ elapsed = timing do
151
+ ci.instance_eval(&block)
152
+ ci.run_parallel_steps! if ci.parallel? && ci.parallel_steps.any?
153
+ end
154
+
155
+ @skip_until = ci.instance_variable_get(:@skip_until)
156
+ @skipping = ci.instance_variable_get(:@skipping)
157
+
158
+ if ci.success?
159
+ echo "\n✅ #{title} passed in #{elapsed}", type: :success
160
+ clear_state
161
+ else
162
+ echo "\n❌ #{title} failed in #{elapsed}", type: :error
163
+
164
+ if ci.multiple_results?
165
+ ci.failures.each do |success, step_title|
166
+ echo " ↳ #{step_title} failed", type: :error
167
+ end
168
+ end
169
+ end
170
+
171
+ results.concat ci.results
172
+ ensure
173
+ Signal.trap("INT", prev_int || "DEFAULT")
174
+ Signal.trap("TERM", prev_term || "DEFAULT")
175
+ end
176
+
177
+ def failures
178
+ results.reject(&:first)
179
+ end
180
+
181
+ def multiple_results?
182
+ results.size > 1
183
+ end
184
+
185
+ def fail_fast?
186
+ ARGV.include?("-f") || ARGV.include?("--fail-fast") ||
187
+ ARGV.include?("-fc") || ARGV.include?("-cf")
188
+ end
189
+
190
+ def continue_mode?
191
+ ARGV.include?("-c") || ARGV.include?("--continue") ||
192
+ ARGV.include?("-fc") || ARGV.include?("-cf")
193
+ end
194
+
195
+ def parallel?
196
+ ARGV.include?("-p") || ARGV.include?("--parallel")
197
+ end
198
+
199
+ def plain?
200
+ return true if ARGV.include?("--plain")
201
+ return true if !$stdout.tty?
202
+ return true if ENV["TERM"] == "dumb"
203
+ return true if ENV["NO_COLOR"]
204
+ return true if ENV["CI_PLAIN"] == "1" || ENV["CI_PLAIN"] == "true"
205
+
206
+ false
207
+ end
208
+
209
+ attr_reader :parallel_steps
210
+
211
+ MAX_OUTPUT_BYTES = 100 * 1024 # 100KB max per output stream
212
+
213
+ def run_parallel_steps!
214
+ total = @parallel_steps.size
215
+ @running_jobs = []
216
+ completed = []
217
+ completed_by_index = {}
218
+
219
+ echo "\n⏳ Running #{total} steps in parallel:", type: :subtitle
220
+ unless plain?
221
+ @parallel_steps.each do |step|
222
+ echo format_parallel_line(step[:title], :pending), type: :pending
223
+ end
224
+ end
225
+
226
+ @parallel_steps.each_with_index do |step_info, idx|
227
+ title = step_info[:title]
228
+ command = step_info[:command]
229
+
230
+ stdout_file = Tempfile.new(["ci_stdout_#{idx}_", ".log"])
231
+ stderr_file = Tempfile.new(["ci_stderr_#{idx}_", ".log"])
232
+
233
+ # Commands are defined in config/ci.rb, not user input
234
+ pid = Process.spawn(*command, out: stdout_file.path, err: stderr_file.path, pgroup: true) # brakeman:disable:Execute
235
+
236
+ @running_jobs << {
237
+ pid: pid,
238
+ index: idx,
239
+ title: title,
240
+ command: command,
241
+ stdout_file: stdout_file,
242
+ stderr_file: stderr_file,
243
+ started_at: Time.now.to_f
244
+ }
245
+ end
246
+
247
+ while @running_jobs.any?
248
+ reaped_any = false
249
+
250
+ @running_jobs.dup.each do |job|
251
+ pid, status = Process.waitpid2(job[:pid], Process::WNOHANG)
252
+ next unless pid
253
+
254
+ reaped_any = true
255
+ @running_jobs.delete(job)
256
+
257
+ duration = Time.now.to_f - job[:started_at]
258
+ success = status.success?
259
+
260
+ completed << job.merge(success: success, duration: duration, exit_code: status.exitstatus)
261
+ completed_by_index[job[:index]] = completed.last
262
+
263
+ type = success ? :success : :error
264
+ line = format_parallel_line(job[:title], type, duration: duration)
265
+ update_parallel_line(job[:index], line, type) unless plain?
266
+
267
+ results << [success, job[:title]]
268
+
269
+ cleanup_job_files!(job) if success
270
+ rescue Errno::ECHILD
271
+ @running_jobs.delete(job)
272
+ end
273
+
274
+ sleep 0.1 unless reaped_any
275
+ end
276
+
277
+ if plain?
278
+ @parallel_steps.each_with_index do |step, idx|
279
+ job = completed_by_index[idx]
280
+ type = job[:success] ? :success : :error
281
+ echo format_parallel_line(step[:title], type, duration: job[:duration]), type: type
282
+ end
283
+ end
284
+
285
+ print_parallel_summary(completed)
286
+ ensure
287
+ cleanup_all_jobs!
288
+ end
289
+
290
+ def interrupt_parallel!
291
+ return unless defined?(@running_jobs) && @running_jobs&.any?
292
+
293
+ @running_jobs.each do |job|
294
+ Process.kill("TERM", -job[:pid])
295
+ rescue Errno::ESRCH
296
+ end
297
+
298
+ sleep 1.0
299
+
300
+ @running_jobs.each do |job|
301
+ Process.kill("KILL", -job[:pid])
302
+ rescue Errno::ESRCH
303
+ end
304
+
305
+ @running_jobs.each do |job|
306
+ Process.wait(job[:pid])
307
+ rescue Errno::ECHILD
308
+ end
309
+
310
+ cleanup_all_jobs!
311
+ end
312
+
313
+ def cleanup_job_files!(job)
314
+ job[:stdout_file]&.close!
315
+ job[:stderr_file]&.close!
316
+ rescue
317
+ end
318
+
319
+ def cleanup_all_jobs!
320
+ return unless defined?(@running_jobs)
321
+ @running_jobs&.each { |job| cleanup_job_files!(job) }
322
+ end
323
+
324
+ def truncated_file_content(file, max_bytes: MAX_OUTPUT_BYTES)
325
+ file.rewind
326
+ size = file.size
327
+ content = if size > max_bytes
328
+ file.seek(-max_bytes, IO::SEEK_END)
329
+ "[... truncated #{size - max_bytes} bytes ...]\n" + file.read
330
+ else
331
+ file.read
332
+ end
333
+ content.strip
334
+ end
335
+
336
+ private
337
+
338
+ def print_parallel_summary(completed)
339
+ failed_jobs = completed.reject { |j| j[:success] }
340
+ return if failed_jobs.empty?
341
+
342
+ echo "\n" + ("─" * 60), type: :error
343
+ echo "Failed step output:", type: :error
344
+ echo ("─" * 60), type: :error
345
+
346
+ failed_jobs.each do |job|
347
+ echo "\n┌── #{job[:title]} (exit #{job[:exit_code]})", type: :error
348
+ echo "│ Command: #{job[:command].join(" ")}", type: :subtitle
349
+
350
+ stdout_content = truncated_file_content(job[:stdout_file])
351
+ stderr_content = truncated_file_content(job[:stderr_file])
352
+
353
+ if stdout_content.empty? && stderr_content.empty?
354
+ echo "│ (no output)", type: :subtitle
355
+ else
356
+ unless stdout_content.empty?
357
+ echo "│", type: :subtitle
358
+ echo "│ ── stdout ──", type: :subtitle
359
+ stdout_content.each_line { |line| echo "│ #{line.chomp}", type: :subtitle }
360
+ end
361
+
362
+ unless stderr_content.empty?
363
+ echo "│", type: :subtitle
364
+ echo "│ ── stderr ──", type: :error
365
+ stderr_content.each_line { |line| echo "│ #{line.chomp}", type: :error }
366
+ end
367
+ end
368
+
369
+ echo "└" + ("─" * 59), type: :error
370
+
371
+ cleanup_job_files!(job)
372
+ end
373
+ end
374
+
375
+ def format_duration(seconds)
376
+ min, sec = seconds.divmod(60)
377
+ "#{"#{min.to_i}m" if min > 0}%.2fs" % sec
378
+ end
379
+
380
+ def format_parallel_line(title, status, duration: nil)
381
+ indicator = parallel_indicator(status)
382
+ if duration
383
+ " #{indicator} #{title} (#{format_duration(duration)})"
384
+ else
385
+ " #{indicator} #{title}"
386
+ end
387
+ end
388
+
389
+ def parallel_indicator(status)
390
+ return {pending: "-", success: "OK", error: "FAIL"}[status] if plain?
391
+
392
+ {pending: "•", success: "✅", error: "❌"}[status]
393
+ end
394
+
395
+ def update_parallel_line(index, text, type)
396
+ return echo(text, type: type) if plain?
397
+
398
+ lines_up = @parallel_steps.size - index
399
+ print "\033[s"
400
+ print "\033[#{lines_up}A" if lines_up > 0
401
+ print "\r\033[2K"
402
+ print colorize(text, type)
403
+ print "\033[u"
404
+ end
405
+
406
+ def state_file_path
407
+ File.join(Dir.pwd, STATE_FILE)
408
+ end
409
+
410
+ def save_failed_step(title)
411
+ File.write(state_file_path, title)
412
+ end
413
+
414
+ def load_failed_step
415
+ return nil unless File.exist?(state_file_path)
416
+ File.read(state_file_path).strip
417
+ end
418
+
419
+ def clear_state
420
+ File.delete(state_file_path) if File.exist?(state_file_path)
421
+ end
422
+
423
+ def timing
424
+ started_at = Time.now.to_f
425
+ yield
426
+ min, sec = (Time.now.to_f - started_at).divmod(60)
427
+ "#{"#{min}m" if min > 0}%.2fs" % sec
428
+ end
429
+
430
+ def colorize(text, type)
431
+ return text if plain?
432
+
433
+ "#{COLORS.fetch(type)}#{text}\033[0m"
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module LocalCiPlus
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def copy_ci_runner
11
+ if File.exist?(File.join(destination_root, "bin/ci")) && !options[:force]
12
+ say_status :skipped, "bin/ci already exists (use --force to overwrite)", :yellow
13
+ return
14
+ end
15
+
16
+ template "bin_ci", "bin/ci"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../config/boot"
5
+ require "local_ci_plus"
6
+ require "active_support/continuous_integration"
7
+
8
+ CI = ActiveSupport::ContinuousIntegration
9
+ require_relative "../config/ci"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module LocalCiPlus
6
+ module Generators
7
+ class UpdateGenerator < Rails::Generators::Base
8
+ def update_ci_runner
9
+ if File.exist?(File.join(destination_root, "bin/ci"))
10
+ unless ci_requires_local_ci_plus?
11
+ inject_into_file "bin/ci", "require \"local_ci_plus\"\n", after: "require_relative \"../config/boot\"\n"
12
+ end
13
+ else
14
+ say_status :skipped, "bin/ci does not exist", :yellow
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def ci_requires_local_ci_plus?
21
+ File.read(File.join(destination_root, "bin/ci")).include?("require \"local_ci_plus\"")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module LocalCiPlus
6
+ class Railtie < Rails::Railtie
7
+ generators do
8
+ require_relative "generators/install_generator"
9
+ require_relative "generators/update_generator"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LocalCiPlus
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "active_support/continuous_integration"
5
+ rescue LoadError
6
+ end
7
+
8
+ require_relative "local_ci_plus/continuous_integration"
9
+ require_relative "local_ci_plus/railtie" if defined?(Rails::Railtie)
10
+ require_relative "local_ci_plus/version"
11
+
12
+ module LocalCiPlus
13
+ class Error < StandardError; end
14
+ end
15
+
16
+ if defined?(ActiveSupport)
17
+ ActiveSupport.send(:remove_const, :ContinuousIntegration) if
18
+ ActiveSupport.const_defined?(:ContinuousIntegration, false)
19
+ ActiveSupport.const_set(:ContinuousIntegration, LocalCiPlus::ContinuousIntegration)
20
+ end
metadata ADDED
@@ -0,0 +1,50 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: local_ci_plus
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Scott Watermasysk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Adds parallel execution, fail-fast, resume, and plain output to Rails'
13
+ local CI runner.
14
+ email:
15
+ - gems@scottw.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - AGENTS.md
21
+ - README.md
22
+ - lib/local_ci_plus.rb
23
+ - lib/local_ci_plus/continuous_integration.rb
24
+ - lib/local_ci_plus/generators/install_generator.rb
25
+ - lib/local_ci_plus/generators/templates/bin_ci
26
+ - lib/local_ci_plus/generators/update_generator.rb
27
+ - lib/local_ci_plus/railtie.rb
28
+ - lib/local_ci_plus/version.rb
29
+ homepage: https://github.com/scottwater/local_ci_plus
30
+ licenses:
31
+ - MIT
32
+ metadata: {}
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.1'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.6.7
48
+ specification_version: 4
49
+ summary: Enhanced local CI runner for Rails apps
50
+ test_files: []