Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ group :development, :test do

# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false

# Parallel RSpec processes and runtime-based grouping for local CI.
gem "parallel_tests", "~> 5.9", require: false
end

group :development do
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ GEM
openssl (4.0.1)
ostruct (0.6.3)
parallel (1.27.0)
parallel_tests (5.9.0)
parallel
parser (3.3.10.2)
ast (~> 2.4.1)
racc
Expand Down Expand Up @@ -549,6 +551,7 @@ DEPENDENCIES
jbuilder
kamal
mysql2 (~> 0.5)
parallel_tests (~> 5.9)
pg (~> 1.5)
propshaft
puma (>= 5.0)
Expand Down Expand Up @@ -686,6 +689,7 @@ CHECKSUMS
openssl (4.0.1) sha256=e27974136b7b02894a1bce46c5397ee889afafe704a839446b54dc81cb9c5f7d
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
parallel_tests (5.9.0) sha256=593169873cfeefe54f233103238cb3b078320e6eac0155d0f1107b39233f98f8
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99
pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ bin/rails runner 'puts CoPlan::Ai.available?'
bundle exec rspec
```

Run the full local checks in parallel with `bin/ci`. It uses isolated databases
and a CPU-based worker count; use `bin/ci --jobs 12 --browser-jobs 6` to choose
your own limits. See [the CI guide](docs/CI.md) for setup, reports, and options.

See [CONTRIBUTING.md](./CONTRIBUTING.md) for pull request, spec, and visual testing requirements.
Interactive features are tested with JavaScript-enabled browser specs in `spec/system/`.

Expand Down
26 changes: 23 additions & 3 deletions bin/ci
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "active_support/continuous_integration"
require "etc"
require "optparse"
require "securerandom"

CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"
Dir.chdir(File.expand_path("..", __dir__)) do
jobs = [ [ Etc.nprocessors / 2, 1 ].max, 8 ].min
browsers, seed, list = nil, Random.new_seed % 65536, false
OptionParser.new do |args|
args.banner = "Usage: bin/ci [--jobs N] [--browser-jobs N] [--seed N] [--list]"
args.on("--jobs N", Integer, "Parallel test processes (default: half the CPUs, up to 8)") { |n| jobs = n }
args.on("--browser-jobs N", Integer, "Parallel browser processes (default: up to 4)") { |n| browsers = n }
args.on("--seed N", Integer, "RSpec random-order seed") { |n| seed = n }
args.on("--list", "Preview the checks without creating databases") { list = true }
args.on("-h", "--help") { puts args; exit }
end.parse!
browsers ||= [ jobs, 4 ].min
abort "jobs must be positive; browser-jobs must be between 1 and jobs" unless jobs.positive? && browsers.between?(1, jobs)
abort "Unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
ENV["COPLAN_CI_JOBS"], ENV["COPLAN_CI_BROWSER_JOBS"], ENV["COPLAN_CI_SEED"] = jobs.to_s, browsers.to_s, seed.to_s
ENV["COPLAN_CI_DIRECTORY"] = File.expand_path("tmp/local-ci/#{Time.now.utc.strftime('%Y%m%dT%H%M%S')}-#{Process.pid}-#{SecureRandom.hex(3)}")
exec "bundle", "exec", "rake", "--rakefile", "config/ci.rb", "--jobs", jobs.to_s, list ? "preview" : "ci"
rescue OptionParser::ParseError => error
abort error.message
end
12 changes: 12 additions & 0 deletions bin/ci-db
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require_relative "../lib/ci_database"

database = CiDatabase.new
arguments = case ARGV
when [ "prepare" ] then database.prepare
when [ "drop" ] then [ "db:drop" ]
when [ "seed" ] then [ "db:seed" ]
else abort "Usage: bin/ci-db prepare|drop|seed (called by bin/ci)"
end
exec "bin/rails", *arguments
99 changes: 82 additions & 17 deletions config/ci.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,88 @@
# Run using bin/ci
# Standalone Rakefile: the coordinator never boots Rails or opens a database.
require "rake"
require "fileutils"
require "json"
require "shellwords"

CI.run do
step "Setup", "bin/setup --skip-server"
jobs = Integer(ENV.fetch("COPLAN_CI_JOBS"))
browsers = Integer(ENV.fetch("COPLAN_CI_BROWSER_JOBS"))
seed = ENV.fetch("COPLAN_CI_SEED")
directory = ENV.fetch("COPLAN_CI_DIRECTORY")
results = {}
checks = {
"rubocop" => [ "bin/rubocop", "--no-parallel" ],
"gem-audit" => [ "bin/bundler-audit" ],
"importmap-audit" => [ "bin/importmap", "audit" ],
"brakeman" => [ "bin/brakeman", "--quiet", "--no-pager", "--exit-on-warn", "--exit-on-error" ]
}

step "Style: Ruby", "bin/rubocop"
run = lambda do |name, env, *command|
path = File.join(directory, "#{name}.log")
puts "START #{name} — #{path}"
result = system(env, *command, out: path, err: [ :child, :out ])
puts "#{result ? 'PASS' : 'FAIL'} #{name} — #{path}"
result
end

parallel = lambda do |env, count, *args|
[ env, "bundle", "exec", "parallel_test", "-n", count.to_s, "--first-is-1", *args ]
end

step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
pool = lambda do |name, count, files|
raise "No #{name} specs found" if files.empty?
count = [ count, files.length ].min
env = { "COPLAN_CI_GROUP" => name }
runtime = File.expand_path("tmp/parallel_runtime_#{name}.log")
options = Shellwords.join([ "--require", "./lib/ci_database", "--seed", seed, "--format", "progress",
"--format", "ParallelTests::RSpec::RuntimeLogger", "--out", runtime,
"--format", "json", "--out", File.join(directory, "#{name}-$TEST_ENV_NUMBER.json") ])
begin
passed = run.call("#{name}-prepare", *parallel.call(env, count, "--exec", "bin/ci-db prepare"))
if passed
passed = run.call(name, env, "bundle", "exec", "parallel_rspec", "-n", count.to_s, "--first-is-1",
"--runtime-log", runtime, "--test-options", options, *files)
reports = Dir[File.join(directory, "#{name}-*.json")]
passed &&= reports.length == count && reports.all? do |path|
summary = JSON.parse(File.read(path)).fetch("summary")
summary.fetch("example_count").positive? && summary.fetch("failure_count").zero?
end
end
ensure
cleaned = run.call("#{name}-drop", *parallel.call(env, count, "--exec", "bin/ci-db drop"))
results[name] = passed && cleaned
end
end

step "Tests: Rails", "bin/rails test"
step "Tests: System", "bin/rails test:system"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
checks.each do |name, command|
task name do
results[name] = run.call(name, {}, *command)
end
end
multitask checks: checks.keys

task :preview do
puts "Local CI: parallel_tests, #{jobs} non-browser workers, #{browsers} browser workers, seed #{seed}"
puts "Rake checks: #{checks.keys.join(', ')}"
puts "RSpec pools: non-browser, then browser; seeds twice on a separate database"
puts "Logs and reports: #{directory}"
end

# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
task :ci do
$stdout.sync = true
FileUtils.mkdir_p(directory)
Rake::Task[:preview].invoke
Rake::Task[:checks].invoke
browser_files = Dir["spec/system/**/*_spec.rb"]
pool.call("specs", jobs, Dir["spec/**/*_spec.rb"] - browser_files)
pool.call("browser", browsers, browser_files)
env = { "COPLAN_CI_GROUP" => "seeds", "TEST_ENV_NUMBER" => "1" }
begin
results["seeds"] = run.call("seeds", env, "bin/ci-db", "prepare") &&
run.call("seeds-first", env, "bin/ci-db", "seed") && run.call("seeds-second", env, "bin/ci-db", "seed")
ensure
cleaned = run.call("seeds-drop", env, "bin/ci-db", "drop")
results["seeds"] &&= cleaned
end
File.write(File.join(directory, "summary.json"), JSON.pretty_generate(results))
abort "Local CI failed; see #{directory}" unless results.values.all?
end
2 changes: 1 addition & 1 deletion config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ development:
# Do not set this db to the same as development or production.
test:
<<: *default
database: coplan_test
database: <%= ENV.fetch("COPLAN_TEST_DATABASE", "coplan_test") %>

# As with config/credentials.yml, you never want to store sensitive information,
# like your database password, in your source code. If your source code is
Expand Down
65 changes: 65 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,70 @@
# GitHub CI

## Local checks in parallel

Run `bundle install` and start MySQL, then use:

```sh
bin/ci
```

This runs every RSpec file, including real-browser system specs, plus RuboCop,
the gem and importmap audits, Brakeman, and the demo seeds twice. It does not
run `bin/setup`, alter the development database, or replant your usual test
database. Dependencies must already be installed. Chrome for Testing is
provisioned by Selenium using the same local configuration as ordinary system specs.

[parallel_tests](https://github.com/grosser/parallel_tests) runs RSpec in separate
processes. The default is half the detected CPU count, capped at eight and at
least one. The non-browser pool runs first, followed by a browser pool capped at
four. These pools do not overlap. Rake runs the independent lint/security checks
concurrently in an earlier phase; RuboCop's nested parallelism is disabled.
To use more cores or less memory:

```sh
bin/ci --jobs 12 --browser-jobs 6
bin/ci --jobs 2 --browser-jobs 1
bin/ci --jobs 1
```

Each RSpec process and the seed check get a newly named database, unique to
this checkout and invocation, and a separate schema file. The gem assigns
`TEST_ENV_NUMBER` starting at 1; `CiDatabase` maps that number and the pool to
private database/schema names before Rails loads. The database account needs
permission to create and drop databases. Databases are dropped after each check,
including after failures and Ctrl+C. If the coordinator is forcibly killed or
the database server becomes unavailable, cleanup may need to be done manually;
the database names are in the check logs.

Without `DATABASE_URL`, workers use the test connection in `config/database.yml`.
With it, workers retain its connection settings and replace the database name.
For example, PostgreSQL checks migrate each new database instead of loading the
MySQL schema:

```sh
DATABASE_URL=postgresql://postgres@localhost/coplan_local_test bin/ci
```

Preview the worker limits and checks without running commands:

```sh
bin/ci --list
```

The gem balances files by recorded runtime when enough measurements exist,
falling back to file size. Browser and non-browser runtime logs live at
`tmp/parallel_runtime_browser.log` and `tmp/parallel_runtime_specs.log`.
Every discovered spec runs once. Rake prints each check's start, result and log
path. Pool logs, numbered RSpec JSON reports, private schema files, and
`summary.json` remain under the printed `tmp/local-ci/<run>/` directory.
An empty suite, a missing report, a failed audit, or a cleanup failure makes
`bin/ci` exit nonzero. Other checks finish so one failure does not hide their results.
Repeat a test order with `bin/ci --seed <printed-seed>`; keep the same worker
counts and runtime logs to preserve the same groups. GitHub's extra repeated
regression cohorts remain available separately through `bin/rspec-stress`.

## GitHub Actions

The workflow in `.github/workflows/ci.yml` runs these jobs independently:

| Job | Database | Runner | Coverage |
Expand Down
40 changes: 40 additions & 0 deletions lib/ci_database.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
require "digest"
require "erb"
require "fileutils"
require "uri"
require "yaml"

# The only project-specific parallel_tests setup: fresh database/schema names.
class CiDatabase
attr_reader :env, :postgres

def initialize(source = ENV)
directory = source.fetch("COPLAN_CI_DIRECTORY")
group = source.fetch("COPLAN_CI_GROUP")
number = source.fetch("TEST_ENV_NUMBER")
raise ArgumentError, "Invalid CI worker" unless group.match?(/\A(?:specs|browser|seeds)\z/) && number.match?(/\A[1-9]\d*\z/)
database = "coplan_ci_#{Digest::SHA256.hexdigest(directory)[0, 12]}_#{group}_#{number}"
config = YAML.safe_load(ERB.new(File.read("config/database.yml")).result, aliases: true).fetch("test")
url = URI.parse(source.fetch("DATABASE_URL")) if source["DATABASE_URL"]
adapter = url ? url.scheme : config.fetch("adapter")
raise ArgumentError, "Local CI supports mysql2 and PostgreSQL databases" unless %w[mysql2 postgres postgresql].include?(adapter)
@postgres = %w[postgres postgresql].include?(adapter)
@env = { "RAILS_ENV" => "test", "COPLAN_TEST_DATABASE" => database, "SCHEMA" => File.join(directory, "#{group}-#{number}-schema.rb") }
if url
url.path = "/#{database}"
params = URI.decode_www_form(url.query || "").reject { |key, _| key == "database" }
params << [ "encoding", "unicode" ] if postgres && params.none? { |key, _| key == "encoding" }
url.query = params.empty? ? nil : URI.encode_www_form(params)
@env["DATABASE_URL"] = url.to_s
end
end

def prepare
FileUtils.mkdir_p(File.dirname(env.fetch("SCHEMA")))
FileUtils.cp("db/schema.rb", env.fetch("SCHEMA")) unless postgres
[ "db:create", postgres ? "db:migrate" : "db:schema:load" ]
end
end

# parallel_rspec loads this before rails_helper, using its TEST_ENV_NUMBER.
ENV.update(CiDatabase.new.env) if ENV["COPLAN_CI_GROUP"]
31 changes: 31 additions & 0 deletions spec/lib/ci_database_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require "spec_helper"
require_relative "../../lib/ci_database"

RSpec.describe CiDatabase do
let(:source) { { "COPLAN_CI_DIRECTORY" => "/tmp/coplan-ci-example", "COPLAN_CI_GROUP" => "specs", "TEST_ENV_NUMBER" => "1" } }

it "keeps workers, cohorts and concurrent invocations in different databases" do
inputs = [ source, source.merge("TEST_ENV_NUMBER" => "2"), source.merge("COPLAN_CI_GROUP" => "browser"),
source.merge("COPLAN_CI_DIRECTORY" => "/tmp/coplan-ci-other") ]
configs = inputs.map { |input| described_class.new(input).env }
expect(configs.map { |env| env.fetch("COPLAN_TEST_DATABASE") }.uniq.length).to eq(4)
expect(configs.map { |env| env.fetch("SCHEMA") }.uniq.length).to eq(4)
expect(configs.map { |env| env.fetch("RAILS_ENV") }).to all(eq("test"))
end

it "replaces URL database selectors without changing the connection" do
config = described_class.new(source.merge("DATABASE_URL" => "postgresql://test-user@localhost:5433/shared?database=shared&sslmode=require"))
url = URI.parse(config.env.fetch("DATABASE_URL"))
expect(url.path).to eq("/#{config.env.fetch('COPLAN_TEST_DATABASE')}")
expect([ url.user, url.host, url.port ]).to eq([ "test-user", "localhost", 5433 ])
expect(URI.decode_www_form(url.query).to_h).to eq("sslmode" => "require", "encoding" => "unicode")
expect(config.postgres).to be true
end

it "rejects missing or invalid worker numbers rather than using a shared database" do
[ "", "0", "../shared" ].each do |number|
expect { described_class.new(source.merge("TEST_ENV_NUMBER" => number)) }.to raise_error(ArgumentError)
end
expect { described_class.new(source.merge("COPLAN_CI_GROUP" => "development")) }.to raise_error(ArgumentError)
end
end
Loading
Loading