Gems

tests mutation tests linter type checker gem version

Ruby wrapper for the RubyGems.org API.

Installation

Install the gem and add to the application's Gemfile:

bundle add gems

Or, if Bundler is not being used to manage dependencies:

gem install gems

Documentation

https://www.rubydoc.info/gems/gems

Upgrading from 2.x? See UPGRADING.md for what changed.

Usage Examples

require 'gems'

# Return some basic information about rails.
gem = Gems.rubygem 'rails'
gem.name             # => "rails"
gem.version          # => "8.1.3.1"
gem.downloads        # => 704478420
gem.runtime_dependencies.map(&:name) # => ["actioncable", "actionmailbox", ...]

# Return some basic information about rails version 7.0.6.
version = Gems.version 'rails', '7.0.6'
version.number       # => "7.0.6"
version.built_at     # => 2023-06-29 00:00:00 UTC
version.sha          # => "5dfbd481...", the checksum of the gem file
version.spec_sha     # => "9c8cfe74...", the checksum of the gemspec it was pushed with
version.runtime_dependencies.map(&:name) # => ["actioncable", "actionmailbox", ...]

# Return information about a version for a specific platform.
Gems.version 'nokogiri', '1.15.0', platform: 'java'

# Return information about the version of a platform built for a specific Ruby ABI.
Gems.version 'nokogiri', '1.15.0', platform: 'x86_64-linux', ruby_abi: '3.4'

# Defaults to the latest version if no version is specified.
Gems.version 'rails'

# Return the SHA-256 checksum of every file in a version.
Gems.contents('rails', '8.1.3.1')['README.md']['sha256']

# Return the sigstore attestations published with a version.
Gems.attestations 'rails', '8.1.3.1'
Gems.attestations 'nokogiri', '1.15.0', platform: 'java'

# Return an array of active gems that match the query.
Gems.search 'cucumber'

# Walk every page of results, a page at a time, without tracking page numbers.
Gems.search_each('cucumber').first(100)
Gems.search_each('cucumber') { |gem| puts gem.name }

# Return the names of gems that match the query, for a search box.
Gems.autocomplete 'nokogiri'

# Return all gems that you own.
Gems.owned_gems

# Return all gems owned by Erik Berlin.
Gems.owned_gems 'sferik'

# Return basic information about a user, by handle or ID.
Gems.profile('sferik').handle

# Submit a gem to RubyGems.org, given as a path or an open file.
# The gem is sent as a stream, so a large gem is never held in memory.
Gems.push 'gemcutter-0.2.1.gem'
Gems.push File.new 'gemcutter-0.2.1.gem'

# Push the sigstore attestations a gem was signed with, one or several.
Gems.push 'gemcutter-0.2.1.gem', attestations: 'gemcutter-0.2.1.gem.sigstore.json'

# Remove a gem from RubyGems.org's index.
# Defaults to the latest version if no version is specified.
Gems.yank 'bills', '0.0.1'

# A gem or version object stands in for both the gem and the version.
Gems.yank Gems.version('bills', '0.0.1')
Gems.contents Gems.rubygem('rails')

# Return an array of versions of coulda, and the newest of them.
Gems.versions('coulda').map(&:number)
Gems.versions('coulda').max.number

# Return the latest version of coulda.
Gems.latest_version 'coulda'

# Return the total number of downloads of all gems.
Gems.total_downloads

# Return the number of downloads of rails_admin and of version 0.0.1.
# (Defaults to the latest version if no version is specified.)
Gems.downloads('rails_admin', '0.0.1').version_downloads

# The counts name the version they are for, which the endpoint answers without.
Gems.downloads('rails_admin', '0.0.1').full_name

# Return the downloads of a version for a specific platform.
Gems.downloads('nokogiri', '1.15.0', platform: 'java').version_downloads

# Returns an array containing the top 50 downloaded gem versions of all time.
Gems.most_downloaded.first.full_name

# View all owners of a gem.
Gems.owners('gemcutter').map(&:handle)

# Add an owner to a RubyGem you own, giving that user permission to manage it.
Gems.add_owner 'gemcutter', '[email protected]'

# Add a maintainer, who can push but cannot manage owners.
Gems.add_owner 'gemcutter', '[email protected]', role: 'maintainer'

# Change the role of an existing owner.
Gems.update_owner 'gemcutter', '[email protected]', role: 'owner'

# Remove a user's permission to manage a RubyGem you own.
Gems.remove_owner 'gemcutter', '[email protected]'

