Skip to content
Draft
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
137 changes: 137 additions & 0 deletions bin/lib/matrix.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# frozen_string_literal: true

# Shared machinery for the per-matrix tooling. A "cell" is one row of a gem's
# test-matrix.json (the single source of truth CI reads): a Ruby plus the
# dependency versions that gem's Gemfile keys off. Both bin/relock (which
# materializes the committed lockfiles) and bin/test (which runs that cell's
# specs locally) expand cells the same way so neither can drift from CI.
#
# Each cell runs under a matching Ruby provided by mise (https://mise.jdx.dev);
# the required Rubies are declared in .mise.toml and installed with `mise install`.

require "json"

module Matrix
ROOT = File.expand_path("../..", __dir__)

# The gem each matrix axis pins -> env var the gem's Gemfile reads. The gem
# name is also the filename segment (the part before the first "-"); the
# matrix key is "<gem>_version" (redis is the exception: redis_rb_version), so
# we recover the gem from a matrix key with key.split("_").first.
#
# Values pass through verbatim: the matrix already normalizes them the way
# GitHub Actions renders the matrix (rack-2 not rack-2.0), and the Gemfiles
# wrap them in Gem::Version.new(...), so "2" and "2.0" resolve identically.
GEM_ENV_MAPPING = {
"rack" => "RACK_VERSION",
"redis" => "REDIS_RB_VERSION",
"rails" => "RAILS_VERSION",
"sidekiq" => "SIDEKIQ_VERSION"
}.freeze

# rubyopt is a test-time concern (it doesn't affect dependency resolution, so
# relock ignores it) but it changes how specs run, so bin/test honors it.
Cell = Struct.new(:gem, :base, :ruby, :env, :rubyopt, keyword_init: true) do
def wrapper
"#{gem}/gemfiles/#{base}.gemfile"
end

def lock
"#{wrapper}.lock"
end

def label
"#{gem} / #{base}"
end
end

module_function

# Expand one test-matrix.json entry into a cell. The entry's keys (in file
# order) become the filename segments and the env the Gemfile reads:
# {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"}
# -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4}
def cell_from_entry(gem, entry)
ruby = entry.fetch("ruby_version")
segments = ["ruby-#{ruby}"]
env = {}

entry.each do |key, value|
next if key == "ruby_version" || key == "options"

name = key.split("_").first
var = GEM_ENV_MAPPING[name]
abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var
segments << "#{name}-#{value}"
env[var] = value
end

Cell.new(gem: gem, base: segments.join("_"), ruby: ruby, env: env, rubyopt: entry.dig("options", "rubyopt"))
end

# Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back
# into a cell (used by relock's --cell, which addresses a cell by path).
# rubyopt isn't recoverable from the path; callers that need it expand from
# test-matrix.json via cell_from_entry instead.
def parse_cell(gem, base)
segments = base.split("_")
ruby = segments.shift.sub(/\Aruby-/, "")

env = {}
segments.each do |seg|
name, value = seg.split("-", 2)
var = GEM_ENV_MAPPING[name]
abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var
env[var] = value
end

Cell.new(gem: gem, base: base, ruby: ruby, env: env)
end

def matrix_path(gem)
File.join(ROOT, gem, "test-matrix.json")
end

def discover_cells(gems)
gems.flat_map do |gem|
path = matrix_path(gem)
abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path)
JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper)
end
end

def all_gems
Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort
end

# Absolute path to the mise binary. It's usually a shell function (so plain
# `mise` won't resolve via execvp); ask a login shell where the real binary is.
def mise_bin
@mise_bin ||= begin
found = `sh -lc 'command -v mise' 2>/dev/null`.strip
found = found.lines.last.to_s.strip if found.include?("\n")
candidates = [ENV["MISE_BIN"], found, "/opt/homebrew/bin/mise", File.expand_path("~/.local/bin/mise"), "/usr/local/bin/mise"]
candidates.compact.find { |c| File.executable?(c) } ||
abort("mise not found. Install it: https://mise.jdx.dev")
end
end

# Whether mise has this Ruby spec installed (declared in .mise.toml).
def installed?(ruby)
system(mise_bin, "where", "ruby@#{ruby}", out: File::NULL, err: File::NULL)
end

# Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are
# declared in .mise.toml and provisioned once via `mise install`.
def ensure_installed(cells)
missing = cells.map(&:ruby).uniq.reject { |spec| installed?(spec) }
return if missing.empty?

warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}."
abort "Run `mise install` first."
end

