Skip to content

Commit bcd5c93

Browse files
jeremyDavid Heinemeier Hanssondhh
authored
Structured CI with bin/ci (rails#54693)
* Local CI with bin/ci Introduce `bin/ci`, a low-rent local CI for new apps. `bin/ci` runs your all tests, linters, and security scanners. And it optionally signs off on your work by giving your PR a green status. This is your final check after completing a major chunk of work, before you merge your PR, etc. For everyday dev/test, use targeted `rails t …`. Use in tandem with [gh-signoff](https://github.com/basecamp/gh-signoff) to set a green PR status when `bin/ci` completes successfully. * Extract CI DSL * Beef up DSL and speed up the binstub * Less spacing * Clarify purpose * Allow steps to have multi-arg command definitions to escape nicely for system * I hate these warts so much * Include bundler-audit as default step * Fix excess whitespace in generation * No need for instance_eval * Fix that CI DSL now lives in config * Expand scope of bin/ci * Add documentation * Not just local * CI.run * Eliminates the inner report call * Ensures we exit with non-zero status on failure * Introduces success? so we can do conditional steps like signoff * Introduces heading to simplify title/subtitle output * Clarify doc * Include heading setting in the factory method * New call signature * More cleanup * Add test for ContinuousIntegration class * Give a hint as to how to run this CI flow using the binstub * Be brief * Appease Rubocop Silly rule * Show DSL * Absolutely not on this rule --------- Co-authored-by: David Heinemeier Hansson <[email protected]> Co-authored-by: David Heinemeier Hansson <[email protected]>
1 parent 182503c commit bcd5c93

File tree

11 files changed

+304
-3
lines changed

11 files changed

+304
-3
lines changed

.rubocop.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,6 @@ Performance/StringInclude:
387387
Minitest/AssertNil:
388388
Enabled: true
389389

390-
Minitest/AssertPredicate:
391-
Enabled: true
392-
393390
Minitest/AssertRaisesWithRegexpArgument:
394391
Enabled: true
395392

activesupport/lib/active_support.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ module ActiveSupport
4040
autoload :CodeGenerator
4141
autoload :ActionableError
4242
autoload :ConfigurationFile
43+
autoload :ContinuousIntegration
4344
autoload :CurrentAttributes
4445
autoload :Dependencies
4546
autoload :DescendantsTracker
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveSupport
4+
# Provides a DSL for declaring a continuous integration workflow that can be run either locally or in the cloud.
5+
# Each step is timed, reports success/error, and is aggregated into a collective report that reports total runtime,
6+
# as well as whether the entire run was successful or not.
7+
#
8+
# Example:
9+
#
10+
# ActiveSupport::ContinuousIntegration.run do
11+
# step "Setup", "bin/setup --skip-server"
12+
# step "Style: Ruby", "bin/rubocop"
13+
# step "Security: Gem audit", "bin/bundler-audit"
14+
# step "Tests: Rails", "bin/rails test test:system"
15+
#
16+
# if success?
17+
# step "Signoff: Ready for merge and deploy", "gh signoff"
18+
# else
19+
# failure "Skipping signoff; CI failed.", "Fix the issues and try again."
20+
# end
21+
# end
22+
#
23+
# Starting with Rails 8.1, a default `bin/ci` and `config/ci.rb` file are created to provide out-of-the-box CI.
24+
class ContinuousIntegration
25+
COLORS = {
26+
banner: "\033[1;32m", # Green
27+
title: "\033[1;35m", # Purple
28+
subtitle: "\033[1;90m", # Medium Gray
29+
error: "\033[1;31m", # Red
30+
success: "\033[1;32m" # Green
31+
}
32+
33+
attr_reader :results
34+
35+
# Perform a CI run. Execute each step, show their results and runtime, and exit with a non-zero status if there are any failures.
36+
#
37+
# Pass an optional title, subtitle, and a block that declares the steps to be executed.
38+
#
39+
# Sets the CI environment variable to "true" to allow for conditional behavior in the app, like enabling eager loading and disabling logging.
40+
#
41+
# Example:
42+
#
43+
# ActiveSupport::ContinuousIntegration.run do
44+
# step "Setup", "bin/setup --skip-server"
45+
# step "Style: Ruby", "bin/rubocop"
46+
# step "Security: Gem audit", "bin/bundler-audit"
47+
# step "Tests: Rails", "bin/rails test test:system"
48+
#
49+
# if success?
50+
# step "Signoff: Ready for merge and deploy", "gh signoff"
51+
# else
52+
# failure "Skipping signoff; CI failed.", "Fix the issues and try again."
53+
# end
54+
# end
55+
def self.run(title = "Continuous Integration", subtitle = "Running tests, style checks, and security audits", &block)
56+
new.tap do |ci|
57+
ENV["CI"] = "true"
58+
ci.heading title, subtitle, padding: false
59+
ci.report(title, &block)
60+
abort unless ci.success?
61+
end
62+
end
63+
64+
def initialize(&block)
65+
@results = []
66+
end
67+
68+
# Declare a step with a title and a command. The command can either be given as a single string or as multiple
69+
# strings that will be passed to `system` as individual arguments (and therefore correctly escaped for paths etc).
70+
#
71+
# Examples:
72+
#
73+
# step "Setup", "bin/setup"
74+
# step "Single test", "bin/rails", "test", "--name", "test_that_is_one"
75+
def step(title, *command)
76+
heading title, command.join(" "), type: :title
77+
report(title) { results << system(*command) }
78+
end
79+
80+
# Returns true if all steps were successful.
81+
def success?
82+
results.all?(&:itself)
83+
end
84+
85+
# Display an error heading with the title and optional subtitle to reflect that the run failed.
86+
def failure(title, subtitle = nil)
87+
heading title, subtitle, type: :error
88+
end
89+
90+
# Display a colorized heading followed by an optional subtitle.
91+
#
92+
# Examples:
93+
#
94+
# heading "Smoke Testing", "End-to-end tests verifying key functionality", padding: false
95+
# heading "Skipping video encoding tests", "Install FFmpeg to run these tests", type: :error
96+
#
97+
# See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
98+
def heading(heading, subtitle = nil, type: :banner, padding: true)
99+
echo "#{padding ? "\n\n" : ""}#{heading}", type: type
100+
echo "#{subtitle}#{padding ? "\n" : ""}", type: :subtitle if subtitle
101+
end
102+
103+
# Echo text to the terminal in the color corresponding to the type of the text.
104+
#
105+
# Examples:
106+
#
107+
# echo "This is going to be green!", type: :success
108+
# echo "This is going to be red!", type: :error
109+
#
110+
# See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
111+
def echo(text, type:)
112+
puts colorize(text, type)
113+
end
114+
115+
# :nodoc:
116+
def report(title, &block)
117+
Signal.trap("INT") { abort colorize(:error, "\n#{title} interrupted") }
118+
119+
ci = self.class.new
120+
elapsed = timing { ci.instance_eval(&block) }
121+
122+
if ci.success?
123+
echo "\n#{title} passed in #{elapsed}", type: :success
124+
else
125+
echo "\n#{title} failed in #{elapsed}", type: :error
126+
end
127+
128+
results.concat ci.results
129+
ensure
130+
Signal.trap("INT", "-")
131+
end
132+
133+
private
134+
def timing
135+
started_at = Time.now.to_f
136+
yield
137+
min, sec = (Time.now.to_f - started_at).divmod(60)
138+
"#{"#{min}m" if min > 0}%.2fs" % sec
139+
end
140+
141+
def colorize(text, type)
142+
"#{COLORS.fetch(type)}#{text}\033[0m"
143+
end
144+
end
145+
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "abstract_unit"
4+
require "active_support/continuous_integration"
5+
6+
class ContinuousIntegrationTest < ActiveSupport::TestCase
7+
setup { @CI = ActiveSupport::ContinuousIntegration.new }
8+
9+
test "successful step" do
10+
output = capture_io { @CI.step "Success!", "which ruby > /dev/null" }.to_s
11+
assert_match(/Success! passed/, output)
12+
assert @CI.success?
13+
end
14+
15+
test "failed step" do
16+
output = capture_io { @CI.step "Failed!", "which rubyxx > /dev/null" }.to_s
17+
assert_match(/Failed! failed/, output)
18+
assert_not @CI.success?
19+
end
20+
21+
test "report with only successful steps combined gives success" do
22+
output = capture_io do
23+
@CI.report("CI") do
24+
step "Success!", "which ruby > /dev/null"
25+
step "Success again!", "which ruby > /dev/null"
26+
end
27+
end.to_s
28+
29+
assert_match(/CI passed/, output)
30+
assert @CI.success?
31+
end
32+
33+
test "report with successful and failed steps combined gives failure" do
34+
output = capture_io do
35+
@CI.report("CI") do
36+
step "Success!", "which ruby > /dev/null"
37+
step "Failed!", "which rubyxx > /dev/null"
38+
end
39+
end.to_s
40+
41+
assert_match(/CI failed/, output)
42+
assert_not @CI.success?
43+
end
44+
45+
test "echo uses terminal coloring" do
46+
output = capture_io { @CI.echo "Hello", type: :success }.first.to_s
47+
assert_equal "\e[1;32mHello\e[0m\n", output
48+
end
49+
50+
test "heading" do
51+
output = capture_io { @CI.heading "Hello", "To all of you" }.first.to_s
52+
assert_match(/Hello[\s\S]*To all of you/, output)
53+
end
54+
55+
test "failure output" do
56+
output = capture_io { @CI.failure "This sucks", "But such is the life of programming sometimes" }.first.to_s
57+
assert_equal "\e[1;31m\n\nThis sucks\e[0m\n\e[1;90mBut such is the life of programming sometimes\n\e[0m\n", output
58+
end
59+
end

railties/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
* Introduce `bin/ci` for running your tests, style checks, and security audits locally or in the cloud.
2+
3+
The specific steps are defined by a new DSL in `config/ci.rb`.
4+
5+
```ruby
6+
ActiveSupport::ContinuousIntegration.run do
7+
step "Setup", "bin/setup --skip-server"
8+
step "Style: Ruby", "bin/rubocop"
9+
step "Security: Gem audit", "bin/bundler-audit"
10+
step "Tests: Rails", "bin/rails test test:system"
11+
end
12+
```
13+
14+
Optionally use [gh-signoff](https://github.com/basecamp/gh-signoff) to
15+
set a green PR status - ready for merge.
16+
17+
*Jeremy Daer*, *DHH*
18+
119
* Generate session controller tests when running the authentication generator.
220

321
*Jerome Dalbert*

railties/lib/rails/generators/rails/app/app_generator.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def config
129129
template "environment.rb"
130130
template "bundler-audit.yml"
131131
template "cable.yml" unless options[:update] || options[:skip_action_cable]
132+
template "ci.rb"
132133
template "puma.rb"
133134
template "storage.yml" unless options[:update] || skip_active_storage?
134135

@@ -141,6 +142,7 @@ def config
141142
def config_when_updating
142143
action_cable_config_exist = File.exist?("config/cable.yml")
143144
active_storage_config_exist = File.exist?("config/storage.yml")
145+
ci_config_exist = File.exist?("config/ci.rb")
144146
bundle_audit_config_exist = File.exist?("config/bundler-audit.yml")
145147
rack_cors_config_exist = File.exist?("config/initializers/cors.rb")
146148
assets_config_exist = File.exist?("config/initializers/assets.rb")
@@ -159,6 +161,10 @@ def config_when_updating
159161
template "config/storage.yml"
160162
end
161163

164+
if !ci_config_exist
165+
template "config/ci.rb"
166+
end
167+
162168
if skip_asset_pipeline? && !assets_config_exist
163169
remove_file "config/initializers/assets.rb"
164170
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
require_relative "../config/boot"
2+
require "active_support/continuous_integration"
3+
4+
CI = ActiveSupport::ContinuousIntegration
5+
require_relative "../config/ci.rb"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Run using bin/ci
2+
3+
CI.run do
4+
step "Setup", "bin/setup --skip-server"
5+
<% unless options.skip_rubocop? %>
6+
step "Style: Ruby", "bin/rubocop"
7+
<% end -%>
8+
9+
step "Security: Gem audit", "bin/bundler-audit"
10+
<% if using_node? -%>
11+
step "Security: Yarn vulnerability audit", "yarn audit"
12+
<% end -%>
13+
<% if options[:javascript] == "importmap" && !options[:api] && !options[:skip_javascript] -%>
14+
step "Security: Importmap vulnerability audit", "bin/importmap audit"
15+
<% end -%>
16+
<% unless options.skip_brakeman? -%>
17+
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
18+
<% end -%>
19+
<% if options[:api] || options[:skip_system_test] -%>
20+
step "Tests: Rails", "bin/rails test"
21+
<% else %>
22+
step "Tests: Rails", "bin/rails test test:system"
23+
<% end -%>
24+
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
25+
26+
# Optional: set a green GitHub commit status to unblock PR merge.
27+
# Requires the `gh` CLI and and `gh extension install basecamp/gh-signoff`.
28+
# if success?
29+
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
30+
# else
31+
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
32+
# end
33+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
require "isolation/abstract_unit"
4+
5+
module ApplicationTests
6+
class BinCiTest < ActiveSupport::TestCase
7+
include ActiveSupport::Testing::Isolation
8+
9+
setup :build_app
10+
teardown :teardown_app
11+
12+
test "bin/ci exists and is executable with default content" do
13+
Dir.chdir(app_path) do
14+
assert File.exist?("bin/ci"), "bin/ci does not exist"
15+
assert File.executable?("bin/ci"), "bin/ci is not executable"
16+
17+
content = File.read("config/ci.rb")
18+
19+
# Default steps
20+
assert_match(/bin\/rubocop/, content)
21+
assert_match(/bin\/brakeman/, content)
22+
assert_match(/bin\/rails test/, content)
23+
assert_match(/bin\/rails db:seed:replant/, content)
24+
25+
# Node-specific steps excluded by default
26+
assert_no_match(/yarn audit/, content)
27+
28+
# GitHub signoff is commented
29+
assert_match(/# .*gh signoff/, content)
30+
end
31+
end
32+
end
33+
end

railties/test/generators/app_generator_test.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
app/views/pwa/service-worker.js
3434
bin/brakeman
3535
bin/bundler-audit
36+
bin/ci
3637
bin/dev
3738
bin/docker-entrypoint
3839
bin/rails
@@ -45,6 +46,7 @@
4546
config/boot.rb
4647
config/bundler-audit.yml
4748
config/cable.yml
49+
config/ci.rb
4850
config/credentials.yml.enc
4951
config/database.yml
5052
config/environment.rb

0 commit comments

Comments
 (0)