fun_ci 1.0.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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_config"
4
+
5
+ module FunCi
6
+ class SetupChecker
7
+ def self.run(project_root:, stdout: $stdout, stderr: $stderr)
8
+ new(project_root: project_root, stdout: stdout).run
9
+ end
10
+
11
+ def initialize(project_root:, stdout:)
12
+ @config = ProjectConfig.new(project_root)
13
+ @stdout = stdout
14
+ end
15
+
16
+ def run
17
+ errors = @config.validate
18
+
19
+ if errors.empty?
20
+ @stdout.puts "All OK — project is configured."
21
+ 0
22
+ else
23
+ errors.each { |e| @stdout.puts e }
24
+ 1
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ class Spinner
5
+ BRAILLE_FRAMES = %W[\u2800 \u2801 \u2803 \u2807 \u280F \u281F \u283F \u287F].freeze
6
+
7
+ attr_reader :frames
8
+
9
+ def initialize
10
+ @frames = BRAILLE_FRAMES
11
+ @index = 0
12
+ end
13
+
14
+ def current_frame
15
+ @frames[@index]
16
+ end
17
+
18
+ def advance!
19
+ @index = (@index + 1) % @frames.length
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module StageJob
7
+ TERMINAL_STATUSES = %w[completed failed timed_out cancelled].freeze
8
+
9
+ def self.create(db, pipeline_run_id:, stage:)
10
+ db.execute(
11
+ "INSERT INTO stage_jobs (pipeline_run_id, stage, status) VALUES (?, ?, 'scheduled')",
12
+ [pipeline_run_id, stage]
13
+ )
14
+ db.last_insert_row_id
15
+ end
16
+
17
+ def self.find(db, id)
18
+ row = db.execute("SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE id = ?", [id]).first
19
+ return nil unless row
20
+ row_to_hash(row)
21
+ end
22
+
23
+ def self.update_status(db, id, new_status)
24
+ now = Time.now.utc.iso8601
25
+ if new_status == "running"
26
+ db.execute("UPDATE stage_jobs SET status = ?, started_at = ? WHERE id = ?", [new_status, now, id])
27
+ elsif TERMINAL_STATUSES.include?(new_status)
28
+ db.execute("UPDATE stage_jobs SET status = ?, completed_at = ? WHERE id = ?", [new_status, now, id])
29
+ else
30
+ db.execute("UPDATE stage_jobs SET status = ? WHERE id = ?", [new_status, id])
31
+ end
32
+ end
33
+
34
+ def self.elapsed_duration(job)
35
+ return nil unless job[:started_at] && job[:completed_at]
36
+ Time.parse(job[:completed_at]) - Time.parse(job[:started_at])
37
+ end
38
+
39
+ def self.row_to_hash(row)
40
+ { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
41
+ end
42
+ private_class_method :row_to_hash
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module FunCi
7
+ class StageRunner
8
+ def initialize(commit_hash:, stdout:, command_runner: nil, time_budgets: {}, recorder: NullRecorder.new)
9
+ @commit_hash = commit_hash
10
+ @stdout = stdout
11
+ @command_runner = command_runner
12
+ @time_budgets = time_budgets
13
+ @recorder = recorder
14
+ end
15
+
16
+ def run_stage(config, stage)
17
+ script = config.script_path(stage)
18
+ budget = @time_budgets[stage]
19
+
20
+ job_id = @recorder.start_stage(stage)
21
+ output, status, timed_out = run_with_timeout(script, budget)
22
+
23
+ if timed_out
24
+ @recorder.end_stage(job_id, "timed_out")
25
+ @stdout.puts "#{stage_label(stage)} killed -- exceeded #{budget}s time budget."
26
+ @stdout.puts budget_advice(stage)
27
+ return false
28
+ end
29
+
30
+ unless status.success?
31
+ @recorder.end_stage(job_id, "failed")
32
+ @stdout.puts output unless output.empty?
33
+ @stdout.puts "#{stage_label(stage)} failed."
34
+ return false
35
+ end
36
+
37
+ @recorder.end_stage(job_id, "completed")
38
+ true
39
+ end
40
+
41
+ private
42
+
43
+ def run_with_timeout(script, budget)
44
+ cmd = "#{script} #{@commit_hash}"
45
+
46
+ if @command_runner
47
+ begin
48
+ Timeout.timeout(budget) do
49
+ output, status = @command_runner.call(cmd)
50
+ [output, status, false]
51
+ end
52
+ rescue Timeout::Error
53
+ ["", nil, true]
54
+ end
55
+ else
56
+ run_process_with_timeout(cmd, budget)
57
+ end
58
+ end
59
+
60
+ def run_process_with_timeout(cmd, budget)
61
+ pid = nil
62
+ output = ""
63
+ r, w = IO.pipe
64
+ pid = Process.spawn(cmd, out: w, err: w)
65
+ w.close
66
+
67
+ begin
68
+ Timeout.timeout(budget) do
69
+ output = r.read
70
+ _, status = Process.waitpid2(pid)
71
+ pid = nil
72
+ [output, status, false]
73
+ end
74
+ rescue Timeout::Error
75
+ Process.kill("TERM", pid) rescue nil
76
+ Process.kill("KILL", pid) rescue nil
77
+ Process.waitpid(pid) rescue nil
78
+ r.close rescue nil
79
+ ["", nil, true]
80
+ ensure
81
+ r.close rescue nil
82
+ end
83
+ end
84
+
85
+ def stage_label(stage)
86
+ case stage
87
+ when "lint" then "Lint"
88
+ when "build" then "Build"
89
+ when "fast" then "Fast suite"
90
+ when "slow" then "Slow suite"
91
+ else stage
92
+ end
93
+ end
94
+
95
+ def budget_advice(stage)
96
+ case stage
97
+ when "lint"
98
+ "Trim your linter config or split into stages."
99
+ when "build"
100
+ "Keep your build efficient and not let it become a bottleneck."
101
+ when "fast"
102
+ "Your fast tests have gotten too slow. Split or speed them up."
103
+ when "slow"
104
+ "Pare down integration tests, parallelise, or raise the budget."
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline_run"
4
+
5
+ module FunCi
6
+ class StalePipelineCanceller
7
+ def initialize(db:, branch:, stdout:, process_killer: nil)
8
+ @db = db
9
+ @branch = branch
10
+ @stdout = stdout
11
+ @process_killer = process_killer || method(:default_process_killer)
12
+ end
13
+
14
+ def cancel(new_commit_hash:)
15
+ run = PipelineRun.find_running_with_pid(@db, @branch)
16
+ return unless run
17
+
18
+ kill_process(run[:pid])
19
+ PipelineRun.update_status(@db, run[:id], "cancelled")
20
+ @stdout.puts "Cancelled stale pipeline for #{run[:commit_hash]}. Starting fresh for #{new_commit_hash}."
21
+ end
22
+
23
+ def store_pid(run_id, pid)
24
+ PipelineRun.store_pid(@db, run_id, pid)
25
+ end
26
+
27
+ private
28
+
29
+ def kill_process(pid)
30
+ @process_killer.call(0, pid)
31
+ safe_signal("TERM", pid)
32
+ safe_signal("KILL", pid)
33
+ begin
34
+ Process.waitpid(pid)
35
+ rescue Errno::ECHILD, Errno::ESRCH # rubocop:disable Lint/SuppressedException
36
+ end
37
+ rescue Errno::ESRCH
38
+ # Process already dead
39
+ end
40
+
41
+ def safe_signal(signal, pid)
42
+ @process_killer.call(signal, pid)
43
+ rescue Errno::ESRCH
44
+ # Already dead
45
+ end
46
+
47
+ def default_process_killer(signal, pid)
48
+ Process.kill(signal, pid)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ # Enforced state machine for pipeline runs and stage jobs.
5
+ #
6
+ # States: scheduled, running, completed, failed, timed_out, cancelled
7
+ #
8
+ # Valid transitions:
9
+ # scheduled -> running
10
+ # scheduled -> cancelled
11
+ # running -> completed
12
+ # running -> failed
13
+ # running -> timed_out
14
+ # running -> cancelled
15
+ class StateMachine
16
+ class InvalidTransition < StandardError; end
17
+
18
+ STATES = %i[scheduled running completed failed timed_out cancelled].freeze
19
+
20
+ TRANSITIONS = {
21
+ scheduled: %i[running cancelled],
22
+ running: %i[completed failed timed_out cancelled],
23
+ completed: [],
24
+ failed: [],
25
+ timed_out: [],
26
+ cancelled: []
27
+ }.freeze
28
+
29
+ TERMINAL_STATES = %i[completed failed timed_out cancelled].freeze
30
+
31
+ attr_reader :current_state
32
+
33
+ def initialize(initial_state)
34
+ validate_state!(initial_state)
35
+ @current_state = initial_state
36
+ end
37
+
38
+ def transition_to!(new_state)
39
+ validate_state!(new_state)
40
+ unless TRANSITIONS.fetch(@current_state).include?(new_state)
41
+ raise InvalidTransition,
42
+ "Cannot transition from #{@current_state} to #{new_state}"
43
+ end
44
+ @current_state = new_state
45
+ end
46
+
47
+ def terminal?
48
+ TERMINAL_STATES.include?(@current_state)
49
+ end
50
+
51
+ private
52
+
53
+ def validate_state!(state)
54
+ return if STATES.include?(state)
55
+
56
+ raise ArgumentError, "Unknown state: #{state}. Valid states: #{STATES.join(", ")}"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module StreakCounter
5
+ PASS_STATUS = "completed"
6
+ ACTIVE_STATUSES = %w[running scheduled].freeze
7
+
8
+ # Counts consecutive passed runs from the most recent terminal run.
9
+ # Skips running/scheduled pipelines.
10
+ # Returns nil if no terminal runs exist, 0 if streak is broken.
11
+ def self.count(runs)
12
+ terminal_runs = runs.reject { |r| ACTIVE_STATUSES.include?(r[:status]) }
13
+ return nil if terminal_runs.empty?
14
+
15
+ streak = 0
16
+ terminal_runs.each do |run|
17
+ break unless run[:status] == PASS_STATUS
18
+ streak += 1
19
+ end
20
+ streak
21
+ end
22
+
23
+ def self.format_text(streak)
24
+ return nil if streak.nil?
25
+ return "Streak broken" if streak == 0
26
+
27
+ "#{streak} in a row!"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ class TemplateWriter
5
+ TEMPLATES = {
6
+ ruby_bundler: {
7
+ "lint.sh" => "#!/bin/sh\nbundle exec rubocop\n",
8
+ "build.sh" => "#!/bin/sh\nbundle install --quiet\n",
9
+ "fast.sh" => "#!/bin/sh\nbundle exec rake test\n",
10
+ "slow.sh" => "#!/bin/sh\nbundle exec rake test:slow\n"
11
+ },
12
+ jvm_gradle_kotlin: {
13
+ "lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
14
+ "build.sh" => "#!/bin/sh\n./gradlew assemble\n",
15
+ "fast.sh" => "#!/bin/sh\n./gradlew test\n",
16
+ "slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
17
+ },
18
+ jvm_gradle_groovy: {
19
+ "lint.sh" => "#!/bin/sh\n./gradlew check -x test\n",
20
+ "build.sh" => "#!/bin/sh\n./gradlew assemble\n",
21
+ "fast.sh" => "#!/bin/sh\n./gradlew test\n",
22
+ "slow.sh" => "#!/bin/sh\n./gradlew integrationTest\n"
23
+ },
24
+ jvm_maven: {
25
+ "lint.sh" => "#!/bin/sh\nmvn verify -DskipTests\n",
26
+ "build.sh" => "#!/bin/sh\nmvn compile\n",
27
+ "fast.sh" => "#!/bin/sh\nmvn test\n",
28
+ "slow.sh" => "#!/bin/sh\nmvn verify\n"
29
+ }
30
+ }.freeze
31
+
32
+ def initialize(template_id, target_dir, lint_override: nil)
33
+ @template_id = template_id
34
+ @target_dir = target_dir
35
+ @lint_override = lint_override
36
+ end
37
+
38
+ def write
39
+ scripts = TEMPLATES.fetch(@template_id)
40
+ fun_ci_dir = File.join(@target_dir, ".fun-ci")
41
+ Dir.mkdir(fun_ci_dir)
42
+
43
+ scripts.each do |name, content|
44
+ content = "#!/bin/sh\n#{@lint_override}\n" if name == "lint.sh" && @lint_override
45
+ path = File.join(fun_ci_dir, name)
46
+ File.write(path, content)
47
+ File.chmod(0o755, path)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require_relative "project_config"
6
+ require_relative "pipeline_recorder"
7
+ require_relative "background_wrapper"
8
+ require_relative "stage_runner"
9
+ require_relative "stale_pipeline_canceller"
10
+ require_relative "progress_reporter"
11
+ require_relative "pipeline_forker"
12
+
13
+ module FunCi
14
+ class Trigger
15
+ DEFAULT_BUDGETS = {
16
+ "lint" => 30,
17
+ "build" => 30,
18
+ "fast" => 10,
19
+ "slow" => 300
20
+ }.freeze
21
+
22
+ def self.run_from_args(args, stdout: $stdout, stderr: $stderr, recorder: NullRecorder.new, pipeline_forker: nil)
23
+ positional = args.reject { |a| a.start_with?("--") }
24
+ if positional.length < 2
25
+ stderr.puts "fun-ci: commit hash and branch name are required."
26
+ stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
27
+ return 1
28
+ end
29
+ commit_hash, branch = positional
30
+ if args.include?("--no-validate")
31
+ db_path = recorder.db_path
32
+ recorder.close
33
+ (pipeline_forker || PipelineForker.method(:fork_pipeline)).call(
34
+ commit_hash: commit_hash, branch: branch, db_path: db_path
35
+ )
36
+ return 0
37
+ end
38
+ new(project_root: Dir.pwd, commit_hash: commit_hash, branch: branch,
39
+ stdout: stdout, stderr: stderr, recorder: recorder).run
40
+ end
41
+
42
+ attr_writer :command_runner
43
+
44
+ def initialize(project_root:, commit_hash:, branch:, stdout: $stdout, stderr: $stderr, command_runner: nil, time_budgets: {}, commit_validator: nil, recorder: NullRecorder.new, background_launcher: nil)
45
+ @project_root = project_root
46
+ @commit_hash = commit_hash
47
+ @branch = branch
48
+ @stdout = stdout
49
+ @stderr = stderr
50
+ @command_runner = command_runner
51
+ @time_budgets = DEFAULT_BUDGETS.merge(time_budgets)
52
+ @commit_validator = commit_validator || method(:default_commit_validator)
53
+ @recorder = recorder
54
+ @background_launcher = background_launcher || method(:default_background_launcher)
55
+ end
56
+
57
+ def run
58
+ config = ProjectConfig.new(@project_root)
59
+ return handle_config_errors(config) if config.validate.any?
60
+ unless @commit_validator.call(@commit_hash)
61
+ @stderr.puts "fun-ci: commit #{@commit_hash} not found in this repository."
62
+ return 1
63
+ end
64
+ cancel_stale_pipelines
65
+ @recorder.create_run(commit_hash: @commit_hash, branch: @branch, project_path: @project_root)
66
+ stage_runner = make_stage_runner
67
+ progress = ProgressReporter.new(stdout: @stdout)
68
+ results = run_phase_one(stage_runner, config)
69
+ progress.phase_one_result(results)
70
+ unless results.values.all?
71
+ @recorder.fail_run
72
+ return 1
73
+ end
74
+ spawn_slow_suite(config)
75
+ progress.slow_launched
76
+ fast_runner = make_stage_runner
77
+ fast_passed = fast_runner.run_stage(config, "fast")
78
+ progress.fast_result(fast_passed)
79
+ unless fast_passed
80
+ @recorder.fail_run
81
+ return 1
82
+ end
83
+ 0
84
+ end
85
+
86
+ private
87
+
88
+ def handle_config_errors(config)
89
+ config.validate.each { |e| @stdout.puts "fun-ci: #{e}" }
90
+ unless config.folder_exists?
91
+ @stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
92
+ @stdout.puts "Commit will proceed without CI."
93
+ end
94
+ 0
95
+ end
96
+
97
+ def run_phase_one(stage_runner, config)
98
+ results = {}
99
+ threads = %w[lint build].map do |stage|
100
+ Thread.new { results[stage] = stage_runner.run_stage(config, stage) }
101
+ end
102
+ threads.each(&:join)
103
+ results
104
+ end
105
+
106
+ def spawn_slow_suite(config)
107
+ cmd = "#{config.script_path("slow")} #{@commit_hash}"
108
+ job_id = @recorder.start_stage("slow")
109
+ runner = @command_runner || ->(c) { Open3.capture2e(c) }
110
+ budget = @time_budgets["slow"]
111
+ executor = -> { Timeout.timeout(budget) { runner.call(cmd) } }
112
+ @background_launcher.call(
113
+ db_path: @recorder.db_path, pipeline_run_id: @recorder.pipeline_run_id,
114
+ job_id: job_id, executor: executor
115
+ )
116
+ end
117
+
118
+ def make_stage_runner
119
+ StageRunner.new(
120
+ commit_hash: @commit_hash, stdout: @stdout,
121
+ command_runner: @command_runner,
122
+ time_budgets: @time_budgets, recorder: @recorder
123
+ )
124
+ end
125
+
126
+ def cancel_stale_pipelines
127
+ return unless @recorder.db
128
+ StalePipelineCanceller.new(
129
+ db: @recorder.db, branch: @branch, stdout: @stdout
130
+ ).cancel(new_commit_hash: @commit_hash)
131
+ end
132
+
133
+ def default_background_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
134
+ @recorder.close
135
+ pid = fork do
136
+ recorder = DbRecorder.for_background(db_path, pipeline_run_id)
137
+ BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
138
+ recorder.close
139
+ end
140
+ @recorder = DbRecorder.for_background(db_path, pipeline_run_id)
141
+ Process.detach(pid)
142
+ PipelineRun.store_pid(@recorder.db, pipeline_run_id, pid)
143
+ end
144
+
145
+ def default_commit_validator(commit_hash)
146
+ _, status = Open3.capture2e("git", "cat-file", "-t", commit_hash)
147
+ status.success?
148
+ end
149
+ end
150
+ end
data/lib/fun_ci.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fun_ci/database"
4
+ require_relative "fun_ci/state_machine"
5
+ require_relative "fun_ci/pipeline_run"
6
+ require_relative "fun_ci/stage_job"
7
+ require_relative "fun_ci/project_config"
8
+
9
+ module FunCi
10
+ VERSION = "1.0.0"
11
+
12
+ BUILD_TIMEOUT = 30
13
+ FAST_SUITE_TIMEOUT = 10
14
+ SLOW_SUITE_TIMEOUT = 300
15
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fun_ci
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Erik Thyge Madsen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sqlite3
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: Opinionated local CI that checks your code before it leaves your machine.
27
+ Runs a four-stage pipeline (lint, build, fast tests, slow tests) on every commit
28
+ with strict time budgets. Hooks into git pre-commit and pre-push, stores results
29
+ in SQLite, and includes a TUI dashboard for monitoring.
30
+ executables:
31
+ - fun-ci
32
+ - fun-ci-trigger
33
+ - fun-ci-tui
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - exe/fun-ci
41
+ - exe/fun-ci-trigger
42
+ - exe/fun-ci-tui
43
+ - lib/fun_ci.rb
44
+ - lib/fun_ci/admin_tui.rb
45
+ - lib/fun_ci/ansi.rb
46
+ - lib/fun_ci/background_wrapper.rb
47
+ - lib/fun_ci/board_data.rb
48
+ - lib/fun_ci/cli.rb
49
+ - lib/fun_ci/database.rb
50
+ - lib/fun_ci/duration_formatter.rb
51
+ - lib/fun_ci/hook_writer.rb
52
+ - lib/fun_ci/installer.rb
53
+ - lib/fun_ci/maven_linter_detector.rb
54
+ - lib/fun_ci/pipeline_forker.rb
55
+ - lib/fun_ci/pipeline_recorder.rb
56
+ - lib/fun_ci/pipeline_run.rb
57
+ - lib/fun_ci/progress_reporter.rb
58
+ - lib/fun_ci/project_config.rb
59
+ - lib/fun_ci/project_detector.rb
60
+ - lib/fun_ci/relative_time.rb
61
+ - lib/fun_ci/row_formatter.rb
62
+ - lib/fun_ci/screen.rb
63
+ - lib/fun_ci/setup_checker.rb
64
+ - lib/fun_ci/spinner.rb
65
+ - lib/fun_ci/stage_job.rb
66
+ - lib/fun_ci/stage_runner.rb
67
+ - lib/fun_ci/stale_pipeline_canceller.rb
68
+ - lib/fun_ci/state_machine.rb
69
+ - lib/fun_ci/streak_counter.rb
70
+ - lib/fun_ci/template_writer.rb
71
+ - lib/fun_ci/trigger.rb
72
+ homepage: https://github.com/beatmadsen/fun-ci
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ source_code_uri: https://github.com/beatmadsen/fun-ci
77
+ changelog_uri: https://github.com/beatmadsen/fun-ci/blob/main/CHANGELOG.md
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '3.0'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 4.0.3
93
+ specification_version: 4
94
+ summary: Opinionated local CI that checks your code before it leaves your machine
95
+ test_files: []