Fixing Jekyll Builds That Break After a Ruby Upgrade

Jekyll itself changes slowly. Ruby does not: a new minor version every December, each with library moves and deprecations. A Jekyll site that has not been touched in two years usually still has the same Gemfile — and a CI image, a Homebrew install or a colleague's laptop now running a Ruby three versions newer. The resulting failures look unrelated to each other: cannot load such file -- webrick, a C compiler error from sassc, a wall of warnings about csv and base64, and a Liquid template that renders differently.

This guide fixes those failures on a company engineering blog — Jekyll 4.2, eleven plugins, built on Ruby 2.7 since 2021 — when its CI image moved to Ruby 3.4. It covers each error, its cause and fix, then pins Ruby so the next upgrade is deliberate. It belongs to Jekyll Plugin Ecosystem; the general upgrade process is in Upgrading Jekyll and Ruby Versions Safely.

What Broke

CI used ruby/setup-ruby with ruby-version: '3', which resolved to the latest 3.x. When 3.4 became the latest, the next build failed. The errors, in the order they appeared once each was fixed:

Sequence of Jekyll build failures after moving from Ruby 2.7 to 3.4 Five steps. Bundle install failed compiling sassc. After replacing it, jekyll serve failed loading webrick. After adding webrick, the build warned and then failed on csv and base64 no longer being default gems. After adding those, a plugin failed on a removed Ruby method. Finally the output differed because of a Liquid change in date handling. Errors in the order they appeared sassc won't compile native extension webrick missing removed in Ruby 3.0 csv / base64 missing bundled gems in 3.4 Plugin uses removed API File.exists? Output differs date formatting
Each fix revealed the next failure; the whole sequence took most of a working day the first time.

Error 1: sassc Fails to Compile

Installing sassc 2.4.0 with native extensions
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
  ...libsass/src/... error: no member named 'unary_function' in namespace 'std'

Cause. sassc wraps the deprecated LibSass C++ library, which does not compile with recent compilers and is no longer maintained. Jekyll 4.2's jekyll-sass-converter 2.x depends on it.

Fix. Upgrade to Jekyll 4.3 or later, whose jekyll-sass-converter 3.x uses sass-embedded (Dart Sass, distributed as a binary — nothing to compile). Dart Sass is stricter than LibSass: the blog's SCSS produced 14 deprecation warnings for / as division, fixed with math.div or calc(). One @import of a theme partial also needed its path corrected, because Dart Sass resolves load paths more strictly.

Error 2: webrick Cannot Be Loaded

jekyll 4.2.2 | Error:  cannot load such file -- webrick

Cause. Ruby 3.0 stopped shipping webrick as a default gem; jekyll serve needs it.

Fix. Jekyll 4.3 declares it, so the upgrade in Error 1 fixed this too. On older Jekyll, add gem "webrick" to the Gemfile.

Error 3: Standard Library Gems Moved Out

warning: csv was loaded from the standard library, but is not part of the default gems starting from Ruby 3.4.0.
You can add csv to your Gemfile or gemspec to silence this warning.
...
LoadError: cannot load such file -- base64

Cause. Ruby 3.4 turned several default gems into bundled gems. Under bundle exec, only gems in the lockfile can be loaded, so code that requires csv, base64, bigdecimal or logger without declaring them fails. Two of the blog's plugins required csv (for a data import) and base64 (for an image inliner) without listing them.

Fix. Add them to the Gemfile:

gem "csv"
gem "base64"
gem "bigdecimal"
gem "logger"

Then check whether the plugins have newer versions that declare their own dependencies, which is the better long-term fix. One did; the other was a local plugin in _plugins/, which simply needed the require to resolve — the Gemfile entry was enough.

Error 4: A Plugin Calls a Removed Method

