12 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 two-part series - here I’ll cover the infrastructure and automation challenges.
The Manual Release Hell
Initially, my release process looked like this nightmare checklist:
- Update version in
lib/jekyll-pandoc-exports/version.rb
- Update
CHANGELOG.md
with new version details
- Run tests locally and fix any issues
- Commit version changes to development branch
- Create pull request from dev to main
- Manually review and merge PR
- Create and push git tag
- Build gem locally with
gem build
- Test gem installation locally
- Push to RubyGems with
gem push
- Update documentation and push to Read the Docs
- Create GitHub release with changelog
- 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 earlier this 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:
- Semantic version validation with regex patterns
- Duplicate version detection in changelog
- Conditional test execution with
--skip-tests
flag
- Branch-aware workflow (main vs development branches)
- Comprehensive error handling with meaningful messages
- Automatic changelog generation with proper formatting
- PR body generation with extracted changelog content
Usage Examples:
./bin/release 0.1.6
- Full release with testing
./bin/release 0.1.8 --skip-tests
- Quick patch release
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:**
- Hard reset dev branch to match main exactly
- Version suggestions for next development cycle
- Warning messages about destructive operations
- Simple workflow for post-release cleanup
- Environment validation checks Git installation and repository state
- Branch existence verification ensures main and dev branches exist
- Help system with
--help
flag
- Detailed status updates during each operation
- Error handling with meaningful exit codes
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.
Release Automation Benefits
The automated pipeline reduced release time from 2+ hours to 5 minutes:
- Zero Manual Steps: Single
bin/release
command
- Consistent Process: No forgotten steps or human errors
- Immediate Feedback: Automated verification and links
- Documentation Sync: Read the Docs builds automatically
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
.github/workflows/
: CI/CD pipeline definitions
docs/
: MkDocs documentation source
bin/release
: Automated release orchestration
bin/reset-dev
: Post-release development setup
.readthedocs.yaml
: Documentation build configuration
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:
- PR creation and merging
- Git tagging and pushing
- GitHub release creation
- RubyGems publication
- Documentation updates
- Development branch reset
Next Steps
In Part 2, I’ll cover the actual plugin development - the Jekyll hooks system, Pandoc integration, and technical implementation details that make the automated document exports work.
The infrastructure investment was substantial, but it enabled rapid iteration on the plugin functionality itself. Professional release automation isn’t just about convenience - it’s about enabling sustainable open-source development.
Resources: