# How to publish a Ruby gem to RubyGems.org with trusted publishing

> Register the release workflow as a trusted publisher on RubyGems.org, pending or not, and let the job trade its OIDC token for a short-lived push key.

Source: https://voxgig.com/howto/publish-a-ruby-gem-to-rubygems

- Audience: library-maintainer
- Level: intermediate
- Languages: ruby
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Build the gem with `gem build` from a `gemspec` that carries the required and recommended fields. On RubyGems.org, register the repository owner, repository name, workflow filename, and environment as a trusted publisher, as a pending publisher when the gem does not exist yet. Give the GitHub Actions job `id-token: write` and `rubygems/release-gem`, which exchanges the job's OIDC token for a short-lived push key and runs `rake release`. No API key is stored anywhere.

---
## You will need

Ruby 3.1 or later with RubyGems and Bundler, a RubyGems.org account with MFA enabled, and a
GitHub repository for the gem. Verified 2026-10-21 against Ruby 3.3.6 and RubyGems 3.5.22. The
commands build the gem and compare the workflow with the registered record, so nothing is pushed
to RubyGems.org, and the workflow files are read rather than run.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A scoped API key in a CI secret](https://guides.rubygems.org/api-key-scopes/) | A CI system other than GitHub Actions, or a release machine you control | A long-lived secret to store and rotate, whose scope list is the only limit on what a leak can do | GitHub Actions, where a publisher record replaces the key |
| [gem push run by a maintainer with MFA](https://guides.rubygems.org/setting-up-multifactor-authentication/) | A few releases a year, cut by one person with a security device | A person at a keyboard for every release, and signed-in credentials sitting in that person's home directory | Releases run from CI, or several people cut them |
| [Trusted publishing with rubygems/release-gem](https://guides.rubygems.org/trusted-publishing/) | GitHub Actions, with releases cut by pushing a tag | A publisher record that must match the workflow filename and environment exactly, Bundler's release tasks in the repository, and a token every step in the job can mint, `bundle install` included | Another CI system, or a release that has to run from a laptop |

The API key is the portable option and the one with the longest exposure: it works from any CI
and any laptop, for whoever holds it. Manual `gem push` under MFA keeps a person in the loop at
the cost of making every release wait for one.[^1] Trusted publishing removes the stored secret
and ties the release to one repository, one workflow file, and one environment, which is the
point and also the maintenance.

## Fill a specification RubyGems.org accepts

`gem build` validates the specification before it writes anything. The
[specification reference](https://guides.rubygems.org/specification-reference/) lists five
required attributes, `authors`, `files`, `name`, `summary`, and `version`, and recommends
`description`, `email`, `homepage`, `license`, `metadata`, and `required_ruby_version`. The
`gemspec` below sets all of them and one more: `rubygems_mfa_required` in `metadata`, which makes
RubyGems.org require MFA of every owner from this version on.

```ruby title="acme_sdk.gemspec"
require_relative "lib/acme_sdk/version"

Gem::Specification.new do |spec|
  spec.name = "acme_sdk"
  spec.version = AcmeSdk::VERSION
  spec.authors = ["Acme Developer Platform"]
  spec.email = ["sdk@example.com"]
  spec.summary = "Ruby client for the Acme API"
  spec.description = "A Ruby client for the Acme API, generated from its OpenAPI description."
  spec.homepage = "https://github.com/acme/acme-sdk-ruby"
  spec.license = "MIT"
  spec.required_ruby_version = ">= 3.1"

  spec.metadata = {
    "source_code_uri" => "https://github.com/acme/acme-sdk-ruby",
    "changelog_uri" => "https://github.com/acme/acme-sdk-ruby/blob/main/CHANGELOG.md",
    "rubygems_mfa_required" => "true",
  }

  spec.files = Dir["lib/**/*.rb"] + ["LICENSE.txt", "README.md"]
  spec.require_paths = ["lib"]
end
```

```bash
gem build acme_sdk.gemspec
```

```text output
  Successfully built RubyGem
  Name: acme_sdk
  Version: 0.1.0
  File: acme_sdk-0.1.0.gem
```

The file holds what `files` names and nothing else: the two library files, the license, and the
README. The `Gemfile`, the `Rakefile`, and the workflow stay out of the package, which the test
below pins. For a first release by hand, the [publishing guide](https://guides.rubygems.org/publishing/)
shows `gem push acme_sdk-0.1.0.gem` from a signed-in machine. With a pending publisher, described
next, the first release can come from the workflow as well.

## Register the publisher before the first tag

A trusted publisher on RubyGems.org is four fields: repository owner, repository name, workflow
filename, and an optional GitHub environment. Two more, left blank here, name the repository of a
reusable workflow the job calls. The
[trusted publishing guide](https://guides.rubygems.org/trusted-publishing/) walks through the form
on an existing gem's trusted publishers page, suggests the environment name `release`, and allows
several publishers per gem and several gems per workflow.

A gem that does not exist yet gets a pending publisher instead: the same form, on your profile,
with the gem name added. The guide says the pending record behaves like a normal one, and that
after its first successful push it becomes the gem's publisher and you become the gem's owner.
The first release therefore needs no manual push.[^2]

[configure_trusted_publisher](https://github.com/rubygems/configure_trusted_publisher) does the
same from a terminal. `gem exec configure_trusted_publisher acme_sdk` asks for your RubyGems.org
credentials, asks whether releases should start on a `v*` tag or by hand, and registers the
publisher. The sample keeps the record it registered in `publisher.yml`, so the workflow can be
checked against it.

```yaml title="publisher.yml"
rubygem: acme_sdk
repository_owner: acme
repository_name: acme-sdk-ruby
workflow_filename: release.yml
environment: release
```

## Give the job an identity instead of a key

`release.yml` is the guide's workflow with one permission narrowed: `contents: read` where the
guide asks for `contents: write`.

```yaml title="release.yml"
name: release
on:
  push:
    tags: ["v*"]

jobs:
  push:
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: ruby
          bundler-cache: true
      - uses: rubygems/release-gem@v1
```

Three lines do the work. `id-token: write` lets the job ask GitHub for an OIDC token; the guide
wants it at the job level and calls the workflow level discouraged. `environment: release` must be
present when the record names an environment, because GitHub puts the environment into the token
only for a job that declares one. [`rubygems/release-gem`](https://github.com/rubygems/release-gem)
runs `rubygems/configure-rubygems-credentials`, which posts the token to the
[exchange endpoint](https://guides.rubygems.org/rubygems-org-api/) and receives an API key scoped
to `push_rubygem` with an `expires_at`, then runs `bundle exec rake release`, then waits for the
release to appear on RubyGems.org.

RubyGems.org
[matches the record](https://github.com/rubygems/rubygems.org/blob/master/app/models/oidc/trusted_publisher/github_action.rb)
against the repository, the workflow file, and the environment, never the ref the job ran from. Give
`release` a
[deployment rule](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments)
that admits `v*` tags alone, so a branch carrying an edited copy of `release.yml` cannot run in it.

`rake release` is Bundler's task, from `require "bundler/gem_tasks"` in the `Rakefile`. The
[RubyGems guide](https://guides.rubygems.org/make-your-own-gem/) says it builds the gem into
`pkg`, creates the version tag, pushes the tag, and pushes the gem. The action's README lists what
it assumes: a checked-out repository with Ruby set up, Bundler, those release tasks, and a
publisher already configured.

Bundler's [release task](https://github.com/ruby/rubygems/blob/master/lib/bundler/gem_helper.rb)
skips the tagging and the git push when the version's tag exists, and the tag push that started the
run created it. So the job asks for `contents: read`. The action caches the job's GitHub token for
git before the task runs, and with `contents: write` anything the task loads could push to the
repository.

## Check the workflow against the record

The record and the workflow drift apart silently: a renamed file, an environment removed to skip
an approval, a permissions block tidied away. `check_publisher.rb` reads both and prints what the
token will carry beside what the record expects.[^3]

```ruby title="check_publisher.rb"
# The token's job_workflow_ref names the file; the record names the file it expects.
ref = "#{publisher['repository_owner']}/#{publisher['repository_name']}/.github/workflows/#{name}"
lines << "  job_workflow_ref  #{ref}@<tag>"
if name != publisher["workflow_filename"]
  failures << "the record names #{publisher['workflow_filename']}, this file is #{name}"
end

# GitHub puts the environment in the token only when the job declares one.
env = job["environment"]
env = env["name"] if env.is_a?(Hash)
lines << "  environment       job: #{env || 'none'}, record: #{publisher['environment'] || 'none'}"
if publisher["environment"] && env != publisher["environment"]
  failures << "the record requires environment #{publisher['environment']}, the job runs in #{env || 'none'}"
end

# Without id-token: write there is no token to exchange.
permissions = job["permissions"] || workflow["permissions"] || {}
lines << "  id-token          #{permissions['id-token'] || 'not granted'}"
failures << "id-token: write is missing, so GitHub mints no token" unless permissions["id-token"] == "write"
```

`demo.rb` runs it over the committed workflow and three edited copies.

```bash
ruby demo.rb
```

```text output
release.yml, job push
  job_workflow_ref  acme/acme-sdk-ruby/.github/workflows/release.yml@<tag>
  environment       job: release, record: release
  id-token          write
  ok
push_gem.yml, job push
  job_workflow_ref  acme/acme-sdk-ruby/.github/workflows/push_gem.yml@<tag>
  environment       job: release, record: release
  id-token          write
  FAIL the record names release.yml, this file is push_gem.yml
  the exchange would be refused
release.yml, job push
  job_workflow_ref  acme/acme-sdk-ruby/.github/workflows/release.yml@<tag>
  environment       job: none, record: release
  id-token          write
  FAIL the record requires environment release, the job runs in none
  the exchange would be refused
release.yml, job push
  job_workflow_ref  acme/acme-sdk-ruby/.github/workflows/release.yml@<tag>
  environment       job: release, record: release
  id-token          not granted
  FAIL id-token: write is missing, so GitHub mints no token
  the exchange would be refused
```

GitHub's [OIDC reference](https://docs.github.com/en/actions/reference/security/oidc) is where
the two claims come from: `job_workflow_ref` names the workflow file and the ref it ran from, and
`environment` appears when the job has one. RubyGems.org matches its record against them, so each
of the three edits produces a refused exchange rather than a failed push.

## Check it worked

```bash
ruby -Ilib test/release_test.rb --seed 1
```

```text output
8 runs, 27 assertions, 0 failures, 0 errors, 0 skips
```

Eight tests cover the required and recommended fields of the `gemspec` with the MFA flag, the built
gem's file list, and the workflow's permissions. The rest check the committed workflow against the
record, the three edits that break it, and a job that calls a reusable workflow.
`0 failures` is the line to read. The file list test is the one that catches a `files` glob that
starts shipping `test/`.

## When it goes wrong

The build refuses the `gemspec`. A missing required attribute stops `gem build` before any file is
written, and the attribute is named:

```bash
gem build pitfall/acme_sdk.gemspec
```

```text output
WARNING:  See https://guides.rubygems.org/specification-reference/ for help
ERROR:  While executing gem ... (Gem::InvalidSpecificationException)
    missing value for attribute summary
```

Add the field. A missing recommended attribute, such as the license, is a warning on standard
error and the build goes on; `gem build --strict` turns those warnings into errors, per the
[command reference](https://guides.rubygems.org/command-reference/).

The workflow runs and the token exchange is refused. Someone renamed `release.yml`, or moved the
job out of the `release` environment, after the record was created. Edit the record on the gem's
trusted publishers page, or add a second publisher for the second workflow, which the guide
allows.

The job has no token to exchange. `id-token: write` was never granted, or was granted at the
workflow level and later tidied away, so the action fails before `rake release` runs. The guide's
words: without that permission the publishing action cannot identify itself to RubyGems.org.

## When not to do this

Do not set up trusted publishing for a gem you release from a laptop. The publisher record
describes a GitHub Actions workflow in one repository, so a release from anywhere else needs the
API key or a signed-in `gem push`. At the `UI and API` level the MFA guide describes, every push
asks for a code.

Do not give the API-key route a key with every scope. The scopes guide lets a key carry
`push_rubygem` alone, with an expiry date, and a key that also carries `yank_rubygem` and
`add_owner` in a repository secret hands the account's gems to whoever reads the secret.

Do not grant `id-token: write` to the whole workflow to save a line. Every job in the file can
then mint an identity token, which is why the guide recommends the job level.

Do not drop the environment because it adds a click. The guide's reason for it is to constrain
who can publish when many people have access to the repository. An environment with required
reviewers is the one approval a tag push cannot skip.

## Related how-tos

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

- [Give a Ruby SDK keyword arguments and Faraday middleware](/howto/ruby-sdk-keyword-arguments-and-faraday-middleware)

- [Audit the scopes of the tokens your integrations use](/howto/audit-the-scopes-of-integration-tokens)

- [Set up PyPI trusted publishing from GitHub Actions](/howto/set-up-pypi-trusted-publishing-from-actions)

## Last verified

Verified 2026-10-21 against Ruby 3.3.6 and RubyGems 3.5.22. Every output block is what the
command preceding it printed. `gem build` and the checks ran here. The push, the pending publisher
form, `configure_trusted_publisher`, and `rubygems/release-gem` v1 were read from their
documentation and source rather than run, so no request reached RubyGems.org or GitHub.

[^1]: The MFA requirement is not only a recommendation. The
[command line MFA guide](https://guides.rubygems.org/using-mfa-in-command-line/) says RubyGems
requires owners of any gem with more than 180 million cumulative downloads to enable MFA. The
threshold places the obligation on the gems installed most and read least.

[^2]: The RubyGems guide ends its explanation of the mechanism by sending readers to
[PyPI's security model page](https://docs.pypi.org/trusted-publishers/security-model/), written,
it says, by friends at PyPI. The two registries share the design down to the word pending, and
RubyGems chose to point at PyPI's manual rather than write a second one.

[^3]: `check_publisher.rb` never reads the workflow's `on` key, which is as well.
[YAML 1.1](https://yaml.org/type/bool.html) lists `on`, `off`, `yes`, and `no` among the
spellings of a boolean, so Ruby's YAML parser hands the trigger block back under the key `true`.
GitHub's workflow syntax goes on spelling the key `on`.