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
30 changes: 0 additions & 30 deletions .github/workflows/lint.yml

This file was deleted.

43 changes: 38 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ on:

jobs:
rspec:
name: rspec (${{ matrix.ruby-version }}, ${{ matrix.os }})
name: rspec (${{ matrix.ruby-version }}, ${{ matrix.os }}, shard ${{ matrix.shard }})
timeout-minutes: ${{ matrix.os == 'windows' && 30 || 15 }}
runs-on: ${{ format('{0}-latest', matrix.os) }}
env:
BUNDLE_WITHOUT: lint
SPEC_WORKERS: ${{ matrix.os == 'windows' && 4 || 8 }}
SPEC_SHARD: ${{ matrix.shard }}

strategy:
fail-fast: false
Expand All @@ -24,6 +25,7 @@ jobs:
os:
- ubuntu
- windows
shard: ["1/3", "2/3", "3/3"]

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -54,14 +56,45 @@ jobs:
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.github_token }}
flag-name: ruby${{ matrix.ruby-version }}-${{ matrix.os }}
flag-name: ruby${{ matrix.ruby-version }}-${{ matrix.os }}-shard${{ strategy.job-index }}
parallel: true

finish:
needs: rspec
runs-on: ubuntu-26.04
overcommit:
timeout-minutes: 10
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true

- name: Prepare environment
run: |
git config --global user.email "gh-actions@example.com"
git config --global user.name "GitHub Actions"
bundle exec overcommit --sign
bundle exec overcommit --sign pre-commit

- name: Run pre-commit checks
run: bundle exec overcommit --run

ci:
name: CI
if: ${{ always() }}
needs: [rspec, overcommit]
runs-on: ubuntu-latest
timeout-minutes: 2

steps:
- name: Require all jobs to pass
env:
JOB_RESULTS: ${{ toJSON(needs) }}
run: echo "$JOB_RESULTS" | jq -e 'all(.[]; .result == "success")'

- name: Finalize code coverage report
uses: coverallsapp/github-action@v2
with:
Expand Down
12 changes: 9 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ The best way to see a feature added, however, is to submit a pull request.
bundle exec ruby script/parallel_rspec.rb
# Optionally choose the number of worker processes (default: up to 8 CPUs):
SPEC_WORKERS=4 bundle exec ruby script/parallel_rspec.rb
# Run one runner's share of the suite (default: 1/1, the whole suite):
SPEC_SHARD=1/3 bundle exec ruby script/parallel_rspec.rb
```

Workers run in separate processes because specs change the working directory
and environment. The runner checks that every discovered example ran exactly
once and merges coverage from all workers. You can also pass spec paths to
run a subset. The `lint` dependency group is only needed for lint checks;
test-only CI jobs install with `BUNDLE_WITHOUT=lint`.
once within its shard and merges coverage from all workers. Shards partition
examples in a deterministic order; run every shard to cover the complete suite.
You can also pass spec paths to run a subset. The `lint` dependency group is
only needed for lint checks; test-only CI jobs install with `BUNDLE_WITHOUT=lint`.

The final `CI` check depends on all test shards and lint, and finalizes coverage reporting.
Repository rulesets can require this single check instead of individual matrix jobs.

* Submit your pull request!

Expand Down
13 changes: 10 additions & 3 deletions script/parallel_rspec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# frozen_string_literal: true

require 'etc'
require 'digest'
require 'json'
require 'rbconfig'
require 'tmpdir'
require_relative '../spec/support/spec_shard'

if ARGV.first == '--worker'
require 'rspec/core'
Expand All @@ -15,6 +17,7 @@
# spec files don't leave one worker busy after the others have finished.
workers = Integer(ENV.fetch('SPEC_WORKERS') { [Etc.nprocessors, 8].min.to_s })
abort 'SPEC_WORKERS must be positive' unless workers > 0
shard = SpecShard.new(ENV.fetch('SPEC_SHARD', '1/1'))

Dir.chdir(File.expand_path('..', __dir__))
rspec = [RbConfig.ruby, Gem.bin_path('rspec-core', 'rspec')]
Expand All @@ -27,12 +30,16 @@
'--dry-run', '--format', 'json', '--out', discovery)
abort 'RSpec discovery failed'
end
ids = JSON.parse(File.read(discovery)).fetch('examples').map { |example| example.fetch('id') }
abort 'RSpec discovery found no examples' if ids.empty?
all_ids = JSON.parse(File.read(discovery)).fetch('examples').map { |example| example.fetch('id') }
abort 'RSpec discovery found no examples' if all_ids.empty?

ids = shard.select(all_ids)
abort "Shard #{shard} has no examples" if ids.empty?
puts "Discovered #{all_ids.size} examples (SHA256: #{Digest::SHA256.hexdigest(all_ids.join("\n"))})"

groups = Array.new([workers, ids.size].min) { [] }
ids.each_with_index { |id, index| groups[index % groups.size] << id }
puts "Running all #{ids.size} examples in #{groups.size} processes"
puts "Running shard #{shard}: #{ids.size}/#{all_ids.size} examples in #{groups.size} processes"

processes = groups.each_with_index.map do |group, index|
prefix = File.join(tmpdir, "worker-#{index}")
Expand Down
42 changes: 42 additions & 0 deletions spec/spec_shard_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

require 'spec_helper'

describe SpecShard do
let(:examples) { (1..17).map { |index| "example-#{index}" } }

it 'runs the complete suite when using a single shard' do
described_class.new('1/1').select(examples).should == examples
end

[2, 3].each do |total|
context "with #{total} shards" do
let(:shards) do
(1..total).map { |index| described_class.new("#{index}/#{total}").select(examples) }
end

it 'assigns every example to exactly one shard' do
shards.flatten.sort.should == examples.sort
end

it 'balances example counts within one example' do
counts = shards.map(&:size)
(counts.max - counts.min).should <= 1
end

it 'assigns examples deterministically in their original order' do
shards.first.should == examples.each_slice(total).map(&:first)
end
end
end

it 'handles more shards than examples without duplicating examples' do
described_class.new('3/3').select(['only-example']).should == []
end

['', '1', '0/2', '1/0', '3/2', '-1/2', '1/2/3', '1/2junk'].each do |value|
it "rejects the invalid shard #{value.inspect}" do
expect { described_class.new(value) }.to raise_error(ArgumentError, /SPEC_SHARD/)
end
end
end
23 changes: 23 additions & 0 deletions spec/support/spec_shard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

# Deterministically partitions examples across CI runners before each runner
# distributes its share among local worker processes.
class SpecShard
attr_reader :index, :total

def initialize(value)
match = %r{\A([1-9]\d*)/([1-9]\d*)\z}.match(value)
raise ArgumentError, 'SPEC_SHARD must be INDEX/TOTAL (for example, 1/3)' unless match

@index, @total = match.captures.map(&:to_i)
raise ArgumentError, 'SPEC_SHARD index must not exceed total' if index > total
end

def select(examples)
examples.each_with_index.select { |_, offset| offset % total == index - 1 }.map(&:first)
end

def to_s
"#{index}/#{total}"
end
end
Loading