Skip to content

Instantly share code, notes, and snippets.

@scottwater
Created January 21, 2026 02:03
Show Gist options
  • Select an option

  • Save scottwater/1f65a8127cf595c084af84f743e48ac7 to your computer and use it in GitHub Desktop.

Select an option

Save scottwater/1f65a8127cf595c084af84f743e48ac7 to your computer and use it in GitHub Desktop.
ContinuousIntegration with Fail Fast and Continuation
# 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