From 61d16f5a9e6a3084438978a55ffd24a12b9cbded Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Sat, 8 Aug 2026 23:06:04 -0400 Subject: [PATCH 1/3] Allow yanking a specific Ruby ABI variant Multiple content-addressable versions can share a number and platform, one per Ruby ABI, so resolving a deletion by number and platform alone matched an arbitrary variant. find_version! now scopes on ruby_abi (nil by default, preserving behavior for all existing versions) and the deletions API accepts a ruby_abi param to target a variant. Without the param, yanks against ABI variants return 404 rather than deleting an arbitrary one. The gem yank CLI needs a matching option to pass ruby_abi. --- .../api/v1/deletions_controller.rb | 12 +- app/models/rubygem.rb | 4 +- .../api/v1/deletions_controller_test.rb | 125 ++++++++++++++++++ test/models/rubygem_test.rb | 13 ++ 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/v1/deletions_controller.rb b/app/controllers/api/v1/deletions_controller.rb index 9f4ad880547..e2d242c5831 100644 --- a/app/controllers/api/v1/deletions_controller.rb +++ b/app/controllers/api/v1/deletions_controller.rb @@ -40,9 +40,17 @@ def validate_gem_and_version begin version = params.expect(:version) platform = params.permit(:platform).fetch(:platform, nil) - @version = @rubygem.find_version!(number: version, platform: platform) + ruby_abi = params.permit(:ruby_abi).fetch(:ruby_abi, nil).presence + + if ruby_abi.present? && platform.blank? + return render plain: response_with_mfa_warning("The platform param is required when ruby_abi is specified."), + status: :bad_request + end + + @version = @rubygem.find_version!(number: version, platform: platform, ruby_abi: ruby_abi) rescue ActiveRecord::RecordNotFound - render plain: response_with_mfa_warning("The version #{version}#{" (#{platform})" if platform.present?} does not exist."), + details = "#{" (#{platform})" if platform.present?}#{" (Ruby ABI #{ruby_abi})" if ruby_abi.present?}" + render plain: response_with_mfa_warning("The version #{version}#{details} does not exist."), status: :not_found end end diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb index 0ac55bc01a7..85550542743 100644 --- a/app/models/rubygem.rb +++ b/app/models/rubygem.rb @@ -199,9 +199,9 @@ def public_version_payload(number, platform = nil) payload(version).merge!(version.as_json) if version end - def find_version!(number:, platform:) + def find_version!(number:, platform:, ruby_abi: nil) platform = platform.presence || "ruby" - versions.find_by!(number: number, platform: platform) + versions.find_by!(number: number, platform: platform, ruby_abi: ruby_abi) end def find_version_by_slug!(slug) diff --git a/test/functional/api/v1/deletions_controller_test.rb b/test/functional/api/v1/deletions_controller_test.rb index 72736637f8d..01f0f963daa 100644 --- a/test/functional/api/v1/deletions_controller_test.rb +++ b/test/functional/api/v1/deletions_controller_test.rb @@ -32,6 +32,131 @@ class Api::V1::DeletionsControllerTest < ActionController::TestCase end end + context "for a gem with content-addressable 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")) + create(:ownership, user: @user, rubygem: @rubygem) + RubygemFs.instance.store("gems/#{@abi32.full_name}.gem", "") + RubygemFs.instance.store("gems/#{@abi34.full_name}.gem", "") + end + + context "ON DELETE with a ruby_abi param" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "3.2" } + end + + should respond_with :success + + should "yank only the targeted Ruby ABI variant" do + refute_predicate @abi32.reload, :indexed? + assert_predicate @abi34.reload, :indexed? + end + + should "respond with the deleted variant including its content address and Ruby ABI" do + assert_includes @response.body, "Successfully deleted gem: sandworm (1.0.0-724c5206, Platform: x86_64-linux-musl, Ruby ABI 3.2)" + end + end + + context "ON DELETE without a ruby_abi param" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "1.0.0", platform: "x86_64-linux-musl" } + end + + should respond_with :not_found + + should "not yank either Ruby ABI variant" do + assert_predicate @abi32.reload, :indexed? + assert_predicate @abi34.reload, :indexed? + end + + should "respond that the version does not exist" do + assert_includes @response.body, "The version 1.0.0 (x86_64-linux-musl) does not exist." + end + end + + context "ON DELETE with a ruby_abi param that does not match any variant" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "3.3" } + end + + should respond_with :not_found + + should "not yank either Ruby ABI variant" do + assert_predicate @abi32.reload, :indexed? + assert_predicate @abi34.reload, :indexed? + end + + should "respond that the version does not exist" do + assert_includes @response.body, "The version 1.0.0 (x86_64-linux-musl) (Ruby ABI 3.3) does not exist." + end + end + + context "ON DELETE with a ruby_abi param but no platform" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "1.0.0", ruby_abi: "3.2" } + end + + should respond_with :bad_request + + should "not yank either Ruby ABI variant" do + assert_predicate @abi32.reload, :indexed? + assert_predicate @abi34.reload, :indexed? + end + + should "respond that the platform param is required" do + assert_includes @response.body, "The platform param is required when ruby_abi is specified." + end + end + + context "when a version supporting multiple Ruby ABIs coexists" do + setup do + @multi_abi = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux-musl", gem_platform: "x86_64-linux-musl", + required_ruby_version: ">= 3.2") + RubygemFs.instance.store("gems/#{@multi_abi.full_name}.gem", "") + end + + context "ON DELETE with only a platform param" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "1.0.0", platform: "x86_64-linux-musl" } + end + + should respond_with :success + + should "yank only the version supporting multiple Ruby ABIs and keep the single ABI variants" do + refute_predicate @multi_abi.reload, :indexed? + assert_predicate @abi32.reload, :indexed? + assert_predicate @abi34.reload, :indexed? + end + + should "respond with the deleted platform version" do + assert_includes @response.body, "Successfully deleted gem: sandworm (1.0.0-x86_64-linux-musl)" + end + end + + context "ON DELETE with an empty ruby_abi param" do + setup do + delete :create, params: { gem_name: @rubygem.slug, version: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "" } + end + + should respond_with :success + + should "treat the empty ruby_abi as absent and yank the version supporting multiple Ruby ABIs" do + refute_predicate @multi_abi.reload, :indexed? + assert_predicate @abi32.reload, :indexed? + assert_predicate @abi34.reload, :indexed? + end + + should "respond with the deleted platform version" do + assert_includes @response.body, "Successfully deleted gem: sandworm (1.0.0-x86_64-linux-musl)" + end + end + end + end + context "for a gem SomeGem with a version 0.1.0" do setup do @rubygem = create(:rubygem, name: "SomeGem") diff --git a/test/models/rubygem_test.rb b/test/models/rubygem_test.rb index 483336d591e..e7ebaa25ab4 100644 --- a/test/models/rubygem_test.rb +++ b/test/models/rubygem_test.rb @@ -80,6 +80,19 @@ class RubygemTest < ActiveSupport::TestCase assert_equal version3_ruby, @rubygem.most_recent_version end + should "find versions by number, platform and Ruby ABI" do + plain = create(:version, rubygem: @rubygem, number: "1.0.0", platform: "x86_64-linux-musl", gem_platform: "x86_64-linux-musl", + required_ruby_version: ">= 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("abi34-1.0.0")) + + assert_equal plain, @rubygem.find_version!(number: "1.0.0", platform: "x86_64-linux-musl") + assert_equal abi34, @rubygem.find_version!(number: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "3.4") + assert_raises(ActiveRecord::RecordNotFound) do + @rubygem.find_version!(number: "1.0.0", platform: "x86_64-linux-musl", ruby_abi: "3.2") + end + end + should "mark the latest version for each Ruby ABI per platform" do abi32_old = 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("abi32-1.0.0")) From 0c77274230fd979e851907d1976a0d163761ea8b Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 12:25:26 -0400 Subject: [PATCH 2/3] Record the Ruby ABI on deletions With multiple yanked variants sharing a number and platform, deletion rows and yank events were ambiguous for auditing. Record ruby_abi on Deletion (validated against the version like the other metadata) and include it in the yank, unyank and yank-forbidden event payloads. --- app/avo/resources/deletion.rb | 1 + app/models/deletion.rb | 7 +++++-- app/models/events/rubygem_event.rb | 3 +++ db/migrate/20260811160835_add_ruby_abi_to_deletions.rb | 7 +++++++ db/schema.rb | 3 ++- test/models/deletion_test.rb | 10 ++++++++++ test/system/avo/versions_test.rb | 2 +- 7 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260811160835_add_ruby_abi_to_deletions.rb diff --git a/app/avo/resources/deletion.rb b/app/avo/resources/deletion.rb index c29534aded4..47f669d2694 100644 --- a/app/avo/resources/deletion.rb +++ b/app/avo/resources/deletion.rb @@ -10,6 +10,7 @@ def fields field :rubygem, as: :text field :number, as: :text field :platform, as: :text + field :ruby_abi, as: :text, title: "Ruby ABI" field :user, as: :belongs_to field :version, as: :belongs_to end diff --git a/app/models/deletion.rb b/app/models/deletion.rb index fe6db6a6978..85235c8e88c 100644 --- a/app/models/deletion.rb +++ b/app/models/deletion.rb @@ -54,6 +54,7 @@ def record_yank_forbidden_event! reason: ineligible_reason, number: version.number, platform: version.platform, + ruby_abi: version.ruby_abi, yanked_by: user.display_handle, actor_gid: user.to_gid, version_gid: version.to_gid @@ -75,6 +76,7 @@ def metadata_matches_version errors.add(:rubygem, "does not match version rubygem name") unless rubygem == version.rubygem.name errors.add(:number, "does not match version number") unless number == version.number errors.add(:platform, "does not match version platform") unless platform == version.platform + errors.add(:ruby_abi, "does not match version Ruby ABI") unless ruby_abi == version.ruby_abi end def rubygem_name @@ -85,6 +87,7 @@ def record_metadata self.rubygem = rubygem_name self.number = version.number self.platform = version.platform + self.ruby_abi = version.ruby_abi end def expire_cache @@ -155,11 +158,11 @@ def send_gem_yanked_mail def record_yank_event version.rubygem.record_event!(Events::RubygemEvent::VERSION_YANKED, number: version.number, platform: version.platform, -yanked_by: user&.display_handle, actor_gid: user&.to_gid, version_gid: version.to_gid, force:) +ruby_abi: version.ruby_abi, yanked_by: user&.display_handle, actor_gid: user&.to_gid, version_gid: version.to_gid, force:) end def record_unyank_event version.rubygem.record_event!(Events::RubygemEvent::VERSION_UNYANKED, number: version.number, platform: version.platform, -version_gid: version.to_gid) +ruby_abi: version.ruby_abi, version_gid: version.to_gid) end end diff --git a/app/models/events/rubygem_event.rb b/app/models/events/rubygem_event.rb index 3b589a467b4..387938f187e 100644 --- a/app/models/events/rubygem_event.rb +++ b/app/models/events/rubygem_event.rb @@ -19,6 +19,7 @@ class Events::RubygemEvent < ApplicationRecord VERSION_YANKED = define_event "rubygem:version:yanked" do attribute :number, :string attribute :platform, :string + attribute :ruby_abi, :string attribute :yanked_by, :string @@ -30,6 +31,7 @@ class Events::RubygemEvent < ApplicationRecord VERSION_YANK_FORBIDDEN = define_event "rubygem:version:yank_forbidden" do attribute :number, :string attribute :platform, :string + attribute :ruby_abi, :string attribute :yanked_by, :string @@ -41,6 +43,7 @@ class Events::RubygemEvent < ApplicationRecord VERSION_UNYANKED = define_event "rubygem:version:unyanked" do attribute :number, :string attribute :platform, :string + attribute :ruby_abi, :string attribute :version_gid, :global_id end diff --git a/db/migrate/20260811160835_add_ruby_abi_to_deletions.rb b/db/migrate/20260811160835_add_ruby_abi_to_deletions.rb new file mode 100644 index 00000000000..722fd980a9d --- /dev/null +++ b/db/migrate/20260811160835_add_ruby_abi_to_deletions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddRubyAbiToDeletions < ActiveRecord::Migration[8.1] + def change + add_column :deletions, :ruby_abi, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 183937057f6..72ef349f425 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_23_061553) do +ActiveRecord::Schema[8.1].define(version: 2026_08_11_160835) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" enable_extension "pg_catalog.plpgsql" @@ -148,6 +148,7 @@ t.datetime "created_at", precision: nil, null: false t.string "number" t.string "platform" + t.string "ruby_abi" t.string "rubygem" t.datetime "updated_at", precision: nil, null: false t.integer "user_id" diff --git a/test/models/deletion_test.rb b/test/models/deletion_test.rb index fec472e7fda..f49aa999e6c 100644 --- a/test/models/deletion_test.rb +++ b/test/models/deletion_test.rb @@ -185,6 +185,16 @@ class DeletionTest < ActiveSupport::TestCase assert_equal deletion.rubygem, @version.rubygem.name assert_equal @version.id, deletion.version_id + assert_nil deletion.ruby_abi + end + + should "record the Ruby ABI for versions targeting a single Ruby ABI" do + version = create(:version, rubygem: @version.rubygem, number: "2.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("test-2.0.0-3.4")) + deletion = Deletion.new(version: version, user: @user) + deletion.valid? + + assert_equal "3.4", deletion.ruby_abi end context "with restored gem" do diff --git a/test/system/avo/versions_test.rb b/test/system/avo/versions_test.rb index b9692cb3036..e300cd85909 100644 --- a/test/system/avo/versions_test.rb +++ b/test/system/avo/versions_test.rb @@ -101,7 +101,7 @@ class Avo::VersionsSystemTest < ApplicationSystemTestCase "updated_at" => [deletion.updated_at.as_json, nil], "version_id" => [version.id, nil] }, - "unchanged" => {} + "unchanged" => { "ruby_abi" => nil } }, version_unyank_event.to_gid.to_s => { "changes" => version_unyank_event.attributes.transform_values { [nil, it] }.as_json, From 852b67e27a83d4e195b3e48e8877bd5ff1f1905f Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 11 Aug 2026 12:25:26 -0400 Subject: [PATCH 3/3] Support restoring a specific Ruby ABI variant in restore_version script/restore_version resolved versions by number and platform only, so it could not target a yanked ABI-specific variant. Accept an optional RUBY_ABI argument and scope the Deletion lookup by it. --- script/restore_version | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/script/restore_version b/script/restore_version index c79b7805292..5e55414fe08 100755 --- a/script/restore_version +++ b/script/restore_version @@ -1,20 +1,24 @@ #!/usr/bin/env ruby # frozen_string_literal: true -gem_name, version_number, platform = *ARGV +gem_name, version_number, platform, ruby_abi = *ARGV -abort "Usage: script/restore_version GEM_NAME VESRION_NUMBER [PLATFORM]" if gem_name.nil? || version_number.nil? +abort "Usage: script/restore_version GEM_NAME VERSION_NUMBER [PLATFORM] [RUBY_ABI]" if gem_name.nil? || version_number.nil? ENV["RAILS_ENV"] ||= "production" require_relative "../config/environment" rubygem = Rubygem.find_by_name!(gem_name) -slug = version_number +slug = version_number.dup slug << "-#{platform}" if platform.present? -version = rubygem.find_version!(number: version_number, platform: platform) -raise "Version #{slug} for #{gem_name} was not found" unless version +slug << " (Ruby ABI #{ruby_abi})" if ruby_abi.present? +begin + version = rubygem.find_version!(number: version_number, platform: platform, ruby_abi: ruby_abi.presence) +rescue ActiveRecord::RecordNotFound + abort "Version #{slug} for #{gem_name} was not found" +end -deletion = Deletion.find_by(rubygem: gem_name, number: version_number, platform: version.platform) +deletion = Deletion.find_by(rubygem: gem_name, number: version_number, platform: version.platform, ruby_abi: version.ruby_abi) raise "Deletion record for version: #{version.full_name} was not found" unless deletion deletion.version = version