Skip to content
oss-kit
GitHubInstall

RubyGems

RubyGems is present when a *.gemspec, a Gemfile, or a Gemfile.lock turns up anywhere in the checkout. The three are not interchangeable. A Gemfile lists what an application installs; a gemspec declares a package. RubyGems describes the gemspec as the file holding “the information for a gem”, and requires name, version, authors, summary, and files in it.

RubyGems is shipped when a gemspec declares those fields and publish evidence exists: a release workflow running gem push, or an existing page on rubygems.org for that name.

Sources: Specification reference, Publishing your gem.

Three cases decide most arguments:

  • spec.metadata['allowed_push_host'] set to anything other than rubygems.org means the gem ships to a private host. RubyGems documents it as the way “to prevent accidental pushes to rubygems.org”. Report the gem as shipped and name the host, rather than reading a restriction as an absence.
  • A Gemfile with no gemspec beside it is present and not shipped. A repository that runs Jekyll for its docs, or Rake for its tests, is this case in full: it resolves gems and publishes none.
  • A Gemfile.lock alone is present. So is a gemspec inside a fixture or an example directory, which is present and answers the shipped question for itself rather than for the repository.

RubyGems takes the registry-push track. gem push uploads a built .gem file to the registry under a credential, so there is an upload to secure and a credential to scope, which is what assigns the track. The roster records "track": "registry-push" for RubyGems and the release area’s preamble names RubyGems in its registry-push list.

Verified 2026-07-31 against https://guides.rubygems.org/specification-reference/ and https://guides.rubygems.org/publishing/.

Shields.io serves this one at img.shields.io/gem/v/GEM, taking the gem name as its only path parameter. ?include_prereleases widens it to pre-release versions, which a README wants only while the stable line is the one nobody should install yet.

The default label is gem. Keep it.

Link the badge to the gem page:

[![gem](https://img.shields.io/gem/v/GEM)](https://rubygems.org/gems/GEM)
gem install GEM

That is what RubyGems documents, and it is the right line for a gem a reader installs on its own, which mostly means a command-line tool. A library that lands in an application’s Gemfile shows Bundler’s command instead:

bundle add GEM

Bundler documents that as adding the named gem to the Gemfile and running bundle install, so it is one line where the alternative is a line to paste into a file plus a second command to run. Show one of the two, chosen by how the gem is actually consumed, not both.

Verified 2026-07-31 against RubyGems basics, lib/bundler/man/bundle-add.1.ronn in rubygems/rubygems, and services/gem/gem-version.service.js in badges/shields.

The gemspec declares it. license= takes one name and licenses= takes an array, as in spec.licenses = ['MIT', 'GPL-2.0']. Each value is short, no more than 64 characters, and is meant to be the name of the license rather than its text or a URL. The specification reference tells a maintainer to use the standard SPDX identifier, and to point a custom license at a file shipped with the gem through a LicenseRef-<idstring> value, where the idstring names the file carrying the license text.

Gemfile is the other manifest the roster lists for this ecosystem, and it declares dependencies rather than package metadata, so it carries no license at all. A repository holding a Gemfile and no gemspec, an application rather than a gem, therefore has nothing on the manifest side of the comparison. That is not a failure to declare; it is an application that publishes no package.

So the manifest side of R-COM-01 is the gemspec’s license value or values, and the file side is the root license file. Where the gemspec lists two, both have to be traceable to what the file says, because an array is a list of licenses that apply and not a menu the reader picks from.

Source: Specification reference.

Tidelift’s platform name for this ecosystem is rubygems, so the GitHub funding file’s entry reads tidelift: rubygems/<gem-name> against the name the gem publishes under. The accepted key format is PLATFORM-NAME/PACKAGE-NAME, described in github.md beside the rest of the accepted keys.

The roster at skills/oss-audit/ecosystems.json records "tidelift": "rubygems" and is the canonical copy. This line exists because a single-skill install of oss-community does not carry that file; where the two disagree, the roster is right and this line is corrected to it.

Verified 2026-07-31 against Specification reference and Displaying a sponsor button in your repository.

On GitHub Actions, ruby/setup-ruby downloads a prebuilt Ruby and puts it on the PATH. Drive the matrix from required_ruby_version in the gemspec, which is where a gem declares the Ruby versions it supports. The ruby-version input also accepts engine names, so a gem that claims JRuby or TruffleRuby support adds jruby and truffleruby as matrix entries rather than another MRI version.

strategy:
fail-fast: false
matrix:
ruby: ['3.3', '3.4', '4.0', jruby, truffleruby]
steps:
- uses: ruby/setup-ruby@v1 # oss-harden pins this to a commit SHA
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true

Upstream tells readers to use ruby/setup-ruby@v1 and not to pin further: pinning to a commit freezes the set of Ruby versions the action can install to whatever existed at that commit, so a new Ruby release needs a bump before it can be tested. That collides with R-SEC-01, which oss-harden owns, so raise the conflict there rather than resolving it here. With no ruby-version input the action reads .ruby-version, then .tool-versions, then mise.toml, which pins one version instead of a matrix.

The action supports a fixed list of runner images, and the README carries the table. Check it before adding an operating system to the matrix.

On GitLab, run the job in the Ruby official image and vary the tag with parallel:matrix.

Sources: ruby/setup-ruby, Gemspec reference, docker-library/ruby.

bundler-cache: true runs bundle install and caches the result. It needs a Gemfile, or $BUNDLE_GEMFILE or gems.rb, under the working directory, and it uses bundle config --local path $PWD/vendor/bundle, so the cached directory holds installed gems rather than a download cache. That is the action’s documented design rather than a mistake to correct: because it owns the install, it can key the cache on the lockfile hash and rebuild from it. Do not set bundle config path yourself in the workflow, which moves the directory out from under the cache.

The key comes from the lockfile hash. Where the repository commits no Gemfile.lock, which is the normal case for a gem, the action generates one with bundle lock first and hashes that, so the key still tracks a resolved dependency set rather than a range. A committed Gemfile.lock additionally turns on bundle config --local deployment true. cache-version is the documented escape hatch for a cache that has gone bad, for instance a gem with a C extension built against a system library that has since changed.

On GitLab there is no equivalent. Set BUNDLE_PATH to a project-relative directory, key cache:key:files on Gemfile.lock, and cache that directory, because GitLab caches only paths inside the project directory.

Sources: ruby/setup-ruby, GitLab CI/CD YAML reference, cache.

A gem declares its test task in the Rakefile, and the conventional entry point is bundle exec rake, which runs the default task; setup-ruby’s own examples end on that line. Where the Rakefile defines the suite under a named task, bundle exec rake test, or where the project drives RSpec directly, bundle exec rspec, call the project’s task rather than the runner, so the Rakefile stays the one place the arguments live.

bundle exec is the part that matters for reproducibility: it runs the binary Bundler resolved for this Gemfile.lock rather than whatever version of the gem is on the PATH.

Sources: ruby/setup-ruby.

Verified 2026-07-31 against https://github.com/ruby/setup-ruby, https://guides.rubygems.org/specification-reference/, https://github.com/docker-library/ruby, and https://docs.gitlab.com/ci/yaml/.

Dependabot’s bundler value carries version updates and security updates, and it is one of the few ecosystems where the table also marks vendoring supported, so a project that checks its gems into vendor/cache still gets updates.

On GitLab the Renovate manager is bundler.

Bundler writes Gemfile.lock without being asked, and the frozen mode is configuration rather than a command flag. bundle config set --local frozen true sets it in .bundle/config inside the repository, where the workflow file and a reviewer can both see it. Bundler documents frozen as “Disallow any automatic changes to Gemfile.lock. Bundler commands will be blocked unless the lockfile can be installed exactly as written”, and names the usual trigger, a Gemfile edited by hand without regenerating the lockfile.

deployment is documented as equivalent to setting frozen to true and path to vendor/bundle, so it is the stricter of the two only in where the gems land. Where a project already sets deployment, that is the frozen half satisfied; do not add both and report them as two controls.

What breaks the first time you commit the lockfile

Section titled “What breaks the first time you commit the lockfile”

Committing the lockfile is the easy half. Three things fail afterward, all of them in CI rather than on the machine that generated the lock.

Generate the lock on the lowest Ruby the matrix tests. Frozen mode compares the Gemfile’s declared dependency set against the lock’s DEPENDENCIES section, and refuses when they differ. A gem whose requirement resolves differently by Ruby version therefore records a different DEPENDENCIES set on each one. Generate the lock on the newest Ruby and every job on the oldest fails before install begins, with You have deleted from the Gemfile naming a gem nobody deleted. Pin such a gem to a version the whole matrix resolves, and say in a comment which Ruby floor lifts the pin. A RUBY_VERSION conditional in the Gemfile does not work here and fails harder, because it makes the declared set itself vary by job.

A gemfiles matrix needs its own committed locks, and Dependabot will not maintain them. Dependabot’s Bundler file fetcher looks for Gemfile or gems.rb, and Gemfile.lock or gems.locked, by exact name. An appraisal-style gemfiles/rails_8.0.gemfile and its gemfiles/rails_8.0.gemfile.lock match none of those, so a Dependabot pull request updates the root lock and leaves the matrix locks behind.

That is less dangerous than it reads, because the matrix locks eval_gemfile the root Gemfile and record its requirement strings. A Dependabot change to a requirement therefore turns the matrix jobs red on its own pull request, which is the reminder to regenerate. What drifts quietly is only a bump inside an unchanged requirement, where the matrix keeps testing a slightly older patch version until the next regeneration. Give the project one command that regenerates every lock, and name it in the contributing guide rather than only in a Rakefile comment.

Count the jobs that are actually frozen before relying on that reminder. A matrix job running unfrozen, or marked continue-on-error, cannot report the mismatch.

Add the CI platform. A lock generated on a developer’s macOS records that platform alone, and a Linux runner then has no resolution to install. bundle lock --add-platform x86_64-linux “adds a new platform to the lockfile, re-resolving for the addition of that platform”, with no machine of that platform needed.

Verified 2026-07-31 against dependabot-core’s Bundler file fetcher and bundle lock.

CodeQL supports Ruby, so CodeQL default setup covers this ecosystem on GitHub. GitLab’s SAST table lists Ruby under its Semgrep-based analyzer with GitLab-managed rules.

The dependency graph parses Gemfile.lock as its recommended file, with Gemfile and *.gemspec as additional files. The RubyGems row marks static transitive dependencies, Dependabot graph jobs, and automatic dependency submission all unsupported, so the coverage a committed Gemfile.lock buys is what the lockfile itself lists rather than a resolved graph the forge builds.

Advisories come from the GitHub Advisory Database, which names this ecosystem RubyGems against the rubygems.org registry.

osv-scanner reads Gemfile.lock and gems.locked.

Verified 2026-07-31 against Dependabot supported ecosystems and repositories, Dependency graph supported package ecosystems, GitHub Advisory Database, bundle config, CodeQL supported languages and frameworks, GitLab SAST, Renovate managers, and osv-scanner supported languages and lockfiles.

Concrete flow for the decisions SKILL.md makes, for a gem published to rubygems.org. RubyGems documents one OIDC provider for trusted publishing: GitHub Actions. There is no staged-publishing equivalent to npm’s; the approval gate in Step 4 is entirely the forge environment.

Source: RubyGems Guides, Trusted Publishing.

Read every *.gemspec in the repository; a repository with more than one needs one trusted publisher per gem. Get the owner and repository from the gemspec’s metadata["source_code_uri"] or homepage_uri, falling back to git remote get-url origin. Check whether each gem is already published with curl -s -o /dev/null -w '%{http_code}' https://rubygems.org/api/v1/gems/<name>.json; a 404 means it is not, and Not yet published gems below covers that case.

Only one of the two sections below is a flow. RubyGems supports GitHub Actions and names no GitLab provider, so a GitLab project takes the fallback rather than a shorter version of the same setup.

On rubygems.org, open https://rubygems.org/gems/<name>/trusted_publishers/new for each gem, logged in as an owner, and enter:

  • Repository owner: the owner from Step 1
  • Repository name: the repository name from Step 1
  • Workflow filename: release.yml, the filename only, not the full path
  • Environment: the approval environment name from SKILL.md Step 4, for example release

Leaving Environment empty means RubyGems mints a token for any run of that workflow, approved or not; it is the field that makes the approval gate load-bearing, not optional.

In the workflow, the publish job needs:

permissions:
contents: read
id-token: write

No RUBYGEMS_API_KEY or any other registry secret is needed once the trusted publisher above is configured; the rubygems/configure-rubygems-credentials action or the rubygems/release-gem action exchanges the workflow’s OIDC token for a publish token.

RubyGems’ own guide names GitHub Actions as the OIDC identity provider throughout and does not mention GitLab CI/CD anywhere. Read that guide before writing this section, and treat GitLab CI/CD as unsupported unless it names GitLab as a provider. An open discussion or a draft pull request is not a shipped provider.

Source: rubygems/rubygems.org discussion #4845, “trusted publishing with gitlab CI”.

Where the guide names no GitLab provider, this is the strongest alternative:

Create a scoped API key at https://rubygems.org/profile/api_keys/new, restricted to the Push rubygem scope for this one gem only. The expiry field is a free datetime picker with a minimum of five minutes from the current time, not a list of preset durations; set it to the shortest value that still fits the release cadence. rubygems.org does not let the expiry be edited after creation, so plan to rotate it on that schedule. Store it as a GitLab CI/CD variable that is both masked and protected, so it is redacted from job logs and only available to pipelines running on a protected branch or tag. Put the publish job behind a GitLab protected environment with required approvers, the same gate Step 4 uses elsewhere, so the key’s mere presence in the pipeline is not enough to publish. Sign the built gem: gem push --attestation needs the same OIDC token trusted publishing supplies, which is unavailable here, so use the older certificate-based signing instead, with gem cert --build <email> to create a signing key and spec.signing_key and spec.cert_chain in the gemspec to sign every build.

This is below the bar R-PUB-02 sets, because a scoped, expiring key is still a credential that can leak, unlike a trusted publishing flow where nothing is ever stored; take it only while the guide names no GitLab provider, and re-read the guide before each release process is written.

Write the hardened release workflow (Step 3)

Section titled “Write the hardened release workflow (Step 3)”

The publish job installs no Gemfile dependencies and uses no Bundler cache anywhere in the workflow, including the test job: a restored cache is a cache-poisoning vector in a tag-triggered publishing workflow, and releases are rare enough that the lost cache costs nothing.

Write the separated workflow below by default. Build the gem in one job, pass the .gem file across as an artifact, and sign it explicitly in the publish job with the same tool release-gem calls under the hood:

name: Release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: false
- run: bundle config set --local frozen true
- run: bundle install
- run: bundle exec rake test # oss-ci decides the actual command from CONTRIBUTING.md (R-CI-02)
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: false
- run: ruby -e 'spec = Gem::Specification.load("path/to/name.gemspec"); abort unless ENV.fetch("GITHUB_REF_NAME").delete_prefix("v") == spec.version.to_s'
- run: mkdir -p pkg && gem build path/to/name.gemspec --output pkg/package.gem
- uses: actions/upload-artifact@v7
with:
name: built-gem
path: pkg/package.gem
retention-days: 1
publish:
runs-on: ubuntu-latest
needs: [test, build]
environment: release
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: built-gem
path: pkg/
- uses: ruby/setup-ruby@v1
with:
ruby-version: '<exact project Ruby version>'
bundler-cache: false
- uses: rubygems/configure-rubygems-credentials@main # oss-harden pins this to a commit SHA
- run: gem exec --conservative sigstore-cli:0.2.3 sign pkg/package.gem --bundle pkg/package.gem.sigstore.json
- run: gem push pkg/package.gem --attestation pkg/package.gem.sigstore.json

Replace the gemspec path and Ruby version from the repository. Adapt the version comparison to the actual tag format. Use one explicit gemspec per build and publish job; never let a glob choose among multiple gems.

bundle config set --local frozen true is what keeps the test job inside R-SEC-08, whose Check: asks that every CI install use the package manager’s current frozen mode. A bare bundle install in a workflow this skill writes fails a rule the kit owns, and an audit run after this skill will score it. It needs a committed Gemfile.lock, which R-SEC-08 already requires and oss-harden already produces; where oss-harden has not run yet and the repository commits no lockfile, generate it before adding the line rather than dropping the line. Neither the build nor the publish job installs Gemfile dependencies at all, so neither carries this.

oss-harden pins every uses: line above to a commit SHA and sets this workflow’s permissions:, including the contents: read this skill left off the test and build jobs above; do not pin them or add permissions here. configure-rubygems-credentials documents @main and recommends a commit SHA; resolve that SHA directly from its repository. release-gem pins no sigstore-cli version, so do not look for one there; its default branch is v1, so a raw fetch of main/action.yml returns 404, and its only signing-related input is attestations, which its own description marks experimental. Resolve the current version from the registry instead:

curl -s https://rubygems.org/api/v1/gems/sigstore-cli.json | jq -r .version

If an existing workflow uses secrets.RUBYGEMS_API_KEY or GEM_HOST_API_KEY, remove it from the YAML now and tell the user to delete the corresponding secret once the new flow is verified.

RubyGems officially recommends rubygems/release-gem@v1 instead, which is the escape hatch where the smaller diff matters more than job separation. It runs bundle exec rake release, signs with sigstore-cli, and pushes the release tag, so it needs contents: write and runs the project’s full bundle in the credentialed job. Offer it only with that exposure named.

Pin the publish job to environment: release as above, and create that environment at https://github.com/<owner>/<repo>/settings/environments/new with required reviewers naming at least one person other than an automation account. Required reviewers work for public repositories on current GitHub plans; private or internal repositories need GitHub Enterprise Cloud. RubyGems has no registry-side approval fallback, so report R-PUB-04 as unmet when the repository plan provides no native gate. Require account-level MFA at UI and API level, at https://rubygems.org/settings/edit, and add to every gemspec:

spec.metadata["rubygems_mfa_required"] = "true"

Create it with the API rather than the form. Reviewers and the tag policy are both settable, so nothing here needs a browser.

ENV=release
GHUID=$(gh api user --jq .id)
gh api -X PUT "repos/{owner}/{repo}/environments/$ENV" \
-F wait_timer=0 \
-F prevent_self_review=false \
-f 'reviewers[][type]=User' -F "reviewers[][id]=$GHUID" \
-F 'deployment_branch_policy[protected_branches]=false' \
-F 'deployment_branch_policy[custom_branch_policies]=true'
gh api -X POST "repos/{owner}/{repo}/environments/$ENV/deployment-branch-policies" \
-f 'name=v*' -f type=tag

Three details decide whether that runs. gh api substitutes {owner} and {repo} from the checkout it runs in. Use -F for the booleans and the reviewer id, because -f sends every value as a string and the endpoint rejects a quoted boolean. Do not name the shell variable UID: zsh marks it read only, so the assignment fails before gh runs.

reviewers[][id] takes a numeric user or team id rather than a login. A team needs type=Team and that team’s id.

The explicit sigstore-cli step signs the exact .gem downloaded from the build job using the workflow’s ambient OIDC identity. gem push --attestation uploads that bundle with the same file. Do not rely on implicit signing behavior that varies by RubyGems client version. After the first release, verify the registry record:

curl -s https://rubygems.org/api/v1/attestations/<name>-<version>.json

A non-empty JSON array confirms RubyGems.org serves an attestation for that exact name and version. An empty array is a failed provenance check.

Wait for the index before verifying by installing. gem push returns success as soon as the gem is accepted, and the compact index a client resolves against is written afterwards, so gem install <name> -v <version> immediately after a release fails with Could not find a valid gem '<name>' (= <version>). That is propagation, not a failed publish, and a retry loop around gem install will burn several minutes reporting it. Poll the API instead, which reflects the release first:

until curl -sf "https://rubygems.org/api/v1/versions/<name>.json" | jq -e --arg v "<version>" 'any(.[]; .number == $v)' >/dev/null; do sleep 10; done

Then install once. Report a failure after the API already lists the version as a real failure. release-gem carries an await-release input that does the same waiting, defaulting to true, which is the upstream confirmation that this delay is a property of the registry rather than of one release.

Describe and sign what the release attaches (Step 6)

Section titled “Describe and sign what the release attaches (Step 6)”

Only for a release that attaches a built asset to the forge release. The gem this workflow pushes is signed with sigstore-cli and pushed with --attestation in Step 3, and rubygems.org serves that attestation back, so the gem itself is covered. The source archives GitHub generates for a tag are not built assets. A release that attaches nothing beside the gem goes to Step 7 instead.

Two rules apply here and they ask for different things. R-PUB-05 wants an inventory of what went into the asset, in SPDX or CycloneDX. R-PUB-06 wants the assets signed, or listed by hash in a signed manifest. A manifest of hashes answers the second and nothing about the first, so do not report R-PUB-05 as met by publishing one.

This reference names no SBOM generator for Ruby. The ones in common use are third-party tools rather than part of the packaging toolchain, and a tool that reads the dependency tree inside the release workflow is one the maintainer vets before it goes there. What answers R-PUB-05 without one, on GitHub, is the forge’s own export of the repository’s dependency graph, which is already SPDX and needs nothing installed:

github-release:
runs-on: ubuntu-latest
needs: [publish]
permissions:
contents: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: built-gem
path: pkg/
- run: gh api repos/${{ github.repository }}/dependency-graph/sbom --jq .sbom > pkg/sbom.spdx.json
env:
GH_TOKEN: ${{ github.token }}
- run: (cd pkg && sha256sum *) > SHA256SUMS
- uses: actions/attest@v4
with:
subject-checksums: SHA256SUMS
- run: node .github/scripts/release-notes.mjs "$GITHUB_REF_NAME" > notes.md
- run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md
env:
GH_TOKEN: ${{ github.token }}
- run: gh release upload "$GITHUB_REF_NAME" pkg/* SHA256SUMS
env:
GH_TOKEN: ${{ github.token }}

Writing the SBOM into pkg/ before the manifest step is what puts it under both rules at once: it ships as a release asset for R-PUB-05, and it is listed and attested alongside the gem for R-PUB-06.

State both of the export’s limits to the maintainer rather than leaving them to be discovered. It is GitHub only, so a GitLab project keeps this gap and R-PUB-05 stays unmet there with the reason named. And it covers the repository’s declared dependency graph rather than what is linked into the asset, which is exact for a gem, because a gem carries no bundled dependencies and installs them from the same declarations. The graph resolves past the direct dependencies only where a lockfile is committed, which R-SEC-08 already requires. Read the output back once with gh api repos/<owner>/<repo>/dependency-graph/sbom --jq '.sbom.packages | length' before reporting the rule met: a graph the forge does not parse for this ecosystem returns a near-empty package list rather than an error.

sha256sum runs from inside pkg/ so the names it writes are the names the assets carry on the release. A manifest listing pkg/package.gem cannot be checked against a downloaded package.gem.

subject-checksums makes every file listed in SHA256SUMS a subject of the attestation in its own right, by name and digest, and it takes the format sha256sum writes. Attesting SHA256SUMS itself with subject-path instead would leave a consumer able to verify the manifest and nothing about the assets it lists.

A consumer verifies an asset, then checks the rest of the download against the manifest:

gh attestation verify <asset> --repo <owner>/<repo>
sha256sum -c SHA256SUMS

Run the first command against each asset downloaded, never against SHA256SUMS, which is a subject of nothing. It proves that this workflow in this repository produced those exact bytes, and it prints the signing workflow it accepted; --signer-workflow <owner>/<repo>/.github/workflows/release.yml pins that, so an attestation from any other workflow in the repository fails. The second command is what a consumer without gh has. It proves the files match a list published beside them, and nothing about who produced either. These verify the forge release’s copy; the gem installed from rubygems.org is verified through the registry record in Step 5.

gh release upload fails when no release exists for the tag, which is why the job creates it. The notes come from CHANGELOG.md rather than from --generate-notes, because oss-changelog already mandates the Keep a Changelog structure, so the section for this version is guaranteed to exist and is what the project itself wrote about the release. --generate-notes would list merged pull requests instead, which is a different document and one the project did not write.

release-notes.mjs is oss-changelog’s, at skills/oss-changelog/scripts/release-notes.mjs. Copy it to .github/scripts/release-notes.mjs in the repository, unchanged, and commit it with the workflow. It is one file, it imports Node built-ins only, and every GitHub runner has a Node old enough to run it, so it needs no install step and no setup-node. Where the release job cannot carry the copy, extract the section in the job with the project’s own tooling instead; what must not happen is a regex improvised at release time, which is what this replaces.

Its exit code is the load-bearing half. It exits 2 when the version’s section is absent or empty, so the release job fails rather than publishing a release with a blank body, which nobody looks at twice. Check it against the tag about to be pushed before pushing it, with node .github/scripts/release-notes.mjs vX.Y.Z.

A third job is what makes the grants above safe, so copy the job boundary along with them. The build job runs gem build against the project’s own gemspec, so giving it release-asset writes and an attestation identity is exactly the credential split Step 3 exists to enforce, and it would write assets before the approval gate. The publish job holds the rubygems.org credential. Only a separate job satisfies both, and needs: [publish] is what keeps the assets behind the gate.

The four grants above are copied exactly, on this job only. The workflow’s top-level block stays contents: read. Narrowing anything else, pinning each uses: to a commit SHA, and auditing the result are oss-harden’s.

Unlike npm, RubyGems does not require a manual first release. Create a pending trusted publisher at https://rubygems.org/profile/oidc/pending_trusted_publishers/new, entering the gem name plus the same owner, repository, workflow filename, and release environment as above. The first successful push from that workflow claims the name and converts the pending publisher into a normal one, so the very first release already goes through CI with no API key ever created. Confirm the name is actually free before creating it.

Every gem needs its own trusted publisher entry pointing at the same repository and the same workflow filename; a gem left out of that list stays unprotected. One workflow can release every gem if versions move together; if gems version independently, tag them separately and match the trigger and the build job to the tag’s gem name. spec.metadata["rubygems_mfa_required"] goes in every gemspec, not just the first one.

Step 7 is in SKILL.md: read each R-PUB rule’s Check: line against what this file produced, and fix what fails before reporting done.

Verified 2026-07-31 against RubyGems Guides, Trusted Publishing, which still names GitHub Actions as the only OIDC provider and does not mention GitLab; against rubygems/release-gem, whose default branch is v1 and whose inputs are token, await-release, setup-trusted-publisher, attestations, and working-directory, with no sigstore-cli version among them; and against https://rubygems.org/api/v1/gems/sigstore-cli.json, which reports 0.2.3.

The gemspec’s spec.version is what the built gem carries. Most gems do not write a literal there. The layout bundle gem generates puts a VERSION constant in lib/<gem_name>/version.rb and has the gemspec read it, so the constant is the source and the gemspec is derived from it. Read the gemspec first, then follow it: a repository where the gemspec assigns a string directly has one source, and one where it references a constant has that file as the source and the gemspec as a pointer.

Gemfile.lock is not a version source for a published gem in the usual case, because a library is not expected to commit it. Where a repository does commit one and its Gemfile loads the gemspec, the resolved entry for the gem itself carries the number too, so it becomes a third place a release has to leave consistent.

RubyGems urges gem authors to follow Semantic Versioning, and a plain MAJOR.MINOR.PATCH gem version is both valid SemVer and a valid Gem::Version. The prerelease spelling is where the two part company. A gem version is a prerelease when it contains one or more letters anywhere in the string, and the convention separates the segments with dots rather than SemVer’s hyphen, as in 1.0.0.pre, 2.0.0.rc1, and 1.5.0.beta.3. Those versions install only with gem install <gem> --pre, so a prerelease that does not carry a letter is not treated as one and reaches everybody.

There is no build metadata concept, so a SemVer +build suffix has nowhere to go. Keep the released version free of letters and the syntax question does not arise; R-CHG-02 then turns entirely on the bump decision.

Major version in package identity (R-CHG-07)

Section titled “Major version in package identity (R-CHG-07)”

RubyGems does not encode the major version in package identity. A gem name is stable across majors, so R-CHG-07 does not reach this ecosystem. A project shipping an incompatible rewrite under a new gem name has created a second release unit, not a second identity for the same one.

gem yank GEM -v VERSION is the mechanism. RubyGems documents that it removes the version from RubyGems.org’s index, so the version stops being available to gem install and the other gem commands, and that since 2015-04-20 it also removes the gem file.

Two things follow. The guide warns that several hundred services are pinged when a gem is pushed, through the webhook and mirror system, so a credential pushed by accident has to be rotated even if the version is yanked immediately; a yank is not containment. And the guide does not say whether the same version number can be pushed again after a yank, or offer any deletion of a gem as a whole, so treat the number as spent and publish the fix under a new version rather than re-pushing.

[YANKED] on the changelog heading maps to a yank. The registry stops serving the version, which makes the changelog the only place a user who already installed it can find out why it went.

Verified 2026-07-31 against Removing a published gem, Patterns, and Make your own gem.