Ruby for DevOps — Part 5 of 5
Automation can change infrastructure quickly, which is exactly why it deserves tests. A short script can stop a deployment, delete the wrong resource, or quietly break a pipeline contract. Treating Ruby DevOps code as software—not disposable glue—gives a team safer changes and more freedom to extend it.
This final tutorial makes the project repeatable with Bundler, Rake, Minitest, syntax checks, safe smoke commands, and GitHub Actions. GitHub’s official Ruby Actions guide uses ruby/setup-ruby, Bundler, bundle exec rake, matrix testing, and Bundler caching.1
Fork or follow the code
The source is in the DevOps Coach repository. Open the exact Gemfile, Rakefile, test directory, and copy-ready Ruby CI template.
gh repo fork jjam3774/devop-coach --clone
cd devop-coach/tutorials/ruby-for-devops/code
bundle install
Or follow upstream:
git clone https://github.com/jjam3774/devop-coach.git
cd devop-coach/tutorials/ruby-for-devops/code
A fork is the better route for the exercises because you can push a branch, observe CI, and open a pull request.
Lock the development environment with Bundler
The production code uses Ruby’s standard library. The Gemfile adds Minitest and Rake for development:
source "https://rubygems.org"
ruby ">= 3.2", "< 4.0"
gem "minitest", "~> 5.25"
gem "rake", "~> 13.2"
bundle install
bundle exec rake
Commit both Gemfile and Gemfile.lock. Bundler documents that the lockfile gives collaborators and deployment environments the same third-party code.2 bundle exec ensures the selected Rake and Minitest versions come from the project bundle instead of an unrelated system installation.
Use one Rake task as the validation contract
| Task | Responsibility | External side effects |
|---|---|---|
syntax |
Run ruby -c across executables, examples, libraries, and tests. |
None |
test |
Run every Minitest file. | None; network and child-process tests are controlled. |
smoke |
Exercise safe CLI paths and deterministic examples. | No infrastructure changes; command execution uses dry-run. |
desc "Run all validation tasks"
task default: %i[syntax test smoke]
This creates a single contract:
bundle exec rake
If that command passes locally and in CI, the repository has checked syntax, behavior, CLI wiring, sample configuration, and example output.
Check syntax before behavior
task :syntax do
ruby_files = FileList[
"bin/*",
"examples/**/*.rb",
"lib/**/*.rb",
"test/**/*.rb"
]
ruby_files.each { |file| sh RbConfig.ruby, "-c", file }
end
This catches parse errors quickly. It does not replace tests: a syntactically valid script can still be unsafe or wrong.
bundle exec rake syntax
Write deterministic unit tests
Minitest ships a compact Ruby testing style and supports assertions and standard test patterns.3 A command-runner test uses the current Ruby executable as a deterministic child process:
result = @runner.run([
RbConfig.ruby,
"-e",
"STDOUT.write('ready'); STDERR.write('note')"
])
assert result.success?
assert_equal "ready", result.stdout
assert_equal "note", result.stderr
assert_equal 0, result.exit_status
The timeout test is local too:
result = @runner.run(
[RbConfig.ruby, "-e", "sleep 2"],
timeout: 0.05
)
refute result.success?
assert result.timed_out
assert_nil result.exit_status
Configuration tests create temporary YAML files. Health-check tests inject a fake transport. CLI tests use StringIO. No unit test requires cloud credentials or a public endpoint.
bundle exec ruby -Itest test/command_runner_test.rb
bundle exec rake test
Test safety properties, not only happy paths
| Component | Important properties under test |
|---|---|
| Command runner | stdout and stderr capture, nonzero status preservation, missing executable, timeout, dry run, metacharacters as data, empty-command rejection |
| Configuration loader | valid defaults, non-empty services, HTTP/HTTPS restriction, status range, disabled YAML aliases, missing files |
| Health checker | expected status, required failure, transport exception, optional failure, bounded concurrency, stable output order |
| CLI | JSON output, dry run, configuration exit code, check exit code, unknown-command usage failure |
The shell-metacharacter test passes ; echo this-must-not-run as one argument and verifies that the child receives exactly that string. This guards the design decision to avoid an implicit shell.
Add safe smoke commands
task :smoke do
sh RbConfig.ruby, "-Ilib", "bin/devops-toolkit", "doctor", "--json"
sh RbConfig.ruby, "-Ilib", "bin/devops-toolkit",
"config", "--file", "config/services.yml", "--json"
sh RbConfig.ruby, "-Ilib", "bin/devops-toolkit",
"run", "--dry-run", "--json", "--", "printf", "hello-from-ruby"
sh RbConfig.ruby, "examples/quick_compare.rb"
sh RbConfig.ruby, "examples/log_report.rb", "config/events.jsonl"
end
The smoke task deliberately does not call terraform apply, kubectl apply, or live health endpoints. Put credentialed integration tests in a separately protected workflow with explicit permissions and environment controls.
Run the same contract in GitHub Actions
The repository ships the workflow as a copy-ready template. Activate it in your fork:
mkdir -p .github/workflows
cp tutorials/ruby-for-devops/ci/ruby-devops.yml \
.github/workflows/ruby-devops.yml
The activated workflow grants only read-only source permission and tests a Ruby version matrix:
name: Ruby DevOps Tutorials
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby: ["3.2", "3.3", "3.4"]
- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
working-directory: tutorials/ruby-for-devops/code
- name: Validate the tutorial toolkit
working-directory: tutorials/ruby-for-devops/code
run: bundle exec rake
Matrix testing proves the declared Ruby range works in more than one interpreter version. fail-fast: false lets every matrix job finish, giving a full compatibility picture.
Push a branch and observe CI
git checkout -b ruby-devops-extension
bundle exec rake
git add tutorials/ruby-for-devops .github/workflows/ruby-devops.yml
git commit -m "Extend the Ruby DevOps toolkit"
git push -u origin ruby-devops-extension
Open a pull request on GitHub. The Ruby DevOps Tutorials workflow should run once per Ruby version. If a job fails, reproduce bundle exec rake locally from the code directory. Do not remove a version merely to make CI green unless the project deliberately changes its support policy.
Keep CI permissions boring
A test workflow for this project only needs source access. It does not need write permission, a cloud token, or a deployment environment. If you later add deployment, create a separate workflow or job with environment protection, explicit approvals, pinned actions, and narrowly scoped credentials.
How this creates flexibility
Tests protect the interface while implementation changes. You can replace Net::HTTP, add a JSON inventory source, package the toolkit as a gem, or call it from Python; if result formats and exit codes remain covered, callers stay stable. A version matrix provides runtime flexibility. A locked bundle provides dependency consistency. Injected transports and streams provide test flexibility. These choices make a Ruby component a better citizen in a mixed DevOps system, not a requirement to choose Ruby everywhere.
Series complete
You now have a Ruby DevOps option that is more than a syntax demonstration. The project exposes a stable CLI, executes commands without an implicit shell, validates YAML into a narrow schema, checks services concurrently, streams JSON log data, and validates itself locally and in CI.
Return to the Ruby for DevOps series landing page for the full path and Python-to-Ruby capability map.