Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions exe/deprecations
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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 +58,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 +80,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,6 +95,16 @@ option_parser.parse!
options[:mode] = ARGV.last
path = options[:next] ? "spec/support/deprecation_warning.next.shitlist.json" : "spec/support/deprecation_warning.shitlist.json"

if options[:mode] == "merge"
require_relative "../lib/deprecation_tracker/shard_merger"
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)"
exit 0
end

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

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
57 changes: 57 additions & 0 deletions lib/deprecation_tracker/shard_merger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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
shard_files = Dir.glob(shard_glob).sort

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

dirname = File.dirname(base_path)
FileUtils.mkdir_p(dirname) unless File.directory?(dirname)

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
ext = File.extname(base_path)
"#{base_path.chomp(ext)}.node-*#{ext}"
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
105 changes: 105 additions & 0 deletions spec/shard_merger_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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 "returns an empty hash and writes empty JSON when no shards exist" do
output = subject.merge

expect(output[:result]).to eq({})
expect(output[:shards]).to eq(0)
expect(JSON.parse(File.read(base_path))).to eq({})
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