# Return all the webhooks registered under your account.
Gems.web_hooks.map(&:url)

# Add a webhook.
Gems.add_web_hook 'rails', 'http://example.com'

# Remove a webhook.
Gems.remove_web_hook 'rails', 'http://example.com'

# Test fire a webhook.
Gems.fire_web_hook 'rails', 'http://example.com'

# Returns the 50 gems most recently added to RubyGems.org.
# This endpoint and the one below have no further pages to ask for.
Gems.latest

# Returns the 50 most recently updated gems.
Gems.just_updated

# Returns the gem versions created in a timeframe of up to seven days, 30 at a time.
Gems.timeframe_versions from: Time.now - 86_400
Gems.timeframe_versions from: '2019-01-18T21:24:29Z', to: '2019-01-19T21:24:29Z', page: 2
Gems.timeframe_versions_each(from: Time.now - 86_400).count

# Create an API key using HTTP basic authentication.
# The key is only returned once, so store it somewhere safe.
Gems.configure do |config|
  config.username = '[email protected]'
  config.password = 'schwwwwing'
end
Gems.create_api_key('ci-push', scopes: %i[push_rubygem]).key

# Create a key restricted to one gem that expires in a day and requires a one-time passcode.
Gems.create_api_key('ci-push', scopes: %i[push_rubygem], rubygem_name: 'gems', expires_at: Time.now + 86_400, mfa: true)

# Return your own profile, including its multi-factor authentication level.
Gems.me.mfa

# Verify your account with a security key in a browser, for the one-time passcode `gem push` would ask for.
verification = Gems.webauthn_verification
puts "Open #{verification.path}"
Gems.webauthn_verification_status(verification).code  # => "123456", once it is done

# Update the scopes of an API key, which is granted these alone.
Gems.update_api_key 'rubygems_701243f217cdf23b1370c7b66b65ca97', scopes: %i[push_rubygem yank_rubygem]

# The scopes the API defines, which a misspelled scope is checked against.
Gems::APIKey::SCOPES

# Exchange an OIDC ID token for an API key via trusted publishing.
Gems.exchange_trusted_publisher_token(ENV.fetch('ID_TOKEN')).key

# List your API key roles, and exchange an OIDC ID token for an API key by assuming one.
Gems.api_key_roles.map(&:name)
Gems.assume_api_key_role('0123456789abcdef0123456789abcdef', ENV.fetch('ID_TOKEN')).key

# List the OIDC providers RubyGems.org accepts ID tokens from, and the ID tokens your roles have accepted.
Gems.oidc_providers.map(&:issuer)
Gems.oidc_id_tokens.map { |id_token| id_token.jwt['claims']['repository'] }

# Trust a GitHub Actions workflow to publish a gem, so that it can push without an API key.
Gems.add_trusted_publisher('gems', repository_owner: 'rubygems', repository_name: 'gems',
  workflow_filename: 'push_gem.yml')

# Restrict a trusted publisher to one GitHub Actions environment.
Gems.add_trusted_publisher('gems', repository_owner: 'rubygems', repository_name: 'gems',
  workflow_filename: 'push_gem.yml', environment: 'release')

# Trust a reusable workflow that lives in another repository.
Gems.add_trusted_publisher('gems', repository_owner: 'rubygems', repository_name: 'gems',
  workflow_filename: 'release.yml', workflow_repository_owner: 'rubygems',
  workflow_repository_name: 'workflows')

# List the trusted publishers of a gem, and read one of them.
Gems.trusted_publishers('gems').map(&:name)
Gems.trusted_publisher('gems', 1).workflow_filename

# Stop trusting a publisher.
Gems.remove_trusted_publisher 'gems', Gems.trusted_publishers('gems').first

# The methods that act on your account or your gems, such as push, yank, and the owner, web hook, API key, and
# trusted publisher methods above, require authentication.
# By default, we load your API key as `gem push` does: from GEM_HOST_API_KEY, or from
# ~/.gem/credentials, where `gem signin` stores it (`gem signin --host` for another host).
# You can override this default by specifying a custom API key.
Gems.configure do |config|
  config.key = 'rubygems_701243f217cdf23b1370c7b66b65ca97'
end

# If your account requires multi-factor authentication, provide a one-time passcode.
# By default, we load it as `gem push` does: from GEM_HOST_OTP_CODE.
Gems.configure do |config|
  config.otp = '123456'
