Skip to content

Commit f1c858b

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 f1c858b

6 files changed

Lines changed: 560 additions & 81 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: 94 additions & 51 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,36 @@ 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+
success = report_step(title, command) do
84+
started = Time.now.to_f
85+
[system(*command), Time.now.to_f - started]
86+
end
87+
abort if fail_fast? && !success
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+
Group.new(self, name, parallel: parallel, &block).run
80112
end
81113

82114
# Returns true if all steps were successful.
@@ -95,8 +127,6 @@ def failure(title, subtitle = nil)
95127
#
96128
# heading "Smoke Testing", "End-to-end tests verifying key functionality", padding: false
97129
# 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.
100130
def heading(heading, subtitle = nil, type: :banner, padding: true)
101131
echo "#{padding ? "\n\n" : ""}#{heading}", type: type
102132
echo "#{subtitle}#{padding ? "\n" : ""}", type: :subtitle if subtitle
@@ -108,48 +138,22 @@ def heading(heading, subtitle = nil, type: :banner, padding: true)
108138
#
109139
# echo "This is going to be green!", type: :success
110140
# echo "This is going to be red!", type: :error
111-
#
112-
# See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
113141
def echo(text, type:)
114142
puts colorize(text, type)
115143
end
116144

117145
# :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", "-")
143-
end
144-
145-
# :nodoc:
146-
def failures
147-
results.reject(&:first)
146+
def report_step(title, command)
147+
heading title, command.join(" "), type: :title
148+
success, seconds = yield
149+
result_line(title, success, seconds)
150+
results << [success, title]
151+
success
148152
end
149153

150154
# :nodoc:
151-
def multiple_results?
152-
results.size > 1
155+
def colorize(text, type)
156+
"#{COLORS.fetch(type)}#{text}\033[0m"
153157
end
154158

155159
# :nodoc:
@@ -158,15 +162,54 @@ def fail_fast?
158162
end
159163

160164
private
165+
def failures
166+
results.reject(&:first)
167+
end
168+
169+
def multiple_results?
170+
results.size > 1
171+
end
172+
173+
def execute(title, &block)
174+
Signal.trap("INT") { abort colorize("\n#{title} interrupted", :error) }
175+
176+
seconds = timing { instance_eval(&block) }
177+
178+
unless success?
179+
if multiple_results?
180+
failures.each do |success, title|
181+
unless success
182+
echo " ↳ #{title} failed", type: :error
183+
end
184+
end
185+
end
186+
end
187+
188+
[success?, seconds]
189+
ensure
190+
Signal.trap("INT", "-")
191+
end
192+
193+
def result_line(title, success, seconds)
194+
elapsed = format_elapsed(seconds)
195+
if success
196+
echo "\n#{title} passed in #{elapsed}", type: :success
197+
else
198+
echo "\n#{title} failed in #{elapsed}", type: :error
199+
end
200+
end
201+
202+
def format_elapsed(seconds)
203+
min, sec = seconds.divmod(60)
204+
"#{"#{min.to_i}m" if min > 0}%.2fs" % sec
205+
end
206+
161207
def timing
162208
started_at = Time.now.to_f
163209
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"
210+
Time.now.to_f - started_at
170211
end
171212
end
172213
end
214+
215+
require_relative "continuous_integration/group"

0 commit comments

Comments
 (0)