How-to › Release and secure packages

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.

Audience
Library maintainer
Level
intermediate
Topic
Publish to package registries
Languages
Ruby
Verified

Your release job pushes the gem with a RubyGems.org API key kept in a repository secret. The key expires only if someone remembered to set an expiry, it can push every gem the account owns, and anyone who reads the secret can use it from any machine. Rotating it means a person signing in with MFA and pasting a fresh key into CI, so the rotation never happens.

What you get

You will end up with a gem that builds, a workflow that publishes it from a tag with no stored key, and a check that the workflow matches the registered publisher. This is for you if you maintain a gem and release it from GitHub Actions.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A scoped API key in a CI secretA CI system other than GitHub Actions, or a release machine you controlA long-lived secret to store and rotate, whose scope list is the only limit on what a leak can doGitHub Actions, where a publisher record replaces the key
gem push run by a maintainer with MFAA few releases a year, cut by one person with a security deviceA person at a keyboard for every release, and signed-in credentials sitting in that person’s home directoryReleases run from CI, or several people cut them
Trusted publishing with rubygems/release-gemGitHub Actions, with releases cut by pushing a tagA 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 includedAnother 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 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.

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
gem build acme_sdk.gemspec
  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 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 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 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.

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.

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 runs rubygems/configure-rubygems-credentials, which posts the token to the exchange endpoint 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 against the repository, the workflow file, and the environment, never the ref the job ran from. Give release a deployment rule 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 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 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

# 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.

ruby demo.rb
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 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

ruby -Ilib test/release_test.rb --seed 1
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:

gem build pitfall/acme_sdk.gemspec
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.

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.

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.

Footnotes

  1. The MFA requirement is not only a recommendation. The command line MFA guide 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. ↩︎ Back to text

  2. The RubyGems guide ends its explanation of the mechanism by sending readers to PyPI’s security model page, 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. ↩︎ Back to text

  3. check_publisher.rb never reads the workflow’s on key, which is as well. YAML 1.1 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.