end

# For trusted publishing, provide an OIDC ID token instead of an API key.
# It is exchanged for an API key on the first request.
Gems.configure do |config|
  config.id_token = ENV.fetch('ID_TOKEN')
end

# The methods of the Gems module share one client, Gems.client, which is built again only when the
# configured host or ID token changes, or the key it falls back to when none is configured; any other
# change to the configuration is applied to the client it has. Use it for raw requests.
Gems.client.get '/api/v1/gems/rails.json'

# Raw requests take headers of your own, alongside the User-Agent and the credentials of the client.
Gems.client.get '/api/v1/gems/rails.json', headers: {'X-Trace-Id' => 'abc123'}

# A raw request takes a path on the client's host. Send one to another host with host:, which uses the key
# stored for it; a path that is a URL of another host raises ArgumentError rather than taking the
# credentials of this one there.
Gems.client.get '/api/v1/gems/rails.json', host: 'https://gems.example.com'

# Alternatively, create a client with its own credentials and settings.
client = Gems::Client.new(key: 'rubygems_701243f217cdf23b1370c7b66b65ca97', host: 'https://gems.example.com')
client.rubygem 'rails'

# Given a block, the client is closed once the block is done with it, and the block's value is returned.
versions = Gems::Client.new(host: 'https://gems.example.com') { |client| client.versions 'rails' }

Response objects

Responses are wrapped in objects with readers for each documented field: Gems::Gem, Gems::Version, Gems::Dependency, Gems::Owner, Gems::Profile, Gems::WebHook, Gems::Downloads, Gems::APIKey, Gems::APIKeyRole, Gems::OIDCProvider, Gems::OIDCIDToken, Gems::TrustedPublisher, Gems::WebAuthnVerification, and Gems::WebAuthnVerificationStatus. Timestamps are parsed into Time objects and boolean fields have predicate readers such as yanked?. A gem and a version read the dependencies the endpoint they came from answered with as runtime_dependencies and development_dependencies; the endpoints that answer without them, such as the versions of a gem, leave both empty. Objects are accepted wherever their identifier is expected, so Gems.versions(gem), Gems.remove_owner(gem, owner), Gems.remove_trusted_publisher(gem, trusted_publisher), and Gems.key = api_key all work. A gem or version given where a gem is expected stands in for the version too, when it carries a version number, so Gems.version(gem), Gems.contents(gem), Gems.attestations(gem), Gems.downloads(gem), and Gems.yank(gem) act on the version it names, at the platform and Ruby ABI it names, rather than on the latest version. The first four take no version at all when you mean the latest one; yank raises ArgumentError without a version, since a yank cannot be undone. RubyGems.org looks the downloads and attestations of a version up by a full name, which for a version built for a Ruby ABI is a content address it answers with only in the versions most_downloaded returns, so downloads and attestations raise ArgumentError for any other version built for one rather than answer for the version of its platform built for none. Objects match case/in patterns by their readers, so case gem in {name:, version:} binds both. Objects compare by identity (a gem or version by its name, version number, platform, and Ruby ABI, and so on), so Gems.rubygem('rails') == Gems.rubygem('rails') even when download counts have changed in between, while the builds of one version for two Ruby ABIs are not equal. A gem or version inspects with its platform, unless it is ruby, and with its Ruby ABI, when it was built for one, so that two builds of a version inspect apart. The counts downloads answers with carry the version they were asked for as full_name, since the endpoint answers with the counts alone, and are compared by it as well as by the counts: the downloads of one gem are not the downloads of another that happens to have been downloaded as many times. An API key is compared by all of its attributes, its key among them, so two keys that share a name are not equal. Gems and versions also order by their number as RubyGems orders numbers, rather than as the strings they are written with, so 7.0.10 comes after 7.0.9, where comparing the strings, as max_by(&:number) does, puts it before:

Gems.versions('rails').sort      # oldest first, prereleases before the versions they lead to
Gems.versions('rails').max       # => #<Gems::Version name="rails" number="8.1.3.1">
version.gem_version              # => the number as a Gem::Version, to compare with one of your own

Gems.search('cucumber').sort     # the endpoints that answer with gems order the same way
Gems.timeframe_versions(from: Time.now - 86_400).max
gem.gem_version                  # => the version as a Gem::Version

Comparable is deliberately left out, so < and > are not defined: an object is equal to another by its identity, whatever fields the endpoint it came from answered with, rather than by what it is ordered alongside.

