Packaging and distributing gems and CLIs

Gemspec structure, semantic versioning, publishing to RubyGems, OptionParser and Thor for CLI tools, and changelog discipline.

A gemspec that works

# myapp.gemspec
require_relative "lib/myapp/version"

Gem::Specification.new do |spec|
  spec.name          = "myapp"
  spec.version       = Myapp::VERSION
  spec.authors       = ["Ada Lovelace"]
  spec.summary       = "Report generation for the Myapp platform"
  spec.license       = "MIT"
  spec.required_ruby_version = ">= 3.1"

  spec.files         = Dir["lib/**/*.rb", "exe/*", "README.md", "LICENSE.txt"]
  spec.bindir        = "exe"
  spec.executables   = ["myapp"]
  spec.require_paths = ["lib"]

  spec.add_dependency "json", "~> 2.7"
  spec.add_development_dependency "rspec", "~> 3.13"
  spec.metadata["rubygems_mfa_required"] = "true"
end

# lib/myapp/version.rb
module Myapp
  VERSION = "1.4.0"
end
ChangeVersion bumpExample
Breaking API changeMAJOR1.4.0 to 2.0.0
New feature, backward compatibleMINOR1.4.0 to 1.5.0
Bug fix onlyPATCH1.4.0 to 1.4.1
Pre-releaseSuffix2.0.0.beta1
  • Build the gem before publishing and install it locally: gem build myapp.gemspec, then gem install ./myapp-1.4.0.gem in a scratch directory.
  • spec.files from a Dir glob includes everything; an explicit list or a git-tracked list keeps test fixtures and local files out of the package.
  • Use gem push with MFA enabled and never commit the API key - read it from ~/.gem/credentials.
⚠️
A version published to RubyGems can be yanked but never replaced. If you release a broken 1.4.0, the fix is 1.4.1; do not try to republish the same number, because every existing lockfile already resolved it.

Command line interfaces

#!/usr/bin/env ruby
require "optparse"
require "myapp"

options = { format: "text", limit: 20, verbose: false }

parser = OptionParser.new do |o|
  o.banner = "Usage: myapp report INPUT [options]"
  o.on("-f", "--format FORMAT", %w[text json csv], "Output format") { |v| options[:format] = v }
  o.on("-n", "--limit N", Integer, "Maximum rows")        { |v| options[:limit] = v }
  o.on("-v", "--verbose", "Print progress")               { options[:verbose] = true }
  o.on("-h", "--help") { puts o; exit }
end

begin
  parser.parse!
rescue OptionParser::ParseError => e
  warn "myapp: #{e.message}"
  warn parser
  exit 2
end

input = ARGV.shift or abort "myapp: INPUT is required\n#{parser}"

Myapp::Report.new(input, **options).run
# Thor for a multi-command tool
require "thor"

class Myapp < Thor
  desc "report INPUT", "Generate a report"
  option :format, default: "text", enum: %w[text json csv]
  def report(input)
    say "reading #{input}", :green
    Myapp::Report.new(input, format: options[:format]).run
  end

  desc "version", "Print the version"
  def version = puts Myapp::VERSION

  def self.exit_on_failure? = true
end

Myapp.start(ARGV)
  • Exit codes are part of the interface: 0 success, 1 runtime failure, 2 usage error. Scripts depend on them.
  • Write progress and diagnostics to stderr so myapp report data.csv | jq still works.
  • Honour NO_COLOR and detect a TTY before emitting escape sequences.
  • Keep a CHANGELOG.md with one entry per released version, added in the same commit as the version bump.

FAQ

How do I test a CLI?
Extract the logic into a class with a small interface, then test that directly. Add one or two tests that shell out to the executable to confirm argument parsing and exit codes, but do not test the whole tool through the filesystem.
Should I ship a gem or a Docker image?
A gem when your audience already has Ruby and wants to embed the library. A container when the tool has system dependencies or must run identically everywhere. Many projects publish both.

Gems, Bundler and project layout Strings, files and input/output

Last refreshed 2026-09-18.