Automated releases

A release is a tag

A branch moves; a tag does not. That is why a release starts from a tag and why the pipeline can treat a tag as a promise: the code behind v0.1.0 never changes, so the package built from it can always be built again.

The other half of the promise is the version inside the project. It lives in lib/version.rb, and the gemspec and the executable both read it from there.

module CiDemo VERSION = "0.1.0" end

Check the tag against the version

Two places hold the same number, so they can disagree. A small Rake task compares the tag that the pipeline passes in with the constant, and it stops the release before anything is built:

require_relative "lib/version" namespace :release do desc "Check that the tag matches the version" task :check do tag = ENV.fetch("TAG", "") version = CiDemo::VERSION abort "set TAG=v#{version}" if tag.empty? abort "#{tag} does not match #{version}" unless tag == "v#{version}" puts "ok: #{version}" end end

Three runs show the three answers:

TAG=v0.1.0 bundle exec rake release:check

ok: 0.1.0

TAG=v0.2.0 bundle exec rake release:check

v0.2.0 does not match 0.1.0

bundle exec rake release:check

set TAG=v0.1.0

The last two exit nonzero, so a pipeline stops on them. A release job that starts with this task cannot publish a gem whose tag and version disagree.

The release job

The job runs only when a tag is pushed, checks the version, builds and publishes:

on: push: tags: - "v*" jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: "3.4" bundler-cache: true - run: bundle exec rake release:check env: TAG: ${{ github.ref_name }} - run: gem build ci-demo.gemspec - run: gem push *.gem env: GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}

github.ref_name is the tag without the refs/tags/ prefix, which is exactly what the task compares. On a branch push the job does not run at all.

What to watch for

Bump the version in one commit with the changelog line, then tag that commit. A tag on a commit that changes later is not a release, and a version bumped after the tag is a package nobody can find.

Test the release path with a dry run before the first real tag: build the gem, install the file locally, run the executable. A release job that fails at the push is a tag that already exists and a package that does not.

Next: Making a pipeline fast

Article author: Arthur Isaev