Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

- [BUGFIX: example](https://github.com/fastruby/next_rails/pull/<number>)

- [FEATURE: Add `deprecations merge` command to combine parallel CI shards](https://github.com/fastruby/next_rails/pull/177)
- [FEATURE: Add parallel CI support for DeprecationTracker](https://github.com/fastruby/next_rails/pull/176)

* Your changes/patches go here.

# v1.5.0 / 2026-04-01 [(commits)](https://github.com/fastruby/next_rails/compare/v1.4.8...v1.5.0)
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,69 @@ DEPRECATION_TRACKER=save rspec
DEPRECATION_TRACKER=compare rspec
```

### Parallel CI support

When running tests across parallel CI nodes, each node can write to its own shard file to avoid conflicts. The tracker auto-detects the node index from common CI environment variables (`CI_NODE_INDEX`, `CIRCLE_NODE_INDEX`, `BUILDKITE_PARALLEL_JOB`, `SEMAPHORE_JOB_INDEX`, `CI_NODE_INDEX` for GitLab), or you can set it explicitly via the `node_index` option.

#### RSpec

```ruby
RSpec.configure do |config|
if ENV["DEPRECATION_TRACKER"]
DeprecationTracker.track_rspec(
config,
node_index: ENV["CI_NODE_INDEX"]
)
end
end
```

When `node_index` is set, the tracker writes to a shard file (e.g. `deprecation_warning.shitlist.node-0.json`) instead of the canonical file.

#### Merging shards

After all parallel nodes finish saving, merge shards into the canonical file:

```bash
# Merge all shard files and remove them afterwards
deprecations merge --delete-shards

# Or use --next to merge shards for the next Rails version
deprecations merge --next --delete-shards
```

You can also merge shards programmatically:

```ruby
DeprecationTracker.merge_shards(
"spec/support/deprecation_warning.shitlist.json",
delete_shards: true
)
```

#### Example CI workflow

```yaml
# 1. Save phase — each parallel node writes its own shard
# (runs on every node)
DEPRECATION_TRACKER=save CI_NODE_INDEX=$NODE bundle exec rspec <subset>

# 2. Merge phase — fan-in step, runs once after all nodes finish
deprecations merge --delete-shards

# 3. Compare phase — each parallel node checks its buckets
# against the merged canonical file
DEPRECATION_TRACKER=compare CI_NODE_INDEX=$NODE bundle exec rspec <subset>
```

### `deprecations` command

View, filter, and manage stored deprecation warnings:

```bash
deprecations info
deprecations info --pattern "ActiveRecord::Base"
deprecations merge --delete-shards
deprecations run
deprecations --help
```
Expand Down
49 changes: 34 additions & 15 deletions exe/deprecations
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ require "json"
require "rainbow"
require "optparse"
require "set"
require_relative "../lib/deprecation_tracker/shard_merger"

def run_tests(deprecation_warnings, opts = {})
tracker_mode = opts[:tracker_mode]
Expand Down Expand Up @@ -49,6 +50,7 @@ option_parser = OptionParser.new do |opts|
bin/deprecations --next info # Show top ten deprecations for Rails 5
bin/deprecations --pattern "ActiveRecord::Base" --verbose info # Show full details on deprecations matching pattern
bin/deprecations --tracker-mode save --pattern "pass" run # Run tests that output deprecations matching pattern and update shitlist
bin/deprecations merge --delete-shards # Merge parallel CI shards and remove shard files

Modes:
info
Expand All @@ -57,6 +59,9 @@ option_parser = OptionParser.new do |opts|
run
Run tests that are known to cause deprecation warnings. Use --pattern to filter what tests are run.

merge
Merge parallel CI shard files into the canonical shitlist. Use with --delete-shards to remove shard files after merging.

Options:
MESSAGE

Expand All @@ -76,6 +81,10 @@ option_parser = OptionParser.new do |opts|
options[:verbose] = true
end

opts.on("--delete-shards", "Delete shard files after merging") do
options[:delete_shards] = true
end

opts.on_tail("-h", "--help", "Prints this help") do
puts opts
exit
Expand All @@ -87,24 +96,34 @@ option_parser.parse!
options[:mode] = ARGV.last
path = options[:next] ? "spec/support/deprecation_warning.next.shitlist.json" : "spec/support/deprecation_warning.shitlist.json"

pattern_string = options.fetch(:pattern, ".+")
pattern = /#{pattern_string}/

deprecation_warnings = JSON.parse(File.read(path)).each_with_object({}) do |(test_file, messages), hash|
filtered_messages = messages.select {|message| message.match(pattern) }
hash[test_file] = filtered_messages if !filtered_messages.empty?
end
case options[:mode]
when "merge"
output = DeprecationTracker::ShardMerger.new(path, delete_shards: !!options[:delete_shards]).merge
shards = output[:shards]
result = output[:result]
total_messages = result.values.map(&:size).reduce(0, :+)
puts "Merged #{shards} shard files into #{path} (#{result.size} buckets, #{total_messages} deprecation messages)"
when "run", "info"
pattern_string = options.fetch(:pattern, ".+")
pattern = /#{pattern_string}/

deprecation_warnings = JSON.parse(File.read(path)).each_with_object({}) do |(test_file, messages), hash|
filtered_messages = messages.select {|message| message.match(pattern) }
hash[test_file] = filtered_messages if !filtered_messages.empty?
end

if deprecation_warnings.empty?
abort "No test files with deprecations matching #{pattern.inspect}."
exit 2
end
if deprecation_warnings.empty?
abort "No test files with deprecations matching #{pattern.inspect}."
exit 2
end

case options.fetch(:mode, "info")
when "run" then run_tests(deprecation_warnings, next_mode: options[:next], tracker_mode: options[:tracker_mode])
when "info" then print_info(deprecation_warnings, verbose: options[:verbose])
if options[:mode] == "run"
run_tests(deprecation_warnings, next_mode: options[:next], tracker_mode: options[:tracker_mode])
else
print_info(deprecation_warnings, verbose: options[:verbose])
end
when nil
STDERR.puts Rainbow("Must pass a mode: run or info").red
STDERR.puts Rainbow("Must pass a mode: run, info, or merge").red
puts option_parser
exit 1
else
Expand Down
5 changes: 5 additions & 0 deletions lib/deprecation_tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ def self.track_minitest(opts = {})
ActiveSupport::TestCase.include(MinitestExtension.new(tracker))
end

def self.merge_shards(base_path, delete_shards: false)
require_relative "deprecation_tracker/shard_merger"
ShardMerger.new(base_path, delete_shards: delete_shards).merge[:result]
end

attr_reader :deprecation_messages, :shitlist_path, :transform_message, :bucket, :mode, :node_index

def initialize(shitlist_path, transform_message = nil, mode = :save, node_index: nil)
Expand Down
64 changes: 64 additions & 0 deletions lib/deprecation_tracker/shard_merger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
require "json"
require "fileutils"

class DeprecationTracker
class ShardMerger
attr_reader :base_path, :delete_shards

def initialize(base_path, delete_shards: false)
@base_path = base_path
@delete_shards = delete_shards
end

def merge
dirname = File.dirname(base_path)
unless File.directory?(dirname)
warn "Directory does not exist: #{dirname}"
return { shards: 0, result: {} }
end

shard_files = Dir.glob(shard_glob).sort

if shard_files.empty?
warn "No shards found at #{shard_glob}"
return { shards: 0, result: {} }
end

merged = {}
shard_files.each do |file|
parse_shard(file).each do |bucket, messages|
merged[bucket] = (merged[bucket] || []).concat(Array(messages))
end
end

result = {}
merged.sort.each do |k, v|
result[k] = v.sort
end

begin
File.write(base_path, JSON.pretty_generate(result))
rescue Errno::EACCES => e
raise "Cannot write to #{base_path}: #{e.message}"
end

shard_files.each { |f| File.delete(f) } if delete_shards

{ shards: shard_files.size, result: result }
end

private

def shard_glob
"#{base_path.chomp('.json')}.node-*.json"
end

def parse_shard(file)
JSON.parse(File.read(file))
rescue Errno::ENOENT
raise "Shard file not found: #{file}"
rescue JSON::ParserError => e
raise "Invalid JSON in shard file #{file}: #{e.message}"
end
end
end
118 changes: 118 additions & 0 deletions spec/shard_merger_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require "spec_helper"

require "json"
require "tempfile"
require_relative "../lib/deprecation_tracker/shard_merger"

RSpec.describe DeprecationTracker::ShardMerger do
let(:base_path) do
dir = Dir.tmpdir
File.join(dir, "shitlist-#{Process.pid}-#{rand(1000)}.json")
end

after do
FileUtils.rm_f(base_path)
Dir.glob("#{base_path.chomp('.json')}.node-*.json").each { |f| FileUtils.rm_f(f) }
end

def write_shard(index, data)
path = "#{base_path.chomp('.json')}.node-#{index}.json"
File.write(path, JSON.pretty_generate(data))
path
end

subject { described_class.new(base_path) }

it "merges multiple shard files into the canonical file" do
write_shard(0, { "bucket 1" => ["a"], "bucket 2" => ["b"] })
write_shard(1, { "bucket 3" => ["c"] })

output = subject.merge
result = output[:result]

expect(result).to eq(
"bucket 1" => ["a"],
"bucket 2" => ["b"],
"bucket 3" => ["c"]
)
expect(output[:shards]).to eq(2)
expect(JSON.parse(File.read(base_path))).to eq(result)
end

it "deep-merges overlapping buckets by concatenating and sorting messages" do
write_shard(0, { "bucket 1" => ["b", "a"] })
write_shard(1, { "bucket 1" => ["c", "a"] })

result = subject.merge[:result]

expect(result).to eq("bucket 1" => ["a", "a", "b", "c"])
end

it "warns and returns empty result when no shards exist" do
expect { output = subject.merge }.to output(/No shards found/).to_stderr

output = subject.merge

expect(output[:result]).to eq({})
expect(output[:shards]).to eq(0)
expect(File.exist?(base_path)).to be false
end

it "warns and returns empty result when directory does not exist" do
merger = described_class.new("/nonexistent/path/shitlist.json")

expect { output = merger.merge }.to output(/Directory does not exist/).to_stderr

output = merger.merge

expect(output[:result]).to eq({})
expect(output[:shards]).to eq(0)
end

it "handles a single shard file" do
write_shard(0, { "bucket 1" => ["a"] })

output = subject.merge

expect(output[:result]).to eq("bucket 1" => ["a"])
expect(output[:shards]).to eq(1)
end

it "deletes shard files when delete_shards is true" do
shard0 = write_shard(0, { "bucket 1" => ["a"] })
shard1 = write_shard(1, { "bucket 2" => ["b"] })

merger = described_class.new(base_path, delete_shards: true)
merger.merge

expect(File.exist?(shard0)).to be false
expect(File.exist?(shard1)).to be false
expect(File.exist?(base_path)).to be true
end

it "preserves shard files by default" do
shard0 = write_shard(0, { "bucket 1" => ["a"] })

subject.merge

expect(File.exist?(shard0)).to be true
end

it "sorts buckets alphabetically" do
write_shard(0, { "z_bucket" => ["a"] })
write_shard(1, { "a_bucket" => ["b"] })

result = subject.merge[:result]

expect(result.keys).to eq(["a_bucket", "z_bucket"])
end

it "raises an error for invalid JSON in a shard file" do
shard_path = "#{base_path.chomp('.json')}.node-0.json"
File.write(shard_path, "not valid json")

expect { subject.merge }.to raise_error(/Invalid JSON in shard file/)
end
end
Loading