McGarrah Technical Blog

Ruby Gem Release Automation - Part 1: Infrastructure Implementation

· 14 min read

While developing the jekyll-pandoc-exports plugin, I discovered that building the actual functionality was only half the battle. The real challenge was creating a professional release pipeline that could handle documentation, testing, and publishing automatically. This is Part 1 of a three-part series - here I’ll cover the infrastructure and automation challenges. In Part 2 I cover the plugin’s technical implementation, and in Part 3 I walk through integrating it into a real project and the bugs that surfaced.

The Manual Release Hell

Initially, my release process looked like this nightmare checklist:

  1. Update version in lib/jekyll-pandoc-exports/version.rb
  2. Update CHANGELOG.md with new version details
  3. Run tests locally and fix any issues
  4. Commit version changes to development branch
  5. Create pull request from dev to main
  6. Manually review and merge PR
  7. Create and push git tag
  8. Build gem locally with gem build
  9. Test gem installation locally
  10. Push to RubyGems with gem push
  11. Update documentation and push to Read the Docs
  12. Create GitHub release with changelog
  13. Reset development branch for next iteration

This 13-step process was error-prone, time-consuming, and frankly demoralizing. I needed automation. This was something I already learned in my work writing a Python Library the previous year.

The Infrastructure Challenge

Read the Docs Integration

Coming from Python development, I expected Read the Docs integration to be straightforward. It wasn’t. Ruby gems have different documentation patterns than Python packages:

MkDocs Configuration (.readthedocs.yaml):

version: 2

build:
  os: ubuntu-22.04
  tools:
    python: "3.11"

mkdocs:
  configuration: docs/mkdocs.yml

python:
  install:
    - requirements: docs/requirements.txt

Documentation Structure:

docs/
├── mkdocs.yml
├── requirements.txt
├── index.md
├── installation.md
├── quick-start.md
├── configuration.md
├── hooks.md
├── cli.md
└── testing.md

Unlike Python’s Sphinx autodoc, Ruby documentation required manual organization and cross-referencing.

RubyGems Publishing Automation

RubyGems publishing presented unique challenges compared to PyPI:

Trusted Publishers Setup:

# .github/workflows/publish.yml
name: Publish to RubyGems
on:
  release:
    types: [published]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true
      - name: Publish to RubyGems
        uses: rubygems/release-gem@v1

The trusted publishers feature was newer and less documented than PyPI’s equivalent.

GitHub Actions Complexity

Multi-Ruby Testing Matrix

Ruby version compatibility testing proved more complex than Python:

strategy:
  matrix:
    ruby-version: ['3.0', '3.1', '3.2', '3.3']
    os: [ubuntu-latest, macos-latest]

Bundler Cache Issues: The biggest headache was Bundler’s frozen lockfile behavior in CI:

- name: Install dependencies
  run: |
    bundle config set --local deployment false
    bundle config set --local frozen false
    bundle install

This took weeks to resolve properly across all Ruby versions.

Release Workflow Orchestration

The release workflow needed to coordinate multiple moving parts:

name: Release
on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release'
        required: true
        type: string

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Create PR
        run: |
          gh pr create --title "Release v$" \
                      --body "$changelog_content" \
                      --base main --head dev
      
      - name: Auto-merge PR
        run: |
          gh pr merge --auto --squash
      
      - name: Create and push tag
        run: |
          git tag "v$"
          git push origin "v$"
      
      - name: Create GitHub release
        run: |
          gh release create "v$" \
                           --title "Release v$" \
                           --notes "$changelog_content"

The bin/release Script

The breakthrough was creating a comprehensive Ruby release script with extensive error checking and validation:

#!/usr/bin/env ruby

