If you maintain a Ruby gem, you are definitely familiar with the recurring manual tasks surrounding the release of a new version. After doing this for a while, you inevitably start thinking that some of these steps could be automated. They can! With a few lines of code, you can bring the amazing world of continuous delivery to your project and increase the reliability of the whole process while freeing up some of your time. Double win!

In this article, I will demonstrate how to create a whole process around a simple gem so you can see some of the possibilities.

The case for automation

There are several reasons to bring a continuous delivery (CD) process to any professional or passion project. I will start with probably the most obvious one: it saves you time. Sure, the initial setup will need some upfront investment while you research and try things out until everything falls into place. After that, though, the benefits will stack up throughout the life of your project.

Another great advantage is the reproducibility it brings. Automation implies predictability and that can be expressed through configuration files and commands that will always run in the same order. In turn, this makes debugging easier, in case you run into an issue during deployment.

Finally, the determinism mentioned above will definitely lead to fewer bugs and mishaps during release phases. Instead of trying to remember the right order of commands to run when you are ready to deploy a new version of a gem, you just rely on the tried and tested process you had previously set up to do that for you. It is documented and you know it will be executed flawlessly.

Building a demo

In this tutorial, I will lead you through creating a small project to use as a publishable gem. We will work with something simple: a Ruby gem that adds the method zigzagcase to the String object. It will return a copy of the string alternating the case of the characters: LiKe tHiS. For simplicity’s sake, we will keep our code plain Ruby, which is simple to code and test. You can save writing something that would need to work with Rails, Sinatra, or other Ruby frameworks for another project.

Prerequisites

You will need a few things to follow along with this tutorial:

  • A GitHub account. You can create one here.
  • A Rubygems account to publish our gem
  • A CircleCI account. You can create one here. To easily connect your GitHub projects, you can sign up with your GitHub account.

Note: Make sure that Bundler is installed. You will use it to simplify gem creation. Using Bundler is the main way people in the Ruby community manage their gems, so it makes sense to use it to create one.

Setting up the project

Begin by running:

$ bundle gem zigzagcase --test=minitest

This simple command creates a scaffolding folder and also initializes a git repository. If this is the first time you have ever used Bundler 2.1.4 to run bundle gem, you will be asked whether you want to include a code of conduct and a license. I recommend that you add one; MIT is appropriate for this project. The test flag we passed will automatically pick minitest as our testing framework.

You can read more about the scaffolding created by Bundler (there are quite a few files), but the main points are:

  • zigzagcase/lib/zigzagcase.rb is used as a placeholder to import other files with our code
  • zigzagcase/lib/zigzagcase/version.rb holds the current version of the gem
  • zigzagcase/zigzagcase.gemspec defines the gem: what’s in it, who made it, license, and other technical details

Before going forward, there is a bit of housekeeping to be done. Go to the cd zigzagcase folder and run grep TODO zigzagcase.gemspec. Bundler has left a few things we need to take care of ourselves. If we do not, we will have trouble running other commands, so we might as well address this now. Dig into that file and make the changes shown in the next code block.

require_relative 'lib/zigzagcase/version'

