Continuous Delivery for Gems
A Practical Guide to Implementing Continuous Delivery for Ruby Gems
Update [May 16th, 2025]: I inserted the step in the Continuous Delivery Implementation Runbook: 4. Skip other workflows for PRs created by release-please-action. I also generalized the headings and expanded the content for each step in the Runbook.
TLDR: skip ahead to the Implementation Runbook to started right away.
Introduction
I have been slowly inching toward implementing Continuous Delivery for my open source gems for a long time now. What finally got me over the line was a blog post by my new friend Jonathan Gnagy titled Moving a Ruby Gem's CI to GitHub Actions. You should read Jonathan’s post before continuing on with this post.
This article details my Ruby Gem Continuous Delivery implementation starting with Jonathan's blog post.
Continuous Delivery vs. Continuous Deployment
In modern DevOps practices, Continuous Delivery and Continuous Deployment are two approaches that streamline the software release process, each with its own level of automation and control.
Continuous Delivery ensures that code changes are automatically built, tested, and prepared for a release to production. However, the actual deployment to production is a manual decision. This allows teams to control when production releases occur.
Continuous Deployment takes this automation a step further by automatically deploying every change to production without manual intervention. This leads to faster release cycles but requires a robust automated testing framework to ensure quality.
In the context of this article, the focus is on implementing Continuous Delivery for Ruby Gems. In this workflow, releasing to RubyGems.org remains a deliberate, manual step, preserving control over what gets published.
The original inspiration
I was excited because Jonathan's post actually describes How to implement Continuous Delivery for Ruby Gems (I think that Jonathan buried the lede for this article). The post uses two GitHub actions to achieve this result: the googleapis/release-please-action and the rubygems/release-gem action.
The googleapis/release-please-action automatically creates a release PR which collects the changes merged to the release branch (usually ‘main’ or ‘master’) as other PRs are merged. When you are ready to push a new version of the gem to rubygems.org, merge the release PR.
I followed the instructions from Jonathan's blog (with a couple tweaks) for 13 different open source gems that I maintain. You can read Jonathan's post to see what he recommended. See my Continuous Delivery Implementation Runbook (below) for complete step-by-step instructions you can follow to implement CD for your Ruby Gem projects.
Additional changes I made
The implementation was pretty smooth sailing with one exception: following Jonathan's instructions resulted in two tags being created for each release where only one is desired. You can see this in Jonathan's metatron project.
Change tag format to "v1.0.0"
The Release Please action creates a release tag and converts it to a GitHub release complete with a description containing a list of changes.
By default, this action creates a release tag including the component name (aka gem name). For the metatron project, the release tag would be "metatron/v0.11.1".
To align with my existing release process, I DO NOT want this tag to contain the gem name. From the metatron example, I would want the release tag to be "v0.11.1".
This is easy enough to accomplish by adding the following to the Release Please configuration file:
"include-component-in-tag": false
Only create one tag per release
Unfortunately, the rubygems/release-gem
action ALSO tries to create a tag in this format. This is why the metatron project is currently creating two tags for each release.
Since the rubygems/release-gem
action uses the rake release
command under the hood, it tries to create the same tag ("v0.11.1" for this example). Unfortunately, this fails because the tag already exists having been created earlier by the Release Please action. It would be nice if the release-gem action allowed you to specify a different rake task for this action but it isn't really configurable at all. I am planning on submitting a PR to add that.
As a workaround, I have redefined the rake release
task to not create and push the tag to GitHub. I replaced the release
task with a task that just calls release:rubygem_push
by including this in my project's Rakefile
:
require 'bundler'
require 'bundler/gem_tasks'
# Make it so that calling `rake release` just calls `rake release:rubygems_push` to
# avoid creating and pushing a new tag.
Rake::Task['release'].clear
desc 'Customized release task to avoid creating a new tag'
task release: 'release:rubygem_push'
I'll remove that customization if I can get my change to the rubygems/release-gem
accepted.
Use sentence case for commits listed in the CHANGELOG
The last change I made was to add a plugin to sentence case the commit messages when listing them in the change log. This change is not necessary, but it's my preference. Add this section after the packages section:
"plugins": [
{
"type": "sentence-case"
}
],
Conclusion
Now my release process is fully automated from commit-to-production. Well, almost: you have to merge the release PR.
What I like about the Release Please action is that it will update the release PR as you merge other PRs to master. This means that you don't have to have a release per PR. Release Please will stack up all the changes into a single release PR even updating the target release version number if you later push a feature or a breaking change.
I strongly recommend that you also implement this workflow for your own gems.
Additional considerations
I strongly recommend that your project enforce that all commit messages conform to the Conventional Commits specification.
This is crucial because release-please uses these commit types (like fix:, feat:, feat!: or BREAKING CHANGE:) to automatically determine the correct semantic version bump (patch, minor, major) and to generate accurate CHANGELOG entries.
Furthermore, release-please will ignore any commits that do not conform to convention commits. It will neither trigger a release for non-conforming commits nor list them in the CHANGELOG.
Stay tuned for my next couple of blog post where I discuss how to add this enforcement to your projects.
Implementation Runbook
These are the steps I took to implement continuous delivery in my RubyGem projects.
1. Create the “Release Gem” GitHub Actions workflow
The purpose of this workflow is to:
Create (or update) the a release PR each time a non-release PR is merged to master, and
Push a new version of the gem to RubyGems.org each time a release PR (created in the previous step) is merged to master.
Add .github/workflows/release.yml
to your project with the following content. Replace <gem-name>
with the name of your gem:
---
name: Release Gem
description: |
This workflow creates a new release on GitHub and publishes the gem to
RubyGems.org.
The workflow uses the `googleapis/release-please-action` to handle the
release creation process and the `rubygems/release-gem` action to publish
the gem to rubygems.org
on:
push:
branches: ["main"]
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
environment:
name: RubyGems
url: https://rubygems.org/gems/<gem-name>
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- name: Checkout project
uses: actions/checkout@v4
- name: Create release
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.AUTO_RELEASE_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Setup ruby
uses: ruby/setup-ruby@v1
if: ${{ steps.release.outputs.release_created }}
with:
bundler-cache: true
ruby-version: ruby
- name: Push to RubyGems.org
uses: rubygems/release-gem@v1
if: ${{ steps.release.outputs.release_created }}
2. Add a release manifest to the project
Add the file .release-please-manifest.json
to the root directory of the project with the following content. Replace <last-release-version>
with the last released version of your gem (for example, "0.1.0").
{
".": "<last-release-version>"
}
If you have never released a version of this gem, just leave the json object empty:
{}
3. Add a release configuration to the project
Add the file release-please-config.json to the root directory of the project with the following content. Make the following replacements:
Replace
<commit-sha>
with the SHA of the last release (or leave offbootstrap-sha
if this gem has never been released).Replace
<gem-name>
with the name of your gemReplace
<path>
with the path to the gem's version file (for example,lib/metatron/version.rb
)
All the supported change types are listed so they can be unhidden and to ensure they are consistent with the change types listed in the commitlint configuration.
{
"bootstrap-sha": "<commit-sha>",
"packages": {
".": {
"release-type": "ruby",
"package-name": "<gem-name>",
"changelog-path": "CHANGELOG.md",
"version-file": "<path>",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"draft": false,
"prerelease": false,
"include-component-in-tag": false,
"pull-request-title-pattern": "chore: release v${version}",
"changelog-sections": [
{ "type": "feat", "section": "Features", "hidden": false },
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
{ "type": "build", "section": "Other Changes", "hidden": false },
{ "type": "chore", "section": "Other Changes", "hidden": false },
{ "type": "ci", "section": "Other Changes", "hidden": false },
{ "type": "docs", "section": "Other Changes", "hidden": false },
{ "type": "perf", "section": "Other Changes", "hidden": false },
{ "type": "refactor", "section": "Other Changes", "hidden": false },
{ "type": "revert", "section": "Other Changes", "hidden": false },
{ "type": "style", "section": "Other Changes", "hidden": false },
{ "type": "test", "section": "Other Changes", "hidden": false }
]
}
},
"plugins": [
{
"type": "sentence-case"
}
],
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
4. Skip other workflows for PRs created by release-please-action
You may have existing workflows (other than the Release Gem workflow created above), that you do not want to run for release PRs.
For instance, I have the following workflows that I do not want to run for release PRs:
The Enforce Conventional Commits and Continuous Integration workflows which are run every time a PR is created or updated.
The Experimental Builds workflow which I run whenever master is updated.
These workflows can be skipped using an if:
condition.
However, in GitHub Actions, you can't place a single if:
conditional at the very top level of your workflow file to prevent the workflow from triggering altogether. Triggering a workflow is controlled by the on:
keyword and its configurations (e.g., events, branches, paths).
You can achieve a "skipped" workflow (where the workflow triggers but no jobs execute) by placing an if:
conditional within each job in the workflow. If the condition evaluates to false
for every job, then the workflow is effectively skipped. The workflow run will still appear in your Actions tab, but the jobs will be marked as skipped.
To skip a workflow that is triggered on a PR creation or update, add the following segment to each job in the workflow.
if: >-
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
!startsWith(
github.event.pull_request.head.ref,
'release-please--'
)
)
To skip a workflow triggered by a push to a branch, add the following segment to each job in the workflow. Note that the commit message fragment 'chore: release '
should match the pull-request-title-pattern
value defined in your release-please-config.json
.
if: >-
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'push' &&
!startsWith(
github.event.head_commit.message,
'chore: release '
)
)
5. Customize the Rakefile’s release
task
The rubygems/release-gem
action calls the release
rake task to publish your gem to rubygems.org.
There is one problem I found: the release
task tries overwrite the release tag in GitHub created by the release-please-action
which causes the workflow to fail.
The workaround for this is to redefine the release
task to call the release:rubygem_push
task instead of doing its default behavior.
require 'bundler'
require 'bundler/gem_tasks'
# Make it so that calling `rake release` just calls `rake release:rubygems_push` to
# avoid creating and pushing a new tag.
Rake::Task['release'].clear
desc 'Customized release task to avoid creating a new tag'
task release: 'release:rubygem_push'
6. Add the release credential token to GitHub
The release-please-action
requires a GitHub Personal Access token in order to do its work which includes updating the changelog, creating tags and creating pull requests.
The Release Gem workflow accesses this token from the AUTO_RELEASE_TOKEN
secret.
Create a PAT. Either create a classic token with repo access or a fine-grained token with the following permissions:
Contents: Read and Write
Metadata: Read
Pull Requests: Read and Write
Add the secret by navigating to the project's GitHub page and then selecting Settings -> Secrets and variables -> Actions -> Repository secrets -> New repository secret
Use
AUTO_RELEASE_TOKEN
for the secret name and the token you created as the secret value.
7. Create a PR with the changes above and merge it
Commit the changes above, create a PR, and merge it once the CI build is completed
Commit the changes you made above (on a branch)
Prefix the commit with 'fix:' so that a new release is created
Create a PR
Wait for the CI build to succeed
Merge the PR
Verification:
Verify that the release workflow runs and creates a new release PR
8. Add a trusted publisher for the gem in RubyGems.org
In order to publish the gem to rubygems.org, rubygems/release-gem action requires the gem have trusted publishing configured on RubyGems.org.
Login to Rubygems.org
Go to the page on RubyGems for the gem being published
In the "Links" section, click "Trusted publishers" and enter your password if prompted
Click the "Create" button and enter the publisher information
Trusted publisher type: Github Actions
Repository owner:
<github user or organization name>
Repository name:
<github repository name>
Workflow filename: release.yml
Environment: RubyGems (this must match what is in the
release.yml
)
9. Merge the release PR
Verification:
Verify that the release workflow successfully pushes a new version of the gem to rubygems.org