# A Two-Week Gem Cooldown for Ruby Projects

> Bundler has no minimum release age setting. Here's how to hold your gems 14 days back and verify every locked version against RubyGems.

- Author: Curtis Miller
- Published: 2026-10-31
- Categories: ruby, security
- Canonical URL: https://millarian.com/posts/bundler-gem-cooldown-minimum-release-age/

[The first post in this series](https://millarian.com/posts/why-i-wait-two-weeks-to-install-packages/) covers why I wait two weeks before installing any new release. npm, pnpm, and yarn have a "minimum release age" setting for that. Bundler has no built-in equivalent, so in a Ruby project you have to enforce it yourself.

## Pin, update, unpin

Bundler resolves to the newest version that satisfies the Gemfile, so to get older versions, temporarily cap the gems that are too new:

```ruby
gem 'json', '< 3.1'
gem 'prism', '< 2.0'
gem 'sass-embedded', '<= 1.105.1'
```

Then:

```bash
bundle update --all
```

Once the lockfile looks right, delete the pins from the Gemfile and run `bundle lock` with no `--update`. Loosening a constraint doesn't force Bundler to re-resolve, so it keeps the versions already in `Gemfile.lock`. The Gemfile stays clean, and the lockfile holds the cooldown-compliant versions.

## Verify every locked gem against its publish date

Pinning only covers the gems in your Gemfile. The gems those gems depend on can still update to something too new, and you won't notice unless you check. The only reliable check is the publish date of the exact version in the lockfile.

Native gems like `nokogiri`, `sass-embedded`, `google-protobuf`, and `ffi` add a wrinkle. They ship per-platform builds (`arm64-darwin`, `x86_64-linux-gnu`), each build has its own `created_at`, and builds of the same version can be published on different days. So the check has to match on both version and platform.

This script reads `Gemfile.lock` with Bundler's own parser, looks up each gem in the [RubyGems API](https://guides.rubygems.org/rubygems-org-api/), and reports anything newer than the cutoff or missing entirely. It includes the Bundler version from `BUNDLED WITH`, and skips gems from git or path sources:

```ruby
# Usage: ruby check_cooldown.rb [Gemfile.lock] [days]
require "bundler"
require "date"
require "json"
require "net/http"

lockfile = ARGV[0] || "Gemfile.lock"
days = Integer(ARGV[1] || 14)
cutoff = Date.today - days

parser = Bundler::LockfileParser.new(File.read(lockfile))
specs = parser.specs
  .select { |s| s.source.is_a?(Bundler::Source::Rubygems) }
  .map { |s| [s.name, s.version.to_s, s.platform.to_s] }
specs << ["bundler", parser.bundler_version.to_s, "ruby"] if parser.bundler_version

too_new = specs.uniq.filter_map do |name, version, platform|
  url = URI("https://rubygems.org/api/v1/versions/#{name}.json")
  versions = JSON.parse(Net::HTTP.get(url)) rescue [] # private or unknown gem
  release = versions.find do |v|
    v["number"] == version && v["platform"] == platform
  end
  published = release && Date.parse(release["created_at"])
  "#{name} #{version} (#{platform}): #{published || 'not found'}" if published.nil? || published > cutoff
end

if too_new.empty?
  puts "All #{specs.uniq.size} locked gems are at least #{days} days old."
else
  puts too_new
  exit 1
end
```

Run it with `bundle exec ruby check_cooldown.rb`. If it lists anything, add a pin for each gem, run `bundle update --all` again, and re-check. It exits non-zero when something is too new, so you can also run it in CI and keep the cooldown on every dependency change, not just the ones where you remembered.

## Don't forget JavaScript dependencies

If the project also has a JS lockfile, install from it without running install scripts, since that's how Shai-Hulud spread. The [npm post](https://millarian.com/posts/npm-two-week-cooldown-after-shai-hulud/) covers the rest.

```bash
yarn install --frozen-lockfile --ignore-scripts
```
