From 3dd533f70355c8a79860bf5d8a2a8cf5eb1ccca2 Mon Sep 17 00:00:00 2001 From: Supernova Date: Mon, 19 Oct 2026 17:37:08 +0000 Subject: [PATCH 1/2] Accept RubyLLM 2.0's tool DSL names alongside the 1.x names Add parameter, declared_parameters, parameters(schema/block), parameters_schema_definition and instance parameters_schema. Each 1.x name calls its 2.0 name through plain methods, so overrides of either name are honored by the protocols. Docs use the 2.0 names. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Sam Boland --- README.md | 2 +- docs/_advanced/agent-handoffs.md | 2 +- docs/_advanced/rag.md | 2 +- docs/_advanced/rails-persistence.md | 2 +- docs/_core_features/chat-request-control.md | 2 +- docs/_core_features/citations.md | 2 +- docs/_core_features/tool-parameters.md | 50 ++--- docs/_core_features/tools.md | 18 +- docs/_getting_started/overview.md | 2 +- docs/index.md | 2 +- lib/ruby_llm/tool.rb | 61 +++++- spec/ruby_llm/tool_spec.rb | 214 ++++++++++++++++++++ 12 files changed, 309 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 187bb773e..3a01006ac 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ RubyLLM.moderate "Check if this text is safe" ```ruby # Let AI use your code class Weather < RubyLLM::Tool - desc "Get current weather" + description "Get current weather" def execute(latitude:, longitude:) url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}¤t=temperature_2m,wind_speed_10m" diff --git a/docs/_advanced/agent-handoffs.md b/docs/_advanced/agent-handoffs.md index 069f9c559..91c88a492 100644 --- a/docs/_advanced/agent-handoffs.md +++ b/docs/_advanced/agent-handoffs.md @@ -59,7 +59,7 @@ If you would rather the agent decide mid-conversation, give it a single handoff ```ruby class Handoff < RubyLLM::Tool description "Hand the conversation to the specialist who should answer next" - param :specialist, desc: "billing or technical" + parameter :specialist, description: "billing or technical" def execute(specialist:) = specialist end diff --git a/docs/_advanced/rag.md b/docs/_advanced/rag.md index 01a09a71f..8eebfa6ff 100644 --- a/docs/_advanced/rag.md +++ b/docs/_advanced/rag.md @@ -77,7 +77,7 @@ end ```ruby class DocumentSearch < RubyLLM::Tool description "Searches knowledge base for relevant information" - param :query, desc: "Search query" + parameter :query, description: "Search query" def execute(query:) embedding = RubyLLM.embed(query).vectors diff --git a/docs/_advanced/rails-persistence.md b/docs/_advanced/rails-persistence.md index b3461216d..e84d63031 100644 --- a/docs/_advanced/rails-persistence.md +++ b/docs/_advanced/rails-persistence.md @@ -204,7 +204,7 @@ Tools are Ruby classes that the AI can call. While the tool classes themselves a ```ruby class Weather < RubyLLM::Tool description "Gets current weather for a location" - param :city, desc: "City name" + parameter :city, description: "City name" def execute(city:) "The weather in #{city} is sunny and 22°C." diff --git a/docs/_core_features/chat-request-control.md b/docs/_core_features/chat-request-control.md index 5fe1807b7..8dcdaad10 100644 --- a/docs/_core_features/chat-request-control.md +++ b/docs/_core_features/chat-request-control.md @@ -130,7 +130,7 @@ The same idea applies to tool definitions: ```ruby class ChangelogTool < RubyLLM::Tool description "Formats commits into human-readable changelog entries." - param :commits, type: :array, desc: "List of commits to summarize" + parameter :commits, type: :array, description: "List of commits to summarize" with_params cache_control: { type: 'ephemeral' } diff --git a/docs/_core_features/citations.md b/docs/_core_features/citations.md index 99c1519bf..860e1b6b9 100644 --- a/docs/_core_features/citations.md +++ b/docs/_core_features/citations.md @@ -77,7 +77,7 @@ When your tools fetch documents, e.g. from a vector store, Google Drive, a wiki, ```ruby class KnowledgeBase < RubyLLM::Tool description "Searches the company knowledge base" - param :query, desc: "What to look for" + parameter :query, description: "What to look for" def execute(query:) docs = MyVectorStore.search(query) diff --git a/docs/_core_features/tool-parameters.md b/docs/_core_features/tool-parameters.md index 321dd2194..d34086d8c 100644 --- a/docs/_core_features/tool-parameters.md +++ b/docs/_core_features/tool-parameters.md @@ -23,7 +23,7 @@ description: Declare tool arguments - from inferred signatures to full JSON Sche After reading this guide, you will know: * How RubyLLM infers parameters from an `execute` signature. -* When to reach for the `param` helper versus the `params` DSL. +* When to reach for the `parameter` helper versus the `parameters` DSL. * How to supply your own JSON Schema for full control. * How to return rich content, such as images and documents, from a tool. * How to inject dependencies through custom initialization. @@ -36,18 +36,20 @@ The model only knows what arguments a tool accepts from the schema you give it. RubyLLM ships with three complementary approaches: * **Signature inference** for simple flat arguments. -* The **`param` helper** for quick, flat argument lists. (v1.0+) -* The **`params` DSL** for expressive, structured inputs. (v1.9+) +* The **`parameter` helper** for quick, flat argument lists. (v1.0+) +* The **`parameters` DSL** for expressive, structured inputs. (v1.9+) -Start with the method signature. Add `param` when a flat argument needs a description, type, or optionality that is not obvious from Ruby alone. Use the `params` DSL whenever you need nested objects, arrays, enums, or union types. +The 1.x names (`param`, `desc:`, `params`, and class-level `desc`) still work on this fork. + +Start with the method signature. Add `parameter` when a flat argument needs a description, type, or optionality that is not obvious from Ruby alone. Use the `parameters` DSL whenever you need nested objects, arrays, enums, or union types. ### Signature Inference -When a tool has no `param` or `params` declaration, RubyLLM builds a JSON Schema from `execute` keyword arguments: +When a tool has no `parameter` or `parameters` declaration, RubyLLM builds a JSON Schema from `execute` keyword arguments: ```ruby class Weather < RubyLLM::Tool - desc "Gets current weather for a location" + description "Gets current weather for a location" def execute(latitude:, longitude:, units: "metric") # ... @@ -59,16 +61,16 @@ Required keywords become required string parameters. Optional keywords become op Ruby method signatures do not expose reliable JSON Schema types or descriptions, so add explicit declarations when those details matter. -### Using the `param` Helper for Simple Tools +### Using the `parameter` Helper for Simple Tools -If your tool just needs a few scalar arguments with descriptions or non-string types, use the `param` helper. RubyLLM translates these declarations into JSON Schema under the hood. +If your tool just needs a few scalar arguments with descriptions or non-string types, use the `parameter` helper. RubyLLM translates these declarations into JSON Schema under the hood. ```ruby class Distance < RubyLLM::Tool - desc "Calculates distance between two cities" - param :origin, desc: "Origin city name" - param :destination, description: "Destination city name" - param :units, type: :string, desc: "Unit system (metric or imperial)", required: false + description "Calculates distance between two cities" + parameter :origin, description: "Origin city name" + parameter :destination, description: "Destination city name" + parameter :units, type: :string, description: "Unit system (metric or imperial)", required: false def execute(origin:, destination:, units: "metric") # ... @@ -76,15 +78,15 @@ class Distance < RubyLLM::Tool end ``` -### params DSL +### parameters DSL -When you need nested objects, arrays, enums, or union types, the `params do ... end` DSL produces the JSON Schema that function-calling models expect while staying Ruby-flavoured. +When you need nested objects, arrays, enums, or union types, the `parameters do ... end` DSL produces the JSON Schema that function-calling models expect while staying Ruby-flavoured. ```ruby class Scheduler < RubyLLM::Tool - desc "Books a meeting" + description "Books a meeting" - params do + parameters do object :window, description: "Time window to reserve" do string :start, description: "ISO8601 start time" string :finish, description: "ISO8601 end time" @@ -108,13 +110,13 @@ RubyLLM bundles the DSL through [`ruby_llm-schema`](https://github.com/danielfri ### Supplying JSON Schema Manually -Prefer to own the JSON Schema yourself? Pass a schema hash (or a class/object responding to `#to_json_schema`) directly to `params`: +Prefer to own the JSON Schema yourself? Pass a schema hash (or a class/object responding to `#to_json_schema`) directly to `parameters`: ```ruby class Lookup < RubyLLM::Tool description "Performs catalog lookups" - params type: "object", + parameters type: "object", properties: { sku: { type: "string", description: "Product SKU" }, locale: { type: "string", description: "Country code", default: "US" } @@ -138,7 +140,7 @@ Tools can return `RubyLLM::Content` objects with file attachments, allowing you ```ruby class AnalyzeTool < RubyLLM::Tool description "Analyzes data and returns results with visualizations" - param :query, desc: "Analysis query" + parameter :query, description: "Analysis query" def execute(query:) chart_path = generate_chart(query) @@ -180,12 +182,12 @@ Tools can have custom initialization: class DocumentSearch < RubyLLM::Tool description "Searches documents by relevance" - param :query, - desc: "The search query" + parameter :query, + description: "The search query" - param :limit, + parameter :limit, type: :integer, - desc: "Maximum number of results", + description: "Maximum number of results", required: false def initialize(database) @@ -215,7 +217,7 @@ Some providers accept additional metadata alongside the JSON Schema, for example class TodoTool < RubyLLM::Tool description "Adds a task to the shared TODO list" - params do + parameters do string :title, description: "Human-friendly task description" end diff --git a/docs/_core_features/tools.md b/docs/_core_features/tools.md index c1d5d6944..bce3411e3 100644 --- a/docs/_core_features/tools.md +++ b/docs/_core_features/tools.md @@ -49,7 +49,7 @@ Define a tool by creating a class that inherits from `RubyLLM::Tool`. ```ruby class Weather < RubyLLM::Tool - desc "Gets current weather for a location" + description "Gets current weather for a location" def execute(latitude:, longitude:) url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}¤t=temperature_2m,wind_speed_10m" @@ -65,9 +65,9 @@ end ### Tool Components 1. **Inheritance:** Must inherit from `RubyLLM::Tool`. -2. **`desc` / `description`:** A class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise. +2. **`description`:** A class method defining what the tool does. Crucial for the AI model to understand its purpose. Keep it clear and concise. 3. **`execute` Method:** The instance method containing your Ruby code. RubyLLM v1.15+ infers simple keyword parameters from this signature when no explicit parameter schema is declared. -4. **Parameter declarations:** Optional. Use `param` for simple descriptions and types, or `params` for nested objects, arrays, enums, and full JSON Schema control. +4. **Parameter declarations:** Optional. Use `parameter` for simple descriptions and types, or `parameters` for nested objects, arrays, enums, and full JSON Schema control. > The tool's class name is automatically converted to a snake_case name used in the API call (e.g., `WeatherLookup` becomes `weather_lookup`). This is how the LLM would call it. You can override this by defining a `name` method in your tool class: > @@ -94,14 +94,16 @@ end RubyLLM ships with three complementary approaches to telling the model what arguments a tool accepts: * **Signature inference** for simple flat arguments. -* The **`param` helper** for quick, flat argument lists. (v1.0+) -* The **`params` DSL** for expressive, structured inputs. (v1.9+) +* The **`parameter` helper** for quick, flat argument lists. (v1.0+) +* The **`parameters` DSL** for expressive, structured inputs. (v1.9+) -The simplest case needs nothing extra. When a tool has no `param` or `params` declaration, RubyLLM builds a JSON Schema from the `execute` keyword arguments: +The 1.x names (`param`, `desc:`, `params`, and class-level `desc`) still work on this fork. + +The simplest case needs nothing extra. When a tool has no `parameter` or `parameters` declaration, RubyLLM builds a JSON Schema from the `execute` keyword arguments: ```ruby class Weather < RubyLLM::Tool - desc "Gets current weather for a location" + description "Gets current weather for a location" def execute(latitude:, longitude:, units: "metric") # ... @@ -111,7 +113,7 @@ end Required keywords become required string parameters. Optional keywords become optional string parameters. A tool with `def execute` receives an empty object schema. -Ruby method signatures do not expose reliable JSON Schema types or descriptions, so add explicit declarations when those details matter. For the `param` helper, the `params` DSL, and supplying JSON Schema manually, see [Tool Parameters]({% link _core_features/tool-parameters.md %}). +Ruby method signatures do not expose reliable JSON Schema types or descriptions, so add explicit declarations when those details matter. For the `parameter` helper, the `parameters` DSL, and supplying JSON Schema manually, see [Tool Parameters]({% link _core_features/tool-parameters.md %}). ## Using Tools in Chat diff --git a/docs/_getting_started/overview.md b/docs/_getting_started/overview.md index 35131ba92..199d7aaed 100644 --- a/docs/_getting_started/overview.md +++ b/docs/_getting_started/overview.md @@ -59,7 +59,7 @@ Tools allow AI models to call Ruby code during conversations. This powerful feat ```ruby class Calculator < RubyLLM::Tool - desc "Performs basic arithmetic" + description "Performs basic arithmetic" def execute(expression:) { result: eval(expression) } diff --git a/docs/index.md b/docs/index.md index c965e0576..b22fe4b86 100644 --- a/docs/index.md +++ b/docs/index.md @@ -169,7 +169,7 @@ RubyLLM.moderate "Check if this text is safe" ```ruby # Let AI use your code class Weather < RubyLLM::Tool - desc "Get current weather" + description "Get current weather" def execute(latitude:, longitude:) url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}¤t=temperature_2m,wind_speed_10m" diff --git a/lib/ruby_llm/tool.rb b/lib/ruby_llm/tool.rb index 26442867f..a2f946437 100644 --- a/lib/ruby_llm/tool.rb +++ b/lib/ruby_llm/tool.rb @@ -3,7 +3,7 @@ require 'ruby_llm/schema' module RubyLLM - # Parameter definition for Tool methods. + # Parameter definition for Tool methods. `desc:` is the 1.x name for `description:`. class Parameter attr_reader :name, :type, :description, :required @@ -20,8 +20,6 @@ class Tool POSITIONAL_PARAMETER_KINDS = %i[req opt rest].freeze class << self - attr_reader :params_schema_definition - def description(text = nil) return @description unless text @@ -29,17 +27,40 @@ def description(text = nil) end alias desc description + # RubyLLM 2.0 name. Stores into declared_parameters, as 2.0 does. + def parameter(name, **options) + declared_parameters[name] = Parameter.new(name, **options) + end + + # 1.x name for parameter. def param(name, **options) - parameters[name] = Parameter.new(name, **options) + parameter(name, **options) + end + + # RubyLLM 2.0 name for the declared parameter hash (the storage). + def declared_parameters + @declared_parameters ||= {} end - def parameters - @parameters ||= {} + # Called with a schema or a block: the 2.0 form of params. Called with neither: the 1.x + # getter for the parameter hash (2.0 raises ArgumentError here instead; this fork keeps + # the getter until Supernova no longer calls it). + def parameters(schema = nil, &block) + return declared_parameters if schema.nil? && block.nil? + + define_parameters_schema(schema, block) end + # 1.x name for parameters(schema) / parameters do ... end. def params(schema = nil, &block) - @params_schema_definition = SchemaDefinition.new(schema:, block:) - self + define_parameters_schema(schema, block) + end + + attr_reader :parameters_schema_definition + + # 1.x name for parameters_schema_definition. + def params_schema_definition + parameters_schema_definition end def with_params(**params) @@ -50,6 +71,13 @@ def with_params(**params) def provider_params @provider_params ||= {} end + + private + + def define_parameters_schema(schema, block) + @parameters_schema_definition = SchemaDefinition.new(schema:, block:) + self + end end def name @@ -63,7 +91,14 @@ def description self.class.description end + # 1.x name for declared_parameters. def parameters + declared_parameters + end + + # RubyLLM 2.0 name. Calls the class through its 1.x getter so a class-level override of + # either name is honored. + def declared_parameters self.class.parameters end @@ -71,10 +106,16 @@ def provider_params self.class.provider_params end + # 1.x name for parameters_schema. def params_schema - return @params_schema if defined?(@params_schema) + parameters_schema + end + + # RubyLLM 2.0 name; holds what params_schema did before. + def parameters_schema + return @parameters_schema if defined?(@parameters_schema) - @params_schema = begin + @parameters_schema = begin definition = self.class.params_schema_definition if definition&.present? definition.json_schema diff --git a/spec/ruby_llm/tool_spec.rb b/spec/ruby_llm/tool_spec.rb index 5aad6884d..52c6b344a 100644 --- a/spec/ruby_llm/tool_spec.rb +++ b/spec/ruby_llm/tool_spec.rb @@ -197,4 +197,218 @@ def execute(query:, internal:) ) end end + + describe 'RubyLLM 2.0 tool DSL names' do + def expected_schema + { + 'type' => 'object', + 'properties' => { + 'city' => { 'type' => 'string', 'description' => 'City name' }, + 'days' => { 'type' => 'integer', 'description' => 'Forecast days' }, + 'tags' => { 'type' => 'array', 'description' => 'Tags', 'items' => { 'type' => 'string' } } + }, + 'required' => %w[city tags], + 'additionalProperties' => false, + 'strict' => true + } + end + + def fixed_schema + { 'type' => 'object', 'properties' => { 'q' => { 'type' => 'string' } }, 'required' => ['q'] } + end + + def gemini_fixed_schema + { type: 'OBJECT', properties: { 'q' => { type: 'STRING' } }, required: ['q'] } + end + + def base_tool + Class.new(described_class) do + parameter :base, description: 'Base value' + + def execute(base:) + base + end + end + end + + def merged_hash + base_tool.declared_parameters.merge(extra: RubyLLM::Parameter.new(:extra, description: 'Extra value')) + end + + def merged_schema + { + 'type' => 'object', + 'properties' => { + 'base' => { 'type' => 'string', 'description' => 'Base value' }, + 'extra' => { 'type' => 'string', 'description' => 'Extra value' } + }, + 'required' => %w[base extra], + 'additionalProperties' => false, + 'strict' => true + } + end + + def build_tool(const_name, &) + klass = Class.new(described_class, &) + stub_const(const_name, klass) + klass.new + end + + it 'builds the same schema from param/desc and parameter/description' do + old_tool = build_tool('OldNamesTool') do + param :city, desc: 'City name' + param :days, type: :integer, desc: 'Forecast days', required: false + param :tags, type: :array, desc: 'Tags' + end + new_tool = build_tool('NewNamesTool') do + parameter :city, description: 'City name' + parameter :days, type: :integer, description: 'Forecast days', required: false + parameter :tags, type: :array, description: 'Tags' + end + + [old_tool, new_tool].each do |tool| + expect(tool.params_schema).to eq(expected_schema) + expect(tool.parameters_schema).to eq(expected_schema) + end + end + + it 'accepts params and parameters with a schema hash' do + schema = { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] } + old_tool = build_tool('OldHashTool') { params schema } + new_tool = build_tool('NewHashTool') { parameters schema } + + expect(old_tool.parameters_schema).to eq(fixed_schema) + expect(new_tool.parameters_schema).to eq(old_tool.parameters_schema) + end + + it 'accepts params and parameters with a RubyLLM::Schema block' do + old_tool = build_tool('OldBlockTool') do + params do + string :q + end + end + new_tool = build_tool('NewBlockTool') do + parameters do + string :q + end + end + + expect(old_tool.parameters_schema).to eq(new_tool.parameters_schema) + expect(new_tool.parameters_schema['properties']).to eq('q' => { 'type' => 'string' }) + expect(new_tool.params_schema).to eq(new_tool.parameters_schema) + end + + it 'returns the parameter hash from parameters without arguments and the class from params' do + klass = Class.new(described_class) { parameter :a } + + expect(klass.parameters).to be(klass.declared_parameters) + expect(klass.parameters.keys).to eq([:a]) + expect(klass.params).to be(klass) + end + + it 'honors a class-level parameters override through both names' do + merged = merged_hash + tool = build_tool('ClassParametersOverrideTool') do + define_singleton_method(:parameters) { |*| merged } + end + + expect(tool.params_schema).to eq(merged_schema) + expect(tool.parameters_schema).to eq(merged_schema) + expect(tool.parameters).to eq(merged) + expect(tool.declared_parameters).to eq(merged) + end + + it 'honors a class-level declared_parameters override through both names' do + merged = merged_hash + tool = build_tool('ClassDeclaredOverrideTool') do + define_singleton_method(:declared_parameters) { merged } + end + + expect(tool.params_schema).to eq(merged_schema) + expect(tool.parameters_schema).to eq(merged_schema) + expect(tool.parameters).to eq(merged) + expect(tool.declared_parameters).to eq(merged) + end + + it 'honors instance-level parameters and params_schema overrides at the 1.x names' do + schema = fixed_schema + tool = build_tool('InstanceOldOverrideTool') do + define_method(:parameters) { {} } + define_method(:params_schema) { schema } + end + + # parameters_schema and declared_parameters sit further down the chains than these overrides + # and so deliberately do not see them. The protocols call params_schema and parameters. + expect(tool.params_schema).to eq(fixed_schema) + expect(tool.parameters).to eq({}) + end + + it 'honors instance-level declared_parameters and parameters_schema overrides at the 2.0 names' do + schema = fixed_schema + tool = build_tool('InstanceNewOverrideTool') do + define_method(:declared_parameters) { {} } + define_method(:parameters_schema) { schema } + end + + expect(tool.params_schema).to eq(fixed_schema) + expect(tool.parameters_schema).to eq(fixed_schema) + expect(tool.parameters).to eq({}) + expect(tool.declared_parameters).to eq({}) + end + + %w[InstanceOldOverrideTool InstanceNewOverrideTool].each do |const_name| + context "with #{const_name}" do + let(:tool) do + schema = fixed_schema + if const_name == 'InstanceOldOverrideTool' + build_tool(const_name) do + define_method(:parameters) { {} } + define_method(:params_schema) { schema } + end + else + build_tool(const_name) do + define_method(:declared_parameters) { {} } + define_method(:parameters_schema) { schema } + end + end + end + + it 'reaches Anthropic' do + definition = RubyLLM::Protocols::Anthropic::Tools.function_for(tool) + expect(definition[:input_schema]).to eq(fixed_schema) + end + + it 'reaches Chat Completions' do + definition = RubyLLM::Protocols::ChatCompletions::Tools.tool_for(tool) + expect(definition[:function][:parameters]).to eq(fixed_schema) + end + + it 'reaches Converse' do + definition = RubyLLM::Protocols::Converse::Chat.format_tool(tool) + expect(definition[:toolSpec][:inputSchema][:json]).to eq(fixed_schema) + end + + it 'reaches Gemini' do + formatter = Object.new.extend(RubyLLM::Protocols::Gemini::Tools) + definitions = formatter.format_tools({ tool.name.to_sym => tool }) + expect(definitions[0][:functionDeclarations][0][:parameters]).to eq(gemini_fixed_schema) + end + + it 'reaches Responses' do + definition = RubyLLM::Protocols::Responses::Tools.tool_for(tool) + expect(definition[:parameters]).to eq(fixed_schema) + end + end + end + + it 'does not copy declared parameters into subclasses' do + parent = Class.new(described_class) { parameter :query } + child = Class.new(parent) + + # RubyLLM 2.0 copies a parent's parameters and schema definition into each subclass. + # 1.16 does not, and Supernova handles the difference at the gem bump. + expect(child.declared_parameters).to eq({}) + expect(parent.declared_parameters.keys).to eq([:query]) + end + end end From 536d6a22d34af7833f423fe452e99383f8e00f7c Mon Sep 17 00:00:00 2001 From: Supernova Date: Mon, 19 Oct 2026 17:53:50 +0000 Subject: [PATCH 2/2] Pin the test matrix to ubuntu-24.04 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/_test-matrix.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test-matrix.yml b/.github/workflows/_test-matrix.yml index 4c7f941d5..4e3efc25b 100644 --- a/.github/workflows/_test-matrix.yml +++ b/.github/workflows/_test-matrix.yml @@ -15,7 +15,8 @@ permissions: jobs: test: name: Test (${{ matrix.ruby-version }} / ${{ matrix.rails-version }}) - runs-on: ubuntu-latest + # ubuntu-26.04 has no Ruby 3.1 or JRuby 10.0.2.0 build in ruby/setup-ruby, so pin to 24.04. + runs-on: ubuntu-24.04 env: BUNDLE_PATH: vendor/bundle BUNDLE_DISABLE_SHARED_GEMS: "true"