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 +7 -0
- data/AGENTS.md +13 -0
- data/README.md +114 -0
- data/lib/local_ci_plus/continuous_integration.rb +436 -0
- data/lib/local_ci_plus/generators/install_generator.rb +20 -0
- data/lib/local_ci_plus/generators/templates/bin_ci +9 -0
- data/lib/local_ci_plus/generators/update_generator.rb +25 -0
- data/lib/local_ci_plus/railtie.rb +12 -0
- data/lib/local_ci_plus/version.rb +5 -0
- data/lib/local_ci_plus.rb +20 -0
- metadata +50 -0
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,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,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: []
|