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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A scoped API key in a CI secret | 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 | 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 | 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 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.
Related how-tos
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
-
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
-
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
-
check_publisher.rbnever reads the workflow’sonkey, which is as well. YAML 1.1 listson,off,yes, andnoamong the spellings of a boolean, so Ruby’s YAML parser hands the trigger block back under the keytrue. GitHub’s workflow syntax goes on spelling the keyon. ↩︎ Back to text