Skip to content

Add cooldown to delay newly published gem#9576

Merged
hsbt merged 11 commits into
masterfrom
cooldown-feature
Jun 2, 2026
Merged

Add cooldown to delay newly published gem#9576
hsbt merged 11 commits into
masterfrom
cooldown-feature

Conversation

@hsbt
Copy link
Copy Markdown
Member

@hsbt hsbt commented May 29, 2026

Adds the cooldown setting discussed at #9113, mirroring npm's min-release-age, pnpm's minimumReleaseAge, and Dependabot's cooldown to mitigate freshly published supply-chain attacks.

Users opt in through:

  • --cooldown N on install, update, add, and outdated
  • bundle config set cooldown N or BUNDLE_COOLDOWN
  • per-source via source "URL", cooldown: N in the Gemfile

Design decisions adopted from #9113 and the follow-up review:

  • The value is a non-negative Integer day count. Strings, floats, arrays, and negative numbers raise InvalidOption from both the Gemfile DSL and the CLI handlers, so a typo can't silently disable the filter.
  • Precedence is CLI > config > Gemfile per-source, applied uniformly to every source — not just ones that declare their own cooldown: value.
  • --cooldown 0 is the global escape hatch and applies to the whole resolve including transitive dependencies; combine with --conservative to minimize churn during urgent updates.
  • Per-gem cooldown is out of scope; private registries opt out by declaring source "URL", cooldown: 0 in the Gemfile.
  • The filter runs per-version (rolling delay) rather than from the latest release timestamp.
  • Lockfile-pinned versions bypass the filter, and bundle install against an existing lockfile short-circuits the resolver entirely, so a cooldown setting never invalidates a working lock.
  • bundle update filters and falls back to the newest version satisfying cooldown; bundle outdated annotates the latest-but-cooled version ((cooldown Nd) in the table form, in cooldown for N more days in --parseable) rather than hiding it.
  • When the resolver fails because every matching version is in cooldown, both error paths surface the same hint suggesting --cooldown 0 to bypass.
  • Specs whose created_at is absent are treated as outside the window and remain resolvable.
  • Out of scope: CVE-aware bypass (Dependabot/Renovate territory), semver-level differentiation, gem install -v X exact-version cooldown errors (deferred), and a dedicated exit code for cooldownfailures.

The EndpointSpecification parses created_at defensively against older rubygems whose APISet::GemParser splits ISO8601 on every colon and the flat String shape are accepted, malformed values become nil.

Make sure the following tasks are checked

Copilot AI review requested due to automatic review settings May 29, 2026 08:12
@hsbt hsbt force-pushed the cooldown-feature branch from 71542d2 to f41362e Compare May 29, 2026 08:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in “cooldown” (minimum release age in days) to Bundler installs/updates/resolution, using per-version created_at metadata from the v2 compact index to avoid selecting freshly published versions.

Changes:

  • Introduces cooldown configuration/precedence (CLI > config/env > Gemfile per-source) and wires it through sources/remotes to the resolver.
  • Parses and propagates created_at from compact-index metadata into EndpointSpecification, and filters resolver candidates within the cooldown window.
  • Documents --cooldown across relevant commands and adds comprehensive specs + artifice support.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