Objects are immutable, with their attributes frozen at every level. Every object also exposes the raw response through [] and to_h, so fields without a reader remain accessible. to_h answers with a copy you own and can change, at every level, since the Hashes, Arrays, and Strings an object holds are frozen too; attributes answers with the frozen Hash the object holds:

gem = Gems.rubygem 'rails'
gem['dependencies'] # => {"development" => [...], "runtime" => [...]}
gem.to_h            # => the parsed JSON response, as a Hash you can change
gem.to_h['metadata']['fetched_at'] = Time.now # => the nested values are copies too
gem.attributes      # => the same fields, frozen
gem.to_s            # => '#<Gems::Gem name="rails" version="8.1.3.1">', the summary it inspects as

Two endpoints return their JSON as it is, by design, rather than wrapping it: contents answers with a plain map of path to checksum, and attestations answers with sigstore bundles, whose shape is defined by sigstore rather than by RubyGems.org. reverse_dependencies and autocomplete return arrays of gem names for the same reason, and reverse_dependency_versions an array of the full names of the versions that depend on a gem.

Pagination

The endpoints that return one page at a time — search and timeframe_versions — each have an _each counterpart that walks the pages. It returns an Enumerator, requests a page only once the results of the page before it have been enumerated, and stops at the first empty page:

Gems.search_each('cucumber').first(100)          # requests only the pages it needs
Gems.search_each('cucumber') { |gem| puts gem.name }
Gems.timeframe_versions_each from: Time.now - 86_400

latest and just_updated take no page: the endpoints answer with the 50 gems they name whatever page is asked for, so there is nothing further to walk.

Configuration

Clients default to the global configuration, which can be set with Gems.configure or overridden per client:

Option Description Default
host The RubyGems-compatible host, including scheme RUBYGEMS_HOST or https://rubygems.org
key The API key sent in the Authorization header GEM_HOST_API_KEY or the key stored for that host in ~/.gem/credentials
username The username for HTTP basic authentication nil
password The password for HTTP basic authentication nil
otp The one-time passcode sent in the OTP header GEM_HOST_OTP_CODE
id_token The OIDC ID token exchanged for an API key nil
user_agent The User-Agent header gems/<version> (<ruby> <version>; <platform>)
open_timeout The timeout for opening connections, in seconds 60
read_timeout The timeout for reading responses, in seconds 60
write_timeout The timeout for writing requests, in seconds 60
keep_alive_timeout The seconds an idle connection is kept open for the next request 2
debug_output An IO that receives HTTP debug output, with credentials redacted nil
proxy_url The proxy to use http_proxy/https_proxy environment
max_redirects The maximum number of redirects to follow 10
max_retries The number of times a request that was turned away is sent again 2
max_retry_delay The longest a request waits before it is sent again, in seconds 60
ca_file The path of a file of certificates TLS is verified with OpenSSL's own certificates
ca_path The path of a directory of certificates TLS is verified with OpenSSL's own certificates
cert_store An OpenSSL::X509::Store TLS is verified with OpenSSL's own certificates
client_cert The certificate presented to a host that asks for one nil
client_key The private key of client_cert nil

Trusted publishing takes precedence over the API key, which takes precedence over HTTP basic authentication, and a one-time passcode is sent alongside whichever of them is used. RubyGems.org takes a username and password only to create or update an API key and for me, and an API key for everything else, so a client given both sends the username and password to those three endpoints and the key to the rest.

When no key is configured, the API key is resolved for the host it is sent to, as gem push --host resolves it: a client built for another host, and a request made to one with host:, use the key gem signin --host stored for that host. A key you configure yourself is sent wherever the client sends a request, as gem push --key is, and a username and password are sent to a host no key is sent to.

A host nothing is stored for is sent no key at all, so that the key RubyGems.org issued you is not sent to a host it was not issued for. This is the one place the library resolves a key differently than gem push --host, which falls back to the RubyGems.org key for such a host; store a key for the host with gem signin --host, or configure one, to authenticate there.

An id_token is the exception among configured credentials: the API key it is exchanged for is issued by the host the exchange was made with, for the audience the token names, so a request to another host resolves the key stored for that host rather than carrying the exchanged one there. Assigning host exchanges the token again, for the host the client is pointed at.

# Uses the key stored for gems.example.com, not the RubyGems.org key.
Gems.push 'gemcutter-0.2.1.gem', host: 'https://gems.example.com'

