My musings on technology, science, math, and more

Moving a Ruby Gem’s CI to GitHub Actions

I like to tinker. While tinkering, I’ve created a lot of random tools, and when I think those might be useful to others I try to open-source them. The metatron and bullion ruby gems are good examples of that. An example that hasn’t made it open-source (yet?) is my homegrown CI tool and GitHub App called RoxanneCI.

I started RoxanneCI with high ambitions, expecting to have more free time to develop it — but that didn’t quite happen. Instead of all the wonderful ideas I had for my CI tool panning out, RoxanneCI ended up just being a much worse (but at least functional) Travis CI.

I wanted something better, so I looked at what other options were available and decided to give GitHub Actions a try.

What I had:

  • Free (to me)
  • Automated testing, building, and publishing of my gems
    • This required defining stages in a .roxanne.yml file like this one and creating scripts like these that those stages execute
    • Cutting new gem releases required that PRs merging into main bumped the VERSION constant appropriately, which was very manual

What I never got around to:

  • Automating CHANGELOG.md generation
  • Automating cutting of new gem releases
  • Easy troubleshooting when CI tasks fail
    • I had to use my home lab’s logging tooling to find the job logs
  • Launching related/required “Services” (like Redis or Kubernetes) for things like integration testing
  • Feeling good enough about hardening the approach to allow others to use it
  • Great uptime (RoxanneCI is highly dependent on my home lab)

Given everything that I was missing and how far GitHub Actions has come, I have a hard time justifying continuing work on RoxanneCI.

I’d like my open-source projects to be successful, so I got to work migrating one of my gems (metatron) to GitHub Actions.

Getting Started

The first thing I needed to do was create a .github/workflows directory and a ci.yml file:

git checkout -b switch-to-ghactions
mkdir ./github/workflows
touch ./github/workflows/ci.yml

The ci.yml needs a couple of basic things to be useful. Let’s start with just running rubocop to see if the code lints cleanly:

name: CI

on:
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true

      - name: Lint code for consistent style
        run: bundle exec rubocop -f github

This should make new PRs against the repo automatically run lint checks. This configuration relies on the .ruby-version file in the root of the repo to determine the ruby version to use and requires that the rubocop gem is in the repo’s Gemfile.

The jobs section describes what actions GitHub will perform. Each job needs a name (in this case, lint) and consists of some steps plus a runs-on key that defines the base OS used to execute the actions. Each step within a job also has a name and either a uses key that points to a predefined command, or a run key that defines some script to execute. This barely scratches the surface of what GitHub Action workflows can do. Check out their docs to see how ridiculously flexible they are.

The predefined actions are where things get interesting. The above uses two (actions/checkout@v4 and ruby/setup-ruby@v1) that do all the work of getting the step ready to run the linter; the code is checked out and Ruby is ready to use.

I was able to expand this file a bit more to add testing and verifying that my docs generate correctly:

name: CI

on:
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true

      - name: Lint code for consistent style
        run: bundle exec rubocop -f github
  
  yard:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true

      - name: Generate YARD documentation
        run: bundle exec rake yard

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        ruby-version: ['3.3', '3.4']

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ matrix.ruby-version }}
          bundler-cache: true
      
      - name: Run tests
        run: bundle exec rake spec

Obviously, modify what those jobs do to make sense for your gem.

With those lines merged, my GitHub Actions had nearly caught up with what RoxanneCI could do while also made it easier to troubleshoot when things break.

Publishing to rubygems.org

To reach full feature parity, I needed to be able to publish new releases to rubygems.org. With RoxanneCI, this meant always attempting to publish and hiding failures when a push to main didn’t have any new gem version to publish. To accomplish this with RoxanneCI, I inject a secret and, when I bump the VERSION of my gem, it builds and publishes well enough.

I did some searching and found the rubygems/release-gem GitHub Action for publishing gems. It’s guide is pretty straightforward and using my old approach of manually bumping the VERSION constant would be enough to make it work.

I added a new workflow called release.yml that looked like this:

name: Release

on:
  push:
    branches: [ "main" ]

permissions:
  contents: write
  id-token: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
          ruby-version: .ruby-version

      - uses: rubygems/release-gem@v1

Per the action’s documentation, I also had to set up trusted publishing for my gem on rubygems.org to allow the workflow to perform this action. This was super simple as rubygems.org pre-populated all the fields (it even knew the repo and workflow names)!

This got me feature parity, but it didn’t feel like enough.

The biggest problem was that it required these special (and easy to forget) commits that bump the version. In fact, I found the whole process of bumping gem versions, capturing details in a CHANGELOG.md, and tagging + creating GitHub releases tedious. So tedious that I just went without most of it. Since I’m here, migrating to a new tool, it seemed like the right time to do better.

Fully Automating Releases

I discovered Google’s googleapis/release-please-action GitHub Action and it looked like exactly what I needed. It takes a bit of work to set up but it works well. It has some pretty important requirements worth mentioning:

  • Conventional commits since it parses these commit messages to understand how your repo has changed
  • A standard, plain version file (lib/<gem name>/version.rb)
    • There should be a VERSION constant and it shouldn’t do anything fancy (something like VERSION = "0.1.2")
    • This is how things work by default when creating a new gem, so this was easy enough
  • Extended access to the repo so that it can create and modify PRs, tags, releases, etc.
    • The release-please docs explain pretty clearly why this is necessary

To give release-please the access it needs, I created a personal access token (the classic kind).

I then set up a repository secret on the repo by going to Settings > Secrets and Variables (under “Security”) > Actions, then adding a repository secret. I gave the secret a name (AUTO_RELEASE_TOKEN) and set its value to the personal access token above.

With the token ready, I needed to make a couple of files:

  • .release-please-manifest.json
  • release-please-config.json

The .release-please-manifest.json file is used to set the current gem version. I migrated an existing gem with numerous releases, so I needed to let the tooling know what the latest released version was:

{
  ".": "0.9.0"
}

release-please supports a lot of neat features, including versioning multiple gems in the same repos (so-called “monorepos”). I only put one gem in my repo, so the “component” name here is just “.“. If you’re not doing anything exotic, yours will probably be the same. Be sure to set the version string to the latest release you’ve already published.

The release-please-config.json file has a lot of keys to set, but most are pretty straightforward:

{
  "bootstrap-sha": "35ed7bd080d69b5e323fa748735b4a7ea885f224",
  "last-release-sha": "35ed7bd080d69b5e323fa748735b4a7ea885f224",
  "packages": {
    ".": {
      "package-name": "metatron",
      "changelog-path": "CHANGELOG.md",
      "release-type": "ruby",
      "bump-minor-pre-major": true,
      "bump-patch-for-minor-pre-major": true,
      "version-file": "lib/metatron/version.rb"
    }
  },
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}

The bootstrap-sha and last-release-sha keys point to the commit hash that released the gem version from the manifest file. This informs release-please to not bother looking at git commits before these hashes.

Under the packages key, just like in the manifest file, I only had one, so it is “.“. Here’s a quick breakdown of the settings under this key:

  • package-name: This was just the name of my gem, in this case, metatron
  • changelog-path: Just the path, relative to the repo root, to the changelog file.
  • release-type: This one is important. It tells release-please how to handle manipulating the version file.
  • bump-minor-pre-major and bump-patch-for-minor-pre-major: Both of these deal with a gem with a version < 1.0.0. They’re mostly a matter of preference for new gems.
  • version-file: The location of the Ruby file containing the VERSION constant. I had to set this even though the docs led me to believe it would be detected automatically.

The $schema is the default from bootstrapping release-please via its CLI.

With these files out of the way, I was able to update my .github/workflows/release.yml to use the release-please action:

name: Release

on:
  push:
    branches: [ "main" ]

permissions:
  contents: write
  id-token: write
  pull-requests: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      ## Here's the new step for release-please
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          token: ${{ secrets.AUTO_RELEASE_TOKEN }}

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        if: ${{ steps.release.outputs.release_created }}
        with:
          bundler-cache: true
          ruby-version: .ruby-version

      - uses: rubygems/release-gem@v1
        if: ${{ steps.release.outputs.release_created }}

Not only did I have to add the new step, but I also added the if section to all the other steps. Essentially, I only want the later steps for publishing the gem to run if the step with id: release outputs release_created. This is the mechanism that allows release-please to create PRs against main specifically for version bumps and only merging those PRs triggers those steps.

I also needed to update the permissions top-level key to include pull-requests: write.

With all that done, I was able to commit and merge my changes, create a PR to merge all this into main, then merge that PR. With that PR merged, future PRs will run tests as expected before being mergeable and once merged, will intelligently create release PRs to cut new versions of my gem.

One thing worth noting is that merging these release PRs is “manual” in that they don’t auto-merge or something. The idea is that multiple PRs can be collected into a single release, bundling multiple fixes and features into a single new release.

Hopefully, this guide helps others out there looking to do the same thing!

Spread the love

Leave a Reply