spec/support/shards.rb Adds new cooldown specs to CI sharding.
spec/support/artifice/helpers/compact_index_cooldown.rb Adds an artifice compact-index API variant that emits created_at.
spec/support/artifice/compact_index_cooldown.rb Activates the new cooldown-aware artifice endpoint.
spec/install/cooldown_spec.rb End-to-end coverage for cooldown behavior and precedence.
spec/bundler/source/rubygems/remote_spec.rb Tests Remote#cooldown and #effective_cooldown.
spec/bundler/source_list_spec.rb Tests per-remote cooldown storage on sources.
spec/bundler/settings_spec.rb Tests that cooldown is coerced via settings.
spec/bundler/resolver/cooldown_spec.rb Unit tests for resolver cooldown filtering and hinting.
spec/bundler/endpoint_specification_spec.rb Tests robust parsing of created_at metadata shapes.
spec/bundler/dsl_spec.rb Validates Gemfile DSL input validation for cooldown:.
spec/bundler/compact_index_client/parser_spec.rb Ensures compact index parser accepts created_at metadata.
lib/rubygems/resolver/api_set/gem_parser.rb Fixes parsing to avoid splitting ISO8601 values on colons.
bundler/lib/bundler/source/rubygems/remote.rb Adds cooldown state to remotes and computes effective cooldown.
bundler/lib/bundler/source/rubygems.rb Tracks per-remote cooldowns and passes them into Remote objects.
bundler/lib/bundler/source_list.rb Allows storing cooldown when adding global rubygems remotes.
bundler/lib/bundler/settings.rb Treats cooldown as a numeric setting key.
bundler/lib/bundler/resolver.rb Filters candidate specs by cooldown and adds user-facing hints on failure.
bundler/lib/bundler/man/bundle-update.1.ronn Documents --cooldown on bundle update.
bundler/lib/bundler/man/bundle-update.1 Generated manpage update for bundle update.
bundler/lib/bundler/man/bundle-outdated.1.ronn Documents --cooldown behavior/annotation on bundle outdated.
bundler/lib/bundler/man/bundle-outdated.1 Generated manpage update for bundle outdated.
bundler/lib/bundler/man/bundle-install.1.ronn Documents --cooldown on bundle install.
bundler/lib/bundler/man/bundle-install.1 Generated manpage update for bundle install.
bundler/lib/bundler/man/bundle-config.1.ronn Documents cooldown config/env and precedence rules.
bundler/lib/bundler/man/bundle-config.1 Generated manpage update for bundle config.
bundler/lib/bundler/man/bundle-add.1.ronn Documents --cooldown on bundle add.
bundler/lib/bundler/man/bundle-add.1 Generated manpage update for bundle add.
bundler/lib/bundler/endpoint_specification.rb Adds created_at field and parses it from metadata.
bundler/lib/bundler/dsl.rb Adds Gemfile source ..., cooldown: N parsing + validation + wiring.
bundler/lib/bundler/cli/update.rb Validates/applies --cooldown into settings for updates.
bundler/lib/bundler/cli/outdated.rb Validates/applies --cooldown and annotates cooldown remaining.
bundler/lib/bundler/cli/install.rb Validates/applies --cooldown into settings for installs.
bundler/lib/bundler/cli/common.rb Adds shared cooldown validation helper.
bundler/lib/bundler/cli/add.rb Validates/applies --cooldown for bundle add.
bundler/lib/bundler/cli.rb Exposes --cooldown option on install/update/add/outdated commands.
Comments suppressed due to low confidence (1)

bundler/lib/bundler/settings.rb:53

  • cooldown is treated as a numeric setting via to_i, which silently accepts malformed/negative values (e.g. BUNDLE_COOLDOWN=7days becomes 7, -7 disables filtering via days <= 0). Since cooldown is intended as a supply-chain protection feature, this should be validated as a non-negative integer (and reject floats/garbage) when coming from config/env as well, so typos can’t silently change/disable the filter.
    NUMBER_KEYS = %w[
      cooldown
      jobs
      redirect
      retry
      ssl_verify_mode
      timeout
    ].freeze

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +29 to 31
cooldown = options["cooldown"]
Array(options["remotes"]).reverse_each {|r| add_remote(r, cooldown: cooldown) }

Comment on lines +165 to +170
when "created_at"
value = v.is_a?(Array) ? v.last : v
if value.is_a?(String)
require "time"
begin
@created_at = Time.iso8601(value)
Comment on lines +1 to +4
# frozen_string_literal: true

require_relative "compact_index"

@hsbt hsbt force-pushed the cooldown-feature branch 3 times, most recently from d7711e0 to 0516df9 Compare May 29, 2026 09:33
@hsbt
Copy link
Copy Markdown
Member Author

hsbt commented May 29, 2026

📝 I should inject rake vendor:compact_index into test_setup

https://github.com/ruby/rubygems/blob/master/spec/support/rubygems_ext.rb#L30

hsbt and others added 7 commits June 2, 2026 09:41
The upcoming compact index v2 format introduced by rubygems.org appends a
`created_at:ISO8601` field to each info line, and the timestamp's internal
colons were being treated as additional split delimiters. Splitting only on
the first colon keeps existing keys like `ruby:>= 2.7.0` intact while letting
multi-colon values pass through untouched.

rubygems/rubygems.org#6504

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The cooldown feature needs each gem version's publish timestamp on the
client side. Compact index v2 exposes it as a `created_at:ISO8601`
entry in the info-line metadata; expose a Time-typed `created_at`
attribute on the spec so the resolver can consult it later.

Parsing is defensive against older rubygems whose APISet GemParser
splits ISO8601 timestamps on every colon, accepting both Array and
flat String shapes and silently dropping malformed values. The time
stdlib is required lazily inside the case branch so loading the file
does not activate the `time` default gem during Bundler.setup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `cooldown` to NUMBER_KEYS so that `BUNDLE_COOLDOWN` and
`bundle config set cooldown` are parsed as integer days. Reading the
value is enough for now; later commits plumb it into the resolver and
the Gemfile DSL.

