From 8c64365e447e7c2edff3ad8705ea5b9ed89a93d5 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 17:09:07 -0400 Subject: [PATCH 1/6] Exclude content-addressable versions from the legacy Marshal indexes Multiple Ruby ABI variants of a gem share a number and platform, so they produced duplicate rows in specs.4.8.gz and latest_specs.4.8.gz. Legacy clients also cannot install them: the required rubygems version floor rejects them and their download path is content-addressed rather than platform-based, so resolving the legacy index entry would 404. --- app/models/version.rb | 3 +++ test/models/version_test.rb | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/app/models/version.rb b/app/models/version.rb index e1ff820187f..0a25983f9c5 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -147,6 +147,7 @@ def self.rows_for_index joins(:rubygem) .indexed .release + .where(ruby_abi: nil) .order("rubygems.name asc, position desc") .pluck("rubygems.name", :number, :platform) end @@ -155,6 +156,7 @@ def self.rows_for_latest_index joins(:rubygem) .indexed .latest + .where(ruby_abi: nil) .order("rubygems.name asc, position desc") .pluck("rubygems.name", :number, :platform) end @@ -163,6 +165,7 @@ def self.rows_for_prerelease_index joins(:rubygem) .indexed .prerelease + .where(ruby_abi: nil) .order("rubygems.name asc, position desc") .pluck("rubygems.name", :number, :platform) end diff --git a/test/models/version_test.rb b/test/models/version_test.rb index 8d89b2aea97..297b899c6d5 100644 --- a/test/models/version_test.rb +++ b/test/models/version_test.rb @@ -1342,6 +1342,14 @@ class VersionTest < ActiveSupport::TestCase number: "0.0.2.pre", platform: "java", prerelease: true) + @content_addressable_version = create(:version, + rubygem: @second_rubygem, + number: "0.0.2", + platform: "x86_64-linux-musl", + gem_platform: "x86_64-linux-musl", + required_ruby_version: "~> 3.4.0", + ruby_abi: "3.4", + sha256: Digest::SHA2.base64digest("second-0.0.2-x86_64-linux-musl")) end should "select only name, version, and platform for all gems" do From ed01e2d769519c16acf69a3e798247d56c988e39 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 17:09:26 -0400 Subject: [PATCH 2/6] Expose ruby_abi in the version and rubygem payloads Multiple Ruby ABI variants of a version share a number and platform, so API consumers and webhook receivers could not tell them apart. Include ruby_abi in the version payload (/api/v1/versions and the v2 version endpoint) and in the rubygem payload, which feeds /api/v1/gems and the push and yank webhooks. --- app/models/rubygem.rb | 1 + app/models/version.rb | 1 + test/functional/api/v2/versions_controller_test.rb | 2 +- test/models/rubygem_test.rb | 1 + test/models/version_test.rb | 4 ++-- 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb index 85550542743..1311b7953e0 100644 --- a/app/models/rubygem.rb +++ b/app/models/rubygem.rb @@ -263,6 +263,7 @@ def payload(version = most_recent_version, protocol = Gemcutter::PROTOCOL, host_ "version_created_at" => version.created_at, "version_downloads" => version.downloads_count, "platform" => version.platform, + "ruby_abi" => version.ruby_abi, "authors" => version.authors, "info" => version.info, "licenses" => version.licenses, diff --git a/app/models/version.rb b/app/models/version.rb index 0a25983f9c5..a26f0c51e0e 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -370,6 +370,7 @@ def payload "rubygems_version" => required_rubygems_version, "ruby_version" => required_ruby_version, "prerelease" => prerelease, + "ruby_abi" => ruby_abi, "licenses" => licenses, "requirements" => requirements, "sha" => sha256_hex, diff --git a/test/functional/api/v2/versions_controller_test.rb b/test/functional/api/v2/versions_controller_test.rb index ad12aebf750..c31b7a8a4b3 100644 --- a/test/functional/api/v2/versions_controller_test.rb +++ b/test/functional/api/v2/versions_controller_test.rb @@ -219,7 +219,7 @@ def self.should_respond_to(format) assert_equal( %w[ name downloads version version_created_at version_downloads platform - authors info licenses metadata yanked sha spec_sha project_uri gem_uri + ruby_abi authors info licenses metadata yanked sha spec_sha project_uri gem_uri homepage_uri wiki_uri documentation_uri mailing_list_uri source_code_uri bug_tracker_uri changelog_uri funding_uri dependencies built_at created_at description downloads_count number summary diff --git a/test/models/rubygem_test.rb b/test/models/rubygem_test.rb index e7ebaa25ab4..bf7b3b4e34d 100644 --- a/test/models/rubygem_test.rb +++ b/test/models/rubygem_test.rb @@ -550,6 +550,7 @@ class RubygemTest < ActiveSupport::TestCase assert_equal @rubygem.most_recent_version.created_at.as_json, hash["version_created_at"] assert_equal @rubygem.most_recent_version.downloads_count, hash["version_downloads"] assert_equal @rubygem.most_recent_version.platform, hash["platform"] + assert_nil hash.fetch("ruby_abi") assert_equal @rubygem.most_recent_version.authors, hash["authors"] assert_equal @rubygem.most_recent_version.info, hash["info"] assert_equal @rubygem.most_recent_version.metadata, hash["metadata"] diff --git a/test/models/version_test.rb b/test/models/version_test.rb index 297b899c6d5..e7a9fdaa901 100644 --- a/test/models/version_test.rb +++ b/test/models/version_test.rb @@ -15,7 +15,7 @@ class VersionTest < ActiveSupport::TestCase json = @version.as_json fields = %w[number built_at summary description authors platform - ruby_version rubygems_version prerelease downloads_count licenses + ruby_version rubygems_version prerelease ruby_abi downloads_count licenses requirements sha spec_sha metadata created_at] assert_equal fields.sort, json.keys.sort @@ -44,7 +44,7 @@ class VersionTest < ActiveSupport::TestCase should "only have relevant API fields" do xml = Nokogiri.parse(@version.to_xml) fields = %w[number built-at summary description authors platform - ruby-version rubygems-version prerelease downloads-count licenses + ruby-version rubygems-version prerelease ruby-abi downloads-count licenses requirements sha spec-sha metadata created-at] assert_equal fields.map(&:to_s).sort, From 412cce4696bded20c3ba69a816b06d8619d19f4c Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 17:09:44 -0400 Subject: [PATCH 3/6] Validate the ruby_abi format ruby_abi is derived server-side so the push path can only produce X.Y values, but nothing stopped console sessions or backfills from persisting arbitrary strings that would flow into the compact index and version identities. Validate the format like sha256 and content_address. --- app/models/version.rb | 1 + test/models/version_test.rb | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/app/models/version.rb b/app/models/version.rb index a26f0c51e0e..c792a5ca065 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -56,6 +56,7 @@ class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength validates :sha256, presence: true, if: :content_addressable? validates :content_address, format: { with: CONTENT_ADDRESS_FORMAT }, allow_nil: true validates :content_address, absence: true, unless: :content_addressable? + validates :ruby_abi, format: { with: /\A\d+\.\d+\z/ }, allow_nil: true validates :number, :platform, :gem_platform, :full_name, :gem_full_name, :canonical_number, name_format: { requires_letter: false }, diff --git a/test/models/version_test.rb b/test/models/version_test.rb index e7a9fdaa901..975dfcad038 100644 --- a/test/models/version_test.rb +++ b/test/models/version_test.rb @@ -159,6 +159,15 @@ class VersionTest < ActiveSupport::TestCase refute_predicate @dup_version, :valid? end + should "not allow a ruby_abi that is not a Ruby minor version" do + version = build(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux", gem_platform: "x86_64-linux", + required_ruby_version: "~> 3.4.0", ruby_abi: "banana", + sha256: Digest::SHA2.base64digest("abi-format-1.0.0")) + + refute_predicate version, :valid? + assert_includes version.errors[:ruby_abi], "is invalid" + end + should "allow duplicate versions with different Ruby ABIs" do existing_version = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "arm64-darwin-25", required_ruby_version: "~> 3.3.0", ruby_abi: "3.3") From 8b4a9a4a7e7f529ffbe2824861d2f34dc6333685 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 17:19:37 -0400 Subject: [PATCH 4/6] Allow resolving a specific Ruby ABI variant in the v2 API Rubygem#find_public_version resolved by number and platform only, so the v2 version and contents endpoints returned an arbitrary variant when multiple Ruby ABI builds of a gem coexist. Accept a ruby_abi param, scoped to nil by default so existing lookups are unchanged, matching the deletions API. --- app/controllers/api/v2/contents_controller.rb | 4 +- app/controllers/api/v2/versions_controller.rb | 4 +- app/models/rubygem.rb | 8 ++-- .../api/v2/versions_controller_test.rb | 39 +++++++++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/v2/contents_controller.rb b/app/controllers/api/v2/contents_controller.rb index 103765b64b4..ef7525b7a12 100644 --- a/app/controllers/api/v2/contents_controller.rb +++ b/app/controllers/api/v2/contents_controller.rb @@ -24,8 +24,8 @@ def index protected def find_version - version_params = params.permit(:version_number, :platform) - @version = @rubygem.find_public_version(version_params[:version_number], version_params[:platform]) + version_params = params.permit(:version_number, :platform, :ruby_abi) + @version = @rubygem.find_public_version(version_params[:version_number], version_params[:platform], version_params[:ruby_abi].presence) render plain: "This version could not be found.", status: :not_found unless @version end diff --git a/app/controllers/api/v2/versions_controller.rb b/app/controllers/api/v2/versions_controller.rb index f006a16f6dc..eea778d9d48 100644 --- a/app/controllers/api/v2/versions_controller.rb +++ b/app/controllers/api/v2/versions_controller.rb @@ -8,7 +8,7 @@ def show cache_expiry_headers set_surrogate_key "gem/#{@rubygem.name}" - version = @rubygem.public_version_payload(version_params[:number], version_params[:platform]) + version = @rubygem.public_version_payload(version_params[:number], version_params[:platform], version_params[:ruby_abi].presence) if version respond_to do |format| format.json { render json: version } @@ -22,6 +22,6 @@ def show protected def version_params - params.permit(:platform, :number) + params.permit(:platform, :number, :ruby_abi) end end diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb index 1311b7953e0..3aa185c67b7 100644 --- a/app/models/rubygem.rb +++ b/app/models/rubygem.rb @@ -186,16 +186,16 @@ def public_versions_with_extra_version(extra_version) # NB: this intentionally does not default the platform to ruby. # Without platform, finds the most recent version by (position, created_at) ignoring platform. - def find_public_version(number, platform = nil) + def find_public_version(number, platform = nil, ruby_abi = nil) if platform - public_versions.find_by(number:, platform:) + public_versions.find_by(number:, platform:, ruby_abi:) else public_versions.find_by(number:) end end - def public_version_payload(number, platform = nil) - version = find_public_version(number, platform) + def public_version_payload(number, platform = nil, ruby_abi = nil) + version = find_public_version(number, platform, ruby_abi) payload(version).merge!(version.as_json) if version end diff --git a/test/functional/api/v2/versions_controller_test.rb b/test/functional/api/v2/versions_controller_test.rb index c31b7a8a4b3..80346e1ce0a 100644 --- a/test/functional/api/v2/versions_controller_test.rb +++ b/test/functional/api/v2/versions_controller_test.rb @@ -68,6 +68,45 @@ def self.should_respond_to(format) create(:version, rubygem: @rubygem2, number: "1.0.0") end + context "with versions across Ruby ABIs" do + setup do + @rubygem = create(:rubygem, name: "sandworm") + @abi32 = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux-musl", gem_platform: "x86_64-linux-musl", + required_ruby_version: "~> 3.2.0", ruby_abi: "3.2", sha256: Digest::SHA2.base64digest("sandworm-1.0.0-3.2")) + @abi34 = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux-musl", gem_platform: "x86_64-linux-musl", + required_ruby_version: "~> 3.4.0", ruby_abi: "3.4", sha256: Digest::SHA2.base64digest("sandworm-1.0.0-3.4")) + @multi = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux-musl", gem_platform: "x86_64-linux-musl", + required_ruby_version: ">= 3.2", sha256: Digest::SHA2.base64digest("sandworm-1.0.0-multi"), + spec_sha256: Digest::SHA2.base64digest("spec-multi")) + end + + should "return the targeted Ruby ABI variant when ruby_abi is given" do + get :show, params: { rubygem_name: @rubygem.name, number: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "3.2", format: "json" } + + assert_response :success + payload = JSON.parse(@response.body) + + assert_equal "3.2", payload["ruby_abi"] + assert_equal @abi32.sha256_hex, payload["sha"] + end + + should "return the version supporting multiple Ruby ABIs without a ruby_abi param" do + get :show, params: { rubygem_name: @rubygem.name, number: "1.0.0", platform: "x86_64-linux-musl", format: "json" } + + assert_response :success + payload = JSON.parse(@response.body) + + assert_nil payload["ruby_abi"] + assert_equal @multi.sha256_hex, payload["sha"] + end + + should "return not found for a ruby_abi that does not match any variant" do + get :show, params: { rubygem_name: @rubygem.name, number: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "3.3", format: "json" } + + assert_response :not_found + end + end + should_respond_to(:json) do |body| JSON.parse(body) end From 667b3a1a2af956fb73c7c5e7602c1d9500d20e63 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 17:25:18 -0400 Subject: [PATCH 5/6] Record the Ruby ABI on the pushed version event The yank, unyank and yank-forbidden events carry the Ruby ABI so variants sharing a number and platform can be told apart, but the pushed event only had the sha256 to distinguish them. Record the ABI there too for symmetric auditing. --- app/models/events/rubygem_event.rb | 1 + app/models/version.rb | 4 ++-- test/models/version_test.rb | 11 +++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/models/events/rubygem_event.rb b/app/models/events/rubygem_event.rb index 387938f187e..d796ccd525d 100644 --- a/app/models/events/rubygem_event.rb +++ b/app/models/events/rubygem_event.rb @@ -8,6 +8,7 @@ class Events::RubygemEvent < ApplicationRecord VERSION_PUSHED = define_event "rubygem:version:pushed" do attribute :number, :string attribute :platform, :string + attribute :ruby_abi, :string attribute :sha256, :string attribute :pushed_by, :string diff --git a/app/models/version.rb b/app/models/version.rb index c792a5ca065..e2e35dd365d 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -661,8 +661,8 @@ def create_link_verifications end def record_push_event - rubygem.record_event!(Events::RubygemEvent::VERSION_PUSHED, number: number, platform: platform, sha256: sha256_hex, - pushed_by: pusher&.display_handle, version_gid: to_gid, actor_gid: pusher&.to_gid) + rubygem.record_event!(Events::RubygemEvent::VERSION_PUSHED, number: number, platform: platform, ruby_abi: ruby_abi, + sha256: sha256_hex, pushed_by: pusher&.display_handle, version_gid: to_gid, actor_gid: pusher&.to_gid) end def enqueue_web_hook_jobs diff --git a/test/models/version_test.rb b/test/models/version_test.rb index 975dfcad038..6e7e5688dd6 100644 --- a/test/models/version_test.rb +++ b/test/models/version_test.rb @@ -159,6 +159,17 @@ class VersionTest < ActiveSupport::TestCase refute_predicate @dup_version, :valid? end + should "record the Ruby ABI on the pushed version event" do + version = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux", gem_platform: "x86_64-linux", + required_ruby_version: "~> 3.4.0", ruby_abi: "3.4", + sha256: Digest::SHA2.base64digest("abi-event-1.0.0")) + + pushed = @rubygem.events.where(tag: Events::RubygemEvent::VERSION_PUSHED).sole + + assert_equal "3.4", pushed.additional.ruby_abi + assert_equal version.sha256_hex, pushed.additional.sha256 + end + should "not allow a ruby_abi that is not a Ruby minor version" do version = build(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux", gem_platform: "x86_64-linux", required_ruby_version: "~> 3.4.0", ruby_abi: "banana", From 723bcd8f63928aa7bdfc89c8b2a519b525ee85ee Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 17:25:18 -0400 Subject: [PATCH 6/6] Display the Ruby ABI for deleted versions in events and Avo Version event rows link to the version and render to_title, which includes the Ruby ABI, but the fallback text for hard-deleted versions was built from number and platform only, making yanked ABI variants indistinguishable in the gem history. Include the ABI in the fallback, and surface ruby_abi and content_address on the Avo version resource. --- app/avo/resources/version.rb | 2 ++ .../events/rubygem_event/version/pushed_component.rb | 2 +- .../events/rubygem_event/version/unyanked_component.rb | 2 +- .../rubygem_event/version/yank_forbidden_component.rb | 2 +- .../events/rubygem_event/version/yanked_component.rb | 2 +- app/views/components/events/table_details_component.rb | 7 +++++-- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/avo/resources/version.rb b/app/avo/resources/version.rb index 36ba2a78871..ac84d04eafe 100644 --- a/app/avo/resources/version.rb +++ b/app/avo/resources/version.rb @@ -34,6 +34,8 @@ def fields # rubocop:disable Metrics field :slug, as: :text, hide_on: :index field :number, as: :text field :platform, as: :text + field :ruby_abi, as: :text, title: "Ruby ABI" + field :content_address, as: :text field :canonical_number, as: :text diff --git a/app/views/components/events/rubygem_event/version/pushed_component.rb b/app/views/components/events/rubygem_event/version/pushed_component.rb index 695e8f567a3..7b7b340abfd 100644 --- a/app/views/components/events/rubygem_event/version/pushed_component.rb +++ b/app/views/components/events/rubygem_event/version/pushed_component.rb @@ -6,7 +6,7 @@ class Events::RubygemEvent::Version::PushedComponent < Events::TableDetailsCompo def view_template div do t(".version_html", version: - link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + link_to_version_from_gid(additional.version_gid, additional.number, additional.platform, additional.ruby_abi)) end if additional.sha256.present? sha256 = capture { code(class: "tw-break-all") { additional.sha256 } } diff --git a/app/views/components/events/rubygem_event/version/unyanked_component.rb b/app/views/components/events/rubygem_event/version/unyanked_component.rb index 82f9865fb73..fd002b96ab5 100644 --- a/app/views/components/events/rubygem_event/version/unyanked_component.rb +++ b/app/views/components/events/rubygem_event/version/unyanked_component.rb @@ -2,6 +2,6 @@ class Events::RubygemEvent::Version::UnyankedComponent < Events::TableDetailsComponent def view_template - raw t(".version_html", version: link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + raw t(".version_html", version: link_to_version_from_gid(additional.version_gid, additional.number, additional.platform, additional.ruby_abi)) end end diff --git a/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb b/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb index 41eff628651..bedccb7eca0 100644 --- a/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb +++ b/app/views/components/events/rubygem_event/version/yank_forbidden_component.rb @@ -4,7 +4,7 @@ class Events::RubygemEvent::Version::YankForbiddenComponent < Events::TableDetai def view_template div do t(".version_html", version: - link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + link_to_version_from_gid(additional.version_gid, additional.number, additional.platform, additional.ruby_abi)) end return if additional.yanked_by.blank? div do diff --git a/app/views/components/events/rubygem_event/version/yanked_component.rb b/app/views/components/events/rubygem_event/version/yanked_component.rb index 8e6d0984e3e..8a0bed5afd8 100644 --- a/app/views/components/events/rubygem_event/version/yanked_component.rb +++ b/app/views/components/events/rubygem_event/version/yanked_component.rb @@ -4,7 +4,7 @@ class Events::RubygemEvent::Version::YankedComponent < Events::TableDetailsCompo def view_template div do t(".version_html", version: - link_to_version_from_gid(additional.version_gid, additional.number, additional.platform)) + link_to_version_from_gid(additional.version_gid, additional.number, additional.platform, additional.ruby_abi)) end return if additional.yanked_by.blank? div do diff --git a/app/views/components/events/table_details_component.rb b/app/views/components/events/table_details_component.rb index ec23cc410f1..c0935ee5b5a 100644 --- a/app/views/components/events/table_details_component.rb +++ b/app/views/components/events/table_details_component.rb @@ -26,13 +26,16 @@ def link_to_user_from_gid(gid, text) end end - def link_to_version_from_gid(gid, number, platform) + def link_to_version_from_gid(gid, number, platform, ruby_abi = nil) version = load_gid(gid, only: Version) if version view_context.link_to version.to_title, rubygem_version_path(version.rubygem.slug, version.slug) else - "#{rubygem.name} (#{number}#{"-#{platform}" unless platform.blank? || platform == 'ruby'})" + title = "#{rubygem.name} (#{number}#{"-#{platform}" unless platform.blank? || platform == 'ruby'}" + title << ", Ruby ABI #{ruby_abi}" if ruby_abi.present? + title << ")" + title end end