def cell_env(cell)
{ "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env)
end
end
120 changes: 5 additions & 115 deletions bin/relock
Original file line number Diff line number Diff line change
Expand Up @@ -30,106 +30,12 @@

require "optparse"
require "shellwords"
require "json"
require_relative "lib/matrix"

ROOT = File.expand_path("..", __dir__)

# The gem each matrix axis pins -> env var the gem's Gemfile reads. The gem name
# is also the filename segment (the part before the first "-"); the matrix key
# is "<gem>_version" (redis is the exception: redis_rb_version), so we recover
# the gem from a matrix key with key.split("_").first.
#
# Values pass through verbatim: the matrix already normalizes them the way
# GitHub Actions renders the matrix (rack-2 not rack-2.0), and the Gemfiles wrap
# them in Gem::Version.new(...), so "2" and "2.0" resolve identically — matching CI.
GEM_ENV_MAPPING = {
"rack" => "RACK_VERSION",
"redis" => "REDIS_RB_VERSION",
"rails" => "RAILS_VERSION",
"sidekiq" => "SIDEKIQ_VERSION"
}.freeze

Cell = Struct.new(:gem, :base, :ruby, :env, keyword_init: true) do
def wrapper
"#{gem}/gemfiles/#{base}.gemfile"
end

def lock
"#{wrapper}.lock"
end

def label
"#{gem} / #{base}"
end
end

# Expand one test-matrix.json entry into a cell. The entry's keys (in file
# order) become the filename segments and the env the Gemfile reads:
# {"ruby_version":"3.2","rack_version":"2","redis_rb_version":"4"}
# -> ruby-3.2_rack-2_redis-4, {RACK_VERSION=2, REDIS_RB_VERSION=4}
# "options" (e.g. rubyopt) is a test-time concern and doesn't affect resolution.
def cell_from_entry(gem, entry)
ruby = entry.fetch("ruby_version")
segments = ["ruby-#{ruby}"]
env = {}

entry.each do |key, value|
next if key == "ruby_version" || key == "options"

name = key.split("_").first
var = GEM_ENV_MAPPING[name]
abort "Unknown matrix key: '#{key}' in #{gem}/test-matrix.json" unless var
segments << "#{name}-#{value}"
env[var] = value
end

Cell.new(gem: gem, base: segments.join("_"), ruby: ruby, env: env)
end

# Parse a wrapper/lock path's base name like "ruby-3.2_rack-3_redis-5" back into
# a cell (used by --cell, which addresses a single cell by path).
def parse_cell(gem, base)
segments = base.split("_")
ruby = segments.shift.sub(/\Aruby-/, "")

env = {}
segments.each do |seg|
name, value = seg.split("-", 2)
var = GEM_ENV_MAPPING[name]
abort "Unknown matrix axis '#{name}' in #{gem}/gemfiles/#{base}" unless var
env[var] = value
end

Cell.new(gem: gem, base: base, ruby: ruby, env: env)
end

def matrix_path(gem)
File.join(ROOT, gem, "test-matrix.json")
end

def discover_cells(gems)
gems.flat_map do |gem|
path = matrix_path(gem)
abort "No test-matrix.json for gem '#{gem}'" unless File.exist?(path)
JSON.parse(File.read(path)).map { |entry| cell_from_entry(gem, entry) }.uniq(&:wrapper)
end
end

def all_gems
Dir.glob(File.join(ROOT, "*", "test-matrix.json")).map { |p| File.basename(File.dirname(p)) }.sort
end

# Absolute path to the mise binary. It's usually a shell function (so plain
# `mise` won't resolve via execvp); ask a login shell where the real binary is.
def mise_bin
@mise_bin ||= begin
found = `sh -lc 'command -v mise' 2>/dev/null`.strip
found = found.lines.last.to_s.strip if found.include?("\n")
candidates = [ENV["MISE_BIN"], found, "/opt/homebrew/bin/mise", File.expand_path("~/.local/bin/mise"), "/usr/local/bin/mise"]
candidates.compact.find { |c| File.executable?(c) } ||
abort("mise not found. Install it: https://mise.jdx.dev")
end
end
# Pull the shared cell/mise machinery (GEM_ENV_MAPPING, Cell, cell_from_entry,
# parse_cell, discover_cells, all_gems, mise_bin, ensure_installed, cell_env,
# ROOT) into this script's scope so the call sites below read unqualified.
include Matrix

# Shell run under the cell's Ruby. Writes the wrapper, re-resolves, and adds
# checksums where the bundler version supports them.
Expand All @@ -141,22 +47,6 @@ RESOLVE = <<~SH
bundle lock --update --add-checksums || bundle lock --update
SH

# Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are
# declared in .mise.toml and provisioned once via `mise install`.
def ensure_installed(cells)
missing = cells.map(&:ruby).uniq.reject do |spec|
system(mise_bin, "where", "ruby@#{spec}", out: File::NULL, err: File::NULL)
end
return if missing.empty?

warn "Ruby not installed: #{missing.map { |s| "ruby@#{s}" }.join(', ')}."
abort "Run `mise install` first."
end

def cell_env(cell)
{ "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env)
end

def run_mise(cell)
# bash -c (not -lc): inherit the PATH/env mise just set; a login shell would
# re-source the profile and reset Ruby back to the host default.
Expand Down
Loading
Loading