class ReleaseManager
  def initialize
    @changelog_path = 'CHANGELOG.md'
    @version_file = 'lib/jekyll-pandoc-exports/version.rb'
    @current_version = get_current_version
  end
  
  def run(new_version = nil, skip_tests = false)
    # Version validation
    unless valid_version?(new_version)
      puts "Invalid version format. Use semantic versioning (e.g., 1.0.0)"
      exit 1
    end
    
    if version_exists?(new_version)
      puts "Version #{new_version} already exists in CHANGELOG.md"
      exit 1
    end
    
    # Update version and changelog
    update_version_file(new_version)
    update_changelog(new_version)
    
    # Run tests with skip option
    unless skip_tests
      unless system('bundle exec rake test')
        puts "Tests failed! Use --skip-tests to bypass for patch releases."
        exit 1
      end
    end
    
    # Git operations with error handling
    system("git add #{@version_file} #{@changelog_path} Gemfile.lock")
    system("git commit -m 'Bump version to #{new_version}'")
    
    current_branch = `git branch --show-current`.strip
    
    if current_branch == 'main'
      # Direct release from main
      system("git tag v#{new_version}")
      system("git push origin main && git push origin v#{new_version}")
    else
      # PR workflow with auto-merge
      system("git push origin #{current_branch}")
      
      pr_title = "Release v#{new_version}: #{get_release_description(new_version)}"
      pr_body = generate_pr_body(new_version)
      
      # Create and merge PR
      unless system("gh pr create --base main --head #{current_branch} --title '#{pr_title}' --body '#{pr_body}'")
        puts "❌ Failed to create PR. Manual steps required."
        exit 1
      end
      
      unless system("gh pr merge --merge --delete-branch=false")
        puts "❌ Failed to merge PR automatically."
        exit 1
      end
      
      # Create release tag
      system("git checkout main && git pull origin main")
      system("git tag v#{new_version} && git push origin v#{new_version}")
    end
    
    puts "✅ Release v#{new_version} completed!"
    puts "🔗 Verify at: https://rubygems.org/gems/jekyll-pandoc-exports"
  end
  
  private
  
  def valid_version?(version)
    version&.match?(/^\d+\.\d+\.\d+$/)
  end
  
  def version_exists?(version)
    File.exist?(@changelog_path) && 
    File.read(@changelog_path).include?("## [#{version}]")
  end
  
  def update_version_file(new_version)
    content = File.read(@version_file)
    updated = content.gsub(/VERSION = ['"][^'"]+['"]/, "VERSION = '#{new_version}'")
    File.write(@version_file, updated)
  end
  
  def update_changelog(new_version)
    # Sophisticated changelog parsing and updating
    # Handles unreleased sections and proper formatting
  end
end

ReleaseManager.new.run(ARGV[0], ARGV.include?('--skip-tests'))

Key Ruby Script Features:

Usage Examples:

The Ruby implementation is much more robust than a simple bash script that I started with initially, with proper error handling, validation, and structured code organization.

The bin/reset-dev Companion Script

After releases, the development branch needs to be reset to match main. This enhanced Ruby script handles the cleanup with proper validation:

#!/usr/bin/env ruby

class DevResetManager
  VERSION_FILE = 'lib/jekyll-pandoc-exports/version.rb'
  
  def run
    if ARGV.include?('--help') || ARGV.include?('-h')
      show_help
      exit 0
    end
    
    # Validate environment before proceeding
    validate_environment
    
    puts "🔄 Hard resetting dev branch to match main..."
    puts "⚠️  WARNING: This will discard ALL changes on dev branch!"
    
    # Execute the reset commands with status updates
    puts "📥 Pulling latest main..."
    system("git pull origin main")
    
    puts "🔄 Switching to dev branch..."
    system("git checkout dev")
    
    puts "💥 Hard resetting dev to main..."
    system("git reset --hard main")
    
    puts "📤 Force pushing dev branch..."
    system("git push origin dev --force")
    
    puts "✅ Dev branch hard reset complete!"
    puts "📊 Dev branch is now identical to main branch"
    puts "🚀 Ready for next development cycle!"
    
    suggest_next_version
  end
  
  private
  
  def validate_environment
    # Check if git command exists
    unless system('which git > /dev/null 2>&1')
      puts "❌ Error: Git command not found. Please install Git."
      exit 1
    end
    
    # Check if we're in a git repository
    unless system('git rev-parse --git-dir > /dev/null 2>&1')
      puts "❌ Error: Not in a Git repository. Please run from project root."
      exit 1
    end
    
    # Check if main and dev branches exist
    unless system('git show-ref --verify --quiet refs/heads/main')
      puts "❌ Error: 'main' branch does not exist."
      exit 1
    end
    
    unless system('git show-ref --verify --quiet refs/heads/dev')
      puts "❌ Error: 'dev' branch does not exist."
      exit 1
    end
    
    puts "✅ Environment validation passed"
  end
  
  def suggest_next_version
    current_version = get_current_version
    return unless current_version
    
    parts = current_version.split('.').map(&:to_i)
    patch_version = "#{parts[0]}.#{parts[1]}.#{parts[2] + 1}"
    minor_version = "#{parts[0]}.#{parts[1] + 1}.0"
    
    puts "💡 Next versions:"
    puts "   Patch: #{patch_version} (bug fixes)"
    puts "   Minor: #{minor_version} (new features)"
    puts "🏷️  When ready: bin/release <version>"
  end
