rails-local-ci 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: 8840d3903d7f32a508350be3bfe4dad1d65ca3799bcc4ff08dc891f3d1158bde
4
+ data.tar.gz: 17f2e4fe861ec938aca6bbcccc9139e8149a5d2aadfda7624f4807dccfe3e1ed
5
+ SHA512:
6
+ metadata.gz: 9b423912230035964b67068059469d28539ac9861efd47f34e9149a393a9e3efee5a5fcf4ce95bde08ad7d154cafac1933c754fa70fceab7d5762ab5f3226956
7
+ data.tar.gz: 75caddc8fbf112217648ed8765fa57d886fc29a05bbab54048cd27d8474886888e5fb2e7d5c2fd79df594bc7ce7f7e2991d7b5b341eec96bcd65cd4dc5379fbf
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 rails_local_ci contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # rails-local-ci
2
+
3
+ > Backport of Rails 8.1's `ActiveSupport::ContinuousIntegration` for Rails 5.2–7.x
4
+
5
+ Rails 8.1 introduced a standardized `bin/ci` script and `ActiveSupport::ContinuousIntegration` class ([PR #54693](https://github.com/rails/rails/pull/54693)) that runs the same CI steps locally and in the cloud. This gem ships that class for apps on Rails 5.2–7.x so you don't have to wait for an upgrade.
6
+
7
+ When you do upgrade to Rails 8.1, remove the gem — `bin/ci` and `config/ci.rb` stay unchanged because Rails 8.1 provides the class at the same load path.
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Prerequisites](#prerequisites)
14
+ - [Installation](#installation)
15
+ - [Usage](#usage)
16
+ - [DSL Reference](#dsl-reference)
17
+ - [Parallel Groups](#parallel-groups)
18
+ - [Fail-Fast Mode](#fail-fast-mode)
19
+ - [Commit Signoff](#commit-signoff)
20
+ - [CI Integration](#ci-integration)
21
+ - [Running Tests](#running-tests)
22
+ - [Upgrading to Rails 8.1](#upgrading-to-rails-81)
23
+ - [Compatibility](#compatibility)
24
+ - [How It Works](#how-it-works)
25
+ - [License](#license)
26
+
27
+ ---
28
+
29
+ ## Prerequisites
30
+
31
+ - Ruby >= 2.4
32
+ - Rails >= 5.2 and < 8.1
33
+
34
+ ---
35
+
36
+ ## Installation
37
+
38
+ Add to your `Gemfile`:
39
+
40
+ ```ruby
41
+ gem "rails-local-ci"
42
+ ```
43
+
44
+ Install:
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ Run the generator:
51
+
52
+ ```bash
53
+ rails generate rails_local_ci:install
54
+ ```
55
+
56
+ The generator copies two files into your app:
57
+
58
+ - `bin/ci` — the runner script (set to executable)
59
+ - `config/ci.rb` — your CI step definitions
60
+
61
+ ---
62
+
63
+ ## Usage
64
+
65
+ Run all CI steps locally:
66
+
67
+ ```bash
68
+ ./bin/ci
69
+ ```
70
+
71
+ Expected output:
72
+
73
+ ```
74
+ Continuous Integration
75
+ Running tests, style checks, and security audits
76
+
77
+ Setup
78
+ bin/setup --skip-server
79
+
80
+ ✅ Setup passed in 2.31s
81
+
82
+ Style: Ruby
83
+ bin/rubocop
84
+
85
+ ✅ Style: Ruby passed in 1.04s
86
+
87
+ Tests: Rails
88
+ bin/rails test
89
+
90
+ ✅ Tests: Rails passed in 4.17s
91
+
92
+ ✅ Continuous Integration passed in 8.34s
93
+ ```
94
+
95
+ Define your steps in `config/ci.rb`:
96
+
97
+ ```ruby
98
+ CI.run do
99
+ step "Setup", "bin/setup --skip-server"
100
+ step "Style: Ruby", "bin/rubocop" if File.exist?("bin/rubocop")
101
+ step "Security: Gem audit", "bin/bundler-audit" if File.exist?("bin/bundler-audit")
102
+ step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" if File.exist?("bin/brakeman")
103
+ step "Tests: Rails", "bin/rails test"
104
+
105
+ if success?
106
+ heading "All CI checks passed!", "Your changes are ready for review"
107
+ else
108
+ failure "CI checks failed", "Fix the issues above before submitting your PR"
109
+ end
110
+ end
111
+ ```
112
+
113
+ `CI.run` sets `ENV["CI"] = "true"` for the duration of the run, which Rails uses to enable eager loading and disable verbose logging.
114
+
115
+ ---
116
+
117
+ ## DSL Reference
118
+
119
+ All methods are available inside the `CI.run do ... end` block in `config/ci.rb`.
120
+
121
+ | Method | Description |
122
+ |--------|-------------|
123
+ | `step "Title", "command"` | Run a shell command; records pass/fail and elapsed time |
124
+ | `step "Title", "cmd", "arg1", "arg2"` | Run with multiple args — passed directly to `system`, correctly shell-escaped |
125
+ | `group "Title" do ... end` | Group steps visually; runs sequentially |
126
+ | `group "Title", parallel: N do ... end` | Run up to N steps concurrently in threads |
127
+ | `success?` | Returns `true` if every step so far has passed |
128
+ | `heading "Title"` | Print a green banner |
129
+ | `heading "Title", "Subtitle"` | Print a green banner with a gray subtitle; accepts `padding: false` to suppress blank lines |
130
+ | `failure "Title", "Subtitle"` | Print a red error banner |
131
+ | `echo "Text", type: :success` | Print colorized text to the terminal |
132
+
133
+ **Color types for `echo` and `heading`:**
134
+
135
+ | Type | Color |
136
+ |------|-------|
137
+ | `:banner` | Green (bold) |
138
+ | `:title` | Purple (bold) |
139
+ | `:subtitle` | Gray (bold) |
140
+ | `:error` | Red (bold) |
141
+ | `:success` | Green (bold) |
142
+ | `:progress` | Cyan (bold) |
143
+
144
+ `CI.run` accepts optional title and subtitle overrides:
145
+
146
+ ```ruby
147
+ CI.run "My App CI", "Linting, security, and tests" do
148
+ # ...
149
+ end
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Parallel Groups
155
+
156
+ Run multiple independent steps concurrently using `group`:
157
+
158
+ ```ruby
159
+ CI.run do
160
+ step "Setup", "bin/setup --skip-server"
161
+
162
+ group "Checks", parallel: 3 do
163
+ step "Style: Ruby", "bin/rubocop"
164
+ step "Security: Brakeman", "bin/brakeman --quiet"
165
+ step "Security: Gems", "bin/bundler-audit"
166
+ end
167
+
168
+ step "Tests: Rails", "bin/rails test"
169
+ end
170
+ ```
171
+
172
+ `parallel: N` runs up to N steps in concurrent threads. Output is captured per-step (via PTY when available, Open3 otherwise) and displayed in declaration order after all steps complete — no interleaving. A live progress indicator shows which steps are running.
173
+
174
+ `group` without `parallel:` (or `parallel: 1`) runs steps sequentially, useful for visual grouping.
175
+
176
+ **Nested groups**: a `group` inside a parallel group runs its steps sequentially within that thread slot. Sub-groups cannot themselves be parallelized — attempting to set `parallel:` on a nested group raises `ArgumentError`.
177
+
178
+ ---
179
+
180
+ ## Fail-Fast Mode
181
+
182
+ Stop at the first failing step instead of running all steps:
183
+
184
+ ```bash
185
+ ./bin/ci --fail-fast
186
+ ./bin/ci -f
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Commit Signoff
192
+
193
+ After a successful local CI run, post a green commit status directly to your Git host to unblock PR merge — no cloud CI runner needed.
194
+
195
+ ### GitHub
196
+
197
+ Requires the [`gh` CLI](https://cli.github.com) and the `gh-signoff` extension:
198
+
199
+ ```bash
200
+ gh extension install basecamp/gh-signoff
201
+ ```
202
+
203
+ Add to `config/ci.rb`:
204
+
205
+ ```ruby
206
+ if success?
207
+ step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
208
+ end
209
+ ```
210
+
211
+ ### Bitbucket
212
+
213
+ Install [`bb-signoff`](https://github.com/its-magdy/bb-signoff):
214
+
215
+ ```bash
216
+ curl -fsSL https://raw.githubusercontent.com/its-magdy/bb-signoff/main/bb-signoff \
217
+ -o /usr/local/bin/bb-signoff && chmod +x /usr/local/bin/bb-signoff
218
+ ```
219
+
220
+ Create a Bitbucket repository access token with these scopes:
221
+ - Repositories: Read, Write, Admin
222
+ - Pull Requests: Read, Write
223
+
224
+ Store the token in `~/.bb-signoff` or export it as `BB_API_TOKEN`.
225
+
226
+ Add to `config/ci.rb`:
227
+
228
+ ```ruby
229
+ if success?
230
+ step "Signoff: All systems go. Ready for merge and deploy.", "bb-signoff"
231
+ end
232
+ ```
233
+
234
+ ---
235
+
236
+ ## CI Integration
237
+
238
+ ### GitHub Actions
239
+
240
+ ```yaml
241
+ name: CI
242
+ on: [push, pull_request]
243
+ jobs:
244
+ test:
245
+ runs-on: ubuntu-latest
246
+ steps:
247
+ - uses: actions/checkout@v4
248
+ - uses: ruby/setup-ruby@v1
249
+ with:
250
+ bundler-cache: true
251
+ - run: bin/ci
252
+ ```
253
+
254
+ ### Bitbucket Pipelines
255
+
256
+ ```yaml
257
+ pipelines:
258
+ default:
259
+ - step:
260
+ name: CI
261
+ script:
262
+ - bundle install
263
+ - bin/ci
264
+ ```
265
+
266
+ ---
267
+
268
+ ## Running Tests
269
+
270
+ ```bash
271
+ bundle exec ruby test/continuous_integration_test.rb
272
+ ```
273
+
274
+ ---
275
+
276
+ ## Upgrading to Rails 8.1
277
+
278
+ 1. Remove `gem "rails-local-ci"` from your `Gemfile`
279
+ 2. Run `bundle install`
280
+
281
+ `bin/ci` and `config/ci.rb` require no changes. Rails 8.1 ships `ActiveSupport::ContinuousIntegration` at the same load path this gem uses.
282
+
283
+ ---
284
+
285
+ ## Compatibility
286
+
287
+ | Rails | Ruby | Status |
288
+ |-------|-------|--------|
289
+ | 7.2 | 3.1+ | Tested |
290
+ | 7.1 | 3.0+ | Compatible |
291
+ | 7.0 | 2.7+ | Compatible |
292
+ | 6.1 | 2.7+ | Compatible |
293
+ | 6.0 | 2.7+ | Compatible |
294
+ | 5.2 | 2.4+ | Compatible |
295
+ | 8.1+ | — | Use built-in — remove this gem |
296
+
297
+ ---
298
+
299
+ ## How It Works
300
+
301
+ The gem places `ActiveSupport::ContinuousIntegration` at `lib/active_support/continuous_integration.rb` — the identical load path Rails 8.1 uses. The generated `bin/ci` does:
302
+
303
+ ```ruby
304
+ require_relative "../config/boot"
305
+ require "active_support/continuous_integration" # resolved by this gem, or Rails 8.1 built-in
306
+
307
+ CI = ActiveSupport::ContinuousIntegration
308
+ require_relative "../config/ci.rb"
309
+ ```
310
+
311
+ The generator copies only `bin/ci` and `config/ci.rb` into your app. The class itself lives in the gem.
312
+
313
+ ---
314
+
315
+ ## License
316
+
317
+ [MIT](LICENSE)
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module ActiveSupport
6
+ class ContinuousIntegration
7
+ class Group # :nodoc:
8
+ class TaskCollector
9
+ attr_reader :tasks
10
+
11
+ def initialize(&block)
12
+ @tasks = []
13
+ instance_eval(&block)
14
+ end
15
+
16
+ def step(title, *command)
17
+ @tasks << [:step, title, command]
18
+ end
19
+
20
+ def group(name, **options, &block)
21
+ raise ArgumentError, "Sub-groups cannot be parallelized. Remove the `parallel:` option from the #{name.inspect} group." if options.key?(:parallel)
22
+ @tasks << [:group, name, block]
23
+ end
24
+ end
25
+
26
+ def initialize(ci, name, parallel:, &block)
27
+ @ci = ci
28
+ @name = name
29
+ @parallel = parallel
30
+ @tasks = TaskCollector.new(&block).tasks
31
+ @start_time = Time.now.to_f
32
+ @mutex = Mutex.new
33
+ @running = {}
34
+ @progress_visible = false
35
+ @log_files = []
36
+ end
37
+
38
+ def run
39
+ previous_trap = Signal.trap("INT") { abort @ci.colorize("\n❌ #{@running.keys.join(', ')} interrupted", :error) }
40
+
41
+ queue = Queue.new
42
+ @tasks.each { |task| queue << task }
43
+
44
+ with_progress do
45
+ @parallel.times.map do
46
+ Thread.new do
47
+ while (task = dequeue(queue))
48
+ break if @ci.failing_fast?
49
+ execute_task(*task)
50
+ end
51
+ end
52
+ end.each(&:join)
53
+ end
54
+ ensure
55
+ Signal.trap("INT", previous_trap || "-")
56
+ @log_files.each { |path| File.delete(path) if File.exist?(path) }
57
+ end
58
+
59
+ private
60
+ def with_progress
61
+ stop = false
62
+ thread = Thread.new do
63
+ until stop
64
+ @mutex.synchronize { refresh_progress }
65
+ sleep 0.1
66
+ end
67
+ end
68
+
69
+ yield
70
+
71
+ stop = true
72
+ thread.join
73
+ end
74
+
75
+ def execute_task(type, title, payload)
76
+ case type
77
+ when :step then execute_step(title, payload)
78
+ when :group then execute_group(title, payload)
79
+ end
80
+ end
81
+
82
+ def execute_step(title, command)
83
+ @mutex.synchronize { @running[title] = Time.now.to_f }
84
+ success, log_path = capture_output(command)
85
+ @mutex.synchronize do
86
+ started = @running.delete(title)
87
+ clear_progress
88
+
89
+ @ci.report_step(title, command) do
90
+ replay_and_cleanup(log_path)
91
+ [success, Time.now.to_f - started]
92
+ end
93
+
94
+ refresh_progress
95
+ end
96
+ success
97
+ end
98
+
99
+ def execute_group(name, block)
100
+ all_success = true
101
+ TaskCollector.new(&block).tasks.each do |type, title, payload|
102
+ unless execute_task(type, title, payload)
103
+ all_success = false
104
+ break if @ci.fail_fast?
105
+ end
106
+ end
107
+
108
+ all_success
109
+ end
110
+
111
+ def capture_output(command)
112
+ log_path = Dir::Tmpname.create(["ci-", ".log"]) { }
113
+ @mutex.synchronize { @log_files << log_path }
114
+
115
+ success = spawn_process(command) do |output|
116
+ File.open(log_path, "w") do |f|
117
+ loop { f.write(output.readpartial(8192)) }
118
+ rescue EOFError, Errno::EIO
119
+ # Expected when process exits
120
+ end
121
+ end
122
+
123
+ [success, log_path]
124
+ rescue SystemCallError => e
125
+ File.write(log_path, "#{e.message}: #{command.join(" ")}\n")
126
+ [false, log_path]
127
+ end
128
+
129
+ def spawn_process(command, &block)
130
+ # Prefer PTY if available to retain output colors
131
+ if pty_available?
132
+ spawn_via_pty(command, &block)
133
+ else
134
+ spawn_via_open3(command, &block)
135
+ end
136
+ end
137
+
138
+ def pty_available?
139
+ require "pty"
140
+ true
141
+ rescue LoadError
142
+ false
143
+ end
144
+
145
+ def spawn_via_pty(command)
146
+ output, input, pid = PTY.spawn(*command)
147
+ input.close
148
+ yield output
149
+ Process.waitpid2(pid).last.success?
150
+ rescue PTY::ChildExited => e
151
+ e.status.success?
152
+ end
153
+
154
+ def spawn_via_open3(command)
155
+ require "open3"
156
+ Open3.popen2e(*command) do |input, output, wait_thr|
157
+ input.close
158
+ yield output
159
+ wait_thr.value.success?
160
+ end
161
+ end
162
+
163
+ def replay_and_cleanup(log_path)
164
+ File.open(log_path, "r") do |f|
165
+ while (chunk = f.read(8192))
166
+ print chunk
167
+ end
168
+ end
169
+ File.delete(log_path)
170
+ end
171
+
172
+ def refresh_progress
173
+ if @running.any?
174
+ print "\n\n" unless @progress_visible
175
+ elapsed = format_elapsed_brief(Time.now.to_f - @start_time)
176
+ print "\r\e[K#{@ci.colorize("#{@name} (#{elapsed}) - #{@running.keys.join(' | ')}...", :progress)}"
177
+ @progress_visible = true
178
+ $stdout.flush
179
+ end
180
+ end
181
+
182
+ def clear_progress
183
+ return unless @progress_visible
184
+ print "\r\e[2A\e[J"
185
+ @progress_visible = false
186
+ end
187
+
188
+ def format_elapsed_brief(seconds)
189
+ min, sec = seconds.divmod(60)
190
+ "#{"#{min.to_i}m" if min > 0}#{sec.to_i}s"
191
+ end
192
+
193
+ def dequeue(queue)
194
+ queue.pop(true)
195
+ rescue ThreadError
196
+ nil
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,231 @@
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
+ progress: "\033[1;36m" # Cyan
32
+ }
33
+
34
+ attr_reader :results
35
+
36
+ # Perform a CI run. Execute each step, show their results and runtime, and exit with a non-zero status if there are any failures.
37
+ #
38
+ # Pass an optional title, subtitle, and a block that declares the steps to be executed.
39
+ #
40
+ # Sets the CI environment variable to "true" to allow for conditional behavior in the app, like enabling eager loading and disabling logging.
41
+ #
42
+ # A 'fail fast' option can be passed as a CLI argument (-f or --fail-fast). This exits with a non-zero status directly after a step fails.
43
+ #
44
+ # Example:
45
+ #
46
+ # ActiveSupport::ContinuousIntegration.run do
47
+ # step "Setup", "bin/setup --skip-server"
48
+ # step "Style: Ruby", "bin/rubocop"
49
+ # step "Security: Gem audit", "bin/bundler-audit"
50
+ # step "Tests: Rails", "bin/rails test test:system"
51
+ #
52
+ # if success?
53
+ # step "Signoff: Ready for merge and deploy", "gh signoff"
54
+ # else
55
+ # failure "Skipping signoff; CI failed.", "Fix the issues and try again."
56
+ # end
57
+ # end
58
+ def self.run(title = "Continuous Integration", subtitle = "Running tests, style checks, and security audits", &block)
59
+ ENV["CI"] = "true"
60
+ new.tap { |ci| ci.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?
68
+ end
69
+
70
+ def initialize
71
+ @results = []
72
+ end
73
+
74
+ # Declare a step with a title and a command. The command can either be given as a single string or as multiple
75
+ # strings that will be passed to `system` as individual arguments (and therefore correctly escaped for paths etc).
76
+ #
77
+ # Examples:
78
+ #
79
+ # step "Setup", "bin/setup"
80
+ # step "Single test", "bin/rails", "test", "--name", "test_that_is_one"
81
+ def step(title, *command)
82
+ previous_trap = 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
+ ensure
89
+ Signal.trap("INT", previous_trap || "-")
90
+ end
91
+
92
+ # Declare a group of steps that can be run in parallel. Steps within the group are collected first,
93
+ # then executed either concurrently (when +parallel+ > 1) or sequentially (when +parallel+ is 1).
94
+ #
95
+ # When running in parallel, each step's output is captured to avoid interleaving, and a progress
96
+ # display shows which steps are currently running.
97
+ #
98
+ # Sub-groups within a parallel group occupy a single parallel slot and run their steps sequentially.
99
+ #
100
+ # Examples:
101
+ #
102
+ # group "Checks", parallel: 3 do
103
+ # step "Style: Ruby", "bin/rubocop"
104
+ # step "Security: Brakeman", "bin/brakeman --quiet"
105
+ # step "Security: Gem audit", "bin/bundler-audit"
106
+ # end
107
+ #
108
+ # group "Tests" do
109
+ # step "Unit tests", "bin/rails test"
110
+ # step "System tests", "bin/rails test:system"
111
+ # end
112
+ def group(name, parallel: 1, &block)
113
+ if parallel <= 1
114
+ instance_eval(&block)
115
+ else
116
+ Group.new(self, name, parallel: parallel, &block).run
117
+ end
118
+ abort if failing_fast?
119
+ end
120
+
121
+ # Returns true if all steps were successful.
122
+ def success?
123
+ results.map(&:first).all?
124
+ end
125
+
126
+ # Display an error heading with the title and optional subtitle to reflect that the run failed.
127
+ def failure(title, subtitle = nil)
128
+ heading title, subtitle, type: :error
129
+ end
130
+
131
+ # Display a colorized heading followed by an optional subtitle.
132
+ #
133
+ # Examples:
134
+ #
135
+ # heading "Smoke Testing", "End-to-end tests verifying key functionality", padding: false
136
+ # heading "Skipping video encoding tests", "Install FFmpeg to run these tests", type: :error
137
+ #
138
+ # See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
139
+ def heading(heading, subtitle = nil, type: :banner, padding: true)
140
+ echo "#{padding ? "\n\n" : ""}#{heading}", type: type
141
+ echo "#{subtitle}#{padding ? "\n" : ""}", type: :subtitle if subtitle
142
+ end
143
+
144
+ # Echo text to the terminal in the color corresponding to the type of the text.
145
+ #
146
+ # Examples:
147
+ #
148
+ # echo "This is going to be green!", type: :success
149
+ # echo "This is going to be red!", type: :error
150
+ #
151
+ # See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
152
+ def echo(text, type:)
153
+ puts colorize(text, type)
154
+ end
155
+
156
+ # :nodoc:
157
+ def report_step(title, command)
158
+ heading title, command.join(" "), type: :title
159
+ success, seconds = yield
160
+ result_line(title, success, seconds)
161
+ results << [success, title]
162
+ success
163
+ end
164
+
165
+ # :nodoc:
166
+ def colorize(text, type)
167
+ "#{COLORS.fetch(type)}#{text}\033[0m"
168
+ end
169
+
170
+ # :nodoc:
171
+ def fail_fast?
172
+ ARGV.include?("-f") || ARGV.include?("--fail-fast")
173
+ end
174
+
175
+ # :nodoc:
176
+ def failing_fast?
177
+ fail_fast? && failures.any?
178
+ end
179
+
180
+ private
181
+ def failures
182
+ results.reject(&:first)
183
+ end
184
+
185
+ def multiple_results?
186
+ results.size > 1
187
+ end
188
+
189
+ def execute(title, &block)
190
+ previous_trap = Signal.trap("INT") { abort colorize("\n❌ #{title} interrupted", :error) }
191
+
192
+ seconds = timing { instance_eval(&block) }
193
+
194
+ unless success?
195
+ if multiple_results?
196
+ failures.each do |success, title|
197
+ unless success
198
+ echo " ↳ #{title} failed", type: :error
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ [success?, seconds]
205
+ ensure
206
+ Signal.trap("INT", previous_trap || "-")
207
+ end
208
+
209
+ def result_line(title, success, seconds)
210
+ elapsed = format_elapsed(seconds)
211
+ if success
212
+ echo "\n✅ #{title} passed in #{elapsed}", type: :success
213
+ else
214
+ echo "\n❌ #{title} failed in #{elapsed}", type: :error
215
+ end
216
+ end
217
+
218
+ def format_elapsed(seconds)
219
+ min, sec = seconds.divmod(60)
220
+ "#{"#{min.to_i}m" if min > 0}%.2fs" % sec
221
+ end
222
+
223
+ def timing
224
+ started_at = Time.now.to_f
225
+ yield
226
+ Time.now.to_f - started_at
227
+ end
228
+ end
229
+ end
230
+
231
+ require_relative "continuous_integration/group"
@@ -0,0 +1,22 @@
1
+ require "rails/generators"
2
+
3
+ module RailsLocalCi
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path("templates", __dir__)
6
+ desc "Install Local CI: copies bin/ci and config/ci.rb into your Rails app"
7
+
8
+ def copy_ci_binstub
9
+ copy_file "bin_ci", "bin/ci"
10
+ chmod "bin/ci", 0755
11
+ end
12
+
13
+ def copy_ci_config
14
+ copy_file "config_ci.rb", "config/ci.rb"
15
+ end
16
+
17
+ def show_readme
18
+ say "\n✅ Local CI installed!", :green
19
+ say " Run: ./bin/ci", :cyan
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../config/boot"
3
+ require "active_support/continuous_integration"
4
+
5
+ CI = ActiveSupport::ContinuousIntegration
6
+ require_relative "../config/ci.rb"
@@ -0,0 +1,28 @@
1
+ # Run using bin/ci
2
+
3
+ CI.run do
4
+ step "Setup", "bin/setup --skip-server"
5
+
6
+ # Run independent checks in parallel for faster feedback:
7
+ #
8
+ # group "Checks", parallel: 3 do
9
+ # step "Style: Ruby", "bin/rubocop" if File.exist?("bin/rubocop")
10
+ # step "Security: Brakeman", "bin/brakeman --quiet" if File.exist?("bin/brakeman")
11
+ # step "Security: Gems", "bin/bundler-audit" if File.exist?("bin/bundler-audit")
12
+ # end
13
+
14
+ step "Style: Ruby", "bin/rubocop" if File.exist?("bin/rubocop")
15
+ step "Security: Gem audit", "bin/bundler-audit" if File.exist?("bin/bundler-audit")
16
+ step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" if File.exist?("bin/brakeman")
17
+ step "Tests: Rails", "bin/rails test"
18
+
19
+ # Optional: set a green commit status to unblock PR merge.
20
+ # GitHub: Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
21
+ # Bitbucket: Install bb-signoff (https://github.com/Mohamed-Omar96/bb-signoff).
22
+ # if success?
23
+ # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" # GitHub
24
+ # step "Signoff: All systems go. Ready for merge and deploy.", "bb-signoff" # Bitbucket
25
+ # else
26
+ # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
27
+ # end
28
+ end
@@ -0,0 +1,9 @@
1
+ require "rails/railtie"
2
+
3
+ module RailsLocalCi
4
+ class Railtie < Rails::Railtie
5
+ generators do
6
+ require "generators/rails_local_ci/install_generator"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,2 @@
1
+ require "active_support/continuous_integration"
2
+ require "rails_local_ci/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-local-ci
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mohamed Magdy Omar
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8.1'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.1'
33
+ description: Ships ActiveSupport::ContinuousIntegration and a generator that installs
34
+ bin/ci + config/ci.rb. Any Rails 6/7 app can `gem 'rails-local-ci'` and get the
35
+ same Local CI DX as Rails 8.1 built-in. Upgrading to Rails 8.1 later = just remove
36
+ the gem, zero code changes.
37
+ email:
38
+ - mm.medany96@gmail.com
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - LICENSE
44
+ - README.md
45
+ - lib/active_support/continuous_integration.rb
46
+ - lib/active_support/continuous_integration/group.rb
47
+ - lib/generators/rails_local_ci/install_generator.rb
48
+ - lib/generators/rails_local_ci/templates/bin_ci
49
+ - lib/generators/rails_local_ci/templates/config_ci.rb
50
+ - lib/rails_local_ci.rb
51
+ - lib/rails_local_ci/railtie.rb
52
+ homepage: https://github.com/its-magdy/rails-local-ci
53
+ licenses:
54
+ - MIT
55
+ metadata: {}
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '2.4'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Backport of Rails 8.1 Local CI (ActiveSupport::ContinuousIntegration) for
75
+ Rails 5.2–7.x
76
+ test_files: []