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| Change | Version bump | Example |
|---|---|---|
| Breaking API change | MAJOR | 1.4.0 to 2.0.0 |
| New feature, backward compatible | MINOR | 1.4.0 to 1.5.0 |
| Bug fix only | PATCH | 1.4.0 to 1.4.1 |
| Pre-release | Suffix | 2.0.0.beta1 |
- Build the gem before publishing and install it locally:
gem build myapp.gemspec, thengem install ./myapp-1.4.0.gemin a scratch directory. spec.filesfrom aDirglob includes everything; an explicit list or a git-tracked list keeps test fixtures and local files out of the package.- Use
gem pushwith 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 | jqstill works. - Honour
NO_COLORand 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.
Related
Gems, Bundler and project layout Strings, files and input/output
Last refreshed 2026-09-18.