end

DevResetManager.new.run

Features:

Usage: ./bin/reset-dev or ./bin/reset-dev --help

I am much less proud of this script but it gets the job done. And I got my Ruby groove back doing these rather than just blast out a bash or zsh shell script. It has been several years since I did Ruby for Rails or Groovy for Grails. So the syntax needed a bit of time to saturate my brain and get the muscle memory back.

Lessons Learned

Ruby vs Python Ecosystem Differences

Dependency Management: Bundler’s behavior differs significantly from pip/poetry. Frozen lockfiles in CI required careful configuration.

Documentation: Ruby lacks Python’s autodoc ecosystem. Manual documentation organization was necessary.

Testing: Ruby’s testing culture emphasizes different patterns than Python’s pytest ecosystem.

GitHub Actions Gotchas

Permissions: Token permissions for trusted publishing required specific scopes.

Timing: Automated workflows needed careful sequencing and wait conditions.

Matrix Builds: Ruby version compatibility testing had unique edge cases.

Frozen Lockfiles: The most insidious issue was Gemfile.lock version drift between the gemspec and the lockfile. When you bump the version in version.rb but forget to run bundle install before committing, the lockfile still references the old version. The CI publish workflow runs with bundler-cache: true, which sets frozen mode — and frozen mode refuses to install when the gemspec version doesn’t match the lockfile. The fix is simple (run bundle install after version bumps), but the failure mode is confusing: the gem builds and tests pass locally, the PR merges, the tag is created, and then the publish workflow fails silently on a bundler error. I hit this during the v0.1.12 release and had to delete the tag, update the lockfile, re-tag, and manually trigger the publish workflow.

Jekyll 3.x Compatibility

A subtle Ruby language issue surfaced when integrating the plugin into my resume site, which uses the github-pages gem (Jekyll 3.10.0). The plugin’s :post_write hook block used return to exit early:

Jekyll::Hooks.register :site, :post_write do |site|
  config = setup_configuration(site)
  return unless config['enabled']  # LocalJumpError in Jekyll 3.x
end

In Ruby, return inside a do...end block tries to return from the enclosing method — but hook blocks have no enclosing method in Jekyll 3.x’s invocation path, causing a LocalJumpError. The fix was replacing return with next, which is the correct way to exit early from a block. This worked fine in Jekyll 4.x due to differences in how it invokes hooks, so the bug was invisible during development and testing.

This is the kind of issue that only surfaces when real users integrate your gem into their projects — another argument for eating your own dog food early.

Release Automation Benefits

The automated pipeline reduced release time from 2+ hours to 5 minutes:

Infrastructure Components

Final Architecture

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Development   │──▶│  GitHub Actions  │───▶│   RubyGems.org  │
│     Branch      │    │   CI/CD Pipeline │    │   Publication   │
└─────────────────┘    └──────────────────┘    └─────────────────┘
         │                       │                       │
         ▼                       ▼                       ▼
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│  bin/release    │    │  Read the Docs   │    │  GitHub Release │
│     Script      │    │  Documentation   │    │    Creation     │
└─────────────────┘    └──────────────────┘    └─────────────────┘

Key Files

The Payoff

After weeks of infrastructure work, the release process became:

# Release with full testing:
./bin/release 1.2.0

# Quick patch release (skip tests):
./bin/release 1.2.1 --skip-tests

# Reset dev branch for next cycle:
./bin/reset-dev

Two commands handle the complete release cycle:

Next Steps

In Part 2, I cover the plugin’s technical implementation — the Jekyll hooks system, Pandoc integration, and the architecture that makes automated document exports work. In Part 3, I walk through integrating the plugin into my resume site, where the Jekyll 3.x compatibility bug and several other issues surfaced — and where the release automation described above proved its value by enabling rapid fix-test-release cycles.

Professional release automation isn’t just about convenience — it’s about enabling sustainable open-source development.


Resources:

Categories: ruby, devops, automation, ci-cd

About the Author: Michael McGarrah is a Cloud Architect with 25+ years in enterprise infrastructure, machine learning, and system administration. He holds an M.S. in Computer Science (AI/ML) from Georgia Tech and a B.S. in Computer Science from NC State University, and is currently pursuing an Executive MBA at UNC Wilmington. LinkedIn · GitHub · ORCID · Google Scholar · Resume