Proxies are read from the http_proxy, https_proxy, and no_proxy environment variables unless proxy_url is set. An https:// proxy is connected to over TLS.

TLS certificates are verified, and there is no option to turn that off. A host whose certificate Ruby's OpenSSL does not already trust, such as a private gem server with one of its own, is reached by naming that certificate rather than by skipping the check. ca_file and ca_path add to the certificates OpenSSL trusts; a cert_store of your own replaces them. A host that asks the client for a certificate is given client_cert and client_key:

# Trust the certificate of a private gem server.
Gems::Client.new(host: 'https://gems.example.com', ca_file: '/etc/ssl/certs/internal.pem')

# Present a client certificate to a host behind mutual TLS.
Gems.configure do |config|
  config.client_cert = OpenSSL::X509::Certificate.new(File.read('client.pem'))
  config.client_key = OpenSSL::PKey::RSA.new(File.read('client.key'))
end

A ca_file or ca_path that names nothing raises ArgumentError where it is assigned, as an invalid host does, rather than failing as a TLS error on the next request.

A request is sent on the connection the last request to the same host left open, so that a series of requests does not open a connection each. keep_alive_timeout sets how long an idle connection is kept open, and 0 closes every connection once its request is done. A request that acts on a gem, such as push or yank, is sent on a connection of its own, since a connection the server closed while it was idle cannot be retried for it. close closes the connections a client keeps open; they are opened again as they are needed, so requests can still be made afterwards. A process forked from one that left a connection open, such as a worker of a server that preloads the application, opens one of its own rather than sharing the socket of the process it was forked from. A client built with a block is closed once the block returns or raises, as Net::HTTP.start closes the connection it opened:

Gems.client.close

Gems::Client.new { |client| client.versions 'rails' }  # closed once the block is done with it

Debug output is redacted before it reaches the IO debug_output is set to, so that it can be kept in a log: the Authorization and OTP headers of every request, the Proxy-Authorization header the requests sent through a proxy carry, the ID token of a trusted publishing token exchange, the API key update_api_key sends, the API key an API key or token exchange response returns, and the one-time passcode webauthn_verification_status returns are written as [REDACTED]. Everything else Net::HTTP writes, including the rest of the headers, is left as it is. A client with a debug output asks for its responses uncompressed, since a compressed body would be written as bytes no credential could be found in, and a body read from the socket in parts, or sent in chunks, is written as one line once the response is done, so that a credential split between them is redacted whole.

Retries

RubyGems.org answers a request it rate limited with 429 Too Many Requests and a Retry-After header saying how long to wait. A request is waited for and sent again twice by default, and max_retries sets how many times:

Gems.max_retries = 3  # send a request again up to three times
Gems.max_retries = 0  # raise instead of waiting
Gems.rubygem 'rails'

A 429 is retried for every request, push and yank included: RubyGems.org rate limits a request before it reaches the endpoint, so the endpoint never saw the attempt that was turned away.

A 502 Bad Gateway, a 503 Service Unavailable, a 504 Gateway Timeout, and a Gems::NetworkError are retried for a request that only reads, such as rubygem or versions, and not for one that acts on a gem, such as push, yank, or remove_owner. Each of them can be answered after the origin acted on the request: a 502 or 504 comes from a gateway that read no answer from the origin behind it, a 503 from a CDN that gave up waiting on an origin still at work, and a read that timed out from a request that arrived. A yank sent again after the origin yanked the version answers with the error of the version that is already gone, which would be raised in place of the success the call was owed.

When the retries run out, the response raises the Gems::HTTPError of its status and a network failure is raised as it was. Net::HTTP's own retry of a request whose connection failed is turned off, so max_retries is every time a request is sent again, and a request that times out reading its response waits read_timeout once for each attempt rather than twice.

The trusted publishing token exchange is the exception to all of this: a 429, 502, 503, or 504 answers the exchange with no key, and an exchange lost to the network may have been issued one whose answer went missing, but an exchange sent again for any of them either is issued a key or answers that the token is spent, where the publish would have failed either way, so it is sent again although it is a POST. The wait is the one Retry-After asks for, and doubles from one second up to max_retry_delay when the response does not carry the header, which is the wait after a network failure too, since a request that never arrived has no response to read a wait from. A wait the library chose for itself is jittered down by up to half, so that the clients a server turned away at the same moment do not all send their requests again at the same instant; a wait Retry-After asked for is taken as it is, since the server named the moment it is ready. A response asking to wait longer than max_retry_delay raises instead, so that a server cannot pause your program for as long as it likes. HTTPError#retry_after reads the header yourself when you would rather handle it in your own code.

