Soap4R-ng — my fork of Ruby’s old soap4r library, kept alive across the Ruby 1.8 → 4.0 divide — just cut release 2.1.0. It’s the first proper GitHub Release the project has had.
Given the gem’s whole reason for existing is “the original went unmaintained and something had to keep enterprise SOAP integrations alive,” you’d reasonably expect a release to be about SOAP: envelope parsing, WSDL quirks, XML edge cases. It wasn’t. It was almost entirely about rebuilding CI infrastructure that had quietly stopped existing.
Whatever happened to the green badge#
This project used to have the full set of OSS-era trimmings: a Travis CI badge and a Code Climate badge, both sitting proudly at the top of the README. Travis was the default answer for Ruby CI for years — right up until it wasn’t. The free tier for open-source projects got cut back hard, build queues got unreliable, and eventually the badge was just a static image pointing at a build that hadn’t run in a long time. Code Climate’s free quality/coverage checks for open source quietly went away around the same era, as they pivoted toward being a paid engineering-analytics product. Neither of those is a knock on either company; it’s just what happened to a lot of OSS-era badges over the last several years, and this project had both of the classics.
That left a gap that quietly persisted for a long time: no continuous verification across the Ruby version range this gem claims to support, just local testing before a release and a reasonable amount of hope. For most gems that’s a survivable way to operate. For a library whose entire value proposition is “this works on whatever ancient Ruby you’re stuck on,” it’s not really good enough — and it’s not something a new badge alone would fix. The actual fix was standing up GitHub Actions as real CI/CD, covering Ruby 1.8.7 through 4.0.5 plus JRuby, with Docker keeping local testing honest against it — the same official Ruby images GitHub Actions uses by default, and a proper custom recipe for the versions where those images don’t work. 2.1.0 is that rebuild.
Getting local and CI to agree#
Soap4R-ng claims to support Ruby 1.8.7 through 4.0.5, plus JRuby. That’s over a dozen distinct runtimes across three major eras of the language. Rebuilding CI from scratch meant deciding how to actually provision all of them. The first pass used ruby/setup-ruby to provision each version directly on the GitHub Actions runner VM — which is the normal, sensible way to do this — except that runner kept drifting out from under local Docker-based testing in ways that cost real debugging time: ubuntu-latest rolling the base image forward out from under a pinned Ruby version, ruby-build not always publishing a binary for every Ruby × Ubuntu combination, and Ubuntu 22.04’s ICU-enabled system libxml2 colliding with libxml-ruby’s C extension in a way that never once reproduced locally against Debian.
For a library whose entire value proposition is compatibility you can trust, that gap was worth closing properly. So 2.1.0’s actual scope became: run CI inside the exact same official Ruby Docker images used for local testing, for every version in the matrix, so “works locally” and “works in CI” are the same claim by construction.
That’s not as simple as adding a container: block to the job, though. GitHub Actions injects its own Node.js runtime into container-based jobs to run steps like actions/checkout — and most of these official Ruby images are old enough (Debian jessie/stretch-era glibc) that the injected Node binary can’t even execute. Every job up through Ruby 2.7 failed that way the first time it was tried, well before a single test ran. So checkout happens on the normal, modern runner VM as usual, and a plain docker run does the actual bundle install and test run inside the target image instead.
The legacy end of the range needed its own recipes on top of that. Ruby 1.9.3 and 2.0.0 do have official Docker Hub images, but every tag was published under an old Docker manifest v1 schema that current Docker/containerd versions have simply stopped supporting — so those two build from source via rbenv instead, inside a Dockerfile pinned to Debian bullseye specifically, because it’s the last release still shipping the OpenSSL 1.1.1 and older GCC that ruby-build’s patches for these ancient Rubies actually target (OpenSSL 3.x breaks the build even with the compatibility patches applied). Ruby 1.8.7 doesn’t have an official image at all, and its ext/openssl reaches into OpenSSL struct internals that became fully opaque in OpenSSL 1.1.0 — so a second Dockerfile builds it from source against a vendored OpenSSL 1.0.2u instead. None of this is soap4r logic.
The matrix is green everywhere it’s supposed to be — which isn’t quite the same claim as “fully green.” A handful of jobs are intentionally allowed to fail via continue-on-error: three patch versions (3.0.7/3.1.7/3.2.11) share one understood, non-soap4r quirk in how Test::Unit::TestCase#inspect renders on that narrow range; the 1.8.7 job has two documented environment-level issues (Kernel#singleton_class not existing before Ruby 1.9.2, and a Logger/CGI subprocess interaction that’s narrowed down but not fully root-caused); and JRuby carries its own known set of dependency differences. None of those are swept under the rug — they’re named in the README’s “Known Test Suite Exceptions” specifically so nobody mistakes an expected, understood difference for a regression.
The bugs that were hiding in the noise#
Once the environment was no longer a variable, two real bugs surfaced — both in test teardown, not in the library itself, which is almost worse, because flaky teardown erodes trust in every test that touches it.
The WEBrick race. Several test files spin up a local WEBrick server, run assertions against it, and tear it down. Teardown called Thread#kill on the server thread immediately after calling WEBrick#shutdown. Those two things race: shutdown starts its own async cleanup, and the immediate kill could land in the middle of it and leave a bound port behind. On a shared CI runner, a leaked port means the next test that wants that port hangs or fails, for reasons that have nothing to do with what it’s actually testing. The fix is unglamorous: teardown now does a bounded join and only escalates to kill as a genuine last resort. That one bug, multiplied across 54 test files that share the same teardown pattern, was the single biggest source of “CI just hangs sometimes, rerun it” over the life of this project.
The shell-hop waitpid bug. test/soap/ssl/test_ssl.rb spawns a test SSL server as a child process, then calls Process.waitpid on it during teardown. The spawn was going through a shell (sh -c '...') rather than exec’ing the server directly — which means the actual server process is a grandchild, not a child, of the test process. Process.waitpid can never find it. Every single SSL test run printed a “no Child Process found to wait on” warning and then just… slept for 5 seconds, every time, because something downstream had a timeout-based fallback papering over the wait failure. Fixed by removing the unnecessary shell hop so the server is actually the direct child the code assumes it is.
Neither of these is a soap4r bug. Both of them were costing real CI time and, worse, teaching me to distrust intermittent failures instead of investigating them — which is exactly the instinct that lets real bugs hide.
One related issue is still just a mitigation rather than a fix. All five parsers (Ox, Nokogiri, libxml, Oga, REXML) run as separate, sequential test-suite invocations inside the same long-lived container, sharing one hardcoded port. CI logs have shown that port stay bound across that boundary well past the test suite’s own retry budget — never once reproduced locally, and not fully root-caused (the leading suspect is a WEBrick/CGI-handler subprocess that isn’t fully released). Rather than keep guessing at timing, CI now just force-clears that port before every parser regardless of cause. It’s not as satisfying as a real root cause, but it’s honest about what’s actually understood versus papered over.
The unglamorous cleanup#
The rest of 2.1.0 is deprecation-noise removal: frozen_string_literal/chilled-string warnings on Ruby 3.4+, the mutex_m/Thread::Mutex warning that fired on nearly every Logger instantiation across a huge chunk of the version range, and the logger/getoptlong/webrick “moved to a default gem” notices that show up on Ruby ≥ 3.4/4.0. None of it changes behavior. All of it makes the CI logs something you can actually read when a test does fail for a real reason. There’s also a small functional addition — an options argument on the XSD Mapper/Mapping methods — but honestly, it’s the least interesting part of this release.
Why bother#
Most Ruby projects moved to REST/JSON a long time ago. Soap4r-ng exists for the ones that didn’t get that choice — insurance carriers, healthcare systems, EDI pipelines, the SOAP/WSDL integrations that don’t get to pick their transport. Whoever’s still depending on this gem needs it to work on whatever Ruby they’re actually running, not just the version I happened to test on. A matrix that’s green everywhere it should be — with the handful of known exceptions labeled instead of hidden — is the whole point.
It’s also, not coincidentally, the same instinct behind rescuing legacy systems for clients — the fix that matters most is rarely the flashy one. It’s making sure the thing you already depend on still tells you the truth.