NoMethodError: undefined method `exists?' for class File

Cause. File.exists? was deprecated for years and removed in Ruby 3.2. A local plugin used it.

Fix. File.exist?. A grep across _plugins/ and the installed gems found two more removed methods (Dir.exists? and URI.escape), both in the same local plugin; URI.escape became URI::DEFAULT_PARSER.escape for the one place it was used.

Error 5: Output That Changed Without an Error

With the build passing, the output was compared against a build from the old setup (kept in a Ruby 2.7 Docker image for exactly this purpose):

docker run --rm -v "$PWD":/srv -w /srv ruby:2.7 bash -c 'bundle _2.4.22_ install && bundle exec jekyll build -d /srv/_site-old'
bundle exec jekyll build -d _site-new
diff -rq _site-old _site-new | wc -l

Most differences were in CSS (Dart Sass output formatting). One mattered: post dates in the archive page rendered in UTC instead of the site's timezone on 41 posts published late in the evening, shifting them to the next day. The cause was the timezone setting interacting with Ruby's updated Time parsing for front matter dates without an offset. Setting timezone: Europe/Berlin explicitly in _config.yml — it had been relying on the CI machine's TZ variable, which the new image did not set — fixed all 41.

Changed output files by kind after the Ruby upgrade Horizontal bars. CSS files with formatting differences from Dart Sass 6. Pages with only a changed CSS fingerprint 1,212. Archive and post pages with a date shifted by one day 41. Pages with other content differences 0. What differed in the output CSS fingerprint only 1212 Date shifted a day 41 CSS formatting 6 Other content 0
diff of the Ruby 2.7 and Ruby 3.4 builds; only the date shift was a real regression.

Pin Ruby Everywhere

The incident happened because nothing pinned Ruby. Now:

# .ruby-version
3.4.1
# CI
- uses: ruby/setup-ruby@v1
  with:
    ruby-version: .ruby-version
    bundler-cache: true

setup-ruby reads .ruby-version; rbenv, chruby, asdf and mise do too on developers' machines. Gemfile.lock lists the platforms the site builds on (bundle lock --add-platform x86_64-linux arm64-darwin) so the same resolution is used locally and in CI. For contributors who do not want a Ruby toolchain at all, Running Jekyll in Docker for Reproducible Builds gives a one-command alternative with the same pinned version.

Upgrade on a Schedule

Pinning postpones upgrades; it should not prevent them. Dependabot opens pull requests for gem updates weekly, and a reminder each January bumps .ruby-version to the new Ruby release on a branch, where CI runs with RUBYOPT=-W:deprecated so deprecation warnings appear in the log a year before they become errors. The output diff against main is reviewed before merging.

Effort and errors for the forced upgrade compared with a scheduled one Paired bars. The forced jump from Ruby 2.7 to 3.4 produced 5 distinct errors and took about 7 hours. The next scheduled upgrade, from 3.4 to 3.5 a year later in a rehearsal on a preview release, produced 0 errors and 2 warnings and took about 40 minutes. The upgrade before and after pinning Forced jump 2.7 → 3.4 Scheduled upgrade Distinct errors 5 0 Hours spent 7 0.7
Effort from commit timestamps; the scheduled upgrade was rehearsed on the Ruby preview release.

Measured Impact

MeasureBeforeAfterTool
Ruby in CI'3' (floating)3.4.1 pinnedWorkflow
Build time38 s (Ruby 2.7, LibSass)31 s (Ruby 3.4, Dart Sass)hyperfine
bundle install, cold2 min 10 s (compiling sassc)24 sCI logs
Gems with native extensions62bundle list
Posts with wrong dates410Output diff

Pitfalls & Rollback

  • Floating Ruby versions in CI. ruby-version: '3' changes under you; pin from .ruby-version.
  • Fixing only the first error. Each fix reveals the next; budget for the chain.
  • Relying on the machine's timezone. Set timezone in _config.yml.
  • Skipping the output diff. Date and Sass changes do not fail the build.
  • Rollback: pin .ruby-version back to the old Ruby and restore the old Gemfile.lock; the Docker image of the old setup builds the old output exactly.

Conclusion

Jekyll breaks on new Ruby versions through its dependencies, not its own code: a native Sass compiler that no longer builds, webrick and other standard libraries moved out of Ruby, removed methods in local plugins, and subtle changes such as timezone handling. Upgrading to Jekyll 4.3 with Dart Sass, declaring moved gems, fixing removed calls, diffing the output and pinning Ruby turned a day-long incident into a yearly 40-minute task.

FAQ

Why does Jekyll fail with cannot load such file webrick?

Ruby 3.0 removed webrick from its default gems, and jekyll serve depends on it. Jekyll 4.3 and later list it as a dependency, but older Jekyll versions do not. Adding gem webrick to the Gemfile, or upgrading Jekyll, fixes it. The build command jekyll build does not need webrick, so the error appears only for local serving.

What do warnings about gems no longer being part of the default gems mean?

Ruby 3.4 moved several libraries, including csv, base64, bigdecimal and logger, from default gems to bundled gems. Code that requires them without declaring them in the Gemfile warns on 3.3 and fails on 3.4 under Bundler. Plugins and older Jekyll dependencies are the usual source; adding the gems to the Gemfile or upgrading the plugins fixes it.

Why do native extension gems fail to install after upgrading Ruby?

Gems with C extensions, such as sassc, eventmachine or older versions of ffi, are compiled against a specific Ruby version. Old releases may not compile on new Ruby or new compilers. Upgrading the gem to a version that supports the new Ruby, or replacing it, as with sassc replaced by sass-embedded in Jekyll 4.3, resolves it.

How do I keep Jekyll builds reproducible across machines?

Pin the Ruby version in a .ruby-version file, commit Gemfile.lock with the platforms you build on, use the same version in CI through setup-ruby reading .ruby-version, and run builds through bundle exec. A Docker image with a pinned Ruby gives the strongest guarantee.

Filed underFixesJekyll