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: 22 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Agent Instructions

## Package Manager
Use **Bundler**. Each gem has its own `Gemfile`; run commands from within the gem subdirectory.
## Toolchain (mise)
[mise](https://mise.jdx.dev) manages Rubies and runs tasks. `.mise.toml` pins a single default Ruby for local work; `.mise.ci.toml` (loaded with `MISE_ENV=ci`) pins the full per-matrix Ruby set that CI, `bin/test`, and `bin/relock` resolve against.

```bash
cd sentry-ruby && bundle install
mise install # install the default toolchain (.mise.toml)
mise --env ci install # install the full CI matrix of Rubies (needed for bin/test)
```

## Monorepo Structure
Expand All @@ -19,18 +21,30 @@ cd sentry-ruby && bundle install

Shared test infrastructure lives in `lib/sentry/test/`. Root `Gemfile.dev` defines shared dev dependencies.

## File-Scoped Commands
## Testing
Use `bin/test` (from the repo root) to run a gem's specs under a single CI test-matrix cell — the local mirror of one CI job. The Ruby must already be installed (`mise --env ci install`).
You can also invoke `bin/test` from any of the gem directories themselves which automatically fills in the `--gem` part.

| Task | Command |
|------|---------|
| List every cell to choose from | `bin/test -l` |
| Run a gem (auto-picks newest installed Ruby cell) | `bin/test --gem sentry-rails` |
| Run a single spec | `bin/test --gem sentry-ruby spec/sentry/client_spec.rb` |
| Run a specific cell | `bin/test --cell sentry-ruby/gemfiles/ruby-3.3_rack-3_redis-4.gemfile` |
| Forward args to rspec | `bin/test --cell <cell> -- --tag foo` |
| Run full CI rake task | `bin/test --gem <gem> --rake` |

Root-level `bundle exec rake` runs the E2E/integration spec suite (not individual gem tests).

## Lint
Run from within the target gem directory (e.g. `cd sentry-ruby`):

| Task | Command |
|------|---------|
| Install deps | `bundle install` |
| Run all tests | `bundle exec rake` |
| Run single spec | `bundle exec rspec spec/sentry/client_spec.rb` |
| Lint | `bundle exec rubocop path/to/file.rb` |
| Lint (autofix) | `bundle exec rubocop -a path/to/file.rb` |

Root-level `bundle exec rubocop` lints the entire repo. Root-level `bundle exec rake` runs the E2E/integration spec suite (not individual gem tests).
Root-level `bundle exec rubocop` lints the entire repo.

## Testing Conventions
- Framework: **RSpec**
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ This file defines which specific image and Ruby version will be used to run the
bundle install
```
- Install any additional dependencies. `sentry-sidekiq` assumes you have `redis` running.
- Use `bundle exec rake` to run tests.
- Use the `./bin/test` helper to run tests.
- In `sentry-rails`, you can use `RAILS_VERSION=version` to specify the Rails version to test against. Default is `8.0`
- In `sentry-sidekiq`, you can use `SIDEKIQ_VERSION=version` to specify what version of Sidekiq to install when you run `bundle install`. Default is `7.0`
- Use example apps under the `example` or `examples` folder to test the change. (Remember to change the DSN first)
Expand Down
149 changes: 149 additions & 0 deletions bin/lib/matrix.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 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)
# 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.ci.toml and installed with
# `mise --env ci install`.

require "json"

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

# translate gem versions to env vars picked up by Gemfile
GEM_ENV_MAPPING = {
"rack" => "RACK_VERSION",
"redis" => "REDIS_RB_VERSION",
"rails" => "RAILS_VERSION",
"sidekiq" => "SIDEKIQ_VERSION"
}.freeze

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

# Split a wrapper/lock path into [gem, base] by position, since a cell is
# addressed as <gem>/gemfiles/<base>.gemfile. Works for absolute, relative,
# and .lock-suffixed paths. A gem-relative path (gemfiles/<base>.gemfile, e.g.
# run from inside a gem dir) has no <gem> segment, so gem falls back to
# fallback_gem (nil if none). Returns nil when the path has no gemfiles/<base>.
def cell_path_parts(path, fallback_gem: nil)
parts = path.sub(/\.lock\z/, "").split("/")
gi = parts.rindex("gemfiles")
return nil unless gi && parts[gi + 1]

gem = gi.positive? ? parts[gi - 1] : fallback_gem
[gem, File.basename(parts[gi + 1], ".gemfile")]
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

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

def installed?(ruby)
system(mise_bin, "where", "ruby@#{ruby}", out: File::NULL, err: File::NULL)
end

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 --env ci install` first."
end

def cell_env(cell)
{ "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env)
end
end
148 changes: 11 additions & 137 deletions bin/relock
Original file line number Diff line number Diff line change
Expand Up @@ -31,117 +31,13 @@
# See --help for all options.

require "optparse"
require "json"

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
# `command -v` can emit profile noise on earlier lines; the real path is last.
found = `sh -lc 'command -v mise' 2>/dev/null`.lines.last.to_s.strip
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
require_relative "lib/matrix"
include Matrix

# Shell run under the cell's Ruby. Writes the wrapper, re-resolves, and adds
# checksums where the bundler version supports them. When FORCE is set, the
# existing lock is deleted first so bundler resolves from scratch — slower, but
# sidesteps edge cases where an incremental update gets stuck on a stale lock.
# checksums where the bundler version supports them.
#
# When FORCE is set, the existing lock is deleted first so bundler resolves from scratch, use for edge cases.
RESOLVE = <<~SH
set -euo pipefail
mkdir -p "$(dirname "$BUNDLE_GEMFILE")"
Expand All @@ -162,29 +58,13 @@ RESOLVE = <<~SH
fi
SH

# Abort (don't auto-install) if any cell's Ruby is missing — the Rubies are
# declared in .mise.ci.toml and provisioned once via `mise --env ci 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 --env ci install` first."
end

def cell_env(cell, force: false)
env = { "BUNDLE_GEMFILE" => File.join(ROOT, cell.wrapper) }.merge(cell.env)
env["FORCE"] = "1" if force
env
end

def run_mise(cell, force: false)
# 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.
argv = [mise_bin, "exec", "ruby@#{cell.ruby}", "--", "bash", "-c", RESOLVE]
[cell_env(cell, force: force), argv]
env = cell_env(cell)
env = env.merge("FORCE" => "1") if force
[env, argv]
end

# ---- options -------------------------------------------------------------
Expand All @@ -209,15 +89,9 @@ parser.parse!(ARGV)
# ---- select cells --------------------------------------------------------

if opts[:cell]
# Single explicit cell. Derive gem + base by position
# (<gem>/gemfiles/<base>.gemfile) so it works for absolute, relative, or
# symlinked paths. Accept either the .gemfile or .gemfile.lock form.
parts = opts[:cell].sub(/\.lock\z/, "").split("/")
gi = parts.rindex("gemfiles")
# Positive (not just non-nil): we need a <gem> segment before "gemfiles".
abort "--cell must point at a <gem>/gemfiles/<cell>.gemfile path" unless gi&.positive?
gem = parts[gi - 1]
base = File.basename(parts[gi + 1], ".gemfile")
# single explicit cell
gem, base = cell_path_parts(opts[:cell])
abort "--cell must point at a <gem>/gemfiles/<cell>.gemfile path" unless gem && base
cells = [parse_cell(gem, base)]
else
gems = opts[:gems].empty? ? all_gems : opts[:gems]
Expand Down
Loading
Loading