From 054c85795b5067b4a01d09d3faa572029e289865 Mon Sep 17 00:00:00 2001 From: SamW Date: Thu, 30 Oct 2025 20:38:55 -0700 Subject: [PATCH 001/163] [Hash] Update Hash's signatures and tests to be correct --- core/hash.rbs | 216 +++++----- lib/rbs/test/type_check.rb | 7 +- test/stdlib/Hash_test.rb | 858 ++++++++++++++++++++++++------------- 3 files changed, 693 insertions(+), 388 deletions(-) diff --git a/core/hash.rbs b/core/hash.rbs index df4bbe6c8f..699472f423 100644 --- a/core/hash.rbs +++ b/core/hash.rbs @@ -486,15 +486,29 @@ # * #flatten: Returns an array that is a 1-dimensional flattening of `self`. # * #invert: Returns a hash with the each key-value pair inverted. # -class Hash[unchecked out K, unchecked out V] < Object +class Hash[unchecked out K, unchecked out V] include Enumerable[[ K, V ]] + # Interface that indicates a type can be used as a key to `Hash` lookup methods. + # interface _Key def hash: () -> Integer def eql?: (untyped rhs) -> boolish end + # Interface that indicates a type convertible to `[ K, V ]` via its `#to_ary` method. + # + interface _Pair[K, V] + def to_ary: () -> [ K, V ] + end + + # Interface for comparing equality of types. + # + interface _Equals + def ==: (untyped other) -> boolish + end + # # - def deconstruct_keys: (Array[K] | nil) -> self + def deconstruct_keys: (untyped) -> self # # Calls the given block with each key-value pair; returns `self`: @@ -937,8 +953,8 @@ class Hash[unchecked out K, unchecked out V] < Object # bar: 1 # baz: 2 # - def each: () { ([ K, V ] arg0) -> untyped } -> self - | () -> ::Enumerator[[ K, V ], self] + def each: () -> Enumerator[[ K, V ], self] + | () { ([ K, V ] pair) -> void } -> self # + # # Returns a new `Hash` object whose entries are those for which the block # returns a truthy value: # h = {foo: 0, bar: 1, baz: 2} @@ -1126,10 +1146,14 @@ class Hash[unchecked out K, unchecked out V] < Object # e = h.select # => #0, :bar=>1, :baz=>2}:select> # e.each {|key, value| value < 2 } # => {:foo=>0, :bar=>1} # - def filter: () { (K, V) -> boolish } -> ::Hash[K, V] - | () -> ::Enumerator[[ K, V ], ::Hash[K, V]] + def select: () -> Enumerator[[ K, V ], Hash[K, V]] + | () { (K key, V value) -> boolish } -> Hash[K, V] - # + # # Returns `self`, whose entries are those for which the block returns a truthy # value: # h = {foo: 0, bar: 1, baz: 2} @@ -1142,8 +1166,8 @@ class Hash[unchecked out K, unchecked out V] < Object # e = h.select! # => #0, :bar=>1, :baz=>2}:select!> # e.each { |key, value| value < 2 } # => {:foo=>0, :bar=>1} # - def filter!: () { (K, V) -> boolish } -> self? - | () -> ::Enumerator[[ K, V ], self?] + def select!: () -> Enumerator[[ K, V ], self?] + | () { (K key, V value) -> boolish } -> self? # # Returns `true` if `key` is a key in `self`, otherwise `false`. # - def has_key?: (K arg0) -> bool + def has_key?: (_Key key) -> bool # # Returns `true` if `value` is a value in `self`, otherwise `false`. # - def has_value?: (V arg0) -> bool + def has_value?: (_Equals value) -> bool # # Returns `true` if `key` is a key in `self`, otherwise `false`. @@ -1291,14 +1315,18 @@ class Hash[unchecked out K, unchecked out V] < Object # h = {foo: 0, bar: 1, baz: 2} # h.keys # => [:foo, :bar, :baz] # - def keys: () -> ::Array[K] + def keys: () -> Array[K] - # + # # Returns the count of entries in `self`: # # {foo: 0, bar: 1, baz: 2}.length # => 3 # - def length: () -> Integer + def size: () -> Integer # # Returns `true` if `key` is a key in `self`, otherwise `false`. @@ -1356,8 +1384,8 @@ class Hash[unchecked out K, unchecked out V] < Object # h1 = h.merge { |key, old_value, new_value| raise 'Cannot happen' } # h1 # => {:foo=>0, :bar=>1, :baz=>2} # - def merge: [A, B] (*::Hash[A, B] other_hashes) -> ::Hash[A | K, B | V] - | [A, B, C] (*::Hash[A, B] other_hashes) { (K key, V oldval, B newval) -> C } -> ::Hash[A | K, B | V | C] + def merge: [A, B] (*hash[A, B] other_hashes) -> Hash[A | K, B | V] + | [A, B, C] (*hash[A, B] other_hashes) { (K key, V oldval, B newval) -> C } -> Hash[A | K, B | V | C] # # Merges each of `other_hashes` into `self`; returns `self`. @@ -1401,8 +1429,8 @@ class Hash[unchecked out K, unchecked out V] < Object # h1 = h.merge! { |key, old_value, new_value| raise 'Cannot happen' } # h1 # => {:foo=>0, :bar=>1, :baz=>2} # - def merge!: (*::Hash[K, V] other_hashes) -> self - | (*::Hash[K, V] other_hashes) { (K key, V oldval, V newval) -> V } -> self + def merge!: (*hash[K, V] other_hashes) -> self + | (*hash[K, V] other_hashes) { (K key, V oldval, V newval) -> V } -> self # # Replaces the entire contents of `self` with the contents of `other_hash`; @@ -1477,13 +1507,9 @@ class Hash[unchecked out K, unchecked out V] < Object # h = {foo: 0, bar: 1, baz: 2} # h.replace({bat: 3, bam: 4}) # => {:bat=>3, :bam=>4} # - def replace: (Hash[K, V]) -> self + def replace: (hash[K, V] other) -> self - # + # # Returns a new `Hash` object whose entries are those for which the block # returns a truthy value: # h = {foo: 0, bar: 1, baz: 2} @@ -1494,13 +1520,9 @@ class Hash[unchecked out K, unchecked out V] < Object # e = h.select # => #0, :bar=>1, :baz=>2}:select> # e.each {|key, value| value < 2 } # => {:foo=>0, :bar=>1} # - alias select filter + alias filter select - # + # # Returns `self`, whose entries are those for which the block returns a truthy # value: # h = {foo: 0, bar: 1, baz: 2} @@ -1513,7 +1535,7 @@ class Hash[unchecked out K, unchecked out V] < Object # e = h.select! # => #0, :bar=>1, :baz=>2}:select!> # e.each { |key, value| value < 2 } # => {:foo=>0, :bar=>1} # - alias select! filter! + alias filter! select! # + # # Returns the count of entries in `self`: # # {foo: 0, bar: 1, baz: 2}.length # => 3 # - alias size length + alias length size # # Associates the given `value` with the given `key`; returns `value`. @@ -1580,7 +1598,7 @@ class Hash[unchecked out K, unchecked out V] < Object # h = {foo: 0, bar: 1, baz: 2} # h.to_a # => [[:foo, 0], [:bar, 1], [:baz, 2]] # - def to_a: () -> ::Array[[ K, V ]] + def to_a: () -> Array[[ K, V ]] # # Invokes Module.append_features on each parameter in reverse order. # - def include: (Module, *Module arg0) -> self + def include: (Module module, *Module additional_modules) -> self # # Invokes Module.prepend_features on each parameter in reverse order. # - def prepend: (Module, *Module arg0) -> self + def prepend: (Module module, *Module additional_modules) -> self # # Evaluates the given block in the context of the class/module. The method @@ -416,7 +415,7 @@ class Module # # Hello there! # - def class_exec: [U] (*untyped, **untyped) { (?) [self: self] -> U } -> U + alias class_exec module_exec # # Makes a list of existing constants private. # - def private_constant: (*interned arg0) -> self + def private_constant: () -> self + | (interned name, *interned more_names) -> self # # Makes a list of existing constants public. # - def public_constant: (*interned arg0) -> self + def public_constant: () -> self + | (interned name, *interned more_names) -> self # # The equivalent of `included`, but for extended modules. # @@ -952,8 +951,9 @@ class Module # Assign the module to a constant (name starting uppercase) if you want to treat # it like a regular module. # - def initialize: () -> void - | () { (Module arg0) -> untyped } -> void + def initialize: () ?{ (Module mod) [self: self] -> void } -> void + + def initialize_clone: (Module source, ?freeze: bool?) -> void # # Makes a list of existing constants private. # - def private_constant: () -> self - | (interned name, *interned more_names) -> self + def private_constant: (*interned names) -> self # # Makes a list of existing constants public. # - def public_constant: () -> self - | (interned name, *interned more_names) -> self + def public_constant: (*interned names) -> self # # Returns a string representing this module or class. For basic classes and diff --git a/test/stdlib/Module_test.rb b/test/stdlib/Module_test.rb index 01892d6a50..99af2bac30 100644 --- a/test/stdlib/Module_test.rb +++ b/test/stdlib/Module_test.rb @@ -55,6 +55,14 @@ module RefinedModule end end + def with_untyped_singleton_possible + with_untyped do |untyped| + next if Integer === untyped || Float === untyped || Symbol === untyped + untyped = ::Kernel.instance_method(:dup).bind_call(untyped) if ::Kernel.instance_method(:frozen?).bind_call(untyped) + yield untyped + end + end + def test_op_lt with Object, Float, Hash do |mod| assert_send_type '(Module) -> bool?', @@ -1071,72 +1079,191 @@ def foo = 4 def test_append_features assert_visibility :private, Module.new, :append_features - omit 'todo' + + assert_send_type '(Module) -> Module', + Module.new, :append_features, Module.new end def test_const_added assert_visibility :private, Module.new, :const_added - omit 'todo' + + # const_added directly works + assert_send_type '(Symbol) -> void', + Module.new, :const_added, :foo + + # Setting the constant also works + const_added_module = proc do + assert_type_meth = method(:assert_type) + mod = Module.new do + define_singleton_method :const_added do |name| + assert_type_meth.call('Symbol', name) + end + end + end + + # Make sure the `::` assignment passes a symbol + eval <<~EOS + const_added_module.call()::Foo = 3 + EOS + + # Make sure that `const_set` also always passes a symbol to the `const_added` + with_interned :Foo do |name| + const_added_module.call().const_set(name, 2r) + end end def test_extend_object assert_visibility :private, Module.new, :extend_object - omit 'todo' + + with_untyped_singleton_possible do |untyped| + assert_send_type '[T] (T) -> T', + Module.new, :extend_object, untyped + end + + # No need to make sure `object.extend(module)` works because the signature + # is `(T) -> T`, which means it can take any type (and we aren't testing the + # return value of `extend`) end def test_extended assert_visibility :private, Module.new, :extended - omit 'todo' + + with_untyped_singleton_possible do |untyped| + assert_send_type '(untyped) -> void', + Module.new, :extended, untyped + end + + # No need to make sure `object.extend(module)` works because the signature + # is `(untyped) -> void`, which means it can take any type, and we dont care + # about the return value. end def test_included assert_visibility :private, Module.new, :included - omit 'todo' + + assert_send_type '(Module) -> void', + Module.new, :included, Module.new + + assert_send_type '(Module) -> void', + Module.new, :included, Class.new end def test_method_added assert_visibility :private, Module.new, :method_added - omit 'todo' + + + # method_added directly works + assert_send_type '(Symbol) -> void', + Module.new, :method_added, :foo + + # make sure using `with_intern` always passes a symbol + assert_type_meth = method(:assert_type) + mod = Module.new do + define_singleton_method :method_added do |name| + assert_type_meth.call('Symbol', name) + end + end + + with_interned :foo do |name| + mod.define_method(:foo) {} + mod.undef_method(:foo) # avoid warnings + end end def test_method_removed assert_visibility :private, Module.new, :method_removed - omit 'todo' + + # method_removed directly works + assert_send_type '(Symbol) -> void', + Module.new, :method_removed, :foo + + # make sure using `with_intern` always passes a symbol + assert_type_meth = method(:assert_type) + mod = Module.new do + define_singleton_method :method_removed do |name| + assert_type_meth.call('Symbol', name) + end + end + + with_interned :foo do |name| + mod.define_method(:foo) {} + mod.remove_method(name) + end end def test_method_undefined assert_visibility :private, Module.new, :method_undefined - omit 'todo' + + # method_undefined directly works + assert_send_type '(Symbol) -> void', + Module.new, :method_undefined, :foo + + # make sure using `with_intern` always passes a symbol + assert_type_meth = method(:assert_type) + mod = Module.new do + define_singleton_method :method_undefined do |name| + assert_type_meth.call('Symbol', name) + end + end + + with_interned :foo do |name| + mod.define_method(:foo) {} + mod.undef_method(name) + end end def test_prepend_features assert_visibility :private, Module.new, :prepend_features - omit 'todo' + + assert_send_type '(Module) -> Module', + Module.new, :prepend_features, Module.new + + assert_send_type '(Module) -> Module', + Module.new, :prepend_features, Class.new end def test_prepended assert_visibility :private, Module.new, :prepended - omit 'todo' + + assert_send_type '(Module) -> void', + Module.new, :prepended, Module.new + + assert_send_type '(Module) -> void', + Module.new, :prepended, Class.new end def test_remove_const assert_visibility :private, Module.new, :remove_const - omit 'todo' + + with_interned :Foo do |name| + mod = Module.new + mod.const_set :Foo, 1r + + assert_send_type '(interned) -> untyped', + mod, :remove_const, name + end + end + + + module UsingModule + UsingReturnValue = using Module.new end def test_using assert_visibility :private, Module.new, :using - omit 'todo' + + # Cant actually test `using` in modules, so this is the best we got + assert_type 'Module', UsingModule::UsingModule end end From b8ecab624c5c15d96d13cce04d6aa3fb5ef7c215 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:42:39 +0000 Subject: [PATCH 013/163] Bump actions/github-script from 8 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/milestone.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index f0dc278403..048431e7bf 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -27,7 +27,7 @@ jobs: echo "RBS::VERSION = $version (major=$major, minor=$minor, patch=$patch)" - name: Check milestone - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const pr = context.payload.pull_request; From 2035b439a5109d64f57562670bfb33c837dd630f Mon Sep 17 00:00:00 2001 From: SamW Date: Tue, 14 Apr 2026 12:14:24 -0700 Subject: [PATCH 014/163] bugfix --- core/hash.rbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/hash.rbs b/core/hash.rbs index c0f3cb585a..a37a766213 100644 --- a/core/hash.rbs +++ b/core/hash.rbs @@ -1395,7 +1395,7 @@ class Hash[unchecked out K, unchecked out V] # # Related: see [Methods for Fetching](rdoc-ref:Hash@Methods+for+Fetching). # - def key: (V) -> K? + def key: (_Equals) -> K? # # Returns whether `key` is a key in `self`: From 6a26eecd1cb62a4c3e02e7cb88c13fbdbc892a1c Mon Sep 17 00:00:00 2001 From: SamW Date: Tue, 14 Apr 2026 12:37:58 -0700 Subject: [PATCH 015/163] updated docs --- core/hash.rbs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/core/hash.rbs b/core/hash.rbs index a37a766213..871c688895 100644 --- a/core/hash.rbs +++ b/core/hash.rbs @@ -1205,7 +1205,11 @@ class Hash[unchecked out K, unchecked out V] def fetch_values: (*_Key keys) -> Array[V] | [K2 < _Key, T] (*K2 keys) { (K2 key) -> T } -> Array[V | T] - # + # # With a block given, calls the block with each entry's key and value; returns a # new hash whose entries are those for which the block returns a truthy value: # @@ -1219,7 +1223,11 @@ class Hash[unchecked out K, unchecked out V] def select: () -> Enumerator[[ K, V ], Hash[K, V]] | () { (K key, V value) -> boolish } -> Hash[K, V] - # + # # With a block given, calls the block with each entry's key and value; removes # from `self` each entry for which the block returns `false` or `nil`. # @@ -1423,8 +1431,7 @@ class Hash[unchecked out K, unchecked out V] # # Returns the count of entries in `self`: # @@ -1626,11 +1633,7 @@ class Hash[unchecked out K, unchecked out V] # def replace: (hash[K, V] other) -> self - # + # # With a block given, calls the block with each entry's key and value; returns a # new hash whose entries are those for which the block returns a truthy value: # @@ -1643,11 +1646,7 @@ class Hash[unchecked out K, unchecked out V] # alias filter select - # + # # With a block given, calls the block with each entry's key and value; removes # from `self` each entry for which the block returns `false` or `nil`. # @@ -1680,10 +1679,7 @@ class Hash[unchecked out K, unchecked out V] # def shift: () -> [ K, V ]? - # + # # Returns the count of entries in `self`: # # {foo: 0, bar: 1, baz: 2}.size # => 3 From 033ef0832e2b8e1e08e42173ce0aa2d2353dc74f Mon Sep 17 00:00:00 2001 From: SamW Date: Tue, 14 Apr 2026 16:18:29 -0700 Subject: [PATCH 016/163] update module --- core/module.rbs | 4 ++-- lib/rbs/unit_test/type_assertions.rb | 9 +++++++-- test/stdlib/Module_test.rb | 30 +++++++++++++++++++++++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/core/module.rbs b/core/module.rbs index 9c6dc9c6ff..c07193b578 100644 --- a/core/module.rbs +++ b/core/module.rbs @@ -348,7 +348,7 @@ class Module # # Files that are currently being loaded must not be registered for autoload. # - def autoload: (interned constant, String filename) -> NilClass + def autoload: (interned constant, path filename) -> nil # # The equivalent of `included`, but for extended modules. # diff --git a/lib/rbs/unit_test/type_assertions.rb b/lib/rbs/unit_test/type_assertions.rb index 2fdb3856b8..dee8345ca4 100644 --- a/lib/rbs/unit_test/type_assertions.rb +++ b/lib/rbs/unit_test/type_assertions.rb @@ -296,8 +296,13 @@ def assert_const_type(type, constant_name) assert typecheck.value(constant, definition_type), "`#{constant_name}` (#{constant.inspect}) must be compatible with RBS type definition `#{definition_type}`" end - def assert_visibility(vis, receiver, method) - puts 'TODO: visibility for types' + def assert_visibility(visibility, receiver, method) + _, definition = target + method_entry = definition.methods[method] + + assert method_entry, "Method `#{method}` not found in RBS definition" + assert_equal visibility, method_entry.accessibility, + "Expected `#{method}` to be #{visibility}, but was #{method_entry.accessibility}" end def assert_type(type, value) diff --git a/test/stdlib/Module_test.rb b/test/stdlib/Module_test.rb index 99af2bac30..8662983187 100644 --- a/test/stdlib/Module_test.rb +++ b/test/stdlib/Module_test.rb @@ -511,11 +511,35 @@ def test_ancestors end def test_autoload - omit 'todo' + with_interned :Constant do |constant| + with_path do |path| + assert_send_type '(interned, path) -> nil', + Module.new, :autoload, constant, path + end + end end def test_autoload? - omit 'todo' + autoloaded = Module.new + autoloaded.autoload(:Constant, 'Bar') + + with_interned :Constant do |constant| + assert_send_type '(interned) -> nil', + Module.new, :autoload?, constant + assert_send_type '(interned) -> String', + autoloaded, :autoload?, constant + assert_send_type '(interned) -> String', + Module.new.include(autoloaded), :autoload?, constant + + with_boolish do |inherit| + assert_send_type '(interned, boolish) -> nil', + Module.new, :autoload?, constant, inherit + assert_send_type '(interned, boolish) -> String', + autoloaded, :autoload?, constant, inherit + assert_send_type '(interned, boolish) -> String?', + Module.new.include(autoloaded), :autoload?, constant, inherit + end + end end def test_class_variable_defined? @@ -1264,6 +1288,6 @@ def test_using Module.new, :using # Cant actually test `using` in modules, so this is the best we got - assert_type 'Module', UsingModule::UsingModule + assert_type 'Module', UsingModule::UsingReturnValue end end From 205f42c9057ddebdfb9d86f767014d80a3189757 Mon Sep 17 00:00:00 2001 From: SamW Date: Tue, 14 Apr 2026 16:29:08 -0700 Subject: [PATCH 017/163] cleanup of assert_visibility --- lib/rbs/unit_test/type_assertions.rb | 4 +-- sig/unit_test/type_assertions.rbs | 4 +++ test/stdlib/Module_test.rb | 51 ++++++++++------------------ 3 files changed, 24 insertions(+), 35 deletions(-) diff --git a/lib/rbs/unit_test/type_assertions.rb b/lib/rbs/unit_test/type_assertions.rb index 4370f8421d..6794218a29 100644 --- a/lib/rbs/unit_test/type_assertions.rb +++ b/lib/rbs/unit_test/type_assertions.rb @@ -323,12 +323,12 @@ def assert_const_type(type, constant_name) assert typecheck.value(constant, definition_type), "`#{constant_name}` (#{constant.inspect}) must be compatible with RBS type definition `#{definition_type}`" end - def assert_visibility(visibility, receiver, method) + def assert_visibility(visibility, method) _, definition = target method_entry = definition.methods[method] assert method_entry, "Method `#{method}` not found in RBS definition" - assert_equal visibility, method_entry.accessibility, + assert visibility == method_entry.accessibility, "Expected `#{method}` to be #{visibility}, but was #{method_entry.accessibility}" end diff --git a/sig/unit_test/type_assertions.rbs b/sig/unit_test/type_assertions.rbs index 9e7d32fc5f..253a8fb184 100644 --- a/sig/unit_test/type_assertions.rbs +++ b/sig/unit_test/type_assertions.rbs @@ -179,6 +179,10 @@ module RBS # def assert_type: (String | Types::t value_type, untyped value) -> void + # Asserts if given `value` has a type of `value_type` + # + def assert_visibility: (:private | :public visibility, Symbol method_name) -> void + # Allow non _simple-type_ method types given to `assert_send_type` and `refute_send_type` # # ```ruby diff --git a/test/stdlib/Module_test.rb b/test/stdlib/Module_test.rb index 8662983187..25d1a1288f 100644 --- a/test/stdlib/Module_test.rb +++ b/test/stdlib/Module_test.rb @@ -133,11 +133,10 @@ def test_prepend end def test_refine + assert_visibility :private, :refine + assert_send_type "(Module) { () -> void } -> Refinement", RefinedModule, :refine, Integer do nil end - - assert_visibility :private, - RefinedModule, :refine end def test_refinements @@ -212,8 +211,7 @@ def foo; end def bar; end end - assert_visibility :private, - mod, :module_function + assert_visibility :private, :module_function # No arguments assert_send_type '() -> nil', @@ -281,8 +279,8 @@ def foo; end def bar; end end - assert_visibility :private, - mod, visibility + assert_visibility :private, visibility + # No arguments assert_send_type '() -> nil', mod, visibility @@ -471,8 +469,7 @@ def foo(*x) = 3 def bar(*x) = 3 end - assert_visibility :private, - mod, :ruby2_keywords + assert_visibility :private, :ruby2_keywords with_interned :foo do |foo| assert_send_type '(interned) -> nil', @@ -1101,16 +1098,14 @@ def foo = 4 end def test_append_features - assert_visibility :private, - Module.new, :append_features + assert_visibility :private, :append_features assert_send_type '(Module) -> Module', Module.new, :append_features, Module.new end def test_const_added - assert_visibility :private, - Module.new, :const_added + assert_visibility :private, :const_added # const_added directly works assert_send_type '(Symbol) -> void', @@ -1138,8 +1133,7 @@ def test_const_added end def test_extend_object - assert_visibility :private, - Module.new, :extend_object + assert_visibility :private, :extend_object with_untyped_singleton_possible do |untyped| assert_send_type '[T] (T) -> T', @@ -1152,8 +1146,7 @@ def test_extend_object end def test_extended - assert_visibility :private, - Module.new, :extended + assert_visibility :private, :extended with_untyped_singleton_possible do |untyped| assert_send_type '(untyped) -> void', @@ -1166,8 +1159,7 @@ def test_extended end def test_included - assert_visibility :private, - Module.new, :included + assert_visibility :private, :included assert_send_type '(Module) -> void', Module.new, :included, Module.new @@ -1177,8 +1169,7 @@ def test_included end def test_method_added - assert_visibility :private, - Module.new, :method_added + assert_visibility :private, :method_added # method_added directly works @@ -1200,8 +1191,7 @@ def test_method_added end def test_method_removed - assert_visibility :private, - Module.new, :method_removed + assert_visibility :private, :method_removed # method_removed directly works assert_send_type '(Symbol) -> void', @@ -1222,8 +1212,7 @@ def test_method_removed end def test_method_undefined - assert_visibility :private, - Module.new, :method_undefined + assert_visibility :private, :method_undefined # method_undefined directly works assert_send_type '(Symbol) -> void', @@ -1244,8 +1233,7 @@ def test_method_undefined end def test_prepend_features - assert_visibility :private, - Module.new, :prepend_features + assert_visibility :private, :prepend_features assert_send_type '(Module) -> Module', Module.new, :prepend_features, Module.new @@ -1255,8 +1243,7 @@ def test_prepend_features end def test_prepended - assert_visibility :private, - Module.new, :prepended + assert_visibility :private, :prepended assert_send_type '(Module) -> void', Module.new, :prepended, Module.new @@ -1266,8 +1253,7 @@ def test_prepended end def test_remove_const - assert_visibility :private, - Module.new, :remove_const + assert_visibility :private, :remove_const with_interned :Foo do |name| mod = Module.new @@ -1284,8 +1270,7 @@ module UsingModule end def test_using - assert_visibility :private, - Module.new, :using + assert_visibility :private, :using # Cant actually test `using` in modules, so this is the best we got assert_type 'Module', UsingModule::UsingReturnValue From cac6a80ae60452b8065a5173aa00a79a793a10e7 Mon Sep 17 00:00:00 2001 From: SamW Date: Mon, 27 Apr 2026 22:02:16 -0700 Subject: [PATCH 018/163] added hash_pair test --- test/typecheck/hash_pair/Steepfile | 7 ++++++ test/typecheck/hash_pair/test.rb | 37 ++++++++++++++++++++++++++++++ test/typecheck/hash_pair/test.rbs | 18 +++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 test/typecheck/hash_pair/Steepfile create mode 100644 test/typecheck/hash_pair/test.rb create mode 100644 test/typecheck/hash_pair/test.rbs diff --git a/test/typecheck/hash_pair/Steepfile b/test/typecheck/hash_pair/Steepfile new file mode 100644 index 0000000000..77b2334655 --- /dev/null +++ b/test/typecheck/hash_pair/Steepfile @@ -0,0 +1,7 @@ +D = Steep::Diagnostic + +target :test do + signature "." + check "." + configure_code_diagnostics(D::Ruby.all_error) +end diff --git a/test/typecheck/hash_pair/test.rb b/test/typecheck/hash_pair/test.rb new file mode 100644 index 0000000000..89cc5912de --- /dev/null +++ b/test/typecheck/hash_pair/test.rb @@ -0,0 +1,37 @@ +class ToAry + def initialize (*x) @x = x end + def to_ary = @x +end + +class Pair + def initialize(x, y) @x, @y = x, y end + def to_ary = [@x, @y] +end + +class Test + def self.takes_pair(pair) + x, y = pair.to_ary + x + y + end +end + +is_pair = Pair.new(1, 2r) #: Pair[Integer, Rational] + +Test.takes_pair(is_pair) + + +Hash[[ + Pair.new(:a, 1r), + Pair.new(:b, 2r) +]] #: Hash[Symbol, Rational] + +Hash[ + ToAry.new( + Pair.new(:a, 1r), + Pair.new(:b, 2r) + ) +] #: Hash[Symbol, Rational] + +hash = { a: 3, b: 4 } +hash.to_h { |k, v| Pair.new(k.to_s, v.to_r) } #: Hash[String, Rational] + diff --git a/test/typecheck/hash_pair/test.rbs b/test/typecheck/hash_pair/test.rbs new file mode 100644 index 0000000000..c4e5a22b26 --- /dev/null +++ b/test/typecheck/hash_pair/test.rbs @@ -0,0 +1,18 @@ +class ToAry[X] + @x: Array[X] + def initialize: (*X) -> void + def to_ary: () -> Array[X] +end + +class Pair[X, Y] + @x: X + @y: Y + + def initialize: (X, Y) -> void + + def to_ary: () -> [X, Y] +end + +class Test + def self.takes_pair: (Hash::_Pair[Integer, Rational]) -> Rational +end From c9948121e552d61f2c69dc30f316d79dbdcabe65 Mon Sep 17 00:00:00 2001 From: SamW Date: Tue, 28 Apr 2026 17:48:36 -0700 Subject: [PATCH 019/163] rename from Elem to T --- core/array.rbs | 288 ++++++++++++++--------------- core/builtin.rbs | 8 +- core/enumerable.rbs | 218 +++++++++++----------- core/enumerator.rbs | 56 +++--- core/enumerator/product.rbs | 10 +- core/object_space/weak_key_map.rbs | 14 +- core/range.rbs | 46 ++--- core/set.rbs | 6 +- core/struct.rbs | 32 ++-- core/thread.rbs | 12 +- docs/rbs_by_example.md | 40 ++-- docs/syntax.md | 4 +- sig/shims/enumerable.rbs | 6 +- stdlib/abbrev/0/array.rbs | 2 +- stdlib/csv/0/csv.rbs | 10 +- stdlib/json/0/json.rbs | 12 +- stdlib/shellwords/0/shellwords.rbs | 2 +- stdlib/tsort/0/cyclic.rbs | 2 +- stdlib/tsort/0/interfaces.rbs | 16 +- stdlib/tsort/0/tsort.rbs | 18 +- 20 files changed, 401 insertions(+), 401 deletions(-) diff --git a/core/array.rbs b/core/array.rbs index 03d3f13a63..188e0df81e 100644 --- a/core/array.rbs +++ b/core/array.rbs @@ -546,8 +546,8 @@ # given block. # %a{annotate:rdoc:source:from=array.c} -class Array[unchecked out Elem] < Object - include Enumerable[Elem] +class Array[unchecked out E] < Object + include Enumerable[E] # # With a block given, calls the block with each element of `self`; returns a new @@ -2007,8 +2007,8 @@ class Array[unchecked out Elem] < Object # # Related: see [Methods for Fetching](rdoc-ref:Array@Methods+for+Fetching). # - def filter: () { (Elem item) -> boolish } -> ::Array[Elem] - | () -> ::Enumerator[Elem, ::Array[Elem]] + def filter: () { (E item) -> boolish } -> ::Array[E] + | () -> ::Enumerator[E, ::Array[E]] # # With a block given, calls the block with each element of `self`; removes from @@ -2025,8 +2025,8 @@ class Array[unchecked out Elem] < Object # # Related: see [Methods for Deleting](rdoc-ref:Array@Methods+for+Deleting). # - def filter!: () { (Elem item) -> boolish } -> self? - | () -> ::Enumerator[Elem, self?] + def filter!: () { (E item) -> boolish } -> self? + | () -> ::Enumerator[E, self?] # # Prepends the given `objects` to `self`: @@ -2803,10 +2803,10 @@ class Array[unchecked out Elem] < Object # # Related: see [Methods for Combining](rdoc-ref:Array@Methods+for+Combining). # - def product: () -> ::Array[[ Elem ]] - | [X] (::Array[X] other_ary) -> ::Array[[ Elem, X ]] - | [X, Y] (::Array[X] other_ary1, ::Array[Y] other_ary2) -> ::Array[[ Elem, X, Y ]] - | [U] (*::Array[U] other_arys) -> ::Array[::Array[Elem | U]] + def product: () -> ::Array[[ E ]] + | [X] (::Array[X] other_ary) -> ::Array[[ E, X ]] + | [X, Y] (::Array[X] other_ary1, ::Array[Y] other_ary2) -> ::Array[[ E, X, Y ]] + | [U] (*::Array[U] other_arys) -> ::Array[::Array[E | U]] # # Replaces the elements of `self` with the elements of `other_array`, which must @@ -2976,7 +2976,7 @@ class Array[unchecked out Elem] < Object # # Related: see [Methods for Assigning](rdoc-ref:Array@Methods+for+Assigning). # - def replace: (::Array[Elem]) -> self + def replace: (::Array[E]) -> self # # Returns the new string formed by calling method #inspect on each @@ -3820,7 +3820,7 @@ class Array[unchecked out Elem] < Object # # Related: see [Methods for Combining](rdoc-ref:Array@Methods+for+Combining). # - def union: [T] (*::Array[T] other_arys) -> ::Array[T | Elem] + def union: [T] (*::Array[T] other_arys) -> ::Array[T | E] # # Returns an array of flattened objects returned by the block. @@ -399,8 +399,8 @@ module Enumerable[unchecked out Elem] : _Each[Elem] # # Alias: #collect_concat. # - def collect_concat: [U] () { (Elem) -> (::Array[U] | U) } -> ::Array[U] - | () -> ::Enumerator[Elem, ::Array[untyped]] + def collect_concat: [U] () { (E) -> (::Array[U] | U) } -> ::Array[U] + | () -> ::Enumerator[E, ::Array[untyped]] # # Returns the first element for which the block returns a truthy value. @@ -488,8 +488,8 @@ module Enumerable[unchecked out Elem] : _Each[Elem] # # With no block given, returns an Enumerator. # - def detect: (?Proc ifnone) { (Elem) -> boolish } -> Elem? - | (?Proc ifnone) -> ::Enumerator[Elem, Elem?] + def detect: (?Proc ifnone) { (E) -> boolish } -> E? + | (?Proc ifnone) -> ::Enumerator[E, E?] # # Returns an array containing the items in `self`: # # (0..4).to_a # => [0, 1, 2, 3, 4] # - def entries: () -> ::Array[Elem] + def entries: () -> ::Array[E] def enum_for: (Symbol method, *untyped, **untyped) ?{ (?) -> Integer } -> Enumerator[untyped, untyped] - | () ?{ () -> Integer } -> Enumerator[Elem, self] + | () ?{ () -> Integer } -> Enumerator[E, self] %a{annotate:rdoc:skip} alias to_enum enum_for @@ -639,8 +639,8 @@ module Enumerable[unchecked out Elem] : _Each[Elem] # # Related: #reject. # - def find_all: () { (Elem) -> boolish } -> ::Array[Elem] - | () -> ::Enumerator[Elem, ::Array[Elem]] + def find_all: () { (E) -> boolish } -> ::Array[E] + | () -> ::Enumerator[E, ::Array[E]] # # Returns an array containing elements selected by the block. @@ -697,8 +697,8 @@ module Enumerable[unchecked out Elem] : _Each[Elem] # With no argument and no block given, returns an Enumerator. # def find_index: (untyped value) -> Integer? - | () { (Elem) -> boolish } -> Integer? - | () -> ::Enumerator[Elem, Integer?] + | () { (E) -> boolish } -> Integer? + | () -> ::Enumerator[E, Integer?] # # Returns whether for any element object == element: @@ -972,8 +972,8 @@ module Enumerable[unchecked out Elem] : _Each[Elem] # def inject: (untyped init, Symbol method) -> untyped | (Symbol method) -> untyped - | [A] (A initial) { (A, Elem) -> A } -> A - | () { (Elem, Elem) -> Elem } -> Elem + | [A] (A initial) { (A, E) -> A } -> A + | () { (E, E) -> E } -> E # # Returns an array of objects returned by the block. @@ -1645,8 +1645,8 @@ module Enumerable[unchecked out Elem] : _Each[Elem] # # With no block given, returns an Enumerator. # - def map: [U] () { (Elem arg0) -> U } -> ::Array[U] - | () -> ::Enumerator[Elem, ::Array[untyped]] + def map: [U] () { (E arg0) -> U } -> ::Array[U] + | () -> ::Enumerator[E, ::Array[untyped]] # # Iterates the given block for each element with an arbitrary object, `obj`, and @@ -541,17 +541,17 @@ class Enumerator[unchecked out Elem, out Return = void] < Object # # => foo: 1 # # => foo: 2 # - def with_object: [U] (U obj) { (Elem, U obj) -> untyped } -> U - | [U] (U obj) -> ::Enumerator[[ Elem, U ], U] + def with_object: [U] (U obj) { (E, U obj) -> untyped } -> U + | [U] (U obj) -> ::Enumerator[[ E, U ], U] end # # Generator # -class Enumerator::Generator[out Elem] < Object - include Enumerable[Elem] +class Enumerator::Generator[out E] < Object + include Enumerable[E] - def each: () { (Elem) -> void } -> void + def each: () { (E) -> void } -> void end # @@ -620,7 +620,7 @@ end # # This returns an array of items like a normal enumerator does. # all_checked = active_items.select(&:checked) # -class Enumerator::Lazy[out Elem, out Return = void] < Enumerator[Elem, Return] +class Enumerator::Lazy[out E, out R = void] < Enumerator[E, R] # # Expands `lazy` enumerator to an array. See Enumerable#to_a. # @@ -632,7 +632,7 @@ class Enumerator::Lazy[out Elem, out Return = void] < Enumerator[Elem, Return] # --> # Like Enumerable#compact, but chains operation to be lazy-evaluated. # - def compact: () -> Enumerator::Lazy[Elem, Return] + def compact: () -> Enumerator::Lazy[E, R] # # Returns a non-lazy Enumerator converted from the lazy enumerator. # - def eager: () -> ::Enumerator[Elem, Return] + def eager: () -> ::Enumerator[E, R] end # @@ -675,7 +675,7 @@ end # # This type of objects can be created by Enumerable#chain and Enumerator#+. # -class Enumerator::Chain[out Elem] < Enumerator[Elem, void] +class Enumerator::Chain[out E] < Enumerator[E, void] # # Enumerator::Product generates a Cartesian product of any number of enumerable # objects. Iterating over the product of enumerable objects is roughly @@ -30,7 +30,7 @@ class Enumerator[unchecked out Elem, out Return = void] # # This type of objects can be created by Enumerator.product. # - class Product[unchecked out Elem] < Enumerator[Array[Elem], Product[Elem]] + class Product[unchecked out E] < Enumerator[Array[E], Product[E]] # # Returns `true` if `key` is a key in `self`, otherwise `false`. # - def key?: (Key) -> bool + def key?: (K) -> bool end end diff --git a/core/range.rbs b/core/range.rbs index 11d1a531f5..296891b3ea 100644 --- a/core/range.rbs +++ b/core/range.rbs @@ -235,8 +235,8 @@ # # require 'json/add/range' # -class Range[out Elem] < Object - include Enumerable[Elem] +class Range[out E] < Object + include Enumerable[E] # # Returns the values in `self` as an array: @@ -441,8 +441,8 @@ class Struct[Elem] # # Related: #each_pair. # - def each: () -> Enumerator[Elem, self] - | () { (Elem value) -> void } -> self + def each: () -> Enumerator[E, self] + | () { (E value) -> void } -> self # # With a block given, returns an array of values from `self` for which the block @@ -591,7 +591,7 @@ class Struct[Elem] # Raises RangeError if any element of the range is negative and out of range; # see Array@Array+Indexes. # - def values_at: (*int | range[int?] positions) -> Array[Elem] + def values_at: (*int | range[int?] positions) -> Array[E] # @@ -664,5 +664,5 @@ class Struct[Elem] # h = joe.deconstruct_keys(nil) # h # => {:name=>"Joseph Smith, Jr.", :address=>"123 Maple, Anytown NC", :zip=>12345} # - def deconstruct_keys: (Array[index & Hash::_Key]? indices) -> Hash[index & Hash::_Key, Elem] + def deconstruct_keys: (Array[index & Hash::_Key]? indices) -> Hash[index & Hash::_Key, E] end diff --git a/core/thread.rbs b/core/thread.rbs index d47803fc6c..b3e7d78c17 100644 --- a/core/thread.rbs +++ b/core/thread.rbs @@ -1604,7 +1604,7 @@ end # # consumer.join # -class Thread::Queue[Elem = untyped] < Object +class Thread::Queue[E = untyped] < Object # # Pushes the given `object` to the queue. # @@ -1719,7 +1719,7 @@ class Thread::Queue[Elem = untyped] < Object # If `timeout` seconds have passed and no data is available `nil` is returned. # If `timeout` is `0` it returns immediately. # - def pop: (?boolish non_block, ?timeout: _ToF?) -> Elem? + def pop: (?boolish non_block, ?timeout: _ToF?) -> E? # # Pushes the given `object` to the queue. # - def push: (Elem obj) -> void + def push: (E obj) -> void # # See #as_json. # - def self.json_create: [Elem] (Hash[String, String | Array[Elem]] object) -> Struct[Elem] + def self.json_create: [E] (Hash[String, String | Array[E]] object) -> Struct[E] # # Exception class to be raised when a cycle is found. # diff --git a/stdlib/tsort/0/interfaces.rbs b/stdlib/tsort/0/interfaces.rbs index d560681a4f..8d410d9397 100644 --- a/stdlib/tsort/0/interfaces.rbs +++ b/stdlib/tsort/0/interfaces.rbs @@ -1,20 +1,20 @@ %a{annotate:rdoc:skip} -module TSort[Node] - interface _Sortable[Node] +module TSort[N] + interface _Sortable[N] # #tsort_each_node is used to iterate for all nodes over a graph. # - def tsort_each_node: () { (Node) -> void } -> void + def tsort_each_node: () { (N) -> void } -> void # #tsort_each_child is used to iterate for child nodes of node. # - def tsort_each_child: (Node) { (Node) -> void } -> void + def tsort_each_child: (N) { (N) -> void } -> void end - interface _EachNode[Node] - def call: () { (Node) -> void } -> void + interface _EachNode[N] + def call: () { (N) -> void } -> void end - interface _EachChild[Node] - def call: (Node) { (Node) -> void } -> void + interface _EachChild[N] + def call: (N) { (N) -> void } -> void end end diff --git a/stdlib/tsort/0/tsort.rbs b/stdlib/tsort/0/tsort.rbs index ec5b55cd2c..521f284a9b 100644 --- a/stdlib/tsort/0/tsort.rbs +++ b/stdlib/tsort/0/tsort.rbs @@ -113,7 +113,7 @@ # 1. Tarjan, "Depth First Search and Linear Graph Algorithms", # *SIAM Journal on Computing*, Vol. 1, No. 2, pp. 146-160, June 1972. # -module TSort[Node] : TSort::_Sortable[Node] +module TSort[N] : TSort::_Sortable[N] # From 9483bf6427753c780de22c8dd125fad7a6cc3a1f Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Thu, 21 May 2026 04:06:14 +0900 Subject: [PATCH 030/163] Remove StringScanner methods absent from the latest Ruby release StringScanner#clear, #empty?, #getbyte, #peep, and #restsize were obsolete for over two decades and were removed from strscan 3.1.6, bundled with the latest Ruby release. RBS targets the latest release of Ruby, so drop their definitions. --- stdlib/strscan/0/string_scanner.rbs | 41 ----------------------------- 1 file changed, 41 deletions(-) diff --git a/stdlib/strscan/0/string_scanner.rbs b/stdlib/strscan/0/string_scanner.rbs index 593224ad16..e0419e148e 100644 --- a/stdlib/strscan/0/string_scanner.rbs +++ b/stdlib/strscan/0/string_scanner.rbs @@ -675,14 +675,6 @@ class StringScanner # def check_until: (Regexp) -> String - # - # Equivalent to #terminate. This method is obsolete; use #terminate instead. - # - def clear: () -> void - # - # Equivalent to #eos?. This method is obsolete, use #eos? instead. - # - def empty?: () -> bool - # - # Equivalent to #get_byte. This method is obsolete; use #get_byte instead. - # - def getbyte: () -> String? - # - # Equivalent to #peek. This method is obsolete; use #peek instead. - # - def peep: (Integer) -> String - # # call-seq: # pos -> byte_position @@ -1217,15 +1185,6 @@ class StringScanner # def rest_size: () -> Integer - # - # `s.restsize` is equivalent to `s.rest_size`. This method is obsolete; use - # #rest_size instead. - # - def restsize: () -> Integer - # + # Returns the array of captured match values at indexes (1..) + # if the most recent match attempt succeeded, or nil otherwise; + # see [Captured Match Values](rdoc-ref:StringScanner@Captured+Match+Values): + # scanner = StringScanner.new('Fri Dec 12 1975 14:39') + # scanner.named_captures # => {} + # + # pattern = /(?\w+) (?\w+) (?\d+) / + # scanner.match?(pattern) + # scanner.named_captures # => {"wday"=>"Fri", "month"=>"Dec", "day"=>"12"} + # + # scanner.string = 'nope' + # scanner.match?(pattern) + # scanner.named_captures # => {"wday"=>nil, "month"=>nil, "day"=>nil} + # + # scanner.match?(/nosuch/) + # scanner.named_captures # => {} + # + def named_captures: () -> Hash[String, String?] + # + # Peeks at the current byte and returns it as an integer. + # + # s = StringScanner.new('ab') + # s.peek_byte # => 97 + # + def peek_byte: () -> Integer? + # # call-seq: # pos -> byte_position @@ -1092,7 +1126,7 @@ class StringScanner # scanner.match?(/nope/) # => nil # scanner.post_match # => nil # - def post_match: () -> String + def post_match: () -> String? # + # Scans one byte and returns it as an integer. This method is not multibyte + # character sensitive. See also: #getch. + # + def scan_byte: () -> Integer? # + # If `base` isn't provided or is `10`, then it is equivalent to calling + # #scan with a `[+-]?d+` pattern, and returns an Integer or nil. + # + # If `base` is `16`, then it is equivalent to calling #scan with a + # `[+-]?(0x)?[0-9a-fA-F]+` pattern, and returns an Integer or nil. + # + # The scanned string must be encoded with an ASCII compatible encoding, + # otherwise Encoding::CompatibilityError will be raised. + # + def scan_integer: (?base: Integer) -> Integer? # + # Returns pathname configuration variable using fpathconf(). + # + # *name* should be a constant under `Etc` which begins with `PC_`. + # + # The return value is an integer or nil. nil means indefinite limit. + # (fpathconf() returns -1 but errno is not set.) + # + # require 'etc' + # IO.pipe {|r, w| + # p w.pathconf(Etc::PC_PIPE_BUF) #=> 4096 + # } + # + def pathconf: (Integer name) -> Integer? + # @@ -319,7 +319,7 @@ class Digest::Class # Returns the base64 encoded hash value of a given *string*. The return value # is properly padded with '=' and contains no line feeds. # - def self.base64digest: (string str) -> String + def self.base64digest: (string str, *untyped args) -> String # # Returns the BubbleBabble encoded hash value of a given *string*. # - def self.bubblebabble: (string) -> String + def self.bubblebabble: (string, *untyped) -> String # # Returns system temporary directory; typically "/tmp". # - def self?.systmpdir: () -> ::String + def self?.systmpdir: () -> ::String? # # Raises NotImplementedError. # - def fcntl: (Integer integer_cmd, String | Integer arg) -> Integer + def fcntl: (*untyped) -> bot # # Returns 0; for compatibility with IO. # - def fsync: () -> Integer? + def fsync: () -> Integer # # Returns `nil`; for compatibility with IO. # - def internal_encoding: () -> Encoding + def internal_encoding: () -> nil # + # See IO#pread. + # + def pread: (Integer maxlen, Integer offset, ?String outbuf) -> String + + def read_nonblock: (int len, ?string buf, ?exception: bool) -> String? def readbyte: () -> Integer @@ -1358,6 +1368,16 @@ class StringIO def set_encoding: (?String | Encoding ext_or_ext_int_enc) -> self | (?String | Encoding ext_or_ext_int_enc, ?String | Encoding int_enc) -> self + # + # Sets the encoding according to the BOM (Byte Order Mark) in the string. + # + # Returns `self` if the BOM is found, otherwise +nil. + # + def set_encoding_by_bom: () -> Encoding? + # # Returns the BubbleBabble encoded hash value of a given *string*. # - def self.bubblebabble: (string, *untyped) -> String + def self.bubblebabble: (string) -> String # # Appends each argument in `objects` to `self`; returns `self`: @@ -2615,7 +2617,9 @@ class Array[unchecked out E] # # Related: see [Methods for Querying](rdoc-ref:Array@Methods+for+Querying). # - alias none? all? + def none?: () -> bool + | (_Pattern[E] pattern) -> bool + | () { (E element) -> boolish } -> bool # # Returns the count of elements in `self`: @@ -4140,6 +4147,7 @@ class Array[unchecked out E] alias initialize_copy replace end +%a{deprecated: Use Array::_Rand} interface _Rand def rand: (::Integer max) -> ::Integer end diff --git a/test/stdlib/Array_test.rb b/test/stdlib/Array_test.rb index 34659b84a6..2dc426bfae 100644 --- a/test/stdlib/Array_test.rb +++ b/test/stdlib/Array_test.rb @@ -56,6 +56,10 @@ class ArrayInstanceTest < Test::Unit::TestCase class ArraySubclass < Array end + class RngGen + def rand(max) = ToInt.new(Random.new.rand(max)) + end + def test_op_and with_array [1r, 1i] do |other| assert_send_type '(array[untyped]) -> Array[Rational]', @@ -334,7 +338,21 @@ def test_fetch end def test_fetch_values - omit 'todo' + assert_send_type '() -> Array[Rational]', + [1r, 2r], :fetch_values + assert_send_type '() { (Integer) -> void } -> Array[Rational]', + [1r, 2r], :fetch_values do end + + with_int 1 do |index1| + with_int 2 do |index2| + assert_send_type '(*int) -> Array[Rational]', + [1r, 2r, 3r], :fetch_values, index1, index2 + assert_send_type '[I < _ToInt] (*I) { (I) -> void } -> Array[Rational]', + [1r, 2r, 3r], :fetch_values, index1, index2 do fail end + assert_send_type '[I < _ToInt] (*I) { (I) -> Complex } -> Array[Rational | Complex]', + [1r, 2r], :fetch_values, index1, index2 do |x| x.to_int.i end + end + end end def test_fill @@ -358,7 +376,17 @@ def test_index end def test_first - omit 'todo' + assert_send_type '() -> nil', + [], :first + assert_send_type '() -> Rational', + [1r, 2r], :first + + with_int 2 do |count| + assert_send_type '(int) -> Array[Rational]', + [], :first, count + assert_send_type '(int) -> Array[Rational]', + [1r, 2r], :first, count + end end def test_flatten @@ -412,7 +440,17 @@ def test_keep_if end def test_last - omit 'todo' + assert_send_type '() -> nil', + [], :last + assert_send_type '() -> Rational', + [1r, 2r], :last + + with_int 2 do |count| + assert_send_type '(int) -> Array[Rational]', + [], :last, count + assert_send_type '(int) -> Array[Rational]', + [1r, 2r], :last, count + end end def test_length(method: :length) @@ -500,7 +538,22 @@ def test_rotate! end def test_sample - omit 'todo' + assert_send_type '() -> nil', + [], :sample + assert_send_type '(random: Array::_Rand) -> nil', + [], :sample, random: RngGen.new + + assert_send_type '() -> Rational', + [1r, 2r, 3r], :sample + assert_send_type '(random: Array::_Rand) -> Rational', + [1r, 2r, 3r], :sample, random: RngGen.new + + with_int 2 do |count| + assert_send_type '(int) -> Array[Rational]', + [1r, 2r, 3r], :sample, count + assert_send_type '(int, random: Array::_Rand) -> Array[Rational]', + [1r, 2r, 3r], :sample, count, random: RngGen.new + end end def test_select(method: :select) @@ -524,11 +577,19 @@ def test_shift end def test_shuffle - omit 'todo' + assert_send_type '() -> Array[Rational]', + [1r, 2r, 3r], :shuffle + + assert_send_type '(random: Array::_Rand) -> Array[Rational]', + [1r, 2r, 3r], :shuffle, random: RngGen.new end def test_shuffle! - omit 'todo' + assert_send_type '() -> Array[Rational]', + [1r, 2r, 3r], :shuffle! + + assert_send_type '(random: Array::_Rand) -> Array[Rational]', + [1r, 2r, 3r], :shuffle!, random: RngGen.new end def test_slice! From 6e2f753704a2efa91c46c53e7eac323273e1439d Mon Sep 17 00:00:00 2001 From: SamW Date: Tue, 2 Jun 2026 11:20:45 -0700 Subject: [PATCH 060/163] more updates --- core/array.rbs | 20 +++---- test/stdlib/Array_test.rb | 118 ++++++++++++++++++++++++++++++++------ 2 files changed, 110 insertions(+), 28 deletions(-) diff --git a/core/array.rbs b/core/array.rbs index 943f2d7c22..80d8b75078 100644 --- a/core/array.rbs +++ b/core/array.rbs @@ -1397,7 +1397,7 @@ class Array[unchecked out E] # # Related: see [Methods for Assigning](rdoc-ref:Array@Methods+for+Assigning). # - def concat: (*::Array[E] arrays) -> self + def concat: (*array[E] other_arrays) -> self # # Prepends the given `objects` to `self`: @@ -2838,7 +2838,7 @@ class Array[unchecked out E] # # Related: see [Methods for Assigning](rdoc-ref:Array@Methods+for+Assigning). # - def push: (*E obj) -> self + def push: (*E objects) -> self # # Returns the new string formed by calling method #inspect on each @@ -3808,7 +3808,7 @@ class Array[unchecked out E] # # Related: see [Methods for Converting](rdoc-ref:Array@Methods+for+Converting). # - def transpose: () -> ::Array[::Array[untyped]] + def transpose: () -> Array[Array[untyped]] # # Returns the zero-based integer index of a specified element, or `nil`. @@ -2371,7 +2371,7 @@ class Array[unchecked out E] # # Related: see [Methods for Converting](rdoc-ref:Array@Methods+for+Converting). # - def join: (?string separator) -> String + def join: (?string? separator) -> String # TODO: where E: _ToS # # With a block given, calls the block with each element of `self`; returns a new @@ -2286,7 +2285,7 @@ class Array[unchecked out E] # # Related: see [Methods for Assigning](rdoc-ref:Array@Methods+for+Assigning). # - def insert: (int index, *E obj) -> self + def insert: (int index, *E objects) -> self # + # Returns the first element for which the block returns a truthy value. + # + # With a block given, calls the block with successive elements of the array; + # returns the first element for which the block returns a truthy value: + # + # [1, 3, 5].find {|element| element > 2} # => 3 + # + # If no such element is found, calls `if_none_proc` and returns its return + # value. + # + # [1, 3, 5].find(proc {-1}) {|element| element > 12} # => -1 + # + # With no block given, returns an Enumerator. + # + alias detect find # # With a block given, calls the block with each element of `self`; removes from @@ -2032,8 +2047,7 @@ class Array[unchecked out E] # # Related: see [Methods for Deleting](rdoc-ref:Array@Methods+for+Deleting). # - def filter!: () { (E item) -> boolish } -> self? - | () -> ::Enumerator[E, self?] + alias filter! select! # + # Freezes `self` (if not already frozen); returns `self`: + # + # a = [] + # a.frozen? # => false + # a.freeze + # a.frozen? # => true + # + # No further changes may be made to `self`; raises FrozenError if a change is + # attempted. + # + # Related: Kernel#frozen?. + # + def freeze: () -> self + # # With a block given, calls the block with each element of `self`; returns a new @@ -2713,8 +2745,8 @@ class Array[unchecked out E] # # Related: [Methods for Iterating](rdoc-ref:Array@Methods+for+Iterating). # - def permutation: (?int n) -> ::Enumerator[::Array[E], ::Array[E]] - | (?int n) { (::Array[E] p) -> void } -> ::Array[E] + def permutation: (?int? count) { (Array[E] permutation) -> void } -> self + | (?int? count) -> Enumerator[Array[E], self] # # Replaces the elements of `self` with the elements of `other_array`, which must @@ -3039,8 +3071,8 @@ class Array[unchecked out E] # # Related: see [Methods for Iterating](rdoc-ref:Array@Methods+for+Iterating). # - def reverse_each: () { (E item) -> void } -> self - | () -> ::Enumerator[E, self] + def reverse_each: () { (E element) -> void } -> self + | () -> Enumerator[E, self] # # Returns the zero-based integer index of a specified element, or `nil`. @@ -2332,7 +2332,7 @@ class Array[unchecked out E] # # Related: see [Methods for Converting](rdoc-ref:Array@Methods+for+Converting). # - def inspect: () -> String # TODO: where E: _Inspect + def inspect: () -> String # where E: _Inspect # @@ -2664,7 +2664,7 @@ class Array[unchecked out E] # Related: see [Methods for Querying](rdoc-ref:Array@Methods+for+Querying). # def none?: () -> bool - | (_Pattern[E] pattern) -> bool + | (RBS::Ops::_CaseEqual[E, boolish] pattern) -> bool | () { (E element) -> boolish } -> bool # # The number of base digits for the `double` data type. # @@ -1292,25 +1290,3 @@ Float::NAN: Float # decimal. # Float::RADIX: Integer - -# Deprecated, do not use. -# -# Represents the rounding mode for floating point addition at the start time. -# -# Usually defaults to 1, rounding to the nearest number. -# -# Other modes include: -# -# -1 -# : Indeterminable -# 0 -# : Rounding towards zero -# 1 -# : Rounding to the nearest number -# 2 -# : Rounding towards positive infinity -# 3 -# : Rounding towards negative infinity -# -# -Float::ROUNDS: Integer diff --git a/test/stdlib/Float_test.rb b/test/stdlib/Float_test.rb index 4981ac9e2f..f6873779ee 100644 --- a/test/stdlib/Float_test.rb +++ b/test/stdlib/Float_test.rb @@ -1,5 +1,59 @@ require_relative "test_helper" +class FloatSingletonTest < Test::Unit::TestCase + include TestHelper + + testing 'singleton(::Float)' + + def test_DIG + assert_const_type 'Integer', 'Float::DIG' + end + + def test_EPSILON + assert_const_type 'Float', 'Float::EPSILON' + end + + def test_INFINITY + assert_const_type 'Float', 'Float::INFINITY' + end + + def test_MANT_DIG + assert_const_type 'Integer', 'Float::MANT_DIG' + end + + def test_MAX + assert_const_type 'Float', 'Float::MAX' + end + + def test_MAX_10_EXP + assert_const_type 'Integer', 'Float::MAX_10_EXP' + end + + def test_MAX_EXP + assert_const_type 'Integer', 'Float::MAX_EXP' + end + + def test_MIN + assert_const_type 'Float', 'Float::MIN' + end + + def test_MIN_10_EXP + assert_const_type 'Integer', 'Float::MIN_10_EXP' + end + + def test_MIN_EXP + assert_const_type 'Integer', 'Float::MIN_EXP' + end + + def test_NAN + assert_const_type 'Float', 'Float::NAN' + end + + def test_RADIX + assert_const_type 'Integer', 'Float::RADIX' + end +end + class FloatTest < StdlibTest target Float From 1827122d53dbd93b88e1737da1e64302c60359d5 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 16 Jun 2026 11:22:38 +0900 Subject: [PATCH 083/163] Skip build-conditional Integer::GMP_VERSION in the drift test Integer::GMP_VERSION is defined only when Ruby is built with GMP (USE_GMP), so it is present on the Linux CI build but absent on macOS. Add it to the SKIP map so the gate stays green across platforms. --- test/stdlib/constant_drift_test.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/stdlib/constant_drift_test.rb b/test/stdlib/constant_drift_test.rb index 53b3ecf699..4fb99075ad 100644 --- a/test/stdlib/constant_drift_test.rb +++ b/test/stdlib/constant_drift_test.rb @@ -25,9 +25,14 @@ class ConstantDriftTest < Test::Unit::TestCase ].freeze # Known, intentional exceptions keyed by "::Name" => [:CONST, ...]. Use this - # for runtime constants that are `private_constant` or otherwise legitimately - # undeclared, so the gate stays green there without being weakened elsewhere. - SKIP = {}.freeze + # for build- or platform-conditional constants (and `private_constant`s) that + # are legitimately undeclared, so the gate stays green across CI platforms + # without being weakened elsewhere. + SKIP = { + # Defined only when Ruby is built with GMP (USE_GMP): present on the Linux + # CI build, absent on e.g. macOS. + "::Integer" => [:GMP_VERSION] + }.freeze def env StdlibTest::DEFAULT_ENV From fa6dfd1ccb200509b754fbf163052885245c85e1 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 16 Jun 2026 03:10:38 +0000 Subject: [PATCH 084/163] Add TruffleRuby CI job Add an experimental CI workflow that builds the C extension and runs the test suite on TruffleRuby. The job is marked continue-on-error so it reports the current status without blocking the build. --- .github/workflows/truffleruby.yml | 57 +++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/truffleruby.yml diff --git a/.github/workflows/truffleruby.yml b/.github/workflows/truffleruby.yml new file mode 100644 index 0000000000..fde0830deb --- /dev/null +++ b/.github/workflows/truffleruby.yml @@ -0,0 +1,57 @@ +name: TruffleRuby + +on: + push: + branches: + - master + pull_request: {} + merge_group: {} + +permissions: + contents: read + +jobs: + test: + runs-on: "ubuntu-latest" + # TruffleRuby support is experimental. The job is allowed to fail so that it + # reports the current status without blocking the build. + continue-on-error: true + strategy: + fail-fast: false + matrix: + ruby: ['truffleruby'] + job: + - test + env: + RANDOMIZE_STDLIB_TEST_ORDER: "true" + # TruffleRuby warns and falls back to US-ASCII unless the locale is UTF-8. + LANG: "en_US.UTF-8" + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler: none + - name: Set working directory as safe + run: git config --global --add safe.directory $(pwd) + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libdb-dev curl autoconf automake m4 libtool python3 + - name: Update rubygems & bundler + run: | + ruby -v + gem update --system + - name: install erb + run: gem install erb + - name: bundle config + run: | + # Profilers and the typecheck tooling are not needed for the smoke test, + # and some of them do not support TruffleRuby. + bundle config set --local without 'profilers typecheck_test' + - name: bin/setup + run: | + bin/setup + - name: Run test + run: | + bundle exec rake ${{ matrix.job }} From e8aa6a9ec20dc06650f6a6a71f2d7b88a74455fd Mon Sep 17 00:00:00 2001 From: SamW Date: Mon, 15 Jun 2026 20:10:51 -0700 Subject: [PATCH 085/163] Initial commit---non-coerce, non-odd ones --- core/integer.rbs | 73 ++----- core/numeric.rbs | 3 + test/stdlib/Integer_test.rb | 393 +++++++++++++++++++++++++++++++++++- test/stdlib/test_helper.rb | 9 + 4 files changed, 422 insertions(+), 56 deletions(-) diff --git a/core/integer.rbs b/core/integer.rbs index fd2ef0ae49..039fafb145 100644 --- a/core/integer.rbs +++ b/core/integer.rbs @@ -93,6 +93,8 @@ # given value. # class Integer < Numeric + # TODO: `undef self.new` + # # Returns `1`. # - def denominator: () -> Integer + def denominator: () -> 1 # # - def magnitude: () -> Integer + alias magnitude abs # # Returns `self` modulo `other` as a real numeric (Integer, Float, or Rational). @@ -1009,7 +1010,7 @@ class Integer < Numeric # # Related: Integer#pred (predecessor value). # - def next: () -> Integer + alias next succ # # Returns `self`. # - def numerator: () -> Integer + def numerator: () -> self # # Returns `self`; intended for compatibility to character literals in Ruby 1.9. # - def ord: () -> Integer + def ord: () -> self def polar: () -> [ Integer, Integer | Float ] @@ -1180,8 +1181,7 @@ class Integer < Numeric # # Related: Integer#truncate. # - def round: (?half: :up | :down | :even) -> Integer - | (int digits, ?half: :up | :down | :even) -> (Integer | Float) + def round: (?int digits, ?half: Numeric::round_mode) -> Integer # # Returns `self` (which is already an Integer). # - def to_i: () -> Integer + def to_i: () -> self # # Returns a string containing the place-value representation of `self` in radix @@ -965,7 +965,7 @@ class Integer < Numeric # 3.lcm(-7) #=> 21 # ((1<<31)-1).lcm((1<<61)-1) #=> 4951760154835678088235319297 # - def lcm: (Integer) -> Integer + def lcm: (Integer other_int) -> Integer # # The "sync mode" of the SSLSocket. @@ -9235,7 +9235,7 @@ module OpenSSL # Reads *length* bytes from the SSL connection. If a pre-allocated *buffer* is # provided the data will be written into it. # - def sysread: (Integer length, ?String buffer) -> String + def sysread: (Integer length, ?String? buffer) -> String # # See IO#read. # - def read: (?int? length, ?string outbuf) -> String? + def read: (?int? length, ?string? outbuf) -> String? # # See IO#pread. # - def pread: (Integer maxlen, Integer offset, ?String outbuf) -> String + def pread: (Integer maxlen, Integer offset, ?String? outbuf) -> String - def read_nonblock: (int len, ?string buf, ?exception: bool) -> String? + def read_nonblock: (int len, ?string? buf, ?exception: bool) -> String? def readbyte: () -> Integer @@ -1311,7 +1311,7 @@ class StringIO # def readlines: (?String sep, ?Integer limit, ?chomp: boolish) -> ::Array[String] - def readpartial: (int maxlen, ?string outbuf) -> String + def readpartial: (int maxlen, ?string? outbuf) -> String # # See Zlib::GzipReader documentation for a description. # - def read: (?int? length, ?string outbuf) -> String? + def read: (?int? length, ?string? outbuf) -> String? # - # alias / + # - # def ===: (untyped) -> bool # - # alias << send # - # alias << send # +# Workaround for directly loading Gem::Version in some cases +# module Gem # # Raised when RubyGems is unable to load or activate a gem. Contains the name diff --git a/core/rubygems/requirement.rbs b/core/rubygems/requirement.rbs index 8b20e12996..ecea717da2 100644 --- a/core/rubygems/requirement.rbs +++ b/core/rubygems/requirement.rbs @@ -123,18 +123,8 @@ module Gem # def satisfied_by?: (Gem::Version version) -> bool - # - # alias === satisfied_by? - # - # alias =~ satisfied_by? # +# Workaround for directly loading Gem::Version in some cases +# module Gem interface _HashLike[K, V] def each_pair: () { ([ K, V ]) -> untyped } -> self diff --git a/core/rubygems/specification.rbs b/core/rubygems/specification.rbs index a6d74630e9..1162e0d450 100644 --- a/core/rubygems/specification.rbs +++ b/core/rubygems/specification.rbs @@ -19,5 +19,13 @@ # #metadata for restrictions on the format and size of metadata items you may # add to a specification. # +# Specifications must be deterministic, as in the example above. For instance, +# you cannot define attributes conditionally: +# +# # INVALID: do not do this. +# unless RUBY_ENGINE == "jruby" +# s.extensions << "ext/example/extconf.rb" +# end +# class Gem::Specification < Gem::BasicSpecification end diff --git a/core/rubygems/version.rbs b/core/rubygems/version.rbs index 7aec81635a..e13073926d 100644 --- a/core/rubygems/version.rbs +++ b/core/rubygems/version.rbs @@ -1,165 +1,5 @@ %a{annotate:rdoc:skip} module Gem - # - # The Version class processes string versions into comparable values. A version - # string should normally be a series of numbers separated by periods. Each part - # (digits separated by periods) is considered its own number, and these are used - # for sorting. So for instance, 3.10 sorts higher than 3.2 because ten is - # greater than two. - # - # If any part contains letters (currently only a-z are supported) then that - # version is considered prerelease. Versions with a prerelease part in the Nth - # part sort less than versions with N-1 parts. Prerelease parts are sorted - # alphabetically using the normal Ruby string sorting rules. If a prerelease - # part contains both letters and numbers, it will be broken into multiple parts - # to provide expected sort behavior (1.0.a10 becomes 1.0.a.10, and is greater - # than 1.0.a9). - # - # Prereleases sort between real releases (newest to oldest): - # - # 1. 1.0 - # 2. 1.0.b1 - # 3. 1.0.a.2 - # 4. 0.9 - # - # If you want to specify a version restriction that includes both prereleases - # and regular releases of the 1.x series this is the best way: - # - # s.add_dependency 'example', '>= 1.0.0.a', '< 2.0.0' - # - # ## How Software Changes - # - # Users expect to be able to specify a version constraint that gives them some - # reasonable expectation that new versions of a library will work with their - # software if the version constraint is true, and not work with their software - # if the version constraint is false. In other words, the perfect system will - # accept all compatible versions of the library and reject all incompatible - # versions. - # - # Libraries change in 3 ways (well, more than 3, but stay focused here!). - # - # 1. The change may be an implementation detail only and have no effect on the - # client software. - # 2. The change may add new features, but do so in a way that client software - # written to an earlier version is still compatible. - # 3. The change may change the public interface of the library in such a way - # that old software is no longer compatible. - # - # Some examples are appropriate at this point. Suppose I have a Stack class - # that supports a `push` and a `pop` method. - # - # ### Examples of Category 1 changes: - # - # * Switch from an array based implementation to a linked-list based - # implementation. - # * Provide an automatic (and transparent) backing store for large stacks. - # - # ### Examples of Category 2 changes might be: - # - # * Add a `depth` method to return the current depth of the stack. - # * Add a `top` method that returns the current top of stack (without changing - # the stack). - # * Change `push` so that it returns the item pushed (previously it had no - # usable return value). - # - # ### Examples of Category 3 changes might be: - # - # * Changes `pop` so that it no longer returns a value (you must use `top` to - # get the top of the stack). - # * Rename the methods to `push_item` and `pop_item`. - # - # ## RubyGems Rational Versioning - # - # * Versions shall be represented by three non-negative integers, separated by - # periods (e.g. 3.1.4). The first integers is the "major" version number, - # the second integer is the "minor" version number, and the third integer is - # the "build" number. - # - # * A category 1 change (implementation detail) will increment the build - # number. - # - # * A category 2 change (backwards compatible) will increment the minor - # version number and reset the build number. - # - # * A category 3 change (incompatible) will increment the major build number - # and reset the minor and build numbers. - # - # * Any "public" release of a gem should have a different version. Normally - # that means incrementing the build number. This means a developer can - # generate builds all day long, but as soon as they make a public release, - # the version must be updated. - # - # ### Examples - # - # Let's work through a project lifecycle using our Stack example from above. - # - # Version 0.0.1 - # : The initial Stack class is release. - # - # Version 0.0.2 - # : Switched to a linked=list implementation because it is cooler. - # - # Version 0.1.0 - # : Added a `depth` method. - # - # Version 1.0.0 - # : Added `top` and made `pop` return nil (`pop` used to return the old top - # item). - # - # Version 1.1.0 - # : `push` now returns the value pushed (it used it return nil). - # - # Version 1.1.1 - # : Fixed a bug in the linked list implementation. - # - # Version 1.1.2 - # : Fixed a bug introduced in the last fix. - # - # - # Client A needs a stack with basic push/pop capability. They write to the - # original interface (no `top`), so their version constraint looks like: - # - # gem 'stack', '>= 0.0' - # - # Essentially, any version is OK with Client A. An incompatible change to the - # library will cause them grief, but they are willing to take the chance (we - # call Client A optimistic). - # - # Client B is just like Client A except for two things: (1) They use the `depth` - # method and (2) they are worried about future incompatibilities, so they write - # their version constraint like this: - # - # gem 'stack', '~> 0.1' - # - # The `depth` method was introduced in version 0.1.0, so that version or - # anything later is fine, as long as the version stays below version 1.0 where - # incompatibilities are introduced. We call Client B pessimistic because they - # are worried about incompatible future changes (it is OK to be pessimistic!). - # - # ## Preventing Version Catastrophe: - # - # From: - # https://www.zenspider.com/ruby/2008/10/rubygems-how-to-preventing-catastrophe. - # html - # - # Let's say you're depending on the fnord gem version 2.y.z. If you specify your - # dependency as ">= 2.0.0" then, you're good, right? What happens if fnord 3.0 - # comes out and it isn't backwards compatible with 2.y.z? Your stuff will break - # as a result of using ">=". The better route is to specify your dependency with - # an "approximate" version specifier ("~>"). They're a tad confusing, so here is - # how the dependency specifiers work: - # - # Specification From ... To (exclusive) - # ">= 3.0" 3.0 ... ∞ - # "~> 3.0" 3.0 ... 4.0 - # "~> 3.0.0" 3.0.0 ... 3.1 - # "~> 3.5" 3.5 ... 4.0 - # "~> 3.5.0" 3.5.0 ... 3.6 - # "~> 3" 3.0 ... 4.0 - # - # For the last example, single-digit versions are automatically extended with a - # zero to give a sensible result. - # class Version include Comparable diff --git a/core/thread.rbs b/core/thread.rbs index b3e7d78c17..c5da4e6ffd 100644 --- a/core/thread.rbs +++ b/core/thread.rbs @@ -1565,10 +1565,10 @@ class Thread::Mutex < Object # - # Attempts to grab the lock and waits if it isn't available. Raises - # `ThreadError` if `mutex` was locked by the current thread. + # Releases the lock. Raises `ThreadError` if `mutex` wasn't locked by the + # current thread. # def unlock: () -> self end @@ -1751,11 +1751,6 @@ end # See Thread::Queue for an example of how a Thread::SizedQueue works. # class Thread::SizedQueue[E = untyped] < Thread::Queue[E] - # - # alias << push # - # :method: freeze Freeze both the object returned by `__getobj__` and self. + # Freeze both the object returned by `__getobj__` and self. # def freeze: () -> self @@ -118,6 +118,7 @@ class Delegator < BasicObject # rdoc-file=lib/delegate.rb # - method_missing(m, *args, &block) # --> + # Handles the magic of delegation through `__getobj__`. # def method_missing: (Symbol m, *untyped args, **untyped) { (*untyped, **untyped) -> untyped } -> untyped diff --git a/stdlib/digest/0/digest.rbs b/stdlib/digest/0/digest.rbs index e6f4ab7f9a..cd72b513e1 100644 --- a/stdlib/digest/0/digest.rbs +++ b/stdlib/digest/0/digest.rbs @@ -536,13 +536,19 @@ class Digest::SHA2 < Digest::Class # def update: (String) -> self - private def finish: () -> String - # + # Finishes the digest and returns the resulting hash value. # + # This method is overridden by each implementation subclass and often made + # private, because some of those subclasses may leave internal data + # uninitialized. Do not call this method from outside. Use #digest!() instead, + # which ensures that internal data be reset for security reasons. + # + private def finish: () -> String + alias << update # - # alias === include? # # Calls wait repeatedly until the given block yields a truthy value. # @@ -346,7 +346,7 @@ class MonitorMixin::ConditionVariable # # Calls wait repeatedly while the given block yields a truthy value. # diff --git a/stdlib/openssl/0/openssl.rbs b/stdlib/openssl/0/openssl.rbs index dc04095f13..83046e972f 100644 --- a/stdlib/openssl/0/openssl.rbs +++ b/stdlib/openssl/0/openssl.rbs @@ -5913,10 +5913,17 @@ module OpenSSL def initialize_copy: (self) -> void end - # - # Generic exception that is raised if an operation on a DH PKey fails - # unexpectedly or in case an instantiation of an instance of DH fails due to - # non-conformant input data. + # + # Raised when errors occur during PKey#sign or PKey#verify. + # + # Before version 4.0.0, OpenSSL::PKey::PKeyError had the following subclasses. + # These subclasses have been removed and the constants are now defined as + # aliases of OpenSSL::PKey::PKeyError. + # + # * OpenSSL::PKey::DHError + # * OpenSSL::PKey::DSAError + # * OpenSSL::PKey::ECError + # * OpenSSL::PKey::RSAError # class DHError < OpenSSL::PKey::PKeyError end @@ -6304,10 +6311,17 @@ module OpenSSL def initialize_copy: (self) -> void end - # - # Generic exception that is raised if an operation on a DSA PKey fails - # unexpectedly or in case an instantiation of an instance of DSA fails due to - # non-conformant input data. + # + # Raised when errors occur during PKey#sign or PKey#verify. + # + # Before version 4.0.0, OpenSSL::PKey::PKeyError had the following subclasses. + # These subclasses have been removed and the constants are now defined as + # aliases of OpenSSL::PKey::PKeyError. + # + # * OpenSSL::PKey::DHError + # * OpenSSL::PKey::DSAError + # * OpenSSL::PKey::ECError + # * OpenSSL::PKey::RSAError # class DSAError < OpenSSL::PKey::PKeyError end @@ -7011,6 +7025,18 @@ module OpenSSL end end + # + # Raised when errors occur during PKey#sign or PKey#verify. + # + # Before version 4.0.0, OpenSSL::PKey::PKeyError had the following subclasses. + # These subclasses have been removed and the constants are now defined as + # aliases of OpenSSL::PKey::PKeyError. + # + # * OpenSSL::PKey::DHError + # * OpenSSL::PKey::DSAError + # * OpenSSL::PKey::ECError + # * OpenSSL::PKey::RSAError + # class ECError < OpenSSL::PKey::PKeyError end @@ -9282,6 +9308,7 @@ module OpenSSL # rdoc-file=ext/openssl/ossl_ssl.c # - SSLSocket.new(io) => aSSLSocket # - SSLSocket.new(io, ctx) => aSSLSocket + # - SSLSocket.new(io, ctx, sync_close:) => aSSLSocket # --> # Creates a new SSL socket from *io* which must be a real IO object (not an # IO-like object that responds to read/write). @@ -9289,6 +9316,10 @@ module OpenSSL # If *ctx* is provided the SSL Sockets initial params will be taken from the # context. # + # The optional *sync_close* keyword parameter sets the *sync_close* instance + # variable. Setting this to `true` will cause the underlying socket to be closed + # when the SSL/TLS connection is shut down. + # # The OpenSSL::Buffering module provides additional IO methods. # # This method will freeze the SSLContext if one is provided; however, session @@ -10390,11 +10421,6 @@ module OpenSSL extend OpenSSL::Marshal::ClassMethods - # - # def ==: (self other) -> bool # - # def ==: (self other) -> bool # - # def ==: (self other) -> bool # - # def ==: (untyped other) -> bool # - # def ==: (untyped other) -> bool # # def self?.timeout: [T] (Numeric? sec, ?singleton(Exception) klass, ?String message) { (Numeric sec) -> T } -> T end diff --git a/stdlib/uri/0/generic.rbs b/stdlib/uri/0/generic.rbs index e82a505c5d..f789380b4d 100644 --- a/stdlib/uri/0/generic.rbs +++ b/stdlib/uri/0/generic.rbs @@ -909,11 +909,6 @@ module URI # def merge: (URI::Generic | string oth) -> URI::Generic - # - # alias + merge # :stopdoc: diff --git a/stdlib/zlib/0/zstream.rbs b/stdlib/zlib/0/zstream.rbs index d51ed099d6..ec44ba6033 100644 --- a/stdlib/zlib/0/zstream.rbs +++ b/stdlib/zlib/0/zstream.rbs @@ -153,7 +153,6 @@ module Zlib # rdoc-file=ext/zlib/zlib.c # - flush_next_in -> input # --> - # Flushes input buffer and returns all data in that buffer. # def flush_next_in: () -> String From 10e3c8bf29df8333699021a9033c9a3c1dfed6ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 03:54:11 +0000 Subject: [PATCH 132/163] Update outdated versions used in CI Ruby 3.2 reached EOL on 2026-04-01, and several tool versions pinned in the workflows had fallen behind. * Drop Ruby 3.2 from the test matrix. The README already states that the library code targets non-EOL versions of Ruby (`>= 3.3` as of 2026), and `docs/CONTRIBUTING.md` noted the 3.2 entry as something the CI retained. `required_ruby_version` is left alone here. * Run the type check and the Windows compile job on Ruby 4.0, the version the rest of the workflows already use. * Point the LLVM apt repository at noble instead of jammy: `ubuntu-latest` is Ubuntu 24.04, so the jammy packages no longer matched the runner. Install the signing key into a keyring instead of the deprecated `apt-key`. * Bump re2c 4.3 -> 4.5.1. The generated `src/lexer.c` is unchanged apart from the version banner. * Bump wasmtime v45.0.2 -> v47.0.2 for the WebAssembly smoke test. * Bump the JRuby image in Dockerfile.jruby 10.0.6 -> 10.1.1.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TFnHiHNVLno7GWwjXJqDx4 --- .github/workflows/c-check.yml | 15 ++++++++++----- .github/workflows/ruby.yml | 2 +- .github/workflows/typecheck.yml | 2 +- .github/workflows/wasm.yml | 2 +- .github/workflows/windows.yml | 2 +- Dockerfile.jruby | 2 +- docs/CONTRIBUTING.md | 2 +- src/lexer.c | 2 +- 8 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/c-check.yml b/.github/workflows/c-check.yml index 6aca37ddcd..43745ea35e 100644 --- a/.github/workflows/c-check.yml +++ b/.github/workflows/c-check.yml @@ -27,8 +27,13 @@ jobs: sudo apt-get install -y libdb-dev curl autoconf automake m4 libtool - name: Install clang-format from LLVM run: | - wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - - sudo apt-add-repository "deb http://apt.llvm.org/jammy/ llvm-toolchain-jammy-20 main" + # The codename must match the `runs-on` image: `ubuntu-latest` is 24.04 (noble). + wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key \ + | sudo gpg --dearmor -o /usr/share/keyrings/llvm-archive-keyring.gpg + # Written directly rather than through `apt-add-repository`, which cannot + # parse `[signed-by=...]` inside a one-line `deb` shortcut. + echo "deb [signed-by=/usr/share/keyrings/llvm-archive-keyring.gpg] http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" \ + | sudo tee /etc/apt/sources.list.d/llvm.list sudo apt-get update sudo apt-get install -y clang-format-20 sudo ln -sf /usr/bin/clang-format-20 /usr/local/bin/clang-format @@ -38,9 +43,9 @@ jobs: - name: Install Re2c run: | cd /tmp - curl -L https://github.com/skvadrik/re2c/archive/refs/tags/4.3.tar.gz > re2c-4.3.tar.gz - tar xf re2c-4.3.tar.gz - cd re2c-4.3 + curl -L https://github.com/skvadrik/re2c/archive/refs/tags/4.5.1.tar.gz > re2c-4.5.1.tar.gz + tar xf re2c-4.5.1.tar.gz + cd re2c-4.5.1 cmake --preset=linux-gcc-release-ootree-skeleton-fast cmake --build --preset=linux-gcc-release-ootree-skeleton-fast --parallel="$(nproc)" sudo ln -sf "$(pwd)"/.build/linux-gcc-release-ootree-skeleton-fast/re2c /usr/local/bin/re2c diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index dd4b86da05..958bdcb3f7 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.2', '3.3', '3.4', '4.0', head] + ruby: ['3.3', '3.4', '4.0', head] rubyopt: [""] job: - test diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 299928bd90..f2327abb1d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0" bundler: none - name: Set working directory as safe run: git config --global --add safe.directory $(pwd) diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 845e3b052f..0f3a6c072b 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -21,7 +21,7 @@ env: # Pinned so the smoke test is reproducible. Bump together when upgrading. WASI_SDK_VERSION: "33" WASI_SDK_RELEASE: "33.0" - WASMTIME_VERSION: "v45.0.2" + WASMTIME_VERSION: "v47.0.2" jobs: build: diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index faf63ffde1..dc4dfa05d4 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ['3.4', ucrt, mswin] + ruby: ['4.0', ucrt, mswin] steps: - uses: actions/checkout@v7 - name: load ruby diff --git a/Dockerfile.jruby b/Dockerfile.jruby index 8d23516bdd..106fccd5dd 100644 --- a/Dockerfile.jruby +++ b/Dockerfile.jruby @@ -15,7 +15,7 @@ # C extensions (bigdecimal, stackprof, ...) that cannot build on JRuby. The few # gems the suite needs are installed directly, exactly as the CI does. -FROM jruby:10.0.6-jdk21 +FROM jruby:10.1.1.0-jdk21 # Keep in sync with .github/workflows/wasm.yml and .github/workflows/jruby.yml. ARG WASI_SDK_VERSION=33 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 64a196bf8d..250b50c2a3 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -13,7 +13,7 @@ The RBS repository contains the type definitions of Core API and Standard Librar There are some discussions whether if it is the best to have them in this repository, but we have them and continue updating the files meanwhile. The target version of the bundled type definitions is the [latest _release_ of Ruby](https://www.ruby-lang.org/en/downloads/branches/) -- `4.0` as of 2026. -Note, however, that the CI currently retains the tests on Ruby 3.2 as well. +Note, however, that the CI runs the tests on every non-EOL Ruby -- `3.3` and later as of 2026. **The core API** type definitions are in `core` directory. You will find the familiar class names in the directory, like `string.rbs` or `array.rbs`. diff --git a/src/lexer.c b/src/lexer.c index b590142288..ebbaa650d8 100644 --- a/src/lexer.c +++ b/src/lexer.c @@ -1,4 +1,4 @@ -/* Generated by re2c 4.3 */ +/* Generated by re2c 4.5.1 */ #line 1 "src/lexer.re" #include "rbs/lexer.h" From d73d078282f583b01519f45fd569bb6d042e5eaf Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Mon, 27 Jul 2026 17:47:17 +0900 Subject: [PATCH 133/163] Version 4.1.0 --- CHANGELOG.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++ Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 046f71aac0..f6ce6284b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,93 @@ # CHANGELOG +## 4.1.0 (2026-07-27) + +RBS 4.1 ships with JRuby support. The RBS parser is written in plain C without depending on the Ruby C API, so it is compiled to WebAssembly and runs on a Wasm runtime, with the parsed AST serialized in a binary format and decoded into RBS objects. The full test suite runs on JRuby in CI. + +The inline RBS syntax gets three new features: singleton method definitions (`def self.`), the `module-self` constraint, and instance variable annotations in `module` declarations. Note that RBS inline is still experimental and may change in future releases. + +This release also introduces `RBS::Rewriter`, an API to edit RBS source text while preserving the surrounding content, which `rbs annotate` is now built on. Parsing performance is improved by interning type names in a shared trie and reducing allocations per node. + +`RBS::Prototype::Runtime`, the RDoc plugin parser, and the top-level `float` type alias are deprecated in this release. + +### Signature updates + +**Updated classes/modules/methods:** `ARGF`, `Array`, `CSV`, `Class`, `Delegator`, `Digest`, `ERB`, `Enumerable`, `Enumerator`, `Enumerator::Product`, `Etc`, `File`, `File::Constants`, `File::Stat`, `FileUtils`, `Float`, `Gem`, `Hash`, `IO`, `IPAddr`, `Integer`, `JSON`, `Kernel`, `MatchData`, `Module`, `Monitor`, `Numeric`, `ObjectSpace::WeakKeyMap`, `OpenSSL`, `Pathname`, `RBS::Ops`, `Ractor`, `Range`, `Resolv`, `RubyVM::InstructionSequence`, `Set`, `Shellwords`, `String`, `StringIO`, `StringScanner`, `Struct`, `Tempfile`, `Thread`, `Timeout`, `TSort`, `URI::Generic`, `Zlib::GzipReader`, `Zlib::ZStream` + +* Remove stale Float constants and add a constant drift test ([#2994](https://github.com/ruby/rbs/pull/2994)) +* Update RDoc comments with Ruby 4.0.6 ([#3035](https://github.com/ruby/rbs/pull/3035)) +* Add a dependency on tempfile. ([#3025](https://github.com/ruby/rbs/pull/3025)) +* Support `%a{implicitly-returns-nil}` on `MatchData#[]` ([#2990](https://github.com/ruby/rbs/pull/2990)) +* Update `Array` ([#2987](https://github.com/ruby/rbs/pull/2987)) +* Allow nil output buffers for reader methods ([#3002](https://github.com/ruby/rbs/pull/3002)) +* Add stdlib signature tests for #2967 ([#2989](https://github.com/ruby/rbs/pull/2989)) +* Update `Integer`, phase 1 ([#2995](https://github.com/ruby/rbs/pull/2995)) +* Add RBS::Ops ([#2934](https://github.com/ruby/rbs/pull/2934)) +* Update Module ([#2933](https://github.com/ruby/rbs/pull/2933)) +* Correct core/stdlib signatures to match Ruby 4.0 ([#2967](https://github.com/ruby/rbs/pull/2967)) +* Revert Digest::Class method changes ([#2980](https://github.com/ruby/rbs/pull/2980)) +* Add stdlib tests for updated StringScanner signatures ([#2968](https://github.com/ruby/rbs/pull/2968)) +* Update `StringScanner` signatures to match strscan 3.1.6 ([#2959](https://github.com/ruby/rbs/pull/2959)) +* Remove StringScanner methods absent from the latest Ruby release ([#2961](https://github.com/ruby/rbs/pull/2961)) +* Fix `Resolv#initialize` signature: array of resolvers + `use_ipv6:` ([#2960](https://github.com/ruby/rbs/pull/2960)) +* [Hash] Update Hash's signatures and tests to be correct ([#2694](https://github.com/ruby/rbs/pull/2694)) +* update rbs signatures to be more consistent with generics ([#2867](https://github.com/ruby/rbs/pull/2867)) +* Use top-level `path` in fileutils.rbs to suppress deprecation warning ([#2949](https://github.com/ruby/rbs/pull/2949)) +* Update File::Constants and File::Stat ([#2942](https://github.com/ruby/rbs/pull/2942)) +* Add signature for `RubyVM::InstructionSequence.of` ([#2916](https://github.com/ruby/rbs/pull/2916)) +* fix OpenSSL#session_new_cb signature ([#2924](https://github.com/ruby/rbs/pull/2924)) +* [Class] update class.rbs ([#2898](https://github.com/ruby/rbs/pull/2898)) +* Deprecate top-level float ([#2695](https://github.com/ruby/rbs/pull/2695)) +* [Kernel] Updated querying methods ([#2685](https://github.com/ruby/rbs/pull/2685)) + +### Library changes + +* Convert character offset to byte offset in parse_inline_*_annotation ([#2945](https://github.com/ruby/rbs/pull/2945)) +* Guard against underflow when sysconf(_SC_PAGESIZE) returns 0 ([#3031](https://github.com/ruby/rbs/pull/3031)) +* Report invalid UTF-8 byte in a comment as a parsing error ([#2983](https://github.com/ruby/rbs/pull/2983)) +* Fetch Chicory/ASM jars from Maven instead of bundling in gem ([#3019](https://github.com/ruby/rbs/pull/3019)) +* Add write barrier protection to RBS Location objects ([#3022](https://github.com/ruby/rbs/pull/3022)) +* Rename parameter in rbs_intern_type_name for clarity ([#3023](https://github.com/ruby/rbs/pull/3023)) +* Deprecate RDoc plugin parser ([#3016](https://github.com/ruby/rbs/pull/3016)) +* Run the test suite on JRuby (JRuby support, step 4) ([#3008](https://github.com/ruby/rbs/pull/3008)) +* Run the RBS parser on JRuby via WebAssembly (JRuby support, step 3) ([#3000](https://github.com/ruby/rbs/pull/3000)) +* Add binary serialization of the parsed AST (JRuby support, step 2) ([#2999](https://github.com/ruby/rbs/pull/2999)) +* Build the RBS parser as a WebAssembly module (JRuby support, step 1) ([#2998](https://github.com/ruby/rbs/pull/2998)) +* Reject reversed position ranges in parser/lexer entrypoints ([#2974](https://github.com/ruby/rbs/pull/2974)) +* Fix lexer infinite loop / abort on invalid UTF-8 byte ([#2973](https://github.com/ruby/rbs/pull/2973)) +* Flyweight TypeName / Namespace cached in a shared trie ([#2957](https://github.com/ruby/rbs/pull/2957)) +* Reduce Hash allocation per `Node` creation ([#2946](https://github.com/ruby/rbs/pull/2946)) +* Tidy `ast_translation.c` ([#2947](https://github.com/ruby/rbs/pull/2947)) +* Auto-generate rbs_ast_ruby_annotations_t union from config.yml ([#2939](https://github.com/ruby/rbs/pull/2939)) +* Speed up RBS::InlineParser on large sources with non-ASCII characters ([#2950](https://github.com/ruby/rbs/pull/2950)) +* Support `def self.method_name` singleton method in inline parser ([#2935](https://github.com/ruby/rbs/pull/2935)) +* This is not prism ([#2951](https://github.com/ruby/rbs/pull/2951)) +* Add RBS::Rewriter and use it in rbs annotate ([#2927](https://github.com/ruby/rbs/pull/2927)) +* Add nullability annotations to AST pointer types ([#2922](https://github.com/ruby/rbs/pull/2922)) +* Support `module-self` inline annotation ([#2921](https://github.com/ruby/rbs/pull/2921)) +* Inline instance variable annotation in module declaration ([#2919](https://github.com/ruby/rbs/pull/2919)) + +#### rbs prototype + +* Fix nested declarations in rbs prototype rbi ([#3029](https://github.com/ruby/rbs/pull/3029)) +* Deprecate prototype runtime ([#2956](https://github.com/ruby/rbs/pull/2956)) + +#### rbs collection + +* Clean up partial clone before falling back to full clone ([#2978](https://github.com/ruby/rbs/pull/2978)) +* Suppress spurious warning for non-gem stdlib libraries ([#2929](https://github.com/ruby/rbs/pull/2929)) + +### Miscellaneous + +* Fix Lint/DuplicateMethods in StringIO_test.rb ([#3010](https://github.com/ruby/rbs/pull/3010)) +* Compressed files must be opened in binary mode ([#2981](https://github.com/ruby/rbs/pull/2981)) +* Fix flaky DirSingletonTest GC failures in test_fchdir and test_for_fd ([#3004](https://github.com/ruby/rbs/pull/3004)) +* Add TruffleRuby CI job ([#2996](https://github.com/ruby/rbs/pull/2996)) +* Update the target Ruby version notes to the latest release ([#2962](https://github.com/ruby/rbs/pull/2962)) +* Update docs/inline.md to match current inline parser behavior ([#2953](https://github.com/ruby/rbs/pull/2953)) +* ci: skip Gemfile.lock BUNDLED WITH on ruby-head ([#2952](https://github.com/ruby/rbs/pull/2952)) +* Remove `logger` from sig dependencies ([#2904](https://github.com/ruby/rbs/pull/2904)) + ## 4.0.2 (2026-03-25) ### Library changes diff --git a/Gemfile.lock b/Gemfile.lock index de927985e2..dadb589251 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.0.pre.2) + rbs (4.1.0) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index 2ff70cac74..f947f49245 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.0.pre.2" + VERSION = "4.1.0" end From 0b3201186dcab7019cc739b6098a051c15495cb0 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 13:03:54 +0900 Subject: [PATCH 134/163] Apply `namespace` parameter rename to the template Commit 0af1d497 renamed the `namespace` parameter of `rbs_intern_type_name` to `type_namespace` (it conflicts with a C++ keyword and broke the `C99_compile` CI job), but only edited the generated `ext/rbs_extension/ast_translation.c`. The template it is generated from still had the old name, so running `rake templates` reverted the fix. Co-Authored-By: Claude Opus 5 (1M context) --- templates/ext/rbs_extension/ast_translation.c.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/ext/rbs_extension/ast_translation.c.erb b/templates/ext/rbs_extension/ast_translation.c.erb index 8ca9b66540..d9480bd30a 100644 --- a/templates/ext/rbs_extension/ast_translation.c.erb +++ b/templates/ext/rbs_extension/ast_translation.c.erb @@ -133,8 +133,8 @@ static VALUE rbs_intern_namespace(rbs_translation_context_t ctx, rbs_namespace_t return rb_funcallv(RBS_Namespace, intern_brackets(), 2, args); } -static VALUE rbs_intern_type_name(VALUE namespace, VALUE name) { - VALUE args[2] = { namespace, name }; +static VALUE rbs_intern_type_name(VALUE type_namespace, VALUE name) { + VALUE args[2] = { type_namespace, name }; return rb_funcallv(RBS_TypeName, intern_brackets(), 2, args); } From a5710dca6084597bced2e8392448dafba49c76f6 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 13:03:55 +0900 Subject: [PATCH 135/163] Derive `confirm_templates` targets from the template list `confirm_templates` only diffed `include` and `src`, so divergence between `templates/ext/**` and the generated files under `ext/rbs_extension` went undetected -- which is how the stale `namespace` parameter in the previous commit slipped through CI. Every template generates the file it is named after, so derive the paths to check from `templates/**/*.erb` rather than naming directories. That covers `ext`, and `lib/rbs/wasm/serialization_schema.rb` which a directory list would have missed as well, and it cannot fall behind when a template is added. Raise if a template has no generated file at all, so a template missing from the `templates` task is not silently skipped. Also add the "you may need to run `rake templates`" staleness warning rule for `ext` that `src` and `include` already had. Co-Authored-By: Claude Opus 5 (1M context) --- Rakefile | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Rakefile b/Rakefile index bc1f34f0a6..bf422ec462 100644 --- a/Rakefile +++ b/Rakefile @@ -55,8 +55,15 @@ task :confirm_lexer => :lexer do end task :confirm_templates => :templates do - puts "Testing if generated code under include and src is updated with respect to templates" - sh "git diff --exit-code -- include src" + puts "Testing if generated code is updated with respect to templates" + + # Every template generates the file it is named after: `templates/.erb` generates ``. + generated = Dir.glob("templates/**/*.erb").sort.map { _1.delete_prefix("templates/").delete_suffix(".erb") } + + missing = generated.reject { File.exist?(_1) } + raise "Templates without a generated file: #{missing.join(", ")}. Is the `templates` task missing an entry?" unless missing.empty? + + sh "git diff --exit-code -- #{generated.join(" ")}" end # Task to format C code using clang-format @@ -147,6 +154,9 @@ end rule %r{^include/(.*)\.c} => 'templates/%X.c.erb' do |t| puts "⚠️⚠️⚠️ #{t.name} is older than #{t.source}. You may need to run `rake templates` ⚠️⚠️⚠️" end +rule %r{^ext/(.*)\.c} => 'templates/%X.c.erb' do |t| + puts "⚠️⚠️⚠️ #{t.name} is older than #{t.source}. You may need to run `rake templates` ⚠️⚠️⚠️" +end task :annotate do sh "bin/generate_docs.sh" From 20f747c2ada893e2f264f2832b019655c5c9c2b2 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 13:15:19 +0900 Subject: [PATCH 136/163] Version 4.1.1.pre Co-Authored-By: Claude Opus 5 (1M context) --- Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index dadb589251..8db8139f93 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.0) + rbs (4.1.1.pre) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index f947f49245..e329b7d66c 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.0" + VERSION = "4.1.1.pre" end From a97c4300881a275083147fa80526b3a02d1f0b2e Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 12:13:24 +0900 Subject: [PATCH 137/163] Mark `type_name` as optional on the alias annotations `: class-alias` and `: module-alias` may be written without a name, to be inferred from the Ruby code. The parser then leaves both `type_name` and `type_name_location` unset: rbs_type_name_t *type_name = NULL; rbs_location_range type_name_loc = RBS_LOCATION_NULL_RANGE; if (parser->next_token.type == tUIDENT || parser->next_token.type == pCOLON2) { ... type_name_loc = RBS_RANGE_LEX2AST(type_name_range); } else { // No type name provided - will be inferred } config.yml marked only `type_name_location` as optional, so the generated declarations promised `rbs_type_name_t *RBS_NONNULL type_name` for a field the parser sets to NULL. Nothing changes at run time. `rbs_struct_to_ruby_value` maps NULL to `nil`, `AliasAnnotation#type_name` is already typed `TypeName?`, and `map_type_name` and `type_fingerprint` already guard for nil; `serialize_node` handles NULL too. clang does not flag the current code either, because the local the parser passes carries no nullability annotation of its own, so `-Wnullable-to-nonnull-conversion` stays quiet. What the annotation does affect is generated bindings that trust it. The Rust `ruby-rbs` crate derives its accessors from config.yml, emitting an `Option`-returning one for optional node fields and a plain one otherwise. So `ClassAliasAnnotationNode::type_name` currently hands out a wrapper around a NULL pointer and reading through it dereferences null; it becomes `Option>` once that crate's vendored config.yml is re-pinned to a release containing this. Co-Authored-By: Claude Opus 5 (1M context) --- config.yml | 2 ++ include/rbs/ast.h | 8 ++++---- src/ast.c | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/config.yml b/config.yml index 8551779f3f..7eac7f2195 100644 --- a/config.yml +++ b/config.yml @@ -769,6 +769,7 @@ nodes: c_type: rbs_location_range - name: type_name c_type: rbs_type_name + optional: true # NULL when the name is left out, to be inferred from the Ruby code - name: type_name_location c_type: rbs_location_range optional: true @@ -781,6 +782,7 @@ nodes: c_type: rbs_location_range - name: type_name c_type: rbs_type_name + optional: true # NULL when the name is left out, to be inferred from the Ruby code - name: type_name_location c_type: rbs_location_range optional: true diff --git a/include/rbs/ast.h b/include/rbs/ast.h index feddcaf0a2..d8978e59ae 100644 --- a/include/rbs/ast.h +++ b/include/rbs/ast.h @@ -583,7 +583,7 @@ typedef struct rbs_ast_ruby_annotations_class_alias_annotation { rbs_location_range prefix_location; rbs_location_range keyword_location; - struct rbs_type_name *RBS_NONNULL type_name; + struct rbs_type_name *RBS_NULLABLE type_name; rbs_location_range type_name_location; } rbs_ast_ruby_annotations_class_alias_annotation_t; @@ -631,7 +631,7 @@ typedef struct rbs_ast_ruby_annotations_module_alias_annotation { rbs_location_range prefix_location; rbs_location_range keyword_location; - struct rbs_type_name *RBS_NONNULL type_name; + struct rbs_type_name *RBS_NULLABLE type_name; rbs_location_range type_name_location; } rbs_ast_ruby_annotations_module_alias_annotation_t; @@ -998,12 +998,12 @@ rbs_ast_members_prepend_t *RBS_NONNULL rbs_ast_members_prepend_new(rbs_allocator rbs_ast_members_private_t *RBS_NONNULL rbs_ast_members_private_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location); rbs_ast_members_public_t *RBS_NONNULL rbs_ast_members_public_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location); rbs_ast_ruby_annotations_block_param_type_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_block_param_type_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range ampersand_location, rbs_location_range name_location, rbs_location_range colon_location, rbs_location_range question_location, rbs_location_range type_location, rbs_node_t *RBS_NONNULL type_, rbs_location_range comment_location); -rbs_ast_ruby_annotations_class_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_class_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NONNULL type_name, rbs_location_range type_name_location); +rbs_ast_ruby_annotations_class_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_class_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NULLABLE type_name, rbs_location_range type_name_location); rbs_ast_ruby_annotations_colon_method_type_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_colon_method_type_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_node_list_t *RBS_NONNULL annotations, rbs_node_t *RBS_NONNULL method_type); rbs_ast_ruby_annotations_double_splat_param_type_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_double_splat_param_type_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range star2_location, rbs_location_range name_location, rbs_location_range colon_location, rbs_node_t *RBS_NONNULL param_type, rbs_location_range comment_location); rbs_ast_ruby_annotations_instance_variable_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_instance_variable_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_ast_symbol_t *RBS_NONNULL ivar_name, rbs_location_range ivar_name_location, rbs_location_range colon_location, rbs_node_t *RBS_NONNULL type, rbs_location_range comment_location); rbs_ast_ruby_annotations_method_types_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_method_types_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_node_list_t *RBS_NONNULL overloads, rbs_location_range_list_t *RBS_NONNULL vertical_bar_locations, rbs_location_range dot3_location); -rbs_ast_ruby_annotations_module_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_module_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NONNULL type_name, rbs_location_range type_name_location); +rbs_ast_ruby_annotations_module_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_module_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NULLABLE type_name, rbs_location_range type_name_location); rbs_ast_ruby_annotations_module_self_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_module_self_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_location_range colon_location, rbs_type_name_t *RBS_NONNULL name, rbs_node_list_t *RBS_NONNULL args, rbs_location_range open_bracket_location, rbs_location_range close_bracket_location, rbs_location_range_list_t *RBS_NONNULL args_comma_locations, rbs_location_range comment_location); rbs_ast_ruby_annotations_node_type_assertion_t *RBS_NONNULL rbs_ast_ruby_annotations_node_type_assertion_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_node_t *RBS_NONNULL type); rbs_ast_ruby_annotations_param_type_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_param_type_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range name_location, rbs_location_range colon_location, rbs_node_t *RBS_NONNULL param_type, rbs_location_range comment_location); diff --git a/src/ast.c b/src/ast.c index 7e52681910..1e6e97beb5 100644 --- a/src/ast.c +++ b/src/ast.c @@ -913,7 +913,7 @@ rbs_ast_ruby_annotations_block_param_type_annotation_t *RBS_NONNULL rbs_ast_ruby return instance; } #line 140 "templates/src/ast.c.erb" -rbs_ast_ruby_annotations_class_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_class_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NONNULL type_name, rbs_location_range type_name_location) { +rbs_ast_ruby_annotations_class_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_class_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NULLABLE type_name, rbs_location_range type_name_location) { rbs_ast_ruby_annotations_class_alias_annotation_t *instance = rbs_allocator_alloc(allocator, rbs_ast_ruby_annotations_class_alias_annotation_t); *instance = (rbs_ast_ruby_annotations_class_alias_annotation_t) { @@ -1001,7 +1001,7 @@ rbs_ast_ruby_annotations_method_types_annotation_t *RBS_NONNULL rbs_ast_ruby_ann return instance; } #line 140 "templates/src/ast.c.erb" -rbs_ast_ruby_annotations_module_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_module_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NONNULL type_name, rbs_location_range type_name_location) { +rbs_ast_ruby_annotations_module_alias_annotation_t *RBS_NONNULL rbs_ast_ruby_annotations_module_alias_annotation_new(rbs_allocator_t *RBS_NONNULL allocator, rbs_location_range location, rbs_location_range prefix_location, rbs_location_range keyword_location, rbs_type_name_t *RBS_NULLABLE type_name, rbs_location_range type_name_location) { rbs_ast_ruby_annotations_module_alias_annotation_t *instance = rbs_allocator_alloc(allocator, rbs_ast_ruby_annotations_module_alias_annotation_t); *instance = (rbs_ast_ruby_annotations_module_alias_annotation_t) { From 0b88dde268905aefccebd2b4cf9dd95318cb8ba4 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 17:15:50 +0900 Subject: [PATCH 138/163] Include the trailing `?` in a keyword key's symbol location `parse_keyword_key` handles keys like `foo?:`, where the `?` is part of the method-name-ish key: it interns `foo?` as the constant, but built the symbol node's location from `current_token` alone, so the location covered `foo` and stopped short of the `?`. Extend the range to the end of the `?` token so the location matches the name it is the location of. This is not observable from Ruby -- `rbs_ast_symbol_t` is translated with `ID2SYM` and the wasm serializer writes only the `constant_id`, both of which drop the location. It matters for consumers that read the C AST directly, such as the Rust crate's owned AST. Co-Authored-By: Claude Opus 5 (1M context) --- src/parser.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/parser.c b/src/parser.c index b56970c93e..358e99bbab 100644 --- a/src/parser.c +++ b/src/parser.c @@ -405,18 +405,21 @@ NODISCARD static bool parse_keyword_key(rbs_parser_t *parser, rbs_ast_symbol_t **key) { rbs_parser_advance(parser); - rbs_location_range symbol_range = rbs_location_range_current_token(parser); + rbs_range_t symbol_range = parser->current_token.range; if (parser->next_token.type == pQUESTION) { + // The `?` is part of the key, so it is part of the location too. + symbol_range.end = parser->next_token.range.end; + *key = rbs_ast_symbol_new( ALLOCATOR(), - symbol_range, + RBS_RANGE_LEX2AST(symbol_range), &parser->constant_pool, intern_token_start_end(parser, parser->current_token, parser->next_token) ); rbs_parser_advance(parser); } else { - *key = rbs_ast_symbol_new(ALLOCATOR(), symbol_range, &parser->constant_pool, INTERN_TOKEN(parser, parser->current_token)); + *key = rbs_ast_symbol_new(ALLOCATOR(), RBS_RANGE_LEX2AST(symbol_range), &parser->constant_pool, INTERN_TOKEN(parser, parser->current_token)); } return true; From 02ca70ed98cad6972bad5b4ff516d51e2b23126c Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 11:54:59 +0900 Subject: [PATCH 139/163] Generate the changelog from the previous release tag `rake changelog` searched for pull requests by milestone, which only works while the milestone is maintained by hand and says nothing about what is actually merged into the branch being released. Replace it with `rake gem:changelog[version]`, which walks the commits between the given version (the latest `v*` tag by default) and `HEAD`, and asks GitHub which pull request each commit came from. Going through `associatedPullRequests` rather than parsing commit messages means every merge strategy works, and commits pushed straight to the branch are left out on their own. Pull requests labeled `skip-changelog` are omitted, and the omitted ones are reported so they do not disappear silently. Commits that only touch `rust/` are excluded because the crates have their own release cycle. Only the changelog itself goes to STDOUT, so the output can be piped. `rake gem:changelog:json` prints the same pull requests with the changed files, labels, and body of each, as the input for classifying them into the sections of CHANGELOG.md. Co-Authored-By: Claude Opus 5 --- Rakefile | 273 ++++++++++++++++++++++++++++++++++++++++++------ docs/release.md | 68 +++++++++++- 2 files changed, 308 insertions(+), 33 deletions(-) diff --git a/Rakefile b/Rakefile index bf422ec462..383183c83b 100644 --- a/Rakefile +++ b/Rakefile @@ -505,46 +505,259 @@ NOTES end -desc "Generate changelog template from GH pull requests" -task :changelog do - major, minor, patch, _pre = RBS::VERSION.split(".", 4) - major = major.to_i - minor = minor.to_i - patch = patch.to_i - - if patch == 0 - milestone = "RBS #{major}.#{minor}" - else - milestone = "RBS #{major}.#{minor}.x" - end +# Pull requests with one of these labels are omitted from the changelog. +CHANGELOG_SKIP_LABELS = ["skip-changelog"] + +# Resolves the commit-ish the changelog starts from. +# +# `version` is a version number, a tag, or any commit-ish. When it is omitted, the latest tag +# matching `tag_glob` is used. +# +def resolve_changelog_base(version, tag_glob:) + require "open3" + + from = + if version + # `4.1.0` and `v4.1.0` both mean the tag `v4.1.0`, while `master` or a SHA is used as is. + version.match?(/\A\d/) ? "v#{version}" : version + else + output, status = Open3.capture2("git", "describe", "--tags", "--match", tag_glob, "--abbrev=0") + raise "🚨 Cannot detect the latest tag matching `#{tag_glob}`. Give the previous version explicitly." unless status.success? + output.chomp + end + + _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{from}^{commit}") + raise "🚨 No such commit-ish: `#{from}`" unless status.success? + + from +end - puts "🔍 Finding pull requests that is associated to milestone `#{milestone}`..." +# Runs a GraphQL query against the repository of the working directory. +# +# `body` is the selection set inside `repository`, so a query can use the `$owner` and `$name` +# variables. Returns the contents of `data.repository`. +# +def changelog_graphql(body) + require "open3" + require "json" + + @changelog_repository ||= + begin + output, status = Open3.capture2("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") + raise status.inspect unless status.success? + output.chomp.split("/", 2) + end + owner, name = @changelog_repository + + query = <<~GRAPHQL + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + #{body} + } + } + GRAPHQL + + output, status = Open3.capture2( + "gh", "api", "graphql", + "-f", "query=#{query}", + "-f", "owner=#{owner}", + "-f", "name=#{name}", + binmode: true + ) + raise status.inspect unless status.success? - command = [ - "gh", - "pr", - "list", - "--limit=10000", - "--json", - "url,title,number", - "--search" , - "milestone:\"#{milestone}\" is:merged sort:updated-desc -label:Released" - ] + # GitHub always answers in UTF-8, while the default external encoding follows the locale. Without + # this, a pull request body with an emoji fails to parse under `LANG=C`, as in GitHub Actions. + JSON.parse(output.force_encoding(Encoding::UTF_8), symbolize_names: true).dig(:data, :repository) +end +# Lists the commits between `from` and `HEAD`, newest first. +# +# Giving `paths` limits the commits to the ones touching the paths. +# +def changelog_commits(from, paths: []) require "open3" + + command = ["git", "log", "--format=%H", "#{from}..HEAD"] + # `--simplify-merges` keeps the default history simplification from following only one parent of + # a merge commit, which can drop the other side. Note that `--full-history` alone is wrong here: + # it also lists merge commits that do not touch the paths, bringing back the excluded pull + # requests. The two flags produce the same commits as the default mode for this repository today. + command.push("--full-history", "--simplify-merges", "--", *paths) unless paths.empty? + output, status = Open3.capture2(*command) raise status.inspect unless status.success? - require "json" - json = JSON.parse(output, symbolize_names: true) + output.lines.map(&:chomp).reject(&:empty?) +end - unless json.empty? - puts - json.each do |line| - puts "* #{line[:title]} ([##{line[:number]}](#{line[:url]}))" +# Finds the pull requests the commits came from, keeping the order of `commits`. +# +# Returns the pull requests for the changelog and the ones omitted by `skip_labels`. +# +def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) + pull_requests = {} + skipped = {} + + commits.each_slice(50) do |slice| + # Ask GitHub which pull requests the commits came from, so that any merge strategy -- merge + # commit, squash, or rebase -- is handled without parsing commit messages. + aliases = slice.map.with_index do |commit, index| + <<~GRAPHQL + c#{index}: object(oid: "#{commit}") { + ... on Commit { + associatedPullRequests(first: 10) { + nodes { + number title url merged + labels(first: 100) { nodes { name } } + } + } + } + } + GRAPHQL end + + changelog_graphql(aliases.join("\n")).each_value do |commit| + next unless commit + + commit.dig(:associatedPullRequests, :nodes).each do |pr| + next unless pr[:merged] + + pr = { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } } + if (pr[:labels] & skip_labels).empty? + pull_requests[pr[:number]] ||= pr + else + skipped[pr[:number]] ||= pr + end + end + end + end + + [pull_requests.values, skipped.values] +end + +# Fetches the details that help classifying the pull requests: the changed files and the body. +# +def changelog_pull_request_details(pull_requests) + pull_requests.each_slice(50).flat_map do |slice| + aliases = slice.map do |pr| + <<~GRAPHQL + p#{pr[:number]}: pullRequest(number: #{pr[:number]}) { + body + author { login } + files(first: 100) { + nodes { path } + pageInfo { hasNextPage } + } + } + GRAPHQL + end + + details = changelog_graphql(aliases.join("\n")) + + slice.map do |pr| + detail = details[:"p#{pr[:number]}"] or next pr + + pr.merge( + author: detail.dig(:author, :login), + # The body is a hint for writing the changelog, not a copy source. Keep it short. + body: detail[:body].to_s.strip.slice(0, 1000), + files: detail.dig(:files, :nodes).map { |file| file[:path] }, + files_truncated: detail.dig(:files, :pageInfo, :hasNextPage) + ) + end + end +end + +# Reports the pull requests omitted by their label, so that they do not disappear silently. +# +def warn_skipped_pull_requests(skipped, skip_labels) + return if skipped.empty? + + numbers = skipped.map { |pr| "##{pr[:number]}" } + numbers = numbers.take(20).push("and #{numbers.size - 20} more") if numbers.size > 20 + + $stderr.puts + $stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})" +end + +# Prints the changelog template listing the pull requests merged between `from` and `HEAD`. +# +# The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be +# piped to another command: `rake gem:changelog | pbcopy` +# +def print_changelog(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS) + $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..." + + commits = changelog_commits(from, paths: paths) + if commits.empty? + $stderr.puts " (🤔 There is no commit after `#{from}`.)" + return + end + + pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels) + + if pull_requests.empty? + $stderr.puts " (🤔 No pull request is associated to the commits after `#{from}`.)" else - puts " (🤑 There is no *unreleased* pull request associated to the milestone.)" + $stderr.puts + pull_requests.each do |pr| + puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))" + end + $stdout.flush + end + + warn_skipped_pull_requests(skipped, skip_labels) +end + +# Prints the same pull requests as `print_changelog` as JSON, with the details that help +# classifying them into the sections of CHANGELOG.md. +# +# This is the input for the release automation, so it always prints a valid JSON document. +# +def print_changelog_json(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS) + require "json" + + $stderr.puts "🔍 Finding pull requests merged between `#{from}` and `HEAD`..." + + commits = changelog_commits(from, paths: paths) + pull_requests, skipped = changelog_pull_requests(commits, skip_labels: skip_labels) + pull_requests = changelog_pull_request_details(pull_requests) + + $stderr.puts " (📋 #{pull_requests.size} pull request(s))" + + puts JSON.pretty_generate( + { + from: from, + to: "HEAD", + pull_requests: pull_requests, + skipped: skipped + } + ) + $stdout.flush + + warn_skipped_pull_requests(skipped, skip_labels) +end + +namespace :gem do + # The gem is developed in the whole repository except the Rust crate, which has its own release + # cycle. Note that this is an *exclusion*, not a list of the directories shipped in the gem: + # changes in `test/` or `.github/` are part of the gem's changelog too. + # A constant defined in a `namespace` block is a top-level constant, so it needs the prefix. + GEM_CHANGELOG_PATHS = [".", ":(exclude)rust"] + + desc "Generate changelog template from GH pull requests merged after the given version (defaults to the latest tag)" + task :changelog, [:version] do |_task, args| + from = resolve_changelog_base(args[:version], tag_glob: "v*") + print_changelog(from, paths: GEM_CHANGELOG_PATHS) + end + + namespace :changelog do + desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each" + task :json, [:version] do |_task, args| + from = resolve_changelog_base(args[:version], tag_glob: "v*") + print_changelog_json(from, paths: GEM_CHANGELOG_PATHS) + end end end diff --git a/docs/release.md b/docs/release.md index ced17b0228..497fbc7f6f 100644 --- a/docs/release.md +++ b/docs/release.md @@ -21,9 +21,64 @@ built once in any environment and runs on every JRuby. ## Steps -### 1. Release the `ruby` gem +### 1. Prepare the release -Once the version is bumped and committed, run on CRuby: +Open a pull request that carries everything the release needs: + +- `lib/rbs/version.rb` — set `RBS::VERSION` to the version being released. +- `Gemfile.lock` — run `bundle install` after the bump; the lockfile records the version too, and + `rake release` refuses to run with a dirty working tree. +- `CHANGELOG.md` — add a section for the new version. + +`rake gem:changelog` lists the pull requests merged since the last release, already formatted: + +```console +$ bundle exec rake gem:changelog | pbcopy +``` + +It starts from the latest `v*` tag; pass a version to start somewhere else +(`rake 'gem:changelog[4.1.0]'`). Only the list goes to STDOUT, so it pipes cleanly. Pull requests +labeled `skip-changelog` are left out and reported on STDERR, and pull requests that only touch +`rust/` are left out because the crates have their own release cycle. + +Sort the list into the sections below. `rake gem:changelog:json` prints the same pull requests with +the changed files, labels, and body of each, which is what the sorting is based on. + +```markdown +## X.Y.Z (YYYY-MM-DD) + +### Signature updates + +### Language updates + +### Library changes + +#### rbs prototype + +#### rbs collection + +### Miscellaneous +``` + +The sections always appear in this order; delete the ones that end up empty, which is most of them +on a small release. Two things scale with the size of the release: + +- **Summary paragraphs**, above the first section. A patch release usually has none, 4.1.0 has four + paragraphs, and 4.0.0 has nine. +- **A list of the types whose signatures changed**, as the first line of `### Signature updates`, + written as `**Updated classes/modules/methods:**` followed by the names in backticks. Used on + `X.Y.0` releases only. + +The date is the day the gem is released, matching the `vX.Y.Z` tag — not the day this pull request +is opened. Fix it up before step 2 if the pull request sat for a few days. + +> While `.github/workflows/milestone.yml` is a required check, the release pull request needs a +> milestone matching the new version (`RBS X.Y` for a `.0` release, `RBS X.Y.x` otherwise). Create +> the milestone on GitHub first if the minor version changes. + +### 2. Release the `ruby` gem + +Once the release pull request is merged and `master` is checked out, run on CRuby: ```console $ bundle exec rake release @@ -37,7 +92,7 @@ This re-builds the `ruby`-platform gem and then: - runs `release:note`, which opens a GitHub **draft** release (with `--prerelease` for `*.pre.*` versions) and prints the remaining manual steps. -### 2. Build and push the `java` gem +### 3. Build and push the `java` gem The `java` gem is not built by `rake release`, so build and push it manually: @@ -60,6 +115,13 @@ $ docker run --rm -v "$PWD/pkg:/pkg" -w /tmp rbs-jruby bash -c \ 'gem install /pkg/rbs-X.Y.Z-java.gem && ruby -e "require %q{rbs}; puts [RUBY_ENGINE, RBS::VERSION].join(%q{ })"' ``` +### 4. Start the next development cycle + +Open another pull request setting `RBS::VERSION` to the next prerelease (`4.1.1` → `4.1.2.pre`), +with `Gemfile.lock` regenerated. Without it the version on `master` keeps claiming to be the +released version for the whole development period — and, while the milestone check is in place, +pull requests are checked against the released version's milestone. + ## Notes - Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`; From 14580c0ac596b29400e2ab41f5d73616ada1c696 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 11:55:09 +0900 Subject: [PATCH 140/163] Document preparing the release and starting the next cycle `docs/release.md` started at `rake release`, so the steps that produce the commit it tags -- the version bump, the lockfile, and the changelog entry -- were not written down anywhere, and neither was the version bump that opens the next development cycle. Add both, with the changelog section skeleton and the conventions that are not visible from a single past entry: the section order, that empty sections are dropped, that the summary paragraphs and the list of updated types are only used on larger releases, and that the date is the release date rather than the day the pull request is opened. Co-Authored-By: Claude Opus 5 --- docs/release.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/release.md b/docs/release.md index 497fbc7f6f..a3f0b6cbef 100644 --- a/docs/release.md +++ b/docs/release.md @@ -28,7 +28,12 @@ Open a pull request that carries everything the release needs: - `lib/rbs/version.rb` — set `RBS::VERSION` to the version being released. - `Gemfile.lock` — run `bundle install` after the bump; the lockfile records the version too, and `rake release` refuses to run with a dirty working tree. -- `CHANGELOG.md` — add a section for the new version. +- `CHANGELOG.md` — add a section for the new version, directly under the `# CHANGELOG` heading. + Sections are newest first. + +Label the pull request `skip-changelog`. It carries no change of its own, and without the label it +shows up in the next release's list — that is why 4.1.0's changelog contains a `Version 4.1.0` +entry. `rake gem:changelog` lists the pull requests merged since the last release, already formatted: @@ -118,9 +123,10 @@ $ docker run --rm -v "$PWD/pkg:/pkg" -w /tmp rbs-jruby bash -c \ ### 4. Start the next development cycle Open another pull request setting `RBS::VERSION` to the next prerelease (`4.1.1` → `4.1.2.pre`), -with `Gemfile.lock` regenerated. Without it the version on `master` keeps claiming to be the -released version for the whole development period — and, while the milestone check is in place, -pull requests are checked against the released version's milestone. +with `Gemfile.lock` regenerated, labeled `skip-changelog` like the release pull request itself. +Without it the version on `master` keeps claiming to be the released version for the whole +development period — and, while the milestone check is in place, pull requests are checked against +the released version's milestone. ## Notes From 6a449311a4b5a4b927809d46efb75d29368a4d03 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 12:01:05 +0900 Subject: [PATCH 141/163] Remove the milestone check Milestones were the input to `rake changelog`, which now derives the list from the commits between the previous release tag and `HEAD`. Nothing reads a milestone any more, so requiring one on every pull request -- and the `no-milestone` label for the ones that legitimately have none -- is upkeep with no consumer. The automated pull requests that carried `no-milestone` now carry `skip-changelog` instead, which is the label the changelog generation actually looks at. Co-Authored-By: Claude Opus 5 --- .github/dependabot.yml | 2 +- .github/workflows/bundle-update.yml | 2 +- .github/workflows/milestone.yml | 91 ----------------------------- 3 files changed, 2 insertions(+), 93 deletions(-) delete mode 100644 .github/workflows/milestone.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 748216d506..b7f8701fdf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,4 +21,4 @@ updates: schedule: interval: 'weekly' labels: - - 'no-milestone' + - 'skip-changelog' diff --git a/.github/workflows/bundle-update.yml b/.github/workflows/bundle-update.yml index 1c59dac0b8..fb14768620 100644 --- a/.github/workflows/bundle-update.yml +++ b/.github/workflows/bundle-update.yml @@ -58,6 +58,6 @@ jobs: --body "Automated weekly bundle update" \ --head "$(git rev-parse --abbrev-ref HEAD)" \ --base "${{ github.event.repository.default_branch }}" \ - --label "no-milestone" + --label "skip-changelog" gh pr merge --auto --merge "$(git rev-parse --abbrev-ref HEAD)" diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml deleted file mode 100644 index 0e6b6ee839..0000000000 --- a/.github/workflows/milestone.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Check milestone - -on: - pull_request: - types: [opened, edited, labeled, unlabeled, milestoned, demilestoned, synchronize] - merge_group: {} - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - - steps: - # The milestone is a property of the pull request, so there is nothing to - # check once it enters the merge queue. This job still has to run there - # because `check` is a required status check -- it just reports success - # without doing anything. - - uses: actions/checkout@v7 - if: github.event_name == 'pull_request' - - - name: Extract RBS::Version - id: version - if: github.event_name == 'pull_request' - run: | - # Extract version string from lib/rbs/version.rb - version=$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION') - echo "version=$version" >> "$GITHUB_OUTPUT" - - # Parse major.minor.patch - IFS='.' read -r major minor patch _rest <<< "$version" - echo "major=$major" >> "$GITHUB_OUTPUT" - echo "minor=$minor" >> "$GITHUB_OUTPUT" - echo "patch=$patch" >> "$GITHUB_OUTPUT" - - echo "RBS::VERSION = $version (major=$major, minor=$minor, patch=$patch)" - - - name: Check milestone - if: github.event_name == 'pull_request' - uses: actions/github-script@v9 - with: - script: | - const pr = context.payload.pull_request; - const milestone = pr.milestone; - const labels = pr.labels.map(l => l.name); - - const version = '${{ steps.version.outputs.version }}'; - const major = '${{ steps.version.outputs.major }}'; - const minor = '${{ steps.version.outputs.minor }}'; - const patch = parseInt('${{ steps.version.outputs.patch }}', 10); - - if (!milestone) { - if (labels.includes('no-milestone')) { - core.info('No milestone set, but no-milestone label is present — OK'); - return; - } - core.setFailed( - 'No milestone set. Add a milestone or add the "no-milestone" label.' - ); - return; - } - - if (labels.includes('no-milestone')) { - core.setFailed( - 'Milestone is set but "no-milestone" label is present. Remove the label or the milestone.' - ); - return; - } - - const milestoneName = milestone.title; - core.info(`Milestone: "${milestoneName}", RBS::VERSION: ${version}`); - - // Expected milestone based on version: - // patch == 0 → "RBS major.minor" (e.g. "RBS 4.0") - // patch >= 1 → "RBS major.minor.x" (e.g. "RBS 4.0.x") - let expectedMilestone; - if (patch === 0) { - expectedMilestone = `RBS ${major}.${minor}`; - } else { - expectedMilestone = `RBS ${major}.${minor}.x`; - } - - if (milestoneName !== expectedMilestone) { - core.setFailed( - `Milestone "${milestoneName}" does not match RBS::VERSION ${version}. ` + - `Expected milestone: "${expectedMilestone}"` - ); - } else { - core.info(`Milestone "${milestoneName}" matches RBS::VERSION ${version}`); - } From dcc7f0502f3e7e41d14b6a4261923ea353fff01b Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 13:35:17 +0900 Subject: [PATCH 142/163] Pick the changelog base from the version being released `gem:changelog` always started from the latest tag. That is right for a prerelease -- `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1` -- and wrong for a release proper, where the latest tag is itself a prerelease and the section is supposed to cover the whole cycle, prereleases included. Getting that wrong is quiet: the release comes out with only the tail of the cycle written up. The base now follows `RBS::VERSION`. A prerelease starts from the latest tag; a release proper skips the prerelease tags and starts from the previous release proper. Passing a version explicitly still overrides both. Tags that are not ancestors of the branch being released need no special handling: `git log A..B` already lists what is in B and not in A, which is the same set as from their merge base, and `git describe` only considers reachable tags. A patch release cut from a maintenance branch is therefore skipped on its own. Co-Authored-By: Claude Opus 5 --- Rakefile | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Rakefile b/Rakefile index 383183c83b..ee78e3fc7a 100644 --- a/Rakefile +++ b/Rakefile @@ -511,9 +511,9 @@ CHANGELOG_SKIP_LABELS = ["skip-changelog"] # Resolves the commit-ish the changelog starts from. # # `version` is a version number, a tag, or any commit-ish. When it is omitted, the latest tag -# matching `tag_glob` is used. +# matching `tag_glob` is used, skipping the ones matching `exclude_globs`. # -def resolve_changelog_base(version, tag_glob:) +def resolve_changelog_base(version, tag_glob:, exclude_globs: []) require "open3" from = @@ -521,7 +521,10 @@ def resolve_changelog_base(version, tag_glob:) # `4.1.0` and `v4.1.0` both mean the tag `v4.1.0`, while `master` or a SHA is used as is. version.match?(/\A\d/) ? "v#{version}" : version else - output, status = Open3.capture2("git", "describe", "--tags", "--match", tag_glob, "--abbrev=0") + command = ["git", "describe", "--tags", "--match", tag_glob, "--abbrev=0"] + exclude_globs.each { |glob| command.push("--exclude", glob) } + + output, status = Open3.capture2(*command) raise "🚨 Cannot detect the latest tag matching `#{tag_glob}`. Give the previous version explicitly." unless status.success? output.chomp end @@ -746,17 +749,33 @@ namespace :gem do # A constant defined in a `namespace` block is a top-level constant, so it needs the prefix. GEM_CHANGELOG_PATHS = [".", ":(exclude)rust"] - desc "Generate changelog template from GH pull requests merged after the given version (defaults to the latest tag)" + # The tags a release proper starts *after*, rather than at. + GEM_PRERELEASE_TAGS = ["v*.pre*", "v*.dev*"] + + # Where the changelog of the release being prepared starts, derived from `RBS::VERSION`: + # + # * `X.Y.Z.pre.N` documents what changed since `X.Y.Z.pre.N-1`, so it starts from the latest tag. + # * `X.Y.Z` documents the whole cycle, the prereleases included, so it skips the prerelease tags + # in between and starts from the previous release proper. + # + # This is the step that is easy to get wrong by hand: on a release proper the latest tag is a + # prerelease, so the obvious default would produce only the tail of the cycle. Passing a version + # explicitly overrides all of it. + # + def changelog_base(version) + excluded = Gem::Version.new(RBS::VERSION).prerelease? ? [] : GEM_PRERELEASE_TAGS + resolve_changelog_base(version, tag_glob: "v*", exclude_globs: excluded) + end + + desc "Generate changelog template from GH pull requests merged since the previous release" task :changelog, [:version] do |_task, args| - from = resolve_changelog_base(args[:version], tag_glob: "v*") - print_changelog(from, paths: GEM_CHANGELOG_PATHS) + print_changelog(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS) end namespace :changelog do desc "Print the pull requests of `gem:changelog` as JSON, with the changed files and body of each" task :json, [:version] do |_task, args| - from = resolve_changelog_base(args[:version], tag_glob: "v*") - print_changelog_json(from, paths: GEM_CHANGELOG_PATHS) + print_changelog_json(changelog_base(args[:version]), paths: GEM_CHANGELOG_PATHS) end end end From 38f699070d674c3a4ed346dabf510e44ccff3a97 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 16:31:21 +0900 Subject: [PATCH 143/163] Release the gems from a workflow `docs/release.md` had the maintainer build both gems locally and push them by hand: `rake release` for the `ruby` one, and a Docker image for the `java` one because it needs the WASI SDK to compile `rbs_parser.wasm`. That is a lot of laptop for something that has to be reproducible, and it puts the RubyGems credentials of whoever runs it in the path. `Release gems` does it instead. Dispatched against a `vX.Y.Z` tag it builds both gems, checks them, pushes them to RubyGems through trusted publishing, and publishes the GitHub release. A release is now a pull request, a tag, and one workflow run. The `java` gem is built on CRuby rather than in the JRuby image: the platform comes from `RBS_PLATFORM`, not from the engine running `gem build`, so all the image was providing is the WASI SDK -- which the `wasm` and `jruby` workflows already install directly. JRuby is still needed to *run* the result, so the workflow installs the built gem on JRuby and parses with it. That is the check metadata cannot make: a stale or broken wasm module only shows at require time. Publishing is ordered so that the reversible step always comes first. The tag is created before the workflow runs; the artifacts are uploaded before the push, so a failed push still leaves the gems behind; and the GitHub release comes last, so a failed push never announces a release that has no gems. The trusted publisher has no environment, so the dispatched ref is the only thing deciding what gets published. The workflow refuses to publish unless the tag matches `RBS::VERSION`, and dispatching against a branch builds and stops. `gem:gh_release` takes the notes from the topmost CHANGELOG.md section and is skipped for `.dev.N` versions, which are cut from the development line and are not written up. It reads the file as UTF-8 explicitly: the default external encoding follows the locale, which is C on the runners. Co-Authored-By: Claude Opus 5 --- .github/workflows/release-gems.yml | 164 +++++++++++++++++++++++++++++ Rakefile | 51 +++++++++ docs/release.md | 114 +++++++++++--------- 3 files changed, 279 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/release-gems.yml diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml new file mode 100644 index 0000000000..1056c415f9 --- /dev/null +++ b/.github/workflows/release-gems.yml @@ -0,0 +1,164 @@ +name: Release gems + +# Builds, publishes, and announces a release. Dispatch it against the `vX.Y.Z` tag +# of the release: the tag is created first so that everything published afterwards +# traces back to an immutable ref, and so the reversible step comes before the +# irreversible one. +# +# Dispatching against a branch builds and verifies the gems and stops there, which +# is how the build is exercised without releasing anything. +# +# | Gem | Platform | Parser | +# | -------------------- | ------------- | -------------------------------- | +# | `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension, compiled on install | +# | `rbs-X.Y.Z-java.gem` | `java` (JRuby)| `rbs_parser.wasm`, prebuilt here | +# +# See docs/release.md. The `java` gem is built on CRuby: the platform comes from +# `RBS_PLATFORM`, not from the engine running `gem build`. JRuby is only needed to +# run the result, which the workflow does before it would publish anything. +# +# One job on purpose. Tagging, pushing the gems, and opening the GitHub release +# all belong to a single release, and keeping them in one place keeps their order +# readable -- the tag is created before anything is published, so the reversible +# step comes before the irreversible one. + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + # Keep in sync with .github/workflows/wasm.yml and .github/workflows/jruby.yml. + WASI_SDK_VERSION: "33" + WASI_SDK_RELEASE: "33.0" + +jobs: + release: + name: release + runs-on: ubuntu-latest + permissions: + contents: write # publish the GitHub release + id-token: write # trusted publishing to RubyGems + steps: + # The gemspec takes its file list from `git ls-files`, so both gems are built + # from the committed state. + - uses: actions/checkout@v7 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler: none + - name: Update rubygems & bundler + run: gem update --system + - name: Install gems + run: | + bundle config set --local without libs:profilers + bundle install --jobs 4 --retry 3 + + - name: Read the version + id: version + run: echo "version=$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')" >> "$GITHUB_OUTPUT" + + # Fail before spending a minute on the build, and before anything is pushed: + # the tag is what the release is named after, so it has to be the version the + # tagged commit actually declares. + - name: Check the tag against RBS::VERSION + if: github.ref_type == 'tag' + run: | + if [ "${{ github.ref_name }}" != "v${{ steps.version.outputs.version }}" ]; then + echo "::error::tag ${{ github.ref_name }} does not match RBS::VERSION ${{ steps.version.outputs.version }}" + exit 1 + fi + + - name: Build the ruby gem + run: | + mkdir -p pkg + gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}.gem" + + # `rake wasm:jruby_setup` compiles src/**/*.c to WebAssembly and copies the + # result to lib/rbs/wasm/, where the gemspec picks it up. clang runs as a + # subprocess, so this works on CRuby. + - name: Install the WASI SDK + run: | + url="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_RELEASE}-x86_64-linux.tar.gz" + mkdir -p "$HOME/wasi-sdk" + curl -sSL "$url" | tar xz --strip-components=1 -C "$HOME/wasi-sdk" + echo "WASI_SDK_PATH=$HOME/wasi-sdk" >> "$GITHUB_ENV" + - name: Build rbs_parser.wasm + run: bundle exec rake wasm:jruby_setup + + - name: Build the java gem + env: + RBS_PLATFORM: java + run: gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + + # `git ls-files` vouches for everything else, but rbs_parser.wasm is a build + # artifact, so the java gem is the one that can come out quietly wrong. + - name: Check the built gems + run: | + ruby -rrubygems/package -e ' + ruby_gem, java_gem = ARGV.map { Gem::Package.new(_1).spec } + + raise "unexpected platform: #{ruby_gem.platform}" unless ruby_gem.platform.to_s == "ruby" + raise "the C extension is not declared" if ruby_gem.extensions.empty? + + raise "unexpected platform: #{java_gem.platform}" unless java_gem.platform.to_s == "java" + raise "rbs_parser.wasm is missing" unless java_gem.files.include?("lib/rbs/wasm/rbs_parser.wasm") + raise "the java gem must not declare an extension" unless java_gem.extensions.empty? + + [ruby_gem, java_gem].each { puts "#{_1.full_name}: #{_1.files.size} files" } + ' "pkg/rbs-${{ steps.version.outputs.version }}.gem" \ + "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + + # The checks above cannot tell whether rbs_parser.wasm actually runs. Install + # the gem the way a user would -- jar-dependencies fetches Chicory and ASM + # from Maven during the install -- and parse something with it, so the + # WebAssembly runtime is exercised end to end before anything is published. + - name: Set up JRuby + uses: ruby/setup-ruby@v1 + with: + ruby-version: jruby + bundler: none + - name: Check the java gem on JRuby + run: | + gem install "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + ruby -e ' + require "rbs" + _, _, decls = RBS::Parser.parse_signature("class Foo end") + names = decls.map { _1.name.to_s } + raise "parsed #{names.inspect}, expected [\"Foo\"]" unless names == ["Foo"] + puts "#{RUBY_ENGINE} #{RUBY_VERSION}: rbs #{RBS::VERSION} parses through the WebAssembly runtime" + ' + + - name: Switch back to CRuby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler: none + + # Uploaded before publishing, so a failed push still leaves the gems behind. + - uses: actions/upload-artifact@v7 + with: + name: gems + path: pkg/*.gem + if-no-files-found: error + + # Everything below runs only for a release tag. + - name: Configure RubyGems credentials + if: github.ref_type == 'tag' + # No floating major tag on this action, so the exact release is pinned. + uses: rubygems/configure-rubygems-credentials@v2.1.0 + + - name: Push the gems + if: github.ref_type == 'tag' + run: | + gem push "pkg/rbs-${{ steps.version.outputs.version }}.gem" + gem push "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + + # Last, so that a failed push never announces a release that has no gems. + - name: Publish the GitHub release + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: bundle exec rake gem:gh_release diff --git a/Rakefile b/Rakefile index 383183c83b..9971533203 100644 --- a/Rakefile +++ b/Rakefile @@ -759,6 +759,57 @@ namespace :gem do print_changelog_json(from, paths: GEM_CHANGELOG_PATHS) end end + + desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version" + task :gh_release do + require "open3" + + version = Gem::Version.new(RBS::VERSION) + major, minor, *_ = RBS::VERSION.split(".") + tag = "v#{RBS::VERSION}" + + # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. + # The `.dev.N` ones are cut from the development line for people who need a + # specific change early; they are not written up in the changelog, so there are + # no notes to publish and nothing worth announcing. + if version.segments.include?("dev") + puts "⏭️ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish." + next + end + + # The release is created against an existing tag, so that the artifacts and the + # notes describe a commit that is already immutable. + _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}") + raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success? + + # The topmost section of the changelog is this release, minus its own heading. + # The encoding is explicit because the default external encoding follows the + # locale, and the changelog is not ASCII. + content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8) + section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md" + heading, _, body = section.partition("\n") + heading.include?(RBS::VERSION) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{RBS::VERSION}" + + notes = <<~NOTES + [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}) + + #{body.strip} + NOTES + + # Published rather than drafted: the notes are the changelog section that was + # already reviewed in the release pull request, so there is nothing left to edit. + command = [ + "gh", "release", "create", tag, + "--title=#{RBS::VERSION}", + "--notes=#{notes}" + ] + command << "--prerelease" if version.prerelease? + + output, status = Open3.capture2(*command) + raise "🚨 `gh release create` failed: #{status.inspect}" unless status.success? + + puts "📝 Released #{tag}: #{output.chomp}" + end end desc "Compile extension without C23 extensions" diff --git a/docs/release.md b/docs/release.md index a3f0b6cbef..027b306e61 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,23 +1,38 @@ # Releasing RBS +A release is a pull request, a tag, and one workflow run. Everything that leaves +the repository — both gems and the GitHub release — is produced by the `Release +gems` workflow, so nothing has to be built or pushed from a laptop. + Each release ships **two gems**: -| Gem | Platform | Parser | How it is built | -| --- | --- | --- | --- | -| `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension | `rake release` (re-builds it) | -| `rbs-X.Y.Z-java.gem` | `java` (JRuby) | WebAssembly (`lib/rbs/wasm`) | Docker image, pushed manually | +| Gem | Platform | Parser | +| --- | --- | --- | +| `rbs-X.Y.Z.gem` | `ruby` (MRI) | C extension, compiled on install | +| `rbs-X.Y.Z-java.gem` | `java` (JRuby) | `rbs_parser.wasm`, built by the workflow | The `-java` gem contains no native code — just `rbs_parser.wasm`. The Chicory/ASM jars it needs are not shipped in the gem; they are declared as `jar-dependencies` requirements and fetched from Maven when the gem is installed. So the gem can be built once in any environment and runs on every JRuby. +There are three kinds of release, and they differ in what gets written up: + +| Version | CHANGELOG section | GitHub release | +| --- | --- | --- | +| `X.Y.Z` | The whole cycle since the previous release proper, prereleases included | Published | +| `X.Y.Z.pre.N` | What changed since `X.Y.Z.pre.N-1` | Published, marked as a prerelease | +| `X.Y.Z.dev.N` | None | None | + +`.dev.N` releases are cut from the development line for people who need a change +early, so they are gems and tags and nothing else. + ## Prerequisites -- Push rights to the `rbs` gem on RubyGems (`gem signin`). If your account has - MFA enabled, `gem push` / `rake release` will prompt for an OTP. -- Docker, for the `-java` gem. The WASI SDK is baked into the image, so there is - nothing to install on the host. +Push rights to the `rbs` gem on RubyGems are **not** needed: the workflow +authenticates through a trusted publisher registered for this repository and +`release-gems.yml`. What is needed is write access to the repository, since that +is what lets you dispatch the workflow. ## Steps @@ -26,8 +41,7 @@ built once in any environment and runs on every JRuby. Open a pull request that carries everything the release needs: - `lib/rbs/version.rb` — set `RBS::VERSION` to the version being released. -- `Gemfile.lock` — run `bundle install` after the bump; the lockfile records the version too, and - `rake release` refuses to run with a dirty working tree. +- `Gemfile.lock` — run `bundle install` after the bump; the lockfile records the version too. - `CHANGELOG.md` — add a section for the new version, directly under the `# CHANGELOG` heading. Sections are newest first. @@ -41,10 +55,16 @@ entry. $ bundle exec rake gem:changelog | pbcopy ``` -It starts from the latest `v*` tag; pass a version to start somewhere else -(`rake 'gem:changelog[4.1.0]'`). Only the list goes to STDOUT, so it pipes cleanly. Pull requests -labeled `skip-changelog` are left out and reported on STDERR, and pull requests that only touch -`rust/` are left out because the crates have their own release cycle. +Where it starts follows `RBS::VERSION`, so bump the version first: a prerelease starts from the +latest tag, and a release proper skips the prerelease tags and starts from the previous release +proper. Pass a version to override it (`rake 'gem:changelog[4.1.0]'`). Only the list goes to +STDOUT, so it pipes cleanly. Pull requests labeled `skip-changelog` are left out and reported on +STDERR, and pull requests that only touch `rust/` are left out because the crates have their own +release cycle. + +On a release proper, the `X.Y.Z.pre.N` sections above the previous release are replaced by the one +section being written — their pull requests are in it, and the notes they were published with stay +on their own GitHub releases. Sort the list into the sections below. `rake gem:changelog:json` prints the same pull requests with the changed files, labels, and body of each, which is what the sorting is based on. @@ -77,61 +97,55 @@ on a small release. Two things scale with the size of the release: The date is the day the gem is released, matching the `vX.Y.Z` tag — not the day this pull request is opened. Fix it up before step 2 if the pull request sat for a few days. -> While `.github/workflows/milestone.yml` is a required check, the release pull request needs a -> milestone matching the new version (`RBS X.Y` for a `.0` release, `RBS X.Y.x` otherwise). Create -> the milestone on GitHub first if the minor version changes. - -### 2. Release the `ruby` gem +### 2. Tag the release -Once the release pull request is merged and `master` is checked out, run on CRuby: +Once the pull request is merged, tag the merge commit and push the tag: ```console -$ bundle exec rake release +$ git switch master && git pull +$ git tag "v$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')" +$ git push origin --tags ``` -This re-builds the `ruby`-platform gem and then: +The tag comes before anything is published, so that the gems and the release notes describe a +commit that is already immutable — and because a tag can be deleted, while a version pushed to +RubyGems can only be yanked. -- creates the tag `vX.Y.Z`, -- pushes the current branch and the tag to `origin`, -- pushes the gem to RubyGems, -- runs `release:note`, which opens a GitHub **draft** release (with - `--prerelease` for `*.pre.*` versions) and prints the remaining manual steps. +### 3. Run the `Release gems` workflow against the tag -### 3. Build and push the `java` gem +Dispatch [`release-gems.yml`](../.github/workflows/release-gems.yml) from the Actions tab, picking +the `vX.Y.Z` tag — **not** a branch — in the ref selector. The trusted publisher has no branch +condition, so the ref you pick is what decides what gets published; the workflow refuses to run +unless the tag matches `RBS::VERSION`. -The `java` gem is not built by `rake release`, so build and push it manually: +It then: -```console -# Build from the committed state (the gemspec's file list comes from `git ls-files`). -$ docker build -f Dockerfile.jruby -t rbs-jruby . +- builds `rbs-X.Y.Z.gem`, +- compiles `rbs_parser.wasm` and builds `rbs-X.Y.Z-java.gem`, +- checks both: platforms, the C extension on one and its absence on the other, and that the wasm + module made it into the `java` gem, +- installs the `java` gem on JRuby and parses with it, so the WebAssembly runtime is exercised + before anything is published, +- uploads both gems as an artifact, +- pushes both to RubyGems through trusted publishing, +- publishes the GitHub release with the notes from CHANGELOG.md, skipping this last step for + `.dev.N` versions. -# Build rbs_parser.wasm and the -java gem into ./pkg on the host. The Chicory/ASM -# jars are not bundled; they are fetched from Maven when the gem is installed. -$ docker run --rm -e RBS_PLATFORM=java -v "$PWD/pkg:/out" rbs-jruby \ - gem build rbs.gemspec -o /out/rbs-X.Y.Z-java.gem - -$ gem push pkg/rbs-X.Y.Z-java.gem -``` - -Optionally confirm it installs and runs on JRuby before pushing: - -```console -$ docker run --rm -v "$PWD/pkg:/pkg" -w /tmp rbs-jruby bash -c \ - 'gem install /pkg/rbs-X.Y.Z-java.gem && ruby -e "require %q{rbs}; puts [RUBY_ENGINE, RBS::VERSION].join(%q{ })"' -``` +Dispatching against a branch runs everything up to the artifact and stops, which is how the build +is exercised without releasing. ### 4. Start the next development cycle Open another pull request setting `RBS::VERSION` to the next prerelease (`4.1.1` → `4.1.2.pre`), with `Gemfile.lock` regenerated, labeled `skip-changelog` like the release pull request itself. Without it the version on `master` keeps claiming to be the released version for the whole -development period — and, while the milestone check is in place, pull requests are checked against -the released version's milestone. +development period, and `rake gem:changelog` reads that version to decide where the next changelog +starts. ## Notes - Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`; a plain `gem install rbs` is unaffected. On JRuby, `gem install rbs [--pre]` resolves to the `-java` gem automatically. -- The Dockerfile pins the WASI SDK / Chicory / ASM versions to match the - `wasm` and `jruby` CI workflows. Keep them in sync when bumping. +- `Dockerfile.jruby` pins the WASI SDK / Chicory / ASM versions to match the + `wasm`, `jruby`, and `release-gems` workflows. Keep them in sync when bumping. From 1da5f8f880b82e8a88b1480b1b2b910b28345bd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:37:57 +0000 Subject: [PATCH 144/163] Version 4.1.1.dev.1 A test release for the release automation. `.dev.N` is a prerelease like `.pre.N`, and `Rakefile` already excludes `v*.dev*` when it picks the changelog base of a release proper, so this version does not disturb the 4.1.1 cycle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019aAfsFysWM2BMuPUPMFaze --- Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8db8139f93..989db76d83 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.1.pre) + rbs (4.1.1.dev.1) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index e329b7d66c..4ca0bd9b75 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.1.pre" + VERSION = "4.1.1.dev.1" end From ac1fac921f66a0ff74b65ea69c8627cd8b878f55 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 17:51:28 +0900 Subject: [PATCH 145/163] Version 4.1.1.pre.1 The changelog starts from `v4.1.0` rather than the latest tag: `v4.1.1.dev.1` is a dev release, so it carries no changelog section of its own and the pull requests it shipped would otherwise go unwritten until 4.1.1 proper. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 +++++++++++ Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ce6284b9..a7689e08b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG +## 4.1.1.pre.1 (2026-07-30) + +### Library changes + +* Include the trailing `?` in a keyword key's symbol location ([#3043](https://github.com/ruby/rbs/pull/3043)) +* Mark `type_name` as optional on the alias annotations ([#3039](https://github.com/ruby/rbs/pull/3039)) + +### Miscellaneous + +* Fix template drift for ext/rbs_extension/ast_translation.c ([#3038](https://github.com/ruby/rbs/pull/3038)) + ## 4.1.0 (2026-07-27) RBS 4.1 ships with JRuby support. The RBS parser is written in plain C without depending on the Ruby C API, so it is compiled to WebAssembly and runs on a Wasm runtime, with the parsed AST serialized in a binary format and decoded into RBS objects. The full test suite runs on JRuby in CI. diff --git a/Gemfile.lock b/Gemfile.lock index 989db76d83..3fb98b0d68 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.1.dev.1) + rbs (4.1.1.pre.1) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index 4ca0bd9b75..7df09dd8e0 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.1.dev.1" + VERSION = "4.1.1.pre.1" end From 6c10ad535801cd1645377d7e2b39c902f164b975 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Thu, 30 Jul 2026 18:04:32 +0900 Subject: [PATCH 146/163] Version 4.1.1 4.1.1 proper ships the same three pull requests as `4.1.1.pre.1`, so its `## 4.1.1.pre.1` section is renamed rather than a new one added: the notes the prerelease was published with stay on its own GitHub release. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7689e08b7..8cfe65ee98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -## 4.1.1.pre.1 (2026-07-30) +## 4.1.1 (2026-07-30) ### Library changes diff --git a/Gemfile.lock b/Gemfile.lock index 3fb98b0d68..1ca7c2d31f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.1.pre.1) + rbs (4.1.1) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index 7df09dd8e0..8434b91645 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.1.pre.1" + VERSION = "4.1.1" end From 9b151e37243472c578724f0a33d30fe7bf4deca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 12:25:14 +0000 Subject: [PATCH 147/163] Add the 4.0.3 section to the changelog 4.0.3 was released from `aaa-4.0.x`, so its section was written on that branch and never reached `master`: the changelog here goes from 4.1.0 straight to 4.0.2, as if the release had not happened. The section is copied verbatim from the branch, so it stays the text the v4.0.3 GitHub release was published with, and it is placed in version order rather than at the top, since 4.0.3 predates 4.1.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cfe65ee98..6b7a780fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,14 @@ This release also introduces `RBS::Rewriter`, an API to edit RBS source text whi * ci: skip Gemfile.lock BUNDLED WITH on ruby-head ([#2952](https://github.com/ruby/rbs/pull/2952)) * Remove `logger` from sig dependencies ([#2904](https://github.com/ruby/rbs/pull/2904)) +## 4.0.3 (2026-06-18) + +### Miscellaneous + +* Fix Ruby CI failure with compressed `Zlib::GzipReader` test fixtures. ([#3005](https://github.com/ruby/rbs/pull/3005)) +* Fix flaky `DirSingletonTest#test_fchdir` and `DirSingletonTest#test_for_fd` under aggressive GC. ([#3005](https://github.com/ruby/rbs/pull/3005)) +* Fix Ruby head CI failure caused by the lockfile-pinned Bundler version. ([#3005](https://github.com/ruby/rbs/pull/3005)) + ## 4.0.2 (2026-03-25) ### Library changes From 5cef4148a2c4391780fac8b92d9dc3fec065a88d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 12:40:16 +0000 Subject: [PATCH 148/163] Attribute a backported commit to the pull request it came from A backport is a cherry-pick, so the only pull request its commit is associated with is the one that carried the backport. That pull request says nothing about the change, and every commit it brought over resolves to it, so a release branch's changelog credits all of its entries to one number -- 4.0.3 lists three, all of them #3005. `git cherry-pick -x` records the commit a cherry-pick was made from, so follow it: look the recorded origin up instead of the commit, and the changelog names the pull request the change was written and reviewed in. A commit backported twice carries one line per hop, so the first one, where the change started, is the one used. An origin that leads nowhere -- a commit cherry-picked from a fork, or one that reached the default branch without a pull request -- falls back to the commit in this history, which is at least the backport that brought it here. Cherry-picks made without `-x` record nothing to follow and behave as before. The commit messages are read with `%B`, so the output is forced to UTF-8: unlike the SHAs read until now, a message can hold anything, and splitting one that is not ASCII fails under `LANG=C`, as in CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- Rakefile | 100 ++++++++++++++++++++++++++++++++++++++---------- docs/release.md | 15 ++++++++ 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/Rakefile b/Rakefile index c4c400552e..33cef063fa 100644 --- a/Rakefile +++ b/Rakefile @@ -594,20 +594,54 @@ def changelog_commits(from, paths: []) output.lines.map(&:chomp).reject(&:empty?) end -# Finds the pull requests the commits came from, keeping the order of `commits`. +# What `git cherry-pick -x` appends to the message of the commit it creates. +CHERRY_PICK_ORIGIN = /^\(cherry picked from commit ([0-9a-f]{40})\)$/ + +# Maps the commits that record where they were cherry-picked from to that commit. # -# Returns the pull requests for the changelog and the ones omitted by `skip_labels`. +# A backport is a cherry-pick, so on a release branch it is the recorded origin, not the commit +# itself, that leads to the pull request the change was written and reviewed in. Without this a +# backported change is attributed to the pull request that carried the backport, which says +# nothing about the change and is the same for every commit it brought over. # -def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) - pull_requests = {} - skipped = {} +# A commit backported twice -- the development line, then a release branch -- carries one line +# per hop, appended in order, so the first one is where the change started. +# +def changelog_origins(commits) + return {} if commits.empty? + + require "open3" - commits.each_slice(50) do |slice| - # Ask GitHub which pull requests the commits came from, so that any merge strategy -- merge - # commit, squash, or rebase -- is handled without parsing commit messages. - aliases = slice.map.with_index do |commit, index| + # `--no-walk` prints these commits and nothing else. NUL delimiters keep a commit message -- + # which can contain anything, including what this format looks like -- from being read as the + # format itself. + output, status = Open3.capture2("git", "log", "--no-walk", "--format=%H%x00%B%x00", *commits, binmode: true) + raise status.inspect unless status.success? + + # Commit messages are UTF-8, while the default external encoding follows the locale. Without + # this, splitting a message that is not ASCII fails under `LANG=C`, as in GitHub Actions. + output.force_encoding(Encoding::UTF_8) + + output.split("\0").each_slice(2).each_with_object({}) do |(commit, message), origins| + commit = commit.to_s.strip + next if commit.empty? + + origin = message.to_s[CHERRY_PICK_ORIGIN, 1] or next + origins[commit] = origin + end +end + +# Asks GitHub which pull requests each commit came from, so that any merge strategy -- merge +# commit, squash, or rebase -- is handled without parsing commit messages. +# +# Returns `{ oid => [pull request, ...] }`, with an empty array for the commits GitHub has no +# merged pull request for, including the ones it does not know at all. +# +def changelog_associated_pull_requests(oids) + oids.uniq.each_slice(50).each_with_object({}) do |slice, found| + aliases = slice.map.with_index do |oid, index| <<~GRAPHQL - c#{index}: object(oid: "#{commit}") { + c#{index}: object(oid: "#{oid}") { ... on Commit { associatedPullRequests(first: 10) { nodes { @@ -620,18 +654,44 @@ def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) GRAPHQL end - changelog_graphql(aliases.join("\n")).each_value do |commit| - next unless commit + response = changelog_graphql(aliases.join("\n")) - commit.dig(:associatedPullRequests, :nodes).each do |pr| - next unless pr[:merged] + slice.each_with_index do |oid, index| + nodes = response.dig(:"c#{index}", :associatedPullRequests, :nodes) || [] - pr = { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } } - if (pr[:labels] & skip_labels).empty? - pull_requests[pr[:number]] ||= pr - else - skipped[pr[:number]] ||= pr - end + found[oid] = nodes.select { |pr| pr[:merged] }.map do |pr| + { number: pr[:number], title: pr[:title], url: pr[:url], labels: pr.dig(:labels, :nodes).map { |label| label[:name] } } + end + end + end +end + +# Finds the pull requests the commits came from, keeping the order of `commits`. +# +# Returns the pull requests for the changelog and the ones omitted by `skip_labels`. +# +def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS) + origins = changelog_origins(commits) + found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit }) + + # An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the + # default branch without a pull request -- falls back to the commit in this history, which is + # at least the backport that brought it here. + fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? } + found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty? + + pull_requests = {} + skipped = {} + + commits.each do |commit| + prs = found.fetch(origins[commit] || commit, []) + prs = found.fetch(commit, []) if prs.empty? + + prs.each do |pr| + if (pr[:labels] & skip_labels).empty? + pull_requests[pr[:number]] ||= pr + else + skipped[pr[:number]] ||= pr end end end diff --git a/docs/release.md b/docs/release.md index 027b306e61..ffdffb6512 100644 --- a/docs/release.md +++ b/docs/release.md @@ -142,6 +142,21 @@ Without it the version on `master` keeps claiming to be the released version for development period, and `rake gem:changelog` reads that version to decide where the next changelog starts. +## Backports + +A patch release is cut from a release branch (`aaa-X.Y.x`), and what it carries beyond the previous +release is cherry-picked from the development line. Cherry-pick with `-x`: + +```console +$ git cherry-pick -x +``` + +`-x` records the commit the change was copied from, and that recorded line is what `rake +gem:changelog` follows to reach the pull request the change was written and reviewed in. Without +it, the only pull request a backported commit is associated with is the one that carried the +backport, which says nothing about the change and is the same for every commit it brought over — +that is why the 4.0.3 changelog credits its three entries to the same pull request. + ## Notes - Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`; From ed7be1d9e4ef045bffd85cc7af9d9ff3d59c7b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:30:30 +0000 Subject: [PATCH 149/163] Create the release tag in the workflow, from a commit and a version Pushing a tag needs credentials that a cloud development environment does not have, so the tag is created by `Release gems` rather than by hand before it. The workflow now takes the commit being released and the version that commit declares. Everything is built from the commit, so the run no longer depends on where `master` is when it starts, and the version is the same fact stated a second time: the run stops before anything is built unless it matches `RBS::VERSION` at that commit and the section CHANGELOG.md starts with. Releasing the wrong commit, or the right one under the wrong name, is a failed run rather than a gem to yank. The tag is created once both gems are known to build and run, and before anything is published, so the reversible step still comes before the irreversible one. What it names is decided by the checkout, so nothing rests on it existing first, which is what let it move after the build. `github.ref_type == 'tag'` used to be what separated a release from a build, and a branch dispatch was the dry run. The ref no longer decides anything, so `dry_run` is an explicit input: it builds and checks both gems and stops before the tag. `rake gem:check_release[X.Y.Z]` and `rake gem:tag` are the two steps the workflow runs, so both are also available locally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- .github/workflows/release-gems.yml | 138 +++++++++++++++++++++-------- Rakefile | 70 ++++++++++++--- docs/release.md | 54 ++++++----- 3 files changed, 191 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml index 1056c415f9..8269e41d4a 100644 --- a/.github/workflows/release-gems.yml +++ b/.github/workflows/release-gems.yml @@ -1,12 +1,17 @@ name: Release gems -# Builds, publishes, and announces a release. Dispatch it against the `vX.Y.Z` tag -# of the release: the tag is created first so that everything published afterwards -# traces back to an immutable ref, and so the reversible step comes before the -# irreversible one. +# Builds, publishes, and announces a release. Dispatch it with the commit being +# released and the version that commit declares. # -# Dispatching against a branch builds and verifies the gems and stops there, which -# is how the build is exercised without releasing anything. +# Everything is built from `commit`, not from wherever the default branch happens to +# be when the run starts, so the release describes a state that is already immutable. +# `version` is the same fact stated a second time -- the run stops before anything is +# built unless it matches the `RBS::VERSION` of that commit, so dispatching the wrong +# commit, or the right one under the wrong name, is a failed run rather than a gem +# that has to be yanked. +# +# `dry_run` builds and checks both gems and stops before the tag, which is how the +# build is exercised without releasing anything. # # | Gem | Platform | Parser | # | -------------------- | ------------- | -------------------------------- | @@ -19,11 +24,26 @@ name: Release gems # # One job on purpose. Tagging, pushing the gems, and opening the GitHub release # all belong to a single release, and keeping them in one place keeps their order -# readable -- the tag is created before anything is published, so the reversible -# step comes before the irreversible one. +# readable -- the tag is created once both gems are known to build and run, and +# before anything is published, so the reversible step comes before the irreversible +# one. +# +# The file name is what the RubyGems trusted publisher for `rbs` is registered +# against, so it cannot be renamed without registering the new name first. on: workflow_dispatch: + inputs: + commit: + description: "Commit to release, as a full 40-character SHA" + required: true + version: + description: "Version to release, without the leading `v` (e.g. `4.1.2`)" + required: true + dry_run: + description: "Build and check the gems without tagging or publishing anything" + type: boolean + default: false permissions: contents: read @@ -38,12 +58,57 @@ jobs: name: release runs-on: ubuntu-latest permissions: - contents: write # publish the GitHub release + contents: write # push the tag, publish the GitHub release id-token: write # trusted publishing to RubyGems + env: + # The inputs are read through the environment rather than interpolated into + # the scripts below. + COMMIT: ${{ inputs.commit }} + VERSION: ${{ inputs.version }} + TAG: v${{ inputs.version }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: # The gemspec takes its file list from `git ls-files`, so both gems are built - # from the committed state. + # from the committed state -- of the dispatched commit, since that is what is + # checked out. The full history is needed to tell whether it is on the default + # branch. - uses: actions/checkout@v7 + with: + ref: ${{ inputs.commit }} + fetch-depth: 0 + + # Before anything is installed or built: these are the two things the release + # is named after and built from, and a mistake in either is cheapest to catch + # here. + - name: Check the inputs + run: | + if [[ ! "$COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::\`$COMMIT\` is not a full 40-character SHA. A release names one exact commit." + exit 1 + fi + if [[ ! "$VERSION" =~ ^[0-9][0-9a-zA-Z.]*$ ]]; then + echo "::error::\`$VERSION\` is not a version number. Pass it without the leading \`v\`." + exit 1 + fi + + # A release is cut from the default branch. Anywhere else means the commit + # is not the one that was reviewed and merged. + git fetch --no-tags origin "$DEFAULT_BRANCH" + if ! git merge-base --is-ancestor "$COMMIT" FETCH_HEAD; then + echo "::error::$COMMIT is not on $DEFAULT_BRANCH." + exit 1 + fi + + # A tag that already exists is a version that has already been released, and + # pushing it would fail after the build rather than before it. + - name: Check that the tag does not exist + if: ${{ !inputs.dry_run }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null; then + echo "::error::$TAG already exists, so $VERSION has been released." + exit 1 + fi + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -56,25 +121,16 @@ jobs: bundle config set --local without libs:profilers bundle install --jobs 4 --retry 3 - - name: Read the version - id: version - run: echo "version=$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')" >> "$GITHUB_OUTPUT" - - # Fail before spending a minute on the build, and before anything is pushed: - # the tag is what the release is named after, so it has to be the version the - # tagged commit actually declares. - - name: Check the tag against RBS::VERSION - if: github.ref_type == 'tag' - run: | - if [ "${{ github.ref_name }}" != "v${{ steps.version.outputs.version }}" ]; then - echo "::error::tag ${{ github.ref_name }} does not match RBS::VERSION ${{ steps.version.outputs.version }}" - exit 1 - fi + # Fails before a minute is spent on the build, and before anything is pushed: + # the version has to be the one the released commit declares, and -- unless + # this is a `.dev.N` release -- the one CHANGELOG.md is written up for. + - name: Check the version and the changelog + run: bundle exec rake "gem:check_release[$VERSION]" - name: Build the ruby gem run: | mkdir -p pkg - gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}.gem" + gem build rbs.gemspec -o "pkg/rbs-$VERSION.gem" # `rake wasm:jruby_setup` compiles src/**/*.c to WebAssembly and copies the # result to lib/rbs/wasm/, where the gemspec picks it up. clang runs as a @@ -91,7 +147,7 @@ jobs: - name: Build the java gem env: RBS_PLATFORM: java - run: gem build rbs.gemspec -o "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + run: gem build rbs.gemspec -o "pkg/rbs-$VERSION-java.gem" # `git ls-files` vouches for everything else, but rbs_parser.wasm is a build # artifact, so the java gem is the one that can come out quietly wrong. @@ -108,8 +164,7 @@ jobs: raise "the java gem must not declare an extension" unless java_gem.extensions.empty? [ruby_gem, java_gem].each { puts "#{_1.full_name}: #{_1.files.size} files" } - ' "pkg/rbs-${{ steps.version.outputs.version }}.gem" \ - "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + ' "pkg/rbs-$VERSION.gem" "pkg/rbs-$VERSION-java.gem" # The checks above cannot tell whether rbs_parser.wasm actually runs. Install # the gem the way a user would -- jar-dependencies fetches Chicory and ASM @@ -122,7 +177,7 @@ jobs: bundler: none - name: Check the java gem on JRuby run: | - gem install "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + gem install "pkg/rbs-$VERSION-java.gem" ruby -e ' require "rbs" _, _, decls = RBS::Parser.parse_signature("class Foo end") @@ -138,27 +193,40 @@ jobs: bundler: none # Uploaded before publishing, so a failed push still leaves the gems behind. + # This is also where a dry run ends. - uses: actions/upload-artifact@v7 with: name: gems path: pkg/*.gem if-no-files-found: error - # Everything below runs only for a release tag. + # Everything below runs only for a real release. + + # The tag comes after the gems are known to build and run, and before anything + # is published: a tag can be deleted, while a version pushed to RubyGems can + # only be yanked. What it names was decided by the checkout rather than by the + # tagging, so nothing rests on it being created first. + - name: Tag the release + if: ${{ !inputs.dry_run }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + bundle exec rake gem:tag + - name: Configure RubyGems credentials - if: github.ref_type == 'tag' + if: ${{ !inputs.dry_run }} # No floating major tag on this action, so the exact release is pinned. uses: rubygems/configure-rubygems-credentials@v2.1.0 - name: Push the gems - if: github.ref_type == 'tag' + if: ${{ !inputs.dry_run }} run: | - gem push "pkg/rbs-${{ steps.version.outputs.version }}.gem" - gem push "pkg/rbs-${{ steps.version.outputs.version }}-java.gem" + gem push "pkg/rbs-$VERSION.gem" + gem push "pkg/rbs-$VERSION-java.gem" # Last, so that a failed push never announces a release that has no gems. - name: Publish the GitHub release - if: github.ref_type == 'tag' + if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} run: bundle exec rake gem:gh_release diff --git a/Rakefile b/Rakefile index 33cef063fa..d8da5994c7 100644 --- a/Rakefile +++ b/Rakefile @@ -839,6 +839,60 @@ namespace :gem do end end + # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. The + # `.dev.N` ones are cut from the development line for people who need a specific + # change early; they are not written up in the changelog, so there are no notes to + # publish and nothing worth announcing. + def dev_release?(version) + Gem::Version.new(version).segments.include?("dev") + end + + # The body of the topmost section of CHANGELOG.md, which is the release being + # prepared, minus its own heading. + # + # The encoding is explicit because the default external encoding follows the + # locale, and the changelog is not ASCII. + # + def changelog_section(version) + content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8) + section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md" + heading, _, body = section.partition("\n") + heading.include?(version) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{version}" + body.strip + end + + desc "Check that the working tree is ready to be released as the given version" + task :check_release, [:version] do |_task, args| + version = args[:version] or raise "🚨 Pass the version being released: `rake 'gem:check_release[4.1.2]'`" + Gem::Version.correct?(version) or raise "🚨 `#{version}` is not a version number." + + # The version being released and the version the commit declares are stated + # separately -- one by whoever starts the release, one by the commit itself -- + # so that releasing the wrong commit, or releasing the right one under the wrong + # name, fails here rather than on RubyGems. + version == RBS::VERSION or + raise "🚨 Releasing #{version}, but this commit declares `RBS::VERSION = #{RBS::VERSION.inspect}`." + + if dev_release?(version) + puts "✅ #{version} is the version of this commit. It is a dev release, so CHANGELOG.md is not checked." + else + changelog_section(version) + puts "✅ #{version} is the version of this commit, and CHANGELOG.md documents it." + end + end + + desc "Create and push the `vX.Y.Z` tag for RBS::VERSION" + task :tag do + tag = "v#{RBS::VERSION}" + + # Annotated, so that the tag carries its own author and date rather than + # borrowing the tagged commit's. + sh "git", "tag", "--annotate", "--message", "RBS #{RBS::VERSION}", tag + sh "git", "push", "origin", tag + + puts "🏷️ Pushed #{tag}." + end + desc "Publish the GitHub release for RBS::VERSION, unless it is a `.dev.` version" task :gh_release do require "open3" @@ -847,11 +901,7 @@ namespace :gem do major, minor, *_ = RBS::VERSION.split(".") tag = "v#{RBS::VERSION}" - # There are three kinds of release: `X.Y.Z`, `X.Y.Z.pre.N`, and `X.Y.Z.dev.N`. - # The `.dev.N` ones are cut from the development line for people who need a - # specific change early; they are not written up in the changelog, so there are - # no notes to publish and nothing worth announcing. - if version.segments.include?("dev") + if dev_release?(RBS::VERSION) puts "⏭️ #{RBS::VERSION} is a dev release, so there is no GitHub release to publish." next end @@ -861,18 +911,10 @@ namespace :gem do _, status = Open3.capture2("git", "rev-parse", "--verify", "--quiet", "#{tag}^{commit}") raise "🚨 No such tag: `#{tag}`. Tag the release before creating the GitHub release." unless status.success? - # The topmost section of the changelog is this release, minus its own heading. - # The encoding is explicit because the default external encoding follows the - # locale, and the changelog is not ASCII. - content = File.read(File.join(__dir__, "CHANGELOG.md"), encoding: Encoding::UTF_8) - section = content.scan(/^## \d.*?(?=^## \d)/m)[0] or raise "🚨 Cannot find a release section in CHANGELOG.md" - heading, _, body = section.partition("\n") - heading.include?(RBS::VERSION) or raise "🚨 CHANGELOG.md starts with `#{heading.strip}`, which is not #{RBS::VERSION}" - notes = <<~NOTES [Release note](https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}) - #{body.strip} + #{changelog_section(RBS::VERSION)} NOTES # Published rather than drafted: the notes are the changelog section that was diff --git a/docs/release.md b/docs/release.md index ffdffb6512..f35a15c8a6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,8 +1,8 @@ # Releasing RBS -A release is a pull request, a tag, and one workflow run. Everything that leaves -the repository — both gems and the GitHub release — is produced by the `Release -gems` workflow, so nothing has to be built or pushed from a laptop. +A release is a pull request and one workflow run. Everything that leaves the +repository — the tag, both gems, and the GitHub release — is produced by the +`Release gems` workflow, so nothing has to be built or pushed from a laptop. Each release ships **two gems**: @@ -97,26 +97,28 @@ on a small release. Two things scale with the size of the release: The date is the day the gem is released, matching the `vX.Y.Z` tag — not the day this pull request is opened. Fix it up before step 2 if the pull request sat for a few days. -### 2. Tag the release +### 2. Run the `Release gems` workflow -Once the pull request is merged, tag the merge commit and push the tag: +Once the pull request is merged, dispatch +[`release-gems.yml`](../.github/workflows/release-gems.yml) from the Actions tab with two inputs: -```console -$ git switch master && git pull -$ git tag "v$(ruby -e 'load "lib/rbs/version.rb"; print RBS::VERSION')" -$ git push origin --tags -``` +| Input | Value | +| --- | --- | +| `commit` | The full 40-character SHA of the merge commit, taken from the merged pull request | +| `version` | `X.Y.Z`, without the leading `v` | -The tag comes before anything is published, so that the gems and the release notes describe a -commit that is already immutable — and because a tag can be deleted, while a version pushed to -RubyGems can only be yanked. +The ref selector picks which copy of the workflow file runs, not what gets released — leave it on +`master`. Everything is built from `commit`, so the run is unaffected by whatever lands on `master` +in the meantime. -### 3. Run the `Release gems` workflow against the tag +The two inputs say the same thing twice, once as a commit and once as a name, and the run stops +before anything is built unless they agree with each other and with the repository: -Dispatch [`release-gems.yml`](../.github/workflows/release-gems.yml) from the Actions tab, picking -the `vX.Y.Z` tag — **not** a branch — in the ref selector. The trusted publisher has no branch -condition, so the ref you pick is what decides what gets published; the workflow refuses to run -unless the tag matches `RBS::VERSION`. +- `commit` has to be a full SHA that is on `master`, +- `version` has to be the `RBS::VERSION` that commit declares, +- CHANGELOG.md has to start with a section for `version` (skipped for `.dev.N`, which is not + written up), +- `vX.Y.Z` must not exist yet. It then: @@ -127,14 +129,19 @@ It then: - installs the `java` gem on JRuby and parses with it, so the WebAssembly runtime is exercised before anything is published, - uploads both gems as an artifact, -- pushes both to RubyGems through trusted publishing, +- tags `commit` as `vX.Y.Z` and pushes the tag, +- pushes both gems to RubyGems through trusted publishing, - publishes the GitHub release with the notes from CHANGELOG.md, skipping this last step for `.dev.N` versions. -Dispatching against a branch runs everything up to the artifact and stops, which is how the build -is exercised without releasing. +The tag is created once both gems are known to build and run, and before anything is published: a +tag can be deleted, while a version pushed to RubyGems can only be yanked. + +Checking the `dry_run` box runs everything up to the artifact and stops — no tag, no gems pushed, +no release — which is how the build is exercised without releasing. `version` still has to match +the commit, so a dry run is also how a release is rehearsed before it is cut. -### 4. Start the next development cycle +### 3. Start the next development cycle Open another pull request setting `RBS::VERSION` to the next prerelease (`4.1.1` → `4.1.2.pre`), with `Gemfile.lock` regenerated, labeled `skip-changelog` like the release pull request itself. @@ -164,3 +171,6 @@ that is why the 4.0.3 changelog credits its three entries to the same pull reque resolves to the `-java` gem automatically. - `Dockerfile.jruby` pins the WASI SDK / Chicory / ASM versions to match the `wasm`, `jruby`, and `release-gems` workflows. Keep them in sync when bumping. +- `rake 'gem:check_release[X.Y.Z]'` and `rake gem:tag` are what the workflow runs to + check the release and to create the tag. Both work locally, which is the fallback + if the tag ever has to be created by hand. From 45c9bb6cf5af6e09761b677e2f46c9e229bd6218 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:37:49 +0000 Subject: [PATCH 150/163] Do not require the released commit to be on the default branch A patch release can be cut from a release branch, which is not an ancestor of the default branch, so requiring one rejected a release that is perfectly normal. What is left is that some branch contains the commit -- a commit no branch leads to is one that cannot be found again -- and the branches that do are printed, so the log says where the release came from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BvSmN7ats6HGEyeT7VEbau --- .github/workflows/release-gems.yml | 19 +++++++++++-------- docs/release.md | 10 ++++++++-- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-gems.yml b/.github/workflows/release-gems.yml index 8269e41d4a..f4b813d1c7 100644 --- a/.github/workflows/release-gems.yml +++ b/.github/workflows/release-gems.yml @@ -66,12 +66,10 @@ jobs: COMMIT: ${{ inputs.commit }} VERSION: ${{ inputs.version }} TAG: v${{ inputs.version }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} steps: # The gemspec takes its file list from `git ls-files`, so both gems are built # from the committed state -- of the dispatched commit, since that is what is - # checked out. The full history is needed to tell whether it is on the default - # branch. + # checked out. The full history is needed to tell which branches contain it. - uses: actions/checkout@v7 with: ref: ${{ inputs.commit }} @@ -91,13 +89,18 @@ jobs: exit 1 fi - # A release is cut from the default branch. Anywhere else means the commit - # is not the one that was reviewed and merged. - git fetch --no-tags origin "$DEFAULT_BRANCH" - if ! git merge-base --is-ancestor "$COMMIT" FETCH_HEAD; then - echo "::error::$COMMIT is not on $DEFAULT_BRANCH." + # A release proper is cut from the default branch, while a patch release can + # be cut from a release branch, so which branch the commit is on is not this + # workflow's business. That it is on one is: a commit no branch contains is + # one that nothing in the repository leads to any more. + git fetch --no-tags origin "+refs/heads/*:refs/remotes/origin/*" + branches=$(git branch --remotes --contains "$COMMIT" --format "%(refname:lstrip=3)") + if [ -z "$branches" ]; then + echo "::error::$COMMIT is not on any branch." exit 1 fi + echo "Branches containing $COMMIT:" + printf '%s\n' "$branches" # A tag that already exists is a version that has already been released, and # pushing it would fail after the build rather than before it. diff --git a/docs/release.md b/docs/release.md index f35a15c8a6..9719479ca3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -109,12 +109,13 @@ Once the pull request is merged, dispatch The ref selector picks which copy of the workflow file runs, not what gets released — leave it on `master`. Everything is built from `commit`, so the run is unaffected by whatever lands on `master` -in the meantime. +in the meantime, and a patch release cut from a release branch is dispatched the same way as any +other: the workflow does not care which branch the commit is on. The two inputs say the same thing twice, once as a commit and once as a name, and the run stops before anything is built unless they agree with each other and with the repository: -- `commit` has to be a full SHA that is on `master`, +- `commit` has to be a full SHA that some branch contains, - `version` has to be the `RBS::VERSION` that commit declares, - CHANGELOG.md has to start with a section for `version` (skipped for `.dev.N`, which is not written up), @@ -174,3 +175,8 @@ that is why the 4.0.3 changelog credits its three entries to the same pull reque - `rake 'gem:check_release[X.Y.Z]'` and `rake gem:tag` are what the workflow runs to check the release and to create the tag. Both work locally, which is the fallback if the tag ever has to be created by hand. +- Those two tasks and `rake gem:gh_release` come from the Rakefile of the commit + being released, not from the branch the workflow was dispatched from. Releasing + from a release branch (`aaa-X.Y.x`) therefore needs the release tooling on that + branch as well; without it the run fails on the missing task, before publishing + anything. From 99218e53dd1208343cbfdbd504659037e7489518 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:04:38 +0000 Subject: [PATCH 151/163] Version 4.1.2.dev.1 A dev release cut from `master` after 4.1.1, for people who need one of the changes since it early. `.dev.N` is a prerelease, and it is not written up in CHANGELOG.md nor announced with a GitHub release, so it does not disturb the 4.1.2 cycle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PhJnDiMKwunqnLpuXzRY7v --- Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1ca7c2d31f..a0f1a0bae5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.1) + rbs (4.1.2.dev.1) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index 8434b91645..877e44f30d 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.1" + VERSION = "4.1.2.dev.1" end From 39d98d3f3e79bc4f73ce2ea577f8783f926efecb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 14:44:03 +0000 Subject: [PATCH 152/163] Unpin rdoc on the JRuby CI rdoc 8 needs `rbs >= 4.0.0`, which resolved to the C-extension gem when the pin went in. `gem install rdoc` now picks up `rbs-4.1.1-java`, so the pin is no longer needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PhJnDiMKwunqnLpuXzRY7v --- .github/workflows/jruby.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/jruby.yml b/.github/workflows/jruby.yml index aee09942c9..d898ed65e1 100644 --- a/.github/workflows/jruby.yml +++ b/.github/workflows/jruby.yml @@ -64,12 +64,7 @@ jobs: ruby-version: jruby bundler: none - name: Install runtime and test gems - # rdoc 8.0.0 added a runtime dependency on rbs, which pulls the released - # C-extension rbs gem and fails to build on JRuby. Pin rdoc below 8 until - # a -java rbs gem is published. - run: | - gem install prism rake rake-compiler test-unit rspec minitest json-schema pry --no-document - gem install rdoc -v "< 8" --no-document + run: gem install prism rake rake-compiler test-unit rdoc rspec minitest json-schema pry --no-document # jar-dependencies resolves through the JVM, so download the Chicory/ASM # jars into ~/.m2 here on JRuby (jar-dependencies is not available in the # CRuby step above). From 34b118830781688ead0531622eb2fd048bb0f148 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:25:18 +0000 Subject: [PATCH 153/163] Say what the version on `master` means A bare `X.Y.0.dev` means `X.Y.0` is being developed; any other version means the one after it is. So a release needs no follow-up bump, and starting a new minor is the only version change made by hand. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PhJnDiMKwunqnLpuXzRY7v --- docs/release.md | 74 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/release.md b/docs/release.md index 9719479ca3..a2cd6c6576 100644 --- a/docs/release.md +++ b/docs/release.md @@ -36,6 +36,14 @@ is what lets you dispatch the workflow. ## Steps +The release pull request in step 1 is merged by a person who has reviewed it. Its merge commit is +what step 2 dispatches, tags, and pushes to RubyGems, and none of that can be taken back — so +prepare that pull request and stop there, rather than merging it and carrying on to step 2. + +The bump that starts a new minor is the only other pull request that sets `RBS::VERSION`. It +publishes nothing and another bump undoes it, so one opened on an explicit request can go through +on its own. + ### 1. Prepare the release Open a pull request that carries everything the release needs: @@ -142,13 +150,67 @@ Checking the `dry_run` box runs everything up to the artifact and stops — no t no release — which is how the build is exercised without releasing. `version` still has to match the commit, so a dry run is also how a release is rehearsed before it is cut. -### 3. Start the next development cycle +## The version on `master` -Open another pull request setting `RBS::VERSION` to the next prerelease (`4.1.1` → `4.1.2.pre`), -with `Gemfile.lock` regenerated, labeled `skip-changelog` like the release pull request itself. -Without it the version on `master` keeps claiming to be the released version for the whole -development period, and `rake gem:changelog` reads that version to decide where the next changelog -starts. +`RBS::VERSION` on `master` is read one of two ways, told apart by how the version ends: + +| On `master` | Means | +| --- | --- | +| `X.Y.0.dev` — a bare `.dev` | `X.Y.0` is being developed | +| A complete version — `X.Y.Z`, `X.Y.Z.pre.N`, `X.Y.Z.dev.N` | The version *after* the one named is being developed | + +So `4.1.1` on `master` is not a claim that `master` is 4.1.1. It says 4.1.1 has shipped and what +comes after it is being worked on. `4.1.2.dev.1` says the same thing about itself: that release is +out, and the line continues towards 4.1.2. + +Both become true the moment the release is tagged, so **nothing has to be done to `master` after a +release**. `4.0.1` was followed by `4.0.2` with no version change in between, and `4.1.2.dev.1` is +what `master` carries today. + +The bare `X.Y.0.dev` is the exception because it is the one version that names a target rather than +a predecessor: a new minor is developed towards `X.Y.0` for a long time, before it is known whether +the next thing to ship is `X.Y.0.pre.1` or `X.Y.0` itself. Setting it is the only version change +that has to be made deliberately. + +`rake gem:changelog` reads `RBS::VERSION` too, to decide where the next changelog starts — but the +version is set to the one being released before the changelog is generated, so it sees that rather +than whatever `master` was carrying. + +## Starting a new minor + +`master` is the development line of one minor at a time. Moving it from `X.Y` to `X.(Y+1)` is not +part of any one release — it is the decision that the `X.Y` line is done, taken whenever that +becomes true — and it is the one moment the version on `master` is changed by hand. Two changes, in +opposite places: + +1. **Branch the line being left behind**, from the last `master` commit that belongs to it: + + ```console + $ git switch --create aaa-X.Y.x + $ git push -u origin aaa-X.Y.x + ``` + + Branch from the commit *before* the bump below, so the branch keeps the version its line was + released under. Patch releases of `X.Y` are cut from here from now on, with their changes + cherry-picked from `master` — see [Backports](#backports). The `aaa-` prefix carries no meaning + beyond sorting the release branches to the top of the branch list. + +2. **Bump `master`** to `X.(Y+1).0.dev`, in a pull request with `Gemfile.lock` regenerated and + labeled `skip-changelog` like the release pull request itself. `4.1` was started exactly this + way: `aaa-4.0.x` was branched at the commit before `Start 4.1 development`, which set + `RBS::VERSION` to `4.1.0.dev`. + +Two loose ends that are easy to forget: + +- **The release note of the new line.** `rake gem:gh_release` links every published release to + `https://github.com/ruby/rbs/wiki/Release-Note-X.Y`, built from the version number without + checking that the page is there. Nothing has to be written when the line starts — the page comes + together as the first release proper of the line comes into view — but it does have to exist by + the time that release is published, or its notes link to an empty page. +- **Release branches that are done.** A branch is worth keeping only while its line might still + get a patch. The ones that exist do not cover every line that ever had one — `3.8.1` shipped and + there is no `aaa-3.8.x` — so this is housekeeping rather than a rule, but starting a new minor is + the natural moment to look at the bottom of the branch list and delete what has been superseded. ## Backports From 1da55ae9b58341668e2e44ffd74a1918b3549eeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:25:27 +0000 Subject: [PATCH 154/163] Remove the release path that predates the release workflow `rake release` published from a working copy and built no `-java` gem, and `release:note` drafted the GitHub release that `gem:gh_release` now publishes. README pointed at both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PhJnDiMKwunqnLpuXzRY7v --- README.md | 2 +- Rakefile | 67 ------------------------------------------------------- 2 files changed, 1 insertion(+), 68 deletions(-) diff --git a/README.md b/README.md index ec2773c091..85e758b4f5 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ Here is a list of some places you can talk with active maintainers. After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +To install this gem onto your local machine, run `bundle exec rake install`. Releases are cut by the `Release gems` workflow rather than from a working copy -- see [docs/release.md](docs/release.md). ### C Code Formatting diff --git a/Rakefile b/Rakefile index d8da5994c7..98cfe8d389 100644 --- a/Rakefile +++ b/Rakefile @@ -438,73 +438,6 @@ task :test_generate_stdlib do sh "ruby -c /tmp/Thread_Mutex_test.rb" end -Rake::Task[:release].enhance do - Rake::Task[:"release:note"].invoke -end - -namespace :release do - desc "Explain the post-release steps automatically" - task :note do - version = Gem::Version.new(RBS::VERSION) - major, minor, patch, *_ = RBS::VERSION.split(".") - major = major.to_i - minor = minor.to_i - patch = patch.to_i - - puts "🎉🎉🎉🎉 Congratulations for **#{version}** release! 🎉🎉🎉🎉" - puts - puts "There are a few things left to complete the release. 💪" - puts - - if patch == 0 || version.prerelease? - puts "* [ ] Update release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}" - end - - if patch == 0 && !version.prerelease? - puts "* [ ] Delete `RBS XYZ is the latest version of...` from release note: https://github.com/ruby/rbs/wiki/Release-Note-#{major}.#{minor}" - end - - puts "* [ ] Publish a release at GitHub" - puts "* [ ] Make some announcements on Twitter/Mustdon/Slack/???" - - puts - puts - - puts "✏️ Making a draft release on GitHub..." - - content = File.read(File.join(__dir__, "CHANGELOG.md")) - changelog = content.scan(/^## \d.*?(?=^## \d)/m)[0] - changelog = changelog.sub(/^.*\n^.*\n/, "").rstrip - - notes = <> Done! Open #{output.chomp} and publish the release!" - end - end -end - - # Pull requests with one of these labels are omitted from the changelog. CHANGELOG_SKIP_LABELS = ["skip-changelog"] From a8913a884acb5ae09f28c75d99a2e63cc27eedc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:25:36 +0000 Subject: [PATCH 155/163] Document the JRuby container, and correct what pins the jar versions `Dockerfile.jruby` was not mentioned in the wasm README. Chicory and ASM are pinned once, in `rbs.gemspec`, not in the Dockerfile as docs/release.md claimed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PhJnDiMKwunqnLpuXzRY7v --- docs/release.md | 7 +++++-- wasm/README.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/release.md b/docs/release.md index a2cd6c6576..319c7a0ab6 100644 --- a/docs/release.md +++ b/docs/release.md @@ -232,8 +232,11 @@ that is why the 4.0.3 changelog credits its three entries to the same pull reque - Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`; a plain `gem install rbs` is unaffected. On JRuby, `gem install rbs [--pre]` resolves to the `-java` gem automatically. -- `Dockerfile.jruby` pins the WASI SDK / Chicory / ASM versions to match the - `wasm`, `jruby`, and `release-gems` workflows. Keep them in sync when bumping. +- The WASI SDK version is pinned in `wasm.yml`, `jruby.yml`, `release-gems.yml`, and + `Dockerfile.jruby`, each carrying its own copy. Keep them in sync when bumping. The + Chicory/ASM versions are not duplicated: they are the `jar` requirements in + `rbs.gemspec`, which is where the workflow, `Dockerfile.jruby` and `gem install` all + read them from. - `rake 'gem:check_release[X.Y.Z]'` and `rake gem:tag` are what the workflow runs to check the release and to create the tag. Both work locally, which is the fallback if the tag ever has to be created by hand. diff --git a/wasm/README.md b/wasm/README.md index 89a652cfde..c1993b19fd 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -27,6 +27,39 @@ $ rake wasm:install_jars # download the Chicory/ASM jars into ~/.m2 (run on JRub The compiled `rbs_parser.wasm` is a build artifact and is not checked in. +The WASI SDK is needed for the *build*, not for running the result — the host clang already +knows the `wasm32` target, but there is no wasm32 libc on a normal machine, so it picks up the +host headers and fails on the first `#include`. That is what the SDK supplies, along with the +builtins the link step needs. + +## Running the suite on JRuby + +[`Dockerfile.jruby`](../Dockerfile.jruby) builds an image that has everything this needs, so no +JRuby, JDK or WASI SDK has to be installed to work on the JRuby side: + +```console +$ docker build -f Dockerfile.jruby -t rbs-jruby . +$ docker run --rm rbs-jruby # run the test suite +$ docker run --rm -e RBS_PLATFORM=java rbs-jruby \ + gem build rbs.gemspec # build the -java gem +``` + +Two things in it are not obvious: + +- `build-essential` is for prism, which builds `libprism.so` and loads it through FFI on JRuby + rather than as an MRI C extension. It needs `cc` and `make`. +- Bundler is skipped. The development `Gemfile` pulls in CRuby-only C extensions (bigdecimal, + stackprof, …) that cannot build on JRuby, so the few gems the suite needs are installed + directly, in the same set as [`jruby.yml`](../.github/workflows/jruby.yml). + +The image compiles `rbs_parser.wasm` itself, which is why it carries the WASI SDK. That is not +the only arrangement: the build needs the SDK but not JRuby, and running the suite needs JRuby +but not the SDK, so `jruby.yml` splits them instead — it compiles the module on CRuby and then +switches engines to test against the result. + +`rake wasm:install_jars` is the step that has to be on JRuby either way: it resolves the `jar` +requirements from `rbs.gemspec` through the JVM. + ## Exported functions The module is built as a "reactor": it has no `main`, and the host calls From 75bcf93cfa48b45b99d35bd512954d271cc09cc2 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Thu, 30 Jul 2026 17:54:30 +0000 Subject: [PATCH 156/163] Fix GC_test#test_enable leaving GC disabled for the rest of the suite `GC.enable` returns whether GC was *disabled* before the call (true when the call actually re-enabled GC), not whether it was enabled. The ensure clause `GC.disable unless was_enabled` therefore inverted the restore: in the common case where GC is enabled when the test starts, GC.enable returns false and the ensure clause disables GC permanently. Every test that runs after GC_test then executes with GC turned off, so the whole stdlib test process accumulates garbage until exit. On the ruby/ruby Windows CI runners this reaches the process memory limit and the suite dies with NoMemoryError (deterministically), and on all platforms it silently changes what later tests exercise. Rename the variable to what the value actually means and restore the previous state with `GC.disable if was_disabled`, mirroring test_disable. Co-authored-by: Claude Fable 5 --- test/stdlib/GC_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/stdlib/GC_test.rb b/test/stdlib/GC_test.rb index 04b3b89fca..27f5db6292 100644 --- a/test/stdlib/GC_test.rb +++ b/test/stdlib/GC_test.rb @@ -44,12 +44,12 @@ def test_disable end def test_enable - was_enabled = GC.enable + was_disabled = GC.enable assert_send_type '() -> bool', GC, :enable ensure - GC.disable unless was_enabled + GC.disable if was_disabled end def test_start From 1630d1f6c7a7a074b30497bc5b5e311806eb5e59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:24:37 +0000 Subject: [PATCH 157/163] Keep GC disabled during GC_test#test_stress_and_stress= `GC.stress = true` runs a full GC on every allocation, and each `assert_send_type` allocates heavily while parsing the method type and type checking the call trace. Since #3059 stopped `test_enable` from leaking a disabled GC into the rest of the suite, stress mode is finally live during this test, and the five assertions between `GC.stress = 0` and `GC.stress = false` turn the whole stdlib suite from 193s into 827s -- 647s of it inside this one method. The cost scales with heap size, so it is far worse in the full suite than when the file runs alone. The assertions only check the types of the return values, so no GC needs to actually run. Disable GC for the duration and restore both the stress mode and the previous enabled state afterwards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NtDw9t8cMiaqT2s8wwSsDt --- test/stdlib/GC_test.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/stdlib/GC_test.rb b/test/stdlib/GC_test.rb index 27f5db6292..dd9fdea6ed 100644 --- a/test/stdlib/GC_test.rb +++ b/test/stdlib/GC_test.rb @@ -76,6 +76,10 @@ def test_stat def test_stress_and_stress= old_stress = GC.stress + # Stress mode runs a full GC on every allocation, and the assertions below allocate + # heavily while parsing and type checking. Only the types of the return values matter + # here, so keep GC disabled and let stress mode stay inert. + was_disabled = GC.disable assert_send_type '() -> (Integer | bool)', GC, :stress @@ -92,6 +96,7 @@ def test_stress_and_stress= end ensure GC.stress = old_stress + GC.enable unless was_disabled end def test_total_time From d710ede47db9665447ceda557b2252669bfa9663 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:55:04 +0000 Subject: [PATCH 158/163] Version 4.1.2 The 4.1.2 release proper, cut from `master` after 4.1.1 and the `4.1.2.dev.1` release in between. `Gemfile.lock` is regenerated with the bump, and CHANGELOG.md gains the 4.1.2 section. The changelog covers the whole cycle since 4.1.1, the `.dev.1` release included, which is how a release proper is written up. Both entries are the stdlib test suite, which is excluded from the gem's files, so nothing in this release reaches the gem. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014QUAgW2m9wsYE1cLM1fFbw --- CHANGELOG.md | 7 +++++++ Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7a780fcf..7a6923c174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 4.1.2 (2026-08-03) + +### Miscellaneous + +* Keep GC disabled during GC_test#test_stress_and_stress= ([#3061](https://github.com/ruby/rbs/pull/3061)) +* Fix GC_test#test_enable leaving GC disabled for the rest of the suite ([#3059](https://github.com/ruby/rbs/pull/3059)) + ## 4.1.1 (2026-07-30) ### Library changes diff --git a/Gemfile.lock b/Gemfile.lock index a0f1a0bae5..1da8a68e6d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.2.dev.1) + rbs (4.1.2) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index 877e44f30d..243f184d5b 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.2.dev.1" + VERSION = "4.1.2" end From 6e2ddb6e2992891c4b822831bcea09af3972dc4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 03:11:03 +0000 Subject: [PATCH 159/163] Say how the changelog is assembled without `gh` `gem:changelog` and `gem:changelog:json` go through `gh`, which a Claude Code on the web session cannot reach: `api.github.com` is blocked at the agent proxy for anything the shell does, so `gh` is absent, installing it does not help, and rewriting the tasks against REST or Net::HTTP would be refused the same way. The rest of the release is unaffected -- the other tasks read git, or run on a runner. Document the route that does work there, the GitHub MCP server, as the three steps the task itself takes: resolve the base tag, list the commits, then match the pull requests by `head.sha`. That intersection is what the GraphQL `associatedPullRequests` query answers. The trap it warns about is the obvious shortcut of reading the numbers out of `Merge pull request #N` subjects, which looks like it works and loses pull requests silently: the path filter the task uses drops the merge commits while keeping the commits they merged, and five of the eight numbers of the 4.1.2 cycle went with them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014QUAgW2m9wsYE1cLM1fFbw --- docs/release.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/release.md b/docs/release.md index 319c7a0ab6..19c782d1d4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -77,6 +77,10 @@ on their own GitHub releases. Sort the list into the sections below. `rake gem:changelog:json` prints the same pull requests with the changed files, labels, and body of each, which is what the sorting is based on. +Both tasks reach GitHub through `gh`, which a Claude Code on the web session cannot do. See +[Assembling the changelog without `gh`](#assembling-the-changelog-without-gh) for how the same list +is produced there. + ```markdown ## X.Y.Z (YYYY-MM-DD) @@ -227,6 +231,81 @@ it, the only pull request a backported commit is associated with is the one that backport, which says nothing about the change and is the same for every commit it brought over — that is why the 4.0.3 changelog credits its three entries to the same pull request. +## Assembling the changelog without `gh` + +A release can be prepared from a Claude Code on the web session, with one exception: +`gem:changelog` and `gem:changelog:json` cannot run there. Both go through `gh`, and in such a +session `api.github.com` is blocked at the agent proxy for anything the shell does. `gh` is not +installed, installing it does not help, and rewriting the tasks against REST or Net::HTTP would be +blocked the same way — the refusal is keyed on the session rather than on the client: + +```console +$ curl -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/ruby/rbs +{"message":"GitHub access is not enabled for this session. ..."} # HTTP 403 +``` + +Nothing else in the release is affected. `gem:check_release` and `gem:tag` read git and the working +tree, and `gem:gh_release` runs on a runner, where `gh` and `github.token` both work. + +What the session does have is the GitHub MCP server, which reaches the API through its own +credentials. The changelog is assembled with its tools, in the three steps the rake task takes. + +**1. Where the changelog starts.** The rule is `changelog_base`: a prerelease starts from the +latest tag, a release proper skips the prerelease tags. Tags are not fetched by default. + +```console +$ git fetch origin --tags +$ git describe --tags --match 'v*' --abbrev=0 --exclude 'v*.pre*' --exclude 'v*.dev*' +v4.1.1 +``` + +Drop the two `--exclude` flags for a prerelease, which starts at the latest tag whatever it is. + +**2. The commits.** + +```console +$ git log --format=%H v4.1.1..HEAD +``` + +**3. The pull requests they came from.** List the merged pull requests with `list_pull_requests` +(`base: master`, `state: closed`, `sort: updated`, `direction: desc`, and `fields: number, title, +labels, merged_at, head`), paging back until `merged_at` predates the base tag, and keep the ones +whose `head.sha` appears in the commit list from step 2. That intersection is what the task's +GraphQL `associatedPullRequests` query answers, reached from the other side. + +Then drop the pull requests labeled `skip-changelog` and format the rest newest first, which is the +order of step 2: + +```markdown +* {title} ([#{number}](https://github.com/ruby/rbs/pull/{number})) +``` + +Sorting them into sections needs the changed files, which `gem:changelog:json` would have supplied: +`pull_request_read` with `get_files` per pull request, or `get` for the body. + +Four things about that matching, the first of which is a trap: + +- **Do not read the numbers from `Merge pull request #N` commit subjects.** It looks like it works + on this repository, and it silently loses pull requests. Applying the path filter the task uses + (`git log --full-history --simplify-merges -- . ':(exclude)rust'`) drops the merge commits while + keeping the commits they merged, so on the 4.1.2 cycle five of the eight numbers disappeared with + them. A squashed or rebased pull request never writes that subject at all. Matching `head.sha` + has neither failure mode. +- `head.sha` is in the history because this repository merges pull requests with merge commits. A + squashed or rebased one would need its `merge_commit_sha`, which the listing does not carry. +- The listing reports `merged: false` for pull requests that are merged — the field is not + populated by that endpoint. Read `merged_at` instead. +- On a release branch the commits are cherry-picks, so resolve the `(cherry picked from commit + )` trailer first and match the recorded origin, as `changelog_origins` does. Matching the + cherry-pick itself attributes every backport to the pull request that carried it. + +Pull requests confined to `rust/` are left out too, which the task does by filtering the commits in +step 2 with `-- . ':(exclude)rust'`. Leave step 2 unfiltered here and drop those pull requests by +their `get_files` instead. The filter decides which commits are listed, and a pull request is found +by one specific commit — its head — so a pull request whose last commit happens to touch only +`rust/` would lose that head and disappear even though the rest of it belongs in the changelog. +Matching against every commit and filtering afterwards cannot go wrong that way. + ## Notes - Prereleases (`X.Y.Z.pre.N`) are only installed with `gem install rbs --pre`; From d298bb71f5b3aa36d9248a56f0616525ea3ff68f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 03:18:01 +0000 Subject: [PATCH 160/163] Start 4.2 development Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QHHu1wodDoUBPi2Cs7yhCn --- Gemfile.lock | 2 +- lib/rbs/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1da8a68e6d..de5569be0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,7 +22,7 @@ GIT PATH remote: . specs: - rbs (4.1.2) + rbs (4.2.0.dev) logger prism (>= 1.6.0) tsort diff --git a/lib/rbs/version.rb b/lib/rbs/version.rb index 243f184d5b..db11861cde 100644 --- a/lib/rbs/version.rb +++ b/lib/rbs/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RBS - VERSION = "4.1.2" + VERSION = "4.2.0.dev" end From 1a8a8d005b609276f7911ccfe721656b7280c97f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 08:50:56 +0000 Subject: [PATCH 161/163] Rename NODISCARD macro to RBS_NODISCARD `NODISCARD` is a generic name that can easily collide with macros defined by other headers embedding the RBS parser. Prefix it with `RBS_` to match the other public macros in `include/rbs/defines.h`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SesqemYtV3tnKoNGYuP1JW --- include/rbs/defines.h | 4 +- include/rbs/parser.h | 2 +- src/parser.c | 110 +++++++++++++++++++++--------------------- 3 files changed, 58 insertions(+), 58 deletions(-) diff --git a/include/rbs/defines.h b/include/rbs/defines.h index 56a05b7719..de06c0a09c 100644 --- a/include/rbs/defines.h +++ b/include/rbs/defines.h @@ -69,9 +69,9 @@ **********************************************************************************************************************/ #if defined(_MSC_VER) -#define NODISCARD _Check_return_ +#define RBS_NODISCARD _Check_return_ #else -#define NODISCARD __attribute__((warn_unused_result)) +#define RBS_NODISCARD __attribute__((warn_unused_result)) #endif /** diff --git a/include/rbs/parser.h b/include/rbs/parser.h index efb06ebe5b..598a76aff3 100644 --- a/include/rbs/parser.h +++ b/include/rbs/parser.h @@ -80,7 +80,7 @@ void rbs_parser_push_typevar_table(rbs_parser_t *parser, bool reset); /** * Insert new type variable into the latest table. * */ -NODISCARD bool rbs_parser_insert_typevar(rbs_parser_t *parser, rbs_constant_id_t id); +RBS_NODISCARD bool rbs_parser_insert_typevar(rbs_parser_t *parser, rbs_constant_id_t id); /** * Allocate new rbs_lexer_t object. diff --git a/src/parser.c b/src/parser.c index 358e99bbab..fa3d3f3159 100644 --- a/src/parser.c +++ b/src/parser.c @@ -183,7 +183,7 @@ static void parser_advance_no_gap(rbs_parser_t *parser) { | {(tUIDENT `::`)*} | {} */ -NODISCARD +RBS_NODISCARD static bool parse_type_name(rbs_parser_t *parser, TypeNameKind kind, rbs_range_t *rg, rbs_type_name_t **type_name) { bool absolute = false; @@ -267,7 +267,7 @@ error_handling: { type_list ::= {} type `,` ... <`,`> eol | {} type `,` ... `,` eol */ -NODISCARD +RBS_NODISCARD static bool parse_type_list(rbs_parser_t *parser, enum RBSTokenType eol, rbs_node_list_t *types, bool void_allowed, bool self_allowed, bool classish_allowed) { while (true) { rbs_node_t *type; @@ -297,7 +297,7 @@ static bool parse_type_list(rbs_parser_t *parser, enum RBSTokenType eol, rbs_nod type_list_with_commas ::= {} type `,` ... <`,`> eol | {} type `,` ... `,` eol */ -NODISCARD +RBS_NODISCARD static bool parse_type_list_with_commas(rbs_parser_t *parser, enum RBSTokenType eol, rbs_node_list_t *types, rbs_location_range_list_t *comma_locations, bool void_allowed, bool self_allowed, bool classish_allowed) { while (true) { rbs_node_t *type; @@ -349,7 +349,7 @@ static bool is_keyword_token(enum RBSTokenType type) { function_param ::= {} | {} type */ -NODISCARD +RBS_NODISCARD static bool parse_function_param(rbs_parser_t *parser, rbs_types_function_param_t **function_param, bool self_allowed, bool classish_allowed) { rbs_range_t type_range; type_range.start = parser->next_token.range.start; @@ -401,7 +401,7 @@ static rbs_constant_id_t intern_token_start_end(rbs_parser_t *parser, rbs_token_ keyword_key ::= {} `:` | {} keyword <`?`> `:` */ -NODISCARD +RBS_NODISCARD static bool parse_keyword_key(rbs_parser_t *parser, rbs_ast_symbol_t **key) { rbs_parser_advance(parser); @@ -428,7 +428,7 @@ static bool parse_keyword_key(rbs_parser_t *parser, rbs_ast_symbol_t **key) { /* keyword ::= {} keyword `:` */ -NODISCARD +RBS_NODISCARD static bool parse_keyword(rbs_parser_t *parser, rbs_hash_t *keywords, rbs_hash_t *memo, bool self_allowed, bool classish_allowed) { rbs_ast_symbol_t *key = NULL; CHECK_PARSE(parse_keyword_key(parser, &key)); @@ -513,7 +513,7 @@ static bool parser_advance_if(rbs_parser_t *parser, enum RBSTokenType type) { | {} `?` | {} `**` */ -NODISCARD +RBS_NODISCARD static bool parse_params(rbs_parser_t *parser, method_params *params, bool self_allowed, bool classish_allowed) { if (parser->next_token.type == pQUESTION && parser->next_token2.type == pRPAREN) { params->required_positionals = NULL; @@ -680,7 +680,7 @@ static bool parse_params(rbs_parser_t *parser, method_params *params, bool self_ optional ::= {} | {} simple_type <`?`> */ -NODISCARD +RBS_NODISCARD static bool parse_optional(rbs_parser_t *parser, rbs_node_t **optional, bool void_allowed, bool self_allowed, bool classish_allowed) { rbs_range_t rg; rg.start = parser->next_token.range.start; @@ -720,7 +720,7 @@ static void initialize_method_params(method_params *params, rbs_allocator_t *all self_type_binding ::= {} <> | {} `[` `self` `:` type <`]`> */ -NODISCARD +RBS_NODISCARD static bool parse_self_type_binding(rbs_parser_t *parser, rbs_node_t **self_type, bool self_allowed, bool classish_allowed) { if (parser->next_token.type == pLBRACKET) { rbs_parser_advance(parser); @@ -748,7 +748,7 @@ typedef struct { | {} self_type_binding? `{` self_type_binding `->` optional `}` `->` | {} self_type_binding? `->` */ -NODISCARD +RBS_NODISCARD static bool parse_function(rbs_parser_t *parser, bool accept_type_binding, bool block_allowed, parse_function_result **result, bool self_allowed, bool classish_allowed) { rbs_node_t *function = NULL; rbs_types_block_t *block = NULL; @@ -869,7 +869,7 @@ static bool parse_function(rbs_parser_t *parser, bool accept_type_binding, bool /* proc_type ::= {`^`} */ -NODISCARD +RBS_NODISCARD static bool parse_proc_type(rbs_parser_t *parser, rbs_types_proc_t **proc, bool self_allowed, bool classish_allowed) { rbs_position_t start = parser->current_token.range.start; parse_function_result *result = rbs_allocator_alloc(ALLOCATOR(), parse_function_result); @@ -897,7 +897,7 @@ static void check_key_duplication(rbs_parser_t *parser, rbs_hash_t *fields, rbs_ record_attribute ::= {} keyword_token `:` | {} literal_type `=>` */ -NODISCARD +RBS_NODISCARD static bool parse_record_attributes(rbs_parser_t *parser, rbs_hash_t **fields, bool self_allowed, bool classish_allowed) { *fields = rbs_hash_new(ALLOCATOR()); @@ -967,7 +967,7 @@ static bool parse_record_attributes(rbs_parser_t *parser, rbs_hash_t **fields, b /* symbol ::= {} */ -NODISCARD +RBS_NODISCARD static bool parse_symbol(rbs_parser_t *parser, rbs_location_range location, rbs_types_literal_t **symbol) { size_t offset_bytes = parser->lexer->encoding->char_width((const uint8_t *) ":", (size_t) 1); size_t bytes = rbs_token_bytes(parser->current_token) - offset_bytes; @@ -1016,7 +1016,7 @@ static bool parse_symbol(rbs_parser_t *parser, rbs_location_range location, rbs_ type_args ::= {} <> /empty/ | {} `[` type_list <`]`> */ -NODISCARD +RBS_NODISCARD static bool parse_instance_type(rbs_parser_t *parser, bool parse_alias, rbs_node_t **type) { TypeNameKind expected_kind = (TypeNameKind) (INTERFACE_NAME | CLASS_NAME); if (parse_alias) { @@ -1088,7 +1088,7 @@ static bool parse_instance_type(rbs_parser_t *parser, bool parse_alias, rbs_node /* singleton_type ::= {`singleton`} `(` type_name <`)`> type_args? */ -NODISCARD +RBS_NODISCARD static bool parse_singleton_type(rbs_parser_t *parser, rbs_types_class_singleton_t **singleton, bool self_allowed, bool classish_allowed) { ASSERT_TOKEN(parser, kSINGLETON); @@ -1156,7 +1156,7 @@ static bool parser_typevar_member(rbs_parser_t *parser, rbs_constant_id_t id) { | {} `{` record_attributes <`}`> | {} `^` */ -NODISCARD +RBS_NODISCARD static bool parse_simple(rbs_parser_t *parser, rbs_node_t **type, bool void_allowed, bool self_allowed, bool classish_allowed) { rbs_parser_advance(parser); @@ -1352,7 +1352,7 @@ static bool parse_simple(rbs_parser_t *parser, rbs_node_t **type, bool void_allo intersection ::= {} optional `&` ... '&' | {} */ -NODISCARD +RBS_NODISCARD static bool parse_intersection(rbs_parser_t *parser, rbs_node_t **type, bool void_allowed, bool self_allowed, bool classish_allowed) { rbs_range_t rg; rg.start = parser->next_token.range.start; @@ -1427,7 +1427,7 @@ bool rbs_parse_type(rbs_parser_t *parser, rbs_node_t **type, bool void_allowed, type_param ::= tUIDENT upper_bound? lower_bound? default_type? (module_type_params == false) */ -NODISCARD +RBS_NODISCARD static bool parse_type_params(rbs_parser_t *parser, rbs_range_t *rg, bool module_type_params, rbs_node_list_t **params) { *params = rbs_node_list_new(ALLOCATOR()); @@ -1567,7 +1567,7 @@ static bool parse_type_params(rbs_parser_t *parser, rbs_range_t *rg, bool module return true; } -NODISCARD +RBS_NODISCARD static bool parser_pop_typevar_table(rbs_parser_t *parser) { id_table *table; @@ -1590,7 +1590,7 @@ static bool parser_pop_typevar_table(rbs_parser_t *parser) { /* method_type ::= {} type_params */ -// TODO: Should this be NODISCARD? +// TODO: Should this be RBS_NODISCARD? bool rbs_parse_method_type(rbs_parser_t *parser, rbs_method_type_t **method_type, bool require_eof, bool classish_allowed) { rbs_parser_push_typevar_table(parser, false); @@ -1629,7 +1629,7 @@ bool rbs_parse_method_type(rbs_parser_t *parser, rbs_method_type_t **method_type /* global_decl ::= {tGIDENT} `:` */ -NODISCARD +RBS_NODISCARD static bool parse_global_decl(rbs_parser_t *parser, rbs_node_list_t *annotations, rbs_ast_declarations_global_t **global) { rbs_range_t decl_range; decl_range.start = parser->current_token.range.start; @@ -1654,7 +1654,7 @@ static bool parse_global_decl(rbs_parser_t *parser, rbs_node_list_t *annotations /* const_decl ::= {const_name} `:` */ -NODISCARD +RBS_NODISCARD static bool parse_const_decl(rbs_parser_t *parser, rbs_node_list_t *annotations, rbs_ast_declarations_constant_t **constant) { rbs_range_t decl_range; @@ -1680,7 +1680,7 @@ static bool parse_const_decl(rbs_parser_t *parser, rbs_node_list_t *annotations, /* type_decl ::= {kTYPE} alias_name `=` */ -NODISCARD +RBS_NODISCARD static bool parse_type_decl(rbs_parser_t *parser, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_ast_declarations_type_alias_t **typealias) { rbs_parser_push_typevar_table(parser, true); @@ -1720,7 +1720,7 @@ static bool parse_type_decl(rbs_parser_t *parser, rbs_position_t comment_pos, rb /* annotation ::= {} */ -NODISCARD +RBS_NODISCARD static bool parse_annotation(rbs_parser_t *parser, rbs_ast_annotation_t **annotation) { rbs_range_t rg = parser->current_token.range; @@ -1780,7 +1780,7 @@ static bool parse_annotation(rbs_parser_t *parser, rbs_ast_annotation_t **annota annotations ::= {} annotation ... | {<>} */ -NODISCARD +RBS_NODISCARD static bool parse_annotations(rbs_parser_t *parser, rbs_node_list_t *annotations, rbs_position_t *annot_pos) { *annot_pos = NullPosition; @@ -1807,7 +1807,7 @@ static bool parse_annotations(rbs_parser_t *parser, rbs_node_list_t *annotations method_name ::= {} | {} (IDENT | keyword)~<`?`> */ -NODISCARD +RBS_NODISCARD static bool parse_method_name(rbs_parser_t *parser, rbs_range_t *range, rbs_ast_symbol_t **symbol) { rbs_parser_advance(parser); @@ -1925,7 +1925,7 @@ static InstanceSingletonKind parse_instance_singleton_kind(rbs_parser_t *parser, * @param instance_only `true` to reject singleton method definition. * @param accept_overload `true` to accept overloading (...) definition. * */ -NODISCARD +RBS_NODISCARD static bool parse_member_def(rbs_parser_t *parser, bool instance_only, bool accept_overload, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_ast_members_method_definition_t **method_definition) { rbs_range_t member_range; member_range.start = parser->current_token.range.start; @@ -2073,7 +2073,7 @@ static bool parse_member_def(rbs_parser_t *parser, bool instance_only, bool acce * * @param kind * */ -NODISCARD +RBS_NODISCARD static bool class_instance_name(rbs_parser_t *parser, TypeNameKind kind, rbs_node_list_t *args, rbs_range_t *name_range, rbs_range_t *args_range, rbs_type_name_t **name, bool classish_allowed) { rbs_parser_advance(parser); @@ -2101,7 +2101,7 @@ static bool class_instance_name(rbs_parser_t *parser, TypeNameKind kind, rbs_nod * * @param from_interface `true` when the member is in an interface. * */ -NODISCARD +RBS_NODISCARD static bool parse_mixin_member(rbs_parser_t *parser, bool from_interface, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_node_t **mixin_member) { rbs_range_t member_range; member_range.start = parser->current_token.range.start; @@ -2192,7 +2192,7 @@ static bool parse_mixin_member(rbs_parser_t *parser, bool from_interface, rbs_po * * @param[in] instance_only `true` to reject `self.` alias. * */ -NODISCARD +RBS_NODISCARD static bool parse_alias_member(rbs_parser_t *parser, bool instance_only, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_ast_members_alias_t **alias_member) { rbs_range_t member_range; member_range.start = parser->current_token.range.start; @@ -2243,7 +2243,7 @@ static bool parse_alias_member(rbs_parser_t *parser, bool instance_only, rbs_pos | {kSELF} `.` tAIDENT `:` | {tA2IDENT} `:` */ -NODISCARD +RBS_NODISCARD static bool parse_variable_member(rbs_parser_t *parser, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_node_t **variable_member) { if (annotations->length > 0) { rbs_parser_set_error(parser, parser->current_token, true, "annotation cannot be given to variable members"); @@ -2347,7 +2347,7 @@ static bool parse_variable_member(rbs_parser_t *parser, rbs_position_t comment_p visibility_member ::= {<`public`>} | {<`private`>} */ -NODISCARD +RBS_NODISCARD static bool parse_visibility_member(rbs_parser_t *parser, rbs_node_list_t *annotations, rbs_node_t **visibility_member) { if (annotations->length > 0) { rbs_parser_set_error(parser, parser->current_token, true, "annotation cannot be given to visibility members"); @@ -2385,7 +2385,7 @@ static bool parse_visibility_member(rbs_parser_t *parser, rbs_node_list_t *annot | `(` tAIDENT `)` # Ivar name | `(` `)` # No variable */ -NODISCARD +RBS_NODISCARD static bool parse_attribute_member(rbs_parser_t *parser, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_node_t **attribute_member) { rbs_range_t member_range; @@ -2514,7 +2514,7 @@ static bool parse_attribute_member(rbs_parser_t *parser, rbs_position_t comment_ | mixin_member (interface only) | alias_member (instance only) */ -NODISCARD +RBS_NODISCARD static bool parse_interface_members(rbs_parser_t *parser, rbs_node_list_t **members) { *members = rbs_node_list_new(ALLOCATOR()); @@ -2562,7 +2562,7 @@ static bool parse_interface_members(rbs_parser_t *parser, rbs_node_list_t **memb /* interface_decl ::= {`interface`} interface_name module_type_params interface_members */ -NODISCARD +RBS_NODISCARD static bool parse_interface_decl(rbs_parser_t *parser, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_ast_declarations_interface_t **interface_decl) { rbs_parser_push_typevar_table(parser, true); @@ -2606,7 +2606,7 @@ static bool parse_interface_decl(rbs_parser_t *parser, rbs_position_t comment_po module_self_type ::= | module_name `[` type_list <`]`> */ -NODISCARD +RBS_NODISCARD static bool parse_module_self_types(rbs_parser_t *parser, rbs_node_list_t *array) { while (true) { rbs_parser_advance(parser); @@ -2645,7 +2645,7 @@ static bool parse_module_self_types(rbs_parser_t *parser, rbs_node_list_t *array return true; } -NODISCARD +RBS_NODISCARD static bool parse_nested_decl(rbs_parser_t *parser, const char *nested_in, rbs_position_t annot_pos, rbs_node_list_t *annotations, rbs_node_t **decl); /* @@ -2659,7 +2659,7 @@ static bool parse_nested_decl(rbs_parser_t *parser, const char *nested_in, rbs_p | `public` | `private` */ -NODISCARD +RBS_NODISCARD static bool parse_module_members(rbs_parser_t *parser, rbs_node_list_t **members) { *members = rbs_node_list_new(ALLOCATOR()); @@ -2746,7 +2746,7 @@ static bool parse_module_members(rbs_parser_t *parser, rbs_node_list_t **members module_decl ::= {module_name} module_type_params module_members | {module_name} module_name module_type_params `:` module_self_types module_members */ -NODISCARD +RBS_NODISCARD static bool parse_module_decl0(rbs_parser_t *parser, rbs_range_t keyword_range, rbs_type_name_t *module_name, rbs_range_t name_range, rbs_ast_comment_t *comment, rbs_node_list_t *annotations, rbs_ast_declarations_module_t **module_decl) { rbs_parser_push_typevar_table(parser, true); @@ -2794,7 +2794,7 @@ static bool parse_module_decl0(rbs_parser_t *parser, rbs_range_t keyword_range, | {`module`} module_name module_decl0 */ -NODISCARD +RBS_NODISCARD static bool parse_module_decl(rbs_parser_t *parser, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_node_t **module_decl) { rbs_range_t keyword_range = parser->current_token.range; @@ -2837,7 +2837,7 @@ static bool parse_module_decl(rbs_parser_t *parser, rbs_position_t comment_pos, class_decl_super ::= {} `<` | {<>} */ -NODISCARD +RBS_NODISCARD static bool parse_class_decl_super(rbs_parser_t *parser, rbs_range_t *lt_range, rbs_ast_declarations_class_super_t **super) { if (parser_advance_if(parser, pLT)) { *lt_range = parser->current_token.range; @@ -2865,7 +2865,7 @@ static bool parse_class_decl_super(rbs_parser_t *parser, rbs_range_t *lt_range, /* class_decl ::= {class_name} type_params class_decl_super class_members <`end`> */ -NODISCARD +RBS_NODISCARD static bool parse_class_decl0(rbs_parser_t *parser, rbs_range_t keyword_range, rbs_type_name_t *name, rbs_range_t name_range, rbs_ast_comment_t *comment, rbs_node_list_t *annotations, rbs_ast_declarations_class_t **class_decl) { rbs_parser_push_typevar_table(parser, true); @@ -2904,7 +2904,7 @@ static bool parse_class_decl0(rbs_parser_t *parser, rbs_range_t keyword_range, r class_decl ::= {`class`} class_name `=` | {`class`} class_name */ -NODISCARD +RBS_NODISCARD static bool parse_class_decl(rbs_parser_t *parser, rbs_position_t comment_pos, rbs_node_list_t *annotations, rbs_node_t **class_decl) { rbs_range_t keyword_range = parser->current_token.range; @@ -2949,7 +2949,7 @@ static bool parse_class_decl(rbs_parser_t *parser, rbs_position_t comment_pos, r | {} | {} */ -NODISCARD +RBS_NODISCARD static bool parse_nested_decl(rbs_parser_t *parser, const char *nested_in, rbs_position_t annot_pos, rbs_node_list_t *annotations, rbs_node_t **decl) { rbs_parser_push_typevar_table(parser, true); @@ -3001,7 +3001,7 @@ static bool parse_nested_decl(rbs_parser_t *parser, const char *nested_in, rbs_p return true; } -NODISCARD +RBS_NODISCARD static bool parse_decl(rbs_parser_t *parser, rbs_node_t **decl) { rbs_node_list_t *annotations = rbs_node_list_new(ALLOCATOR()); rbs_position_t annot_pos = NullPosition; @@ -3057,7 +3057,7 @@ static bool parse_decl(rbs_parser_t *parser, rbs_node_t **decl) { namespace ::= {} (`::`)? (`tUIDENT` `::`)* `tUIDENT` <`::`> | {} <> (empty -- returns empty namespace) */ -NODISCARD +RBS_NODISCARD static bool parse_namespace(rbs_parser_t *parser, rbs_range_t *rg, rbs_namespace_t **out_ns) { bool is_absolute = false; @@ -3100,7 +3100,7 @@ static bool parse_namespace(rbs_parser_t *parser, rbs_range_t *rg, rbs_namespace | {} namespace tUIDENT `as` | {} namespace */ -NODISCARD +RBS_NODISCARD static bool parse_use_clauses(rbs_parser_t *parser, rbs_node_list_t *clauses) { while (true) { rbs_range_t namespace_range = NULL_RANGE; @@ -3181,7 +3181,7 @@ static bool parse_use_clauses(rbs_parser_t *parser, rbs_node_list_t *clauses) { /* use_directive ::= {} `use` */ -NODISCARD +RBS_NODISCARD static bool parse_use_directive(rbs_parser_t *parser, rbs_ast_directives_use_t **use_directive) { if (parser->next_token.type == kUSE) { rbs_parser_advance(parser); @@ -3399,7 +3399,7 @@ void rbs_parser_push_typevar_table(rbs_parser_t *parser, bool reset) { parser->vars = table; } -NODISCARD +RBS_NODISCARD bool rbs_parser_insert_typevar(rbs_parser_t *parser, rbs_constant_id_t id) { id_table *table = parser->vars; @@ -3605,7 +3605,7 @@ void rbs_parser_set_error(rbs_parser_t *parser, rbs_token_t tok, bool syntax_err /* parse_method_overload ::= {} annotations */ -NODISCARD +RBS_NODISCARD static bool parse_method_overload(rbs_parser_t *parser, rbs_node_list_t *annotations, rbs_method_type_t **method_type) { rbs_position_t pos = NullPosition; @@ -3622,7 +3622,7 @@ static bool parse_method_overload(rbs_parser_t *parser, rbs_node_list_t *annotat | {} overload `|` ... `|` `...` -- returns true (dot3_location is set) | {<>} -- returns false */ -NODISCARD +RBS_NODISCARD static bool parse_inline_method_overloads(rbs_parser_t *parser, rbs_node_list_t *overloads, rbs_location_range_list_t *bar_locations, rbs_location_range *dot3_location) { while (true) { rbs_node_list_t *annotations = rbs_node_list_new(ALLOCATOR()); @@ -3661,7 +3661,7 @@ static bool parse_inline_method_overloads(rbs_parser_t *parser, rbs_node_list_t } } -NODISCARD +RBS_NODISCARD static bool parse_inline_comment(rbs_parser_t *parser, rbs_location_range *comment_range) { if (parser->next_token.type != tINLINECOMMENT) { *comment_range = RBS_LOCATION_NULL_RANGE; @@ -3674,7 +3674,7 @@ static bool parse_inline_comment(rbs_parser_t *parser, rbs_location_range *comme return true; } -NODISCARD +RBS_NODISCARD static bool parse_inline_param_type_annotation(rbs_parser_t *parser, rbs_ast_ruby_annotations_t **annotation, rbs_range_t rbs_range) { rbs_parser_advance(parser); @@ -3714,7 +3714,7 @@ static bool parse_inline_param_type_annotation(rbs_parser_t *parser, rbs_ast_rub return true; } -NODISCARD +RBS_NODISCARD static bool parse_inline_leading_annotation(rbs_parser_t *parser, rbs_ast_ruby_annotations_t **annotation) { switch (parser->next_token.type) { case pCOLON: { @@ -4118,7 +4118,7 @@ static bool parse_inline_leading_annotation(rbs_parser_t *parser, rbs_ast_ruby_a } } -NODISCARD +RBS_NODISCARD static bool parse_inline_trailing_annotation(rbs_parser_t *parser, rbs_ast_ruby_annotations_t **annotation) { rbs_range_t prefix_range = parser->next_token.range; From 84f4fc28c291d018ddc630b9e99aa09b08dd0c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 14:40:59 +0000 Subject: [PATCH 162/163] Build the wasm module with -DNDEBUG `rake wasm:build` compiled the parser without `NDEBUG`, so every `RBS_ASSERT` survived into `rbs_parser.wasm`. The MRI extension already drops them (ext/rbs_extension/extconf.rb), and the assertions that matter sit on the hottest paths there are: two per character in `rbs_encoding.c`, one per character in `rbs_skip`, and five around the constant pool. Each one is an out-of-line varargs call to `rbs_assert_impl`, which the compiler cannot inline away, so the cost is paid on every character lexed. Parsing core/ + stdlib/ (250 files, 4.3MB) through a WASI host, best of 10: assert build 75.7 ms NDEBUG build 61.9 ms (-18%) Measured the same on wasmtime and on V8. The serialized ASTs for all 250 files are byte-identical between the two builds. `DEBUG=1 rake wasm:build` keeps the assertions, matching the DEBUG convention extconf.rb and the prepare_bench / prepare_profiling tasks already use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012uu4EqN6azBeVjM9F71TUp --- Rakefile | 7 +++++++ wasm/README.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/Rakefile b/Rakefile index 98cfe8d389..387533ed23 100644 --- a/Rakefile +++ b/Rakefile @@ -926,6 +926,12 @@ namespace :wasm do desc "Build the RBS parser as a WebAssembly module (requires WASI_SDK_PATH)" task :build do + # `-DNDEBUG` compiles out `RBS_ASSERT`, the same way ext/rbs_extension does + # for the MRI extension. The assertions sit in the lexer and the constant + # pool, so keeping them costs about 20% of parse time; set `DEBUG=1` to keep + # them when debugging the module itself. + debug_flags = ENV["DEBUG"] ? [] : ["-DNDEBUG"] + mkdir_p WASM_DIR sh wasi_clang, "--target=wasm32-wasip1", @@ -933,6 +939,7 @@ namespace :wasm do "-mexec-model=reactor", "-std=gnu11", "-O2", + *debug_flags, "-Wno-unused-parameter", "-I#{File.join(__dir__, "include")}", "-o", WASM_OUTPUT, diff --git a/wasm/README.md b/wasm/README.md index c1993b19fd..174a56a850 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -27,6 +27,11 @@ $ rake wasm:install_jars # download the Chicory/ASM jars into ~/.m2 (run on JRub The compiled `rbs_parser.wasm` is a build artifact and is not checked in. +Like the MRI extension, the module is compiled with `-DNDEBUG`, which removes the +`RBS_ASSERT` checks — they sit in the lexer and the constant pool, so leaving them +in costs around 20% of parse time. `DEBUG=1 rake wasm:build` keeps them, which is +what you want when debugging the parser itself through the module. + The WASI SDK is needed for the *build*, not for running the result — the host clang already knows the `wasm32` target, but there is no wasm32 libc on a normal machine, so it picks up the host headers and fails on the first `#include`. That is what the SDK supplies, along with the From 6685d3505e218cfcff6d9f3fe21a65bbe8f0202a Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Mon, 10 Aug 2026 11:04:24 +0900 Subject: [PATCH 163/163] Update Rust parser to RBS 4.1.2 --- rust/rbs_version | 2 +- rust/ruby-rbs/src/ast/convert.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/rbs_version b/rust/rbs_version index bda368d509..7d5da2ac88 100644 --- a/rust/rbs_version +++ b/rust/rbs_version @@ -1 +1 @@ -v4.0.2 +v4.1.2 diff --git a/rust/ruby-rbs/src/ast/convert.rs b/rust/ruby-rbs/src/ast/convert.rs index 628f0720b3..163f4b7004 100644 --- a/rust/ruby-rbs/src/ast/convert.rs +++ b/rust/ruby-rbs/src/ast/convert.rs @@ -1212,6 +1212,7 @@ fn node_kind(node: &Node<'_>) -> &'static str { Node::InstanceVariableAnnotation(_) => "InstanceVariableAnnotation", Node::MethodTypesAnnotation(_) => "MethodTypesAnnotation", Node::ModuleAliasAnnotation(_) => "ModuleAliasAnnotation", + Node::ModuleSelfAnnotation(_) => "ModuleSelfAnnotation", Node::NodeTypeAssertion(_) => "NodeTypeAssertion", Node::ParamTypeAnnotation(_) => "ParamTypeAnnotation", Node::ReturnTypeAnnotation(_) => "ReturnTypeAnnotation",