Thread safety

Gems.client is built under a lock, so the methods of the Gems module can be called from many threads at once and share one client between them. That client's connections are pooled per host, and a connection is used by one request at a time, so concurrent requests neither wait for one another nor share a socket. A trusted publishing ID token is exchanged for an API key once, however many threads ask for it at the same time.

What is not synchronized is changing things while requests are in flight. The global configuration is meant to be set up once, before the threads that use it start:

Gems.configure do |config|       # at boot
  config.key = ENV.fetch('GEM_HOST_API_KEY')
  config.max_retries = 3
end

threads = 10.times.map { |i| Thread.new { Gems.search('cucumber', page: i + 1) } }
threads.each(&:join)

Assigning to Gems.key, Gems.otp, or another credential afterwards applies it to the shared client, and assigning to Gems.host or Gems.id_token builds that client again; either way, a request another thread is making at that moment carries the credentials it started with. A Gems::Client of your own is the same: it is safe to make requests with from several threads, and its key=, host=, and otp= writers are meant for the thread that owns it rather than for one racing a request. Give each thread a client of its own when they need different credentials.

Errors

All errors inherit from Gems::Error. HTTP errors are Gems::HTTPError subclasses that expose the response and integer status code, with specific classes such as Gems::NotFound, Gems::Unauthorized, and Gems::Forbidden, and Gems::ClientError or Gems::ServerError for any other 4xx or 5xx status. The message of an error is the response body, or the status message when the body is empty or an HTML page, such as the error page of a CDN. When a 429 or 503 response carries a Retry-After header, retry_after reads it as the seconds to wait. Network failures raise Gems::NetworkError, whose cause is the SystemCallError (an Errno), IOError (such as EOFError), SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Zlib::Error, Net::HTTPBadResponse, or Net::ProtocolError underneath, so a timeout worth retrying can be told from a refused connection that is not; Gems::NetworkError::WRAPPED is the list. Redirect loops raise Gems::TooManyRedirects, and a successful response that cannot be read raises Gems::InvalidResponse: one whose body is not JSON, such as the page of a proxy or captive portal, one whose JSON lacks a field the library reads, or one with a timestamp that cannot be parsed. Asking for the latest version of a gem that has none, directly or by omitting the version from downloads, raises Gems::NoLatestVersion.

Invalid arguments raise ArgumentError rather than a Gems::Error: a host or proxy_url that is not an HTTP or HTTPS URL, a host that carries a user and password, which Net::HTTP would not send, a raw request path that is a URL of another scheme, host, or port, which would otherwise carry the credentials resolved for the client's host to the host it names, a raw request path that climbs out of the prefix its host carries, such as ../.. for a host of https://gems.example.com/rubygems, an API key scope the RubyGems API does not define, which would otherwise be ignored by the server and leave the key scoped differently than it was meant to be, a reverse dependency type other than development or runtime, which the endpoint answers with every reverse dependency for rather than refusing, and an owner role other than owner or maintainer.

The settings that are numbers are checked where they are assigned too: open_timeout, read_timeout, write_timeout, keep_alive_timeout, and max_retry_delay take a number of zero or more seconds, and max_redirects and max_retries take a whole number of zero or more times. Anything else raises ArgumentError there, rather than waiting for a negative number of seconds or comparing a count with a String on the next request, and the setting is left as it was.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rake to run the tests, linters, mutation tests, type checker, and documentation checks. Coverage, mutation testing, and type checking are skipped on JRuby. You can also run bin/console for an interactive prompt that will allow you to experiment.

Supported Ruby Versions

This library aims to support and is tested against the following Ruby implementations:

  • Ruby 3.4
  • Ruby 4.0
  • JRuby

If something doesn't work on one of these interpreters, it's a bug.

This library may inadvertently work (or seem to work) on other Ruby implementations, however support will only be provided for the versions listed above.

If you would like this library to support another Ruby version, you may volunteer to be a maintainer. Being a maintainer entails making sure all tests run and pass on that implementation. When something breaks on your implementation, you will be responsible for providing patches in a timely fashion. If critical issues for a particular implementation exist at the time of a major release, support for that Ruby version may be dropped.

Copyright (c) 2011-2026 Erik Berlin. See LICENSE for details.