Created
January 21, 2026 02:03
-
-
Save scottwater/1f65a8127cf595c084af84f743e48ac7 to your computer and use it in GitHub Desktop.
ContinuousIntegration with Fail Fast and Continuation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # frozen_string_literal: true | |
| # Extended version of Rails' ActiveSupport::ContinuousIntegration with: | |
| # - bin/ci -f (--fail-fast): Stop immediately when a step fails | |
| # - bin/ci -c (--continue): Resume from the last failed step | |
| # - bin/ci -fc: Both options combined | |
| class ContinuousIntegration | |
| COLORS = { | |
| banner: "\033[1;32m", # Green | |
| title: "\033[1;35m", # Purple | |
| subtitle: "\033[1;90m", # Medium Gray | |
| error: "\033[1;31m", # Red | |
| success: "\033[1;32m", # Green | |
| skip: "\033[1;33m" # Yellow | |
| } | |
| STATE_FILE = ".ci_state" | |
| attr_reader :results | |
| def self.run(title = "Continuous Integration", subtitle = "Running tests, style checks, and security audits", &block) | |
| new.tap do |ci| | |
| ENV["CI"] = "true" | |
| ci.heading title, subtitle, padding: false | |
| ci.show_mode_info | |
| ci.report(title, &block) | |
| abort unless ci.success? | |
| end | |
| end | |
| def initialize | |
| @results = [] | |
| @step_names = [] | |
| @skip_until = continue_mode? ? load_failed_step : nil | |
| @skipping = !!@skip_until | |
| end | |
| def step(title, *command) | |
| @step_names << title | |
| if @skipping | |
| if title == @skip_until | |
| @skipping = false | |
| clear_state | |
| else | |
| heading title, "skipped (resuming from: #{@skip_until})", type: :skip | |
| results << [true, title] | |
| return | |
| end | |
| end | |
| heading title, command.join(" "), type: :title | |
| report(title) do | |
| success = system(*command) | |
| results << [success, title] | |
| if !success && fail_fast? | |
| save_failed_step(title) | |
| abort colorize("\n❌ #{title} failed (fail-fast enabled)", :error) | |
| end | |
| end | |
| end | |
| def success? | |
| results.map(&:first).all? | |
| end | |
| def failure(title, subtitle = nil) | |
| heading title, subtitle, type: :error | |
| end | |
| def heading(heading, subtitle = nil, type: :banner, padding: true) | |
| echo "#{"\n\n" if padding}#{heading}", type: type | |
| echo "#{subtitle}#{"\n" if padding}", type: :subtitle if subtitle | |
| end | |
| def echo(text, type:) | |
| puts colorize(text, type) | |
| end | |
| def show_mode_info | |
| modes = [] | |
| modes << "fail-fast" if fail_fast? | |
| modes << "continue from '#{@skip_until}'" if @skip_until | |
| echo "Mode: #{modes.join(", ")}\n", type: :subtitle if modes.any? | |
| end | |
| def report(title, &block) | |
| Signal.trap("INT") { abort colorize("\n❌ #{title} interrupted", :error) } | |
| ci = self.class.new | |
| ci.instance_variable_set(:@skip_until, @skip_until) | |
| ci.instance_variable_set(:@skipping, @skipping) | |
| elapsed = timing { ci.instance_eval(&block) } | |
| @skip_until = ci.instance_variable_get(:@skip_until) | |
| @skipping = ci.instance_variable_get(:@skipping) | |
| if ci.success? | |
| echo "\n✅ #{title} passed in #{elapsed}", type: :success | |
| clear_state | |
| else | |
| echo "\n❌ #{title} failed in #{elapsed}", type: :error | |
| if ci.multiple_results? | |
| ci.failures.each do |success, step_title| | |
| echo " ↳ #{step_title} failed", type: :error | |
| end | |
| end | |
| end | |
| results.concat ci.results | |
| ensure | |
| Signal.trap("INT", "-") | |
| end | |
| def failures | |
| results.reject(&:first) | |
| end | |
| def multiple_results? | |
| results.size > 1 | |
| end | |
| def fail_fast? | |
| ARGV.include?("-f") || ARGV.include?("--fail-fast") || | |
| ARGV.include?("-fc") || ARGV.include?("-cf") | |
| end | |
| def continue_mode? | |
| ARGV.include?("-c") || ARGV.include?("--continue") || | |
| ARGV.include?("-fc") || ARGV.include?("-cf") | |
| end | |
| private | |
| def state_file_path | |
| File.join(Dir.pwd, STATE_FILE) | |
| end | |
| def save_failed_step(title) | |
| File.write(state_file_path, title) | |
| end | |
| def load_failed_step | |
| return nil unless File.exist?(state_file_path) | |
| File.read(state_file_path).strip | |
| end | |
| def clear_state | |
| File.delete(state_file_path) if File.exist?(state_file_path) | |
| end | |
| def timing | |
| started_at = Time.now.to_f | |
| yield | |
| min, sec = (Time.now.to_f - started_at).divmod(60) | |
| "#{"#{min}m" if min > 0}%.2fs" % sec | |
| end | |
| def colorize(text, type) | |
| "#{COLORS.fetch(type)}#{text}\033[0m" | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment