This blog takes a look at matrix testing in GitHub Actions (GHA). They are powerful, but the naive implementation requires some maintenance. But GHA also provides tools to avoid that. A basic familiarity with GHA is assumed. The examples use Ruby, but the techniques apply to most ecosystems.

A quick introduction

Matrix testing in GitHub a typically defined like this:

jobs:
  test:
    name: 'Ruby ${{ matrix.ruby }}'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        ruby:
          - '3.2'
          - '3.1'
          - '3.0'
          - '2.7'
    steps:
      - uses: actions/checkout@v3
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ matrix.ruby }}
          bundler-cache: true
      - run: bundle exec rake test

This gives you a testing matrix where it runs 4 times, once for each Ruby version. Perfect for most places.

Dynamically generating the matrix

In GitHub you can define multiple steps and pass data from one step to the next. It can look something like this:

jobs:
  setup_matrix:
    name: "Determine Ruby versions"
    runs-on: ubuntu-latest
    outputs:
      ruby: ${{ steps.ruby.outputs.versions }}
    steps:
      - id: ruby
        run: echo 'ruby=["3.2", "3.1", "3.0", "2.7"]' >> $GITHUB_OUTPUT

  test:
    # ...
    needs: setup_matrix
    strategy:
      matrix:
        ruby: ${{ fromJSON(needs.setup_matrix.outputs.ruby }}
    # ...

This looks way more complex and you can question why anyone would do this. The trick is that the trivial echo can be replaced with a script that is smarter.

In Ruby it's possible to specify the required_ruby_version in your gemspec. A simplified script to do so follows. In the real world you want to do error checking.

#!/usr/bin/env ruby

require 'json'
require 'rubygems'

VERSIONS = ['3.2', '3.1', '3.0', '2.7', '2.6', '2.5', '2.4']

filename = ARGV.first
spec = Gem::Specification::load(filename)
requirement = spec.required_ruby_version
versions = VERSIONS.select { |version| requirement =~ Gem::Version.new(version) }
File.open(ENV['GITHUB_OUTPUT'], 'a') do |f|
  f.puts("#{ruby=#{versions.to_json}")
end

Now it will try a list of known versions and match it against the compatible versions in your gemspec. Adding and removing support for a Ruby version now comes down to changing required_ruby_version in your gemspec. Even better: Bundler will also respect this.

You may expect me to tell you where to place this script. Instead of storing it inside our gem repository we're going to make it reusable.

Composite workflows

Repeating yourself is a bad pattern. I strongly believe that templates with boilerplate are a bad practice. You end up with massive workflow definitions and if you maintain multiple repositories they are bound to get out of sync. Luckily GitHub has also seen this and came up with composite (and reusable workflows, but more on that later).

A composite workflow is a small single repository with some files in it and a definition describing the action.

First store the script we wrote earlier as some filename. This example uses extract_compatible_ruby_versions. Then define the action.

name: 'Derive Ruby versions'
description: "Determine Ruby version testing matrix based on the gemspec's required_ruby_version field"
inputs:
  filename:
    description: 'Gemspec filename'
    required: false
    default: '*.gemspec'
outputs:
  versions:
    description: "Ruby versions"
    value: ${{ steps.script.outputs.ruby }}
runs:
  using: "composite"
  steps:
    - uses: actions/checkout@v3
    - id: script
      run: ${{ github.action_path }}/extract_compatible_ruby_versions ${{ inputs.filename }}
      shell: bash

This creates a checkout of the repository and runs the script we created on the input filename. By default, it uses *.gemspec and relies on bash to expand it. Keen readers will recognize this doesn't deal with spaces, subdirectories and multiple gemspecs. Those problems are left as an exercise for the reader.

Commit this to a repository. I have done that on ekohl/ruby-version and use the v0 branch for it.

With this knowledge we can update our setup job:

jobs:
  setup_matrix:
    # ...
    steps:
      - id: ruby
        uses: ekohl/ruby-version@v0

When a new version of Ruby comes out all that needs to be updated is the single repository and anything that uses it will automatically get their test matrix expanded with Ruby it. This may be seen as disruptive, but users running that latest version will install your gem. If you don't want this, you should pin your required_ruby_version to avoid that new version.

Reusing workflows

This can be taken a step further with reusable workflows. Where composite workflows aim to abstract away one step (or a few), reusable workflows aim to abstract away whole jobs. Taking the previous code we'll define the whole gem testing workflow.

jobs:
  setup_matrix:
    name: "Determine Ruby versions"
    runs-on: ubuntu-latest
    outputs:
      ruby: ${{ steps.ruby.outputs.versions }}
    steps:
      - id: ruby
        uses: ekohl/ruby-version@v0

  test:
    name: "Ruby ${{ matrix.ruby }}"
    runs-on: ubuntu-latest
    needs: setup_matrix
    strategy:
      fail-fast: false
      matrix:
        ruby: ${{ fromJSON(needs.setup_matrix.outputs.ruby) }}
    steps:
      - uses: actions/checkout@v3
      - name: Setup ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: ${{ matrix.ruby }}
          bundler-cache: true
      - name: Run tests
        run: bundle exec rake test

Now store this in a repository somewhere. I did that in theforeman/actions as .github/workflows/test-gem.yml in the v0 branch.

Now in your gem repository you can define your CI job:

jobs:
  test:
    name: Tests
    uses: theforeman/actions/.github/workflows/test-gem.yml@v0

Now this is something we can maintain easily. With just a few lines there's a minimal maintenance burden.

Bonus: Enabling auto-merge

GitHub has a feature to automatically merge a pull request. The idea is that as a repository maintainer you can approve a PR and enable auto-merge when certain required checks pass. Very often you have one last comment on a PR which is then addressed by the author. You review that small change, approve it and have to wait on CI to complete. Auto-merge helps people like me who forget to check back, which is frustrating for the author. Worst case a merge conflict shows up and you have to go through the whole cycle again.

For our example it would mean an admin would mark the tests Ruby 3.2, Ruby 3.1, Ruby 3.0 and Ruby 2.7 as required. This does mean that when a PR wants to drop Ruby 2.7 support some admin needs to remove Ruby 2.7 as a required check. That would apply to all PRs, meaning there is a period where it could break but still allow auto-merging to continue. Similarly, when adding new versions the repository admin needs to add Ruby 3.3 as a required check. However, the whole point of making things dynamic was to avoid manually maintaining lists.

I'm not aware of a way to make a test matrix a required check. There is a fairly simple way to achieve this though: add a trivial job that needs the matrix job.

jobs:
  setup_matrix:
    # ....

  test:
    # ....

  dummy:
    needs: test
    runs-on: ubuntu-latest
    name: Test suite
    steps:
      - run: echo Test suite completed

Now we can mark Test suite as a required check to be sure that the job test also passed. There is the minor downside that it requires an additional job to run, which does cost a tiny bit of GitHub Action minutes, but it should be minimal and I think the convenience is worth it.

Of course, this can (and should) be put in a reusable workflow.

Conclusion

In this blog I've shown various techniques aimed at avoiding duplication of efforts. Rather than using large blobs of templated workflows the reusable workflows have many advantages. They allow you to quickly act on CI issues, without having to submit it to potentially 100s of repositories. In Vox Pupuli we have about 150 Puppet modules and have used gha-puppet to work around issues. Not only is your git log much cleaner, it also saves submitting pull requests. Fewer pull requests means a lower burden on your maintainers and your CI, which is great for your wallet and the planet.