Skip to content

Commit cd48141

Browse files
committed
Add parallel step groups to CI runner
Steps can be grouped and run in parallel: ```ruby group "Checks", parallel: 3 do step "Style: Ruby", "bin/rubocop" step "Security: Brakeman", "bin/brakeman --quiet" step "Security: Gem audit", "bin/bundler-audit" end ``` `parallel` defaults to 1, which runs steps sequentially. Parallel steps capture output via PTY (or Open3 as fallback) to prevent interleaving, with a live progress line showing running steps. Groups can be nested, but only the outer group can run in parallel. This allows dependent steps to run sequentially within a parallel group: ```ruby group "Checks", parallel: 2 do group "Tests" do step "Tests: Rails", "bin/rails test" step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" end step "Style: Ruby", "bin/rubocop" step "Security: Brakeman", "bin/brakeman --quiet" step "Security: Gem audit", "bin/bundler-audit" end ```
1 parent e0e483e commit cd48141

6 files changed

Lines changed: 510 additions & 80 deletions

File tree

activesupport/CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
* Add `group` method to `ActiveSupport::ContinuousIntegration` for parallel step execution.
2+
3+
Groups collect steps and run them concurrently using a thread pool, reducing CI times
4+
by running independent checks (tests, linting, security audits) in parallel. Sub-groups
5+
run sequentially within a single parallel slot.
6+
7+
```ruby
8+
CI.run do
9+
step "Setup", "bin/setup --skip-server"
10+
11+
group "Checks", parallel: 3 do
12+
step "Style: Ruby", "bin/rubocop"
13+
step "Security: Brakeman", "bin/brakeman --quiet"
14+
step "Security: Gem audit", "bin/bundler-audit"
15+
16+
group "Tests" do
17+
step "Tests: Rails", "bin/rails test"
18+
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
19+
end
20+
end
21+
end
22+
```
23+
24+
*Donal McBreen*
25+
126
* Fix inflections to better handle overlapping acronyms.
227

328
```ruby

activesupport/lib/active_support/continuous_integration.rb

Lines changed: 103 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class ContinuousIntegration
2727
title: "\033[1;35m", # Purple
2828
subtitle: "\033[1;90m", # Medium Gray
2929
error: "\033[1;31m", # Red
30-
success: "\033[1;32m" # Green
30+
success: "\033[1;32m", # Green
31+
progress: "\033[1;36m" # Cyan
3132
}
3233

3334
attr_reader :results
@@ -55,12 +56,15 @@ class ContinuousIntegration
5556
# end
5657
# end
5758
def self.run(title = "Continuous Integration", subtitle = "Running tests, style checks, and security audits", &block)
58-
new.tap do |ci|
59-
ENV["CI"] = "true"
60-
ci.heading title, subtitle, padding: false
61-
ci.report(title, &block)
62-
abort unless ci.success?
63-
end
59+
ENV["CI"] = "true"
60+
new.run(title, subtitle, &block)
61+
end
62+
63+
def run(title, subtitle, &block)
64+
heading title, subtitle, padding: false
65+
success, seconds = execute(title, &block)
66+
result_line(title, success, seconds)
67+
abort unless success?
6468
end
6569

6670
def initialize
@@ -75,8 +79,41 @@ def initialize
7579
# step "Setup", "bin/setup"
7680
# step "Single test", "bin/rails", "test", "--name", "test_that_is_one"
7781
def step(title, *command)
78-
heading title, command.join(" "), type: :title
79-
report(title) { results << [ system(*command), title ] }
82+
Signal.trap("INT") { abort colorize("\n#{title} interrupted", :error) }
83+
report_step(title, command) do
84+
started = Time.now.to_f
85+
[system(*command), Time.now.to_f - started]
86+
end
87+
abort if failing_fast?
88+
end
89+
90+
# Declare a group of steps that can be run in parallel. Steps within the group are collected first,
91+
# then executed either concurrently (when +parallel+ > 1) or sequentially (when +parallel+ is 1).
92+
#
93+
# When running in parallel, each step's output is captured to avoid interleaving, and a progress
94+
# display shows which steps are currently running.
95+
#
96+
# Sub-groups within a parallel group occupy a single parallel slot and run their steps sequentially.
97+
#
98+
# Examples:
99+
#
100+
# group "Checks", parallel: 3 do
101+
# step "Style: Ruby", "bin/rubocop"
102+
# step "Security: Brakeman", "bin/brakeman --quiet"
103+
# step "Security: Gem audit", "bin/bundler-audit"
104+
# end
105+
#
106+
# group "Tests" do
107+
# step "Unit tests", "bin/rails test"
108+
# step "System tests", "bin/rails test:system"
109+
# end
110+
def group(name, parallel: 1, &block)
111+
if parallel <= 1
112+
instance_eval(&block)
113+
else
114+
Group.new(self, name, parallel: parallel, &block).run
115+
end
116+
abort if failing_fast?
80117
end
81118

82119
# Returns true if all steps were successful.
@@ -95,8 +132,6 @@ def failure(title, subtitle = nil)
95132
#
96133
# heading "Smoke Testing", "End-to-end tests verifying key functionality", padding: false
97134
# heading "Skipping video encoding tests", "Install FFmpeg to run these tests", type: :error
98-
#
99-
# See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
100135
def heading(heading, subtitle = nil, type: :banner, padding: true)
101136
echo "#{padding ? "\n\n" : ""}#{heading}", type: type
102137
echo "#{subtitle}#{padding ? "\n" : ""}", type: :subtitle if subtitle
@@ -108,65 +143,83 @@ def heading(heading, subtitle = nil, type: :banner, padding: true)
108143
#
109144
# echo "This is going to be green!", type: :success
110145
# echo "This is going to be red!", type: :error
111-
#
112-
# See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
113146
def echo(text, type:)
114147
puts colorize(text, type)
115148
end
116149

117150
# :nodoc:
118-
def report(title, &block)
119-
Signal.trap("INT") { abort colorize("\n#{title} interrupted", :error) }
120-
121-
ci = self.class.new
122-
elapsed = timing { ci.instance_eval(&block) }
123-
124-
if ci.success?
125-
echo "\n#{title} passed in #{elapsed}", type: :success
126-
else
127-
echo "\n#{title} failed in #{elapsed}", type: :error
128-
129-
abort if ci.fail_fast?
130-
131-
if ci.multiple_results?
132-
ci.failures.each do |success, title|
133-
unless success
134-
echo " ↳ #{title} failed", type: :error
135-
end
136-
end
137-
end
138-
end
139-
140-
results.concat ci.results
141-
ensure
142-
Signal.trap("INT", "-")
151+
def report_step(title, command)
152+
heading title, command.join(" "), type: :title
153+
success, seconds = yield
154+
result_line(title, success, seconds)
155+
results << [success, title]
156+
success
143157
end
144158

145159
# :nodoc:
146-
def failures
147-
results.reject(&:first)
160+
def colorize(text, type)
161+
"#{COLORS.fetch(type)}#{text}\033[0m"
148162
end
149163

150164
# :nodoc:
151-
def multiple_results?
152-
results.size > 1
165+
def fail_fast?
166+
ARGV.include?("-f") || ARGV.include?("--fail-fast")
153167
end
154168

155169
# :nodoc:
156-
def fail_fast?
157-
ARGV.include?("-f") || ARGV.include?("--fail-fast")
170+
def failing_fast?
171+
fail_fast? && failures.any?
158172
end
159173

160174
private
175+
def failures
176+
results.reject(&:first)
177+
end
178+
179+
def multiple_results?
180+
results.size > 1
181+
end
182+
183+
def execute(title, &block)
184+
Signal.trap("INT") { abort colorize("\n#{title} interrupted", :error) }
185+
186+
seconds = timing { instance_eval(&block) }
187+
188+
unless success?
189+
if multiple_results?
190+
failures.each do |success, title|
191+
unless success
192+
echo " ↳ #{title} failed", type: :error
193+
end
194+
end
195+
end
196+
end
197+
198+
[success?, seconds]
199+
ensure
200+
Signal.trap("INT", "-")
201+
end
202+
203+
def result_line(title, success, seconds)
204+
elapsed = format_elapsed(seconds)
205+
if success
206+
echo "\n#{title} passed in #{elapsed}", type: :success
207+
else
208+
echo "\n#{title} failed in #{elapsed}", type: :error
209+
end
210+
end
211+
212+
def format_elapsed(seconds)
213+
min, sec = seconds.divmod(60)
214+
"#{"#{min.to_i}m" if min > 0}%.2fs" % sec
215+
end
216+
161217
def timing
162218
started_at = Time.now.to_f
163219
yield
164-
min, sec = (Time.now.to_f - started_at).divmod(60)
165-
"#{"#{min}m" if min > 0}%.2fs" % sec
166-
end
167-
168-
def colorize(text, type)
169-
"#{COLORS.fetch(type)}#{text}\033[0m"
220+
Time.now.to_f - started_at
170221
end
171222
end
172223
end
224+
225+
require_relative "continuous_integration/group"

0 commit comments

Comments
 (0)