“Can I safely use this new release?”

When you’re a developer maintaining a piece of software, this is a question you ask every time you update one of its dependencies. How do you answer that question? Read the CHANGELOG? Release notes? Pray? No, you look at the version (first, at least). How to interpret that version generally depends on the project, but I’m personally partial to semantic versioning. I think it serves as an elegant contract between dependency author and consumer that can be used to easily answer this question.

That said, I don’t actually care about the version itself, I just care about the contract. Bugfixes bump the patch version, features bump the minor version, breaking changes bump the major version. Actually deciding when to do a release, determining the version to use, and then DOING that version bump and release is super boring. The same critique goes for the release notes. Thankfully, with a little commit discipline, both problems can be solved the same way: conventional commits. If each commit can be used to determine if a given change is a bugfix/feature/breaking change, then you can automate the rest with something like Semantic Release. That’s what I’ve been doing for the better part of a decade, and I can’t recommend it enough.

Historically, publishing a gem to RubyGems automatically from CI was easy: you create an API token scoped to specifically publish that gem, and use that token in your CI environment to push the gem. However, that token is one of the targets for supply chain attacks. Also, using tokens like that requires that your account doesn’t utilize multi-factor authentication (MFA), which you should definitely be using. So what is the solution? Trusted Publishing.

RubyGems Trusted Publishing

Trusted Publishing is pretty well documented elsewhere, so I don’t want to dwell on the details here for too long. For our purposes, let’s gain a high-level understanding of how this works by contrasting it with the API token approach mentioned above.

Without trusted publishing:

  1. Create API token, scoped as tightly as possible to just pushing this gem
  2. Put it in CI variables in as secure a manner as possible (e.g. only defined on your release branch(es), etc.)
  3. When releasing the gem, use that variable to authenticate to RubyGems, and push the gem

Repeat (3) every time you need to release a new version. You can see how, if some part of your supply chain is compromised (e.g. your dependencies, or some part of your build system), that key can be stolen every time (3) runs. And since it’s a long-lived key, the attackers can use it until you happen to detect that it has been compromised. Yes, thanks to your carefulness in (1) the damage they can do is limited to your gem, but they can still turn your gem into an attack vector for your users by pushing malicious versions of it.

With trusted publishing:

  1. Go to your gem’s settings in RubyGems, and add a new Trusted Publisher. This is identified by your GitHub repository, GitHub Actions workflow file, and environment name. You’re basically saying “allow this gem to be published from this GitHub Action running in this environment.”
  2. When releasing the gem (from the workflow and environment you specified in (1)), obtain a new token from RubyGems, use it to authenticate, and push the gem

Repeat (2) every time you need to release a new version. On the surface, perhaps this doesn’t sound so different from the “without trusted publishing” flow, but it is: the token you fetch in (2) is incredibly short-lived. Even if CI is compromised, the window of opportunity for utilizing the stolen token is vanishingly small. This method of authenticating and pushing new releases is far superior.

How do we automatically push new gem versions with Trusted Publishing?

You may have noticed that step (2) above, with the “with trusted publishing” flow, was uselessly high level. I could have written “Magic happens here.” That’s because the process of actually contacting RubyGems to obtain that short-lived token is complicated enough to not really be intended to be done by you, dear developer. RubyGems kindly created a GitHub Action specifically for this, called release-gem. That is nifty, but to quote the end of the documentation:

With the trusted publisher configured and this workflow in your repository, you can release a new version of your gem by simply pushing a git tag. No API tokens, no manual gem push — just tag and push.

If you read the intro to this article, you’ll know that’s a problem for me. In order for me to push a tag, I have to select a version. I have to write the CHANGELOG. I have to do all the boring stuff that I don’t want to do. I want Semantic Release doing this for me! Can I make them work together?

Integrate Semantic Release with Trusted Publishing

First, we need to gain a better understanding of what exactly release-gem does. To glean those details we need to dig into its action.yml, which at the time of this writing looks like this:

name: "Release Gem"
# snip...
runs:
  using: "composite"
  steps:
    - name: Attribute commits to last committer
      run: |
        git config --global user.email "$(git log -1 --pretty=format:'%ae')"
        git config --global user.name "$(git log -1 --pretty=format:'%an')"        
      shell: bash
    - name: Configure Git using cached credentials
      run: |
        git credential-cache --timeout=300 store <<EOF
        protocol=https
        host=github.com
        username=x-access-token
        password=${{ inputs.token }}

        EOF
        git config --local credential.helper 'cache --timeout=300'        
      shell: bash
    - name: Fetch tags
      run: git fetch --tags --force
      shell: bash
    - name: Configure trusted publishing credentials
      if: ${{ inputs.setup-trusted-publisher == 'true' }}
      uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0
    - name: Run release rake task
      run: bundle exec rake release
      shell: bash
      env:
        RUBYOPT: "${{ inputs.attestations == 'true' && format('-r{0}/rubygems-attestation-patch.rb {1}', github.action_path, env.RUBYOPT) || env.RUBYOPT }}"
      working-directory: ${{ inputs.working-directory }}
    - name: Wait for release to propagate
      if: ${{ inputs.await-release == 'true' }}
      run: gem exec rubygems-await pkg/*.gem
      shell: bash
      working-directory: ${{ inputs.working-directory }}
    - name: Clean up credentials
      if: always()
      run: git credential-cache exit || true
      shell: bash

From this, you can see that this isn’t the GitHub Action that actually does the “get my credentials” dance: that’s rubygems/configure-rubygems-credentials. If you just want something to fetch credentials for you to use in your own CI workflow, that is the GitHub Action you need to care about. That’s what we need in order to integrate Trusted Publishing with Semantic Release.

Semantic Release configuration

Let’s take a momentary detour to discuss the Semantic Release configuration file, .releaserc.yaml:

# Standard plugins, minus npm, plus rubygems
plugins:
  - "@semantic-release/commit-analyzer"
  - "@semantic-release/release-notes-generator"
  - "@semantic-release/github"
  - "@webhippie/semantic-release-rubygem"

You’ll notice I’m using the semantic-release-rubygem Semantic Release plugin to actually build/push gems, and the rest are standard.

CI Workflow

Alright, let’s put all the pieces together. Writing the CI workflow is almost exactly as if you had a long-lived token. Here’s an example deploy job:

deploy:
  runs-on: ubuntu-latest
  environment: rubygems

  # Only deploy if running on master, our release branch in this case
  if: github.ref == 'refs/heads/master'

  permissions:
    id-token: write # Required for trusted publishing
    contents: write # Required to push tags

  steps:
    - name: Checkout code
      uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
      with:
        persist-credentials: false

    - name: Setup node
      uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
      with:
        node-version: '26'

    - name: Install semantic release
      run: npm install semantic-release@25 @webhippie/semantic-release-rubygem@7

    - name: Obtain RubyGems credentials
      uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0

    - name: Run semantic release
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: npx semantic-release

That’s it. That CI workflow obtains a short-lived token from RubyGems, and then runs Semantic Release. Semantic Release will process all the commit messages that have taken place since the last release, determine first if a release should even be made, and if so, the version that should be used. Once that is determined, it will tag that commit and push it up. It will then update the gem version, build the gem, and then push it using the credentials obtained by configure-rubygems-credentials.

Conclusion

Semantic versioning is a great way to help your users understand if they can update to your new releases without issue. Combining conventional commits and Semantic Release lets you honor that contract without the tedious work of manually picking versions and writing changelogs. Trusted Publishing eliminates the long-lived API token from that workflow entirely, but the documentation and GitHub Actions make some assumptions that aren’t entirely compatible with Semantic Release. I hope this article was helpful in smoothing over that sharp edge and showing that, with a little care, you can take advantage of the increased security offered by Trusted Publishing without sacrificing any of that release automation. The result is magical.