Gem::Specification.new do |spec|
  spec.name          = "zgzgcase"
  spec.version       = Zigzagcase::VERSION
  spec.authors       = ["Gonçalo Morais"]
  spec.email         = ["author.email@example.com"]

  spec.summary       = %q{MaKe sTrInGs lOoK LiKe tHiS}
  spec.description   = %q{Alternates upper case and lower case across letters}
  spec.homepage      = "https://github.com/CIRCLECI-GWP/zigzagcase"
  spec.license       = "MIT"
  spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")

  spec.metadata["allowed_push_host"] = "https://rubygems.org"

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = "https://github.com/CIRCLECI-GWP/zigzagcase"
  spec.metadata["changelog_uri"] = "https://github.com/CIRCLECI-GWP/zigzagcase/blob/main/CHANGELOG.md"

  # Specify which files should be added to the gem when it is released.
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
  spec.files         = Dir.chdir(File.expand_path('..', __FILE__)) do
    `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
  end
  spec.bindir        = "exe"
  spec.executables   = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
  spec.require_paths = ["lib"]
end

A gem with the name zigzagcase already exists in rubygems. For this tutorial, we have used the name zgzgcase instead. It is the name assigned to spec.name in the previous code block. For you to successfully publish your own gem, you need to choose an alternative name and confirm that there is no gem with that name. Search at rubygems and if there is no gem with the name you chose, you can proceed with the tutorial. Replace every instance of zgzgcase with your particular name.

Our gem depends on a few other gems out of the box. You can check the Gemfile to see which ones. Install them by running bundle install and commit the Gemfile.lock that results from it.

It is a good practice to update the README file as well. First, in the Installation section, update the gem name. Update the URL of the Contributing section with the URL of your GitHub repo. We will create the repo next.

Save this starting structure on GitHub first before moving into writing our code. Make sure you create an empty repo. Bundler initialized a local git repository for you and it is easier to simply send what you have straight to GitHub if the project is empty. If you are unfamiliar with this process or new to GitHub, follow this tutorial.

Testing as a feature

Part of a healthy continuous delivery workflow is setting up a reliable test suite that gives you confidence in what you release. Since it’s such a crucial part of it, let’s make sure we add a few tests that will maintain the integrity of our gem. During our scaffolding, we picked minitest as our testing framework — but you are free to pick any other, the important bit is to write meaningful tests.

If you check the Rakefile created by Bundler, you’ll see that Rake’s default task will be to run tests, so we can give it a spin right now without making any changes yet by executing bundle exec rake.

Run options: --seed 21588

# Running:

.F

Finished in 0.001040s, 1923.0769 runs/s, 1923.0769 assertions/s.

  1) Failure:
ZigzagcaseTest#test_it_does_something_useful [zigzagcase/test/zigzagcase_test.rb:9]:
Expected false to be truthy.

2 runs, 2 assertions, 1 failures, 0 errors, 0 skips
rake aborted!
Command failed with status (1)
/Users/circleci/.rbenv/versions/2.6.5/bin/bundle:23:in `load'
/Users/circleci/.rbenv/versions/2.6.5/bin/bundle:23:in `<main>'
Tasks: TOP => default => test
(See full trace by running task with --trace)

Open test/zigzagcase_test.rb and replace it with a couple of simple tests for our method:

require "test_helper"

class ZigzagcaseTest < Minitest::Test
  def test_alternates_upcase_and_downcase
    assert_equal "AdVeNtUrE TiMe!", "Adventure Time!".zigzagcase
  end

  def test_keeps_original_string_untouched
    original_string = "Adventure Time!"
    expected_string = original_string.dup

    original_string.zigzagcase

    assert_equal expected_string, original_string
  end
end

If you happen to try to run these tests, you will get an error: NoMethodError: undefined method 'zigzagcase' for "Adventure Time!":String. We are getting this error because we have not written any code for our method yet.

Create a string.rb file inside the lib/zigzagcase folder. We will add our code there (lib/zigzagcase/string.rb). For simplicity, this gem will automatically add a zigzagcase method to Ruby’s String class.

class String
  def zigzagcase
    modifiers = %i[upcase downcase].cycle
    self.chars.map{ |char| char.send(modifiers.next) }.join
  end
end

In a nutshell, this code is getting an array of all the characters in the string, returning a copy of that array while alternating between calling upcase or downcase on each character, and finally joining it all back together to return a new string. This new file is not used anywhere yet, so the code we just wrote is not being picked up. We will address that by importing it through the main gem class (lib/zigzagcase.rb). Replace the contents of that file to this:

require "zigzagcase/version"
require "zigzagcase/string"

Now that we have our code in order, we can run out test cases again just to see them pass with flying colors.

Run options: --seed 48145

# Running:

..

Finished in 0.000964s, 2074.6888 runs/s, 2074.6888 assertions/s.

2 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Automating the process

We have run this locally, and now we want to make sure ‘robots’ do the work for us. We will use this example as a reference, but our configuration will be much simpler (.circleci/config.yml):

version: 2.1
orbs:
  ruby: circleci/ruby@1.0.4
jobs:
  test:
    docker:
      - image: cimg/ruby:2.7
    steps:
      - checkout
      - ruby/install-deps
      - run:
          name: Run tests
          command: bundle exec rake
workflows:
  version: 2
  deploy:
    jobs:
      - test

After adding it, make sure you commit the above .circleci/config.yml and push it to GitHub. This will automatically trigger your project’s first build on CircleCI.

Preparing for a release

We will follow semantic versioning to release this gem. Hopefully, you are following a similar practice. When correctly employed, this is a quick and easy way to give a clear heads-up to people about the changes that happened.

Using a versioning policy will allow us to implement the crucial difference between continuous delivery and a continuous deployment process. While building a web application, you might want to make every single code change available to the user as soon as possible. There are no release dates because shipping means getting new features and changes to the hands of users without human intervention or approval. With continuous delivery, you want to retain the manual process of release. While working on a gem, it is possible you want all your latest code to land on your main branch whenever it is merged, but you do not want to release a new version every time that happens.

As an extra detail, it is useful to have a single place to quickly scan the changes between versions, and a changelog file is the perfect spot for it. We will create one and add it to our scaffolding in the next section.

Now we will set up something cool on CircleCI: the ability to run a deployment step if we push a git tag to GitHub. Whenever something gets merged into our main branch, we only want to run our tests. But when we tag something for release, we want CircleCI to do the hard work. After our tests pass, we want it to automatically build a new version of our gem and publish it on Rubygems.

We will set up a way of authenticating on Rubygems, so it can publish gems in our name. After checking the documentation, we know that we can set a GEM_HOST_API_KEY in our server with a key from our Rubygems’ API keys page. Click New API key to create one. Enter a name to identify the API key, select the desired scope, and then click Create. In our example, only the Push rubygems scope is checked.

Rubygems API key

Set that variable in your project.

Environment variable

With the credentials in place, we need to change the CircleCI configuration to include this additional step. We are adding a deploy job that will run only for tags. Here is the updated config file (.circleci/config.yml):

version: 2.1
orbs:
  ruby: circleci/ruby@1.0.4
jobs:
  test:
    docker:
      - image: cimg/ruby:2.7
    steps:
      - checkout
      - ruby/install-deps
      - run:
          name: Run tests
          command: bundle exec rake
  deploy:
    docker:
      - image: cimg/ruby:2.7
    steps:
      - checkout
      - ruby/install-deps
      - run:
          name: Publish gem to Rubygems
          command: bundle exec rake release
workflows:
  version: 2
  test-and-deploy:
    jobs:
      - test:
          filters:
            tags:
              only: /.*/
      - deploy:
          requires:
            - test
          filters:
            tags:
              only: /^v.*/
            branches:
              ignore: /.*/

The two big differences here:

  • Our new job is responsible for building and publishing our gem using Bundler
  • Our workflows section changed to accommodate a bit of logic for filtering the events that trigger the deploy job

In this example, we are matching with any branch (thus ignoring all branches) and matching only with tags starting with a v (v0.0.1 and so on).

Wrapping it all up

Now it is time to set our workflow in action. Whenever you are happy with the changes you merged into the main branch and you are ready to cut out a new version of our app, you only need to tag it correctly and push it to GitHub.

If you want to keep your project stellar, consider keeping a record of the changes the gem goes through. This record can serve as a timeline of its evolution. We will write a summary of the changes in our gem on CHANGELOG.md. Here is an example.

# Change Log

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [0.1.0] - 2020-09-19

### Added
- First release

Edit yours according to your experience. Keep this file up to date, because this is where people will find a summary and useful links about the changes that happened between versions. It is especially important to double-check for possible regressions and security fixes.

Also make sure to tag your gem with as much information as possible, by using annotated tags. This will help you to keep a useful and rich git history.

Commit the changes and tag the commit:

git add .
git commit -m "Update CircleCI config & add CHANGELOG"
git tag -a v0.1.0 -m "First release"

There is one detail here that you must always check to make sure everything will run smoothly: keep the git tag and the gem version in sync. In this case, we are tagging our gem with 0.1.0. That is the same version we find at lib/zigzagcase/version and Gemfile.lock. Whenever you are ready to update your gem version (because you are releasing a bug fix, for example) make sure to:

  • Update it at lib/zigzagcase/version
  • Run bundle in the console afterwards

Your Gemfile.lock will reflect that version increment, so do not forget to git commit it.

We are now ready to send off our shiny new version into the world by running:

git push origin main
git push origin --tags

Go back to CircleCI dashboard to review your successful publish.

Successful test-and-deploy workflow on CircleCI

Output of the publishing step on CircleCI

Conclusion

You have now automated gem publishing! You can find the new version of your published gem on the Rubygems website. Whenever you tag your gem for release, the process you put in place with CircleCI will make sure your fresh code is available to the Ruby community.

I hope this tutorial inspires you to automate your own Rubygems deployments and sparks your curiosity about what else you can automate. You could apply a similar process to NPM modules, iOS pods, or Unix tarballs. Why not CD all the things?


Gonçalo Morais is a computer engineer with a soft spot for the web. He’s currently helping students pick better careers at BridgeU, powered by Rails and JavaScript. Gonçalo is a Recurse Center alumnus, occasional ultrarunner, and boulderer.

Read more posts by Gonçalo Morais