#9113

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lets `source "https://rubygems.org", cooldown: 7` attach a per-remote
value to the global Rubygems source, which the new
`Remote#effective_cooldown` reads with CLI > config > Gemfile
per-source precedence. The cooldown is stored on Source::Rubygems
keyed by URI so that several top-level `source` lines can carry
independent values onto the same global source.

Non-negative Integer values are required at parse time; everything
else (strings, floats, arrays, negative numbers) raises InvalidOption
so a typo can't silently disable the filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the CLI flag and routes it through `set_command_option_if_given`
so the value lands in Bundler.settings' temporary store. This keeps the
CLI > config > Gemfile precedence implicit in the existing settings
layering. The resolver does not yet consult the value.

#9113

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a per-version filter to `Resolver#filter_specs` that drops specs
whose `created_at` falls within the effective cooldown window. The
filter only runs over `@all_specs`, so lockfile-pinned versions in
`@base` survive even if a newer release would now be excluded. Specs
without `created_at` are kept so historical versions and indexes that
do not yet expose timestamps remain usable.

A shared `cooldown_now` memoization ensures every comparison within
one resolve sees the same timestamp, stabilizing tests near a
threshold boundary. When the resolver fails because every matching
version is in cooldown, both `raise_not_found!` and
`no_versions_incompatibility_for` surface a hint suggesting
`--cooldown 0` to bypass; a small `cooldown_hint` helper keeps the
wording in sync between the two error paths and is locked down with
unit specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bundle outdated still surfaces newer-but-cooldown'd versions instead of
hiding them, so the user knows an upgrade is pending rather than
silently missing. The prose form gains ", in cooldown for Nd more days"
and the table form appends "(cooldown Nd)" to the Latest column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@hsbt hsbt force-pushed the cooldown-feature branch from 55b7c3b to 7ff59b7 Compare June 2, 2026 00:43
hsbt and others added 4 commits June 2, 2026 12:41
Adds the `--cooldown=NUMBER` option to the install, update, add, and
outdated man pages and describes the `cooldown` / `BUNDLE_COOLDOWN`
setting in bundle-config, regenerating the rendered .1 files via
`rake man:build`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A `CompactIndexCooldownAPI` subclass overrides `build_gem_version` to
emit `CompactIndex::GemVersionV2` with `created_at` sourced from
`spec.date`, letting tests carry a deterministic timestamp per built
gem. `spec/install/cooldown_spec.rb` exercises the source DSL keyword,
`--cooldown` flag, `BUNDLE_COOLDOWN`, `bundle config set cooldown`,
the rolling-delay filter, the lockfile bypass, and the CLI vs Gemfile
precedence against the new artifice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Gemfile DSL already rejects `cooldown: -7`, but `--cooldown -7` on
install/update/add/outdated slipped through Thor and ended up
disabling the filter silently via the `days <= 0` short-circuit in
the resolver. Add a shared CLI::Common.validate_cooldown! guard so the
CLI surface fails loud with the same message as the DSL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds three E2E checks against the v2 cooldown artifice: the negative
CLI value is rejected at parse time, `bundle outdated --cooldown` tags
the latest-but-cooled version in both table and parseable output, and
`bundle update --cooldown 99999` surfaces the cooldown hint when every
candidate is filtered. These were the remaining coverage gaps called
out in the adversarial review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@hsbt hsbt force-pushed the cooldown-feature branch from 39e7650 to 3df6b30 Compare June 2, 2026 03:44
@hsbt hsbt merged commit 88057ed into master Jun 2, 2026
136 of 138 checks passed
@hsbt hsbt deleted the cooldown-feature branch June 2, 2026 04:29
hsbt added a commit that referenced this pull request Jun 3, 2026
Add `cooldown` to delay newly published gem

(cherry picked from commit 88057ed)
@mark-young-atg
Copy link
Copy Markdown

@hsbt I'm very excited to see you tackle this functionality. Thank you.

Please can you confirm how it will behave in the following scenario:

Day 0: I have popular_gem v1.0 installed
Day 1: popular_gem v1.1 released with hidden malware
Day 5: popular_gem v1.2 released with malware removed
Day 10: I run "bundle update --cooldown 7"

Will I get v1.1 installed and thus suffer from the now known bad code, or will it delay updating until Day 12 when it will give me v1.2?

If I get v1.1 then unfortunately I think that negates what feels like the benefits of this mechanism. The goal for me is safety and security, so I'd want it to automatically prevent me getting the bad release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants