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 +7 -0
- data/LICENSE +21 -0
- data/README.md +317 -0
- data/lib/active_support/continuous_integration/group.rb +200 -0
- data/lib/active_support/continuous_integration.rb +231 -0
- data/lib/generators/rails_local_ci/install_generator.rb +22 -0
- data/lib/generators/rails_local_ci/templates/bin_ci +6 -0
- data/lib/generators/rails_local_ci/templates/config_ci.rb +28 -0
- data/lib/rails_local_ci/railtie.rb +9 -0
- data/lib/rails_local_ci.rb +2 -0
- metadata +76 -0
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,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
|
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: []
|