Skip to content

Fix plugin installation from gemfile#6957

Merged
hsbt merged 13 commits into
ruby:masterfrom
ccutrer:plugin-install
Jun 11, 2026
Merged

Fix plugin installation from gemfile#6957
hsbt merged 13 commits into
ruby:masterfrom
ccutrer:plugin-install

Conversation

@ccutrer

@ccutrer ccutrer commented Sep 13, 2023

Copy link
Copy Markdown
Contributor

What was the end-user or developer problem that led to this PR?

This fixes #6630, #6589, and several related issues. Related to #6643, but a very different approach. Specs were copied from that PR though.

What is your fix for the problem, implemented in this PR?

Instead of blindly calling the installer with the plugins from the gemfile, include plugins as regular dependencies in the main Gemfile, and use its lockfile. This fixes several issues:

Fixes #6630.
Fixes #6589.
Closes #3319.

Make sure the following tasks are checked

@ccutrer

ccutrer commented Sep 13, 2023

Copy link
Copy Markdown
Contributor Author

I've confirmed this also fixes #6589

@ccutrer ccutrer mentioned this pull request Sep 14, 2023

@pboling pboling left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is awesome 🎸

Comment thread bundler/lib/bundler/plugin.rb Outdated
@ccutrer ccutrer force-pushed the plugin-install branch 4 times, most recently from 024ff9c to 7122f47 Compare October 11, 2023 16:43
@slaughter550

Copy link
Copy Markdown

@pboling anything else you wanted to see on this PR?

@pboling

pboling commented Dec 8, 2023

Copy link
Copy Markdown

Moving the ball forward on plugins is amazing. There is probably still some gap before I can do what I was hoping with plugins, but this is a great step toward making them more of a first-class citizen within bundler.

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

This is indeed pretty cool! @ccutrer can you rebase this?

@ccutrer

ccutrer commented Mar 29, 2024

Copy link
Copy Markdown
Contributor Author

There are several conflicts. I'll try to get them resolved later today.

@ccutrer ccutrer force-pushed the plugin-install branch 2 times, most recently from 31d3050 to c014b7f Compare March 29, 2024 19:53
@ccutrer

ccutrer commented Mar 29, 2024

Copy link
Copy Markdown
Contributor Author

Rebased and conflicts resolved; I didn't run rubocop or specs locally -- I'll let GitHub Actions handle that

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

@ccutrer I assume test failures here mean this still needs some work? Happy to have a look if needed!

Comment thread spec/plugins/install_spec.rb
@ccutrer

ccutrer commented May 3, 2024

Copy link
Copy Markdown
Contributor Author

Yes, this still needs attention. I'm sorry I haven't had time for it - have had other priorities at work.

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

No problem at all!

@ccutrer ccutrer force-pushed the plugin-install branch 4 times, most recently from e8f0d0a to 5acea42 Compare May 24, 2024 21:18
@ccutrer

ccutrer commented May 24, 2024

Copy link
Copy Markdown
Contributor Author

@deivid-rodriguez : this is ready for review now. I had to do significant reworking for Bundler 3, and I left the fixes as separate commits. I'd be happy to squash down as you want.

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

Great, thanks! I'll pick this up as soon as I can 👍.

@ccutrer

ccutrer commented Jun 10, 2024

Copy link
Copy Markdown
Contributor Author

@deivid-rodriguez : any idea when you'll have time to look at this?

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

This week!

@deivid-rodriguez deivid-rodriguez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I started reviewing and had the idea of locking plugins in the lockfile normally. Essentially, treating them just like the rest of the gems. I think that could mean not having to extend Bundler::Definition at all? What are your thoughts on this idea?

In general I think the current approach is way better than what we have, but it feels we're basically duplicating the current logic to deal with dependencies for plugins, and I wondered if it's really necessary.

One concern that I have is backwards compatibility. If we start including a bunch of extra gems in the lockfile when gemfile includes plugins, will older versions of Bundler understand that? I think it would not be a problem a older Bundler would just remove the gems they don't consider part of the bundle, but we'd need to try.

Comment thread bundler/lib/bundler/dsl.rb Outdated
Comment thread bundler/lib/bundler/definition.rb Outdated
Comment thread bundler/lib/bundler/plugin.rb Outdated
@ccutrer

ccutrer commented Jun 12, 2024

Copy link
Copy Markdown
Contributor Author

I had considered that, and in many ways it seems far more direct and simpler. For some reason I was thinking new sections of the lockfile can only be introduced in major version updates the Bundler, but maybe I'm wrong? The big thing that turned me away from it though is that plugins can be installed outside the context of a Gemfile (and its lockfile). Or they could be installed when there is a Gemfile and lockfile, but unrelated to it - like if a developer wants to have a bundler plugin in their working directory, but not have others use it. My other idea is to replace the plugin index (in .bundle/plugins/index - either within the app, or in the user's home directory) with a lockfile. But the index serves a greater purpose than simply keeping track of the gems installed as plugins. It is also a more lookup type structure of "for a given hook type, which plugin(s) are registered to receive callbacks?". So perhaps we could switch the index to a lockfile, just with a custom section? Really that would just simplify the piece in Bundler::Plugin where we construct an in-memory lockfile structure. Unless we start treating plugins are regular dependencies in the Gemfile as well (perhaps put them in an implicit plugins group, and always setting them to require: false?), the bifurcation of dependencies and plugins in Dsl and Definition has to continue, otherwise the processes that clean the cache will wipe out any cached gems that are actually plugins, because they'll be unaware of them. I honestly like the idea of adding plugins to the regular lockfile, because it means as a developer that manages a plugin that everyone on my team must use, I can rely on the lockfile itself for defining exactly which version is in use, instead of only using an exactly pinned version in the plugin line in the Gemfile. But again, it doesn't really get to eliminate much if any code in Plugin and Plugin::Index, because those need to continue to operate without the lockfile.

So... let me know if you want me to pursue a different direction, and which.

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

No, just busy, sorry about it, I'll try find some time for this PR.

@ccutrer

ccutrer commented Jun 30, 2025

Copy link
Copy Markdown
Contributor Author

Ping @deivid-rodriguez

@deivid-rodriguez

Copy link
Copy Markdown
Contributor

Sorry again for the delay, I have this PR in mind but I'm not finding time for it. I'll get back to it once I make the releases that I'm working on now.

gillisd added a commit to gillisd/gemvault that referenced this pull request Apr 23, 2026
)

Every `bundle install` reprints four "Installing X" lines for the
:vault plugin's transitive dependencies (command_kit, sqlite3,
gemvault, bundler-source-vault) even when nothing has changed. Root
cause: Bundler::Plugin::Installer#install_definition unconditionally
calls spec.source.install(spec) for every resolved spec — there is no
skip-if-already-installed check. This is a Bundler-side issue, not a
gemvault issue. It affects every bundler plugin that declares runtime
dependencies.

There's no clean fix from the plugin side: our plugins.rb loads during
Bundler.definition — after Plugin.gemfile_install has already
reinstalled everything — so a monkey-patch from there arrives too
late. An upstream Bundler PR (ruby/rubygems#6957) addresses this
and will resolve the noise once it ships in a Bundler release.

- README section links to the upstream bug and fix PR, and documents
  `bundle config set --local plugins false` as a user-opt-in workaround
  with its trade-offs (disables plugin auto-install for the project;
  caller must not commit .bundle/config with the setting, since it
  would affect teammates who rely on plugin auto-install elsewhere).

- Integration spec `stops reinstalling plugin deps once
  BUNDLE_PLUGINS=false is set` reproduces the bug end-to-end (uninstall
  system gemvault to force the plugin gems into Plugin.root only, then
  run `bundle install` twice) and verifies that setting
  BUNDLE_PLUGINS=false neutralises the reinstall. That same spec will
  keep passing after the upstream PR lands, serving as a regression
  check for our side of the contract.
gillisd added a commit to gillisd/gemvault that referenced this pull request Apr 23, 2026
Every `bundle install` reprints four "Installing X" lines for the
:vault plugin's transitive dependencies (command_kit, sqlite3,
gemvault, bundler-source-vault) even when nothing has changed. Root
cause: Bundler::Plugin::Installer#install_definition unconditionally
calls spec.source.install(spec) for every resolved plugin spec — there
is no skip-if-already-installed check. Upstream bug tracked at
ruby/rubygems#6630, fix proposed in ruby/rubygems#6957.

Ship a drop-in wrapper instead of auto-editing the user's config:

    gemvault bundle install           # instead of `bundle install`
    gemvault bundle update --conservative rake
    gemvault bundle lock

`gemvault bundle` execs `bundle` with BUNDLE_PLUGINS=false set only in
the child process environment — no .bundle/config write, no persistent
project setting, no impact on teammates or other bundler plugins the
project uses. Plain `bundle install` still works exactly as before
(including plugin auto-install) for the cases where you actually need
plugin install to run.

- lib/gemvault/cli/commands/bundle.rb — the command, auto-registered
  via command_kit AutoLoad.
- test/cli_bundle_command_test.rb — minitest covering the exec
  behaviour: sets BUNDLE_PLUGINS=false, forwards arguments, works
  with no args.
- spec/integration/bundle_install_spec.rb — new integration spec that
  uninstalls the system-gem copy of bundler-source-vault so the
  reinstall bug manifests, runs a plain `bundle install` twice
  (second run reprints "Installing bundler-source-vault" — the bug),
  then runs `gemvault bundle install` (no reinstall — the fix).
- README section documents the wrapper and the underlying upstream
  bug.
gillisd added a commit to gillisd/gemvault that referenced this pull request Apr 23, 2026
#4)

Bundler's `Plugin.gemfile_install` calls `Installer.new.install_definition`
with the full dependency list on every `bundle install` and has no check
for "all declared plugins are already registered". `install_from_specs`
then blindly reinstalls each spec, so the :vault plugin's four transitive
gems (command_kit, sqlite3, gemvault, bundler-source-vault) reprint
"Installing X" on every single `bundle install`. It's bundler bug
ruby/rubygems#6630 from 2023; a structural fix is proposed in
ruby/rubygems#6957 but hasn't shipped in a release.

Nothing a third-party plugin loads runs before `Plugin.gemfile_install`:
the shim's `plugins.rb` only loads during `Bundler.definition`, which
happens after the bug fires. So the fix has to live inside Bundler's
own file. Ship a CLI that applies it directly:

    gemvault patch-bundler     # one-time, per bundler version on disk
    gemvault unpatch-bundler   # reversible

The patch inserts one early return into `Bundler::Plugin.gemfile_install`:

    plugins = definition.dependencies.map(&:name)
    # gemvault-bundler-patch: skip-reinstalled-plugins
    return if definition.dependencies.map(&:name).all? { |n| index.installed?(n) }
    installed_specs = Installer.new.install_definition(definition)

If every declared plugin is already in the index, gemfile_install returns
before `install_definition` can do anything. Plain `bundle install` and
every other bundler subcommand go back to behaving normally. The patch
carries a marker comment so it's idempotent and unpatch can find it to
reverse it cleanly.

Discovery scans system gem paths, Ruby's stdlib dir (for the
bundled-default bundler that ships with Ruby 4.x), and any
`vendor/ruby/*/gems/bundler-*` under the current directory — so a user
who runs `gemvault patch-bundler` from within a project catches both
the system bundler and their vendored one.

- lib/gemvault/bundler_patch.rb — the patcher, pure string surgery over
  Bundler's source file with a constant marker for idempotency.
- lib/gemvault/cli/commands/{patch_bundler,unpatch_bundler}.rb —
  command_kit commands exposed as `gemvault patch-bundler` and
  `gemvault unpatch-bundler`.
- test/bundler_patch_test.rb — unit tests over the patch/revert logic:
  rewrites pristine source, idempotent, refuses unknown bundler shapes,
  round-trips to the exact pristine byte stream.
- spec/integration/bundle_install_spec.rb — integration spec that
  reproduces the bug end-to-end (uninstall the system-gem copy of
  bundler-source-vault so the plugin is only in Plugin.root, then run
  two plain `bundle install`s — the second reprints "Installing
  bundler-source-vault": RED), runs `gemvault patch-bundler`, then runs
  a third `bundle install` and asserts the reinstall line is gone: GREEN.
- README section documents the command, the scope of its scan, the
  upstream bug/PR, and the reason for patching bundler on disk rather
  than monkey-patching from our plugin.

Removes the earlier `gemvault bundle` wrapper approach, which was a
BUNDLE_PLUGINS=false env-var workaround dressed up as a CLI.
gillisd added a commit to gillisd/gemvault that referenced this pull request Apr 23, 2026
#4)

Bundler's `Plugin.gemfile_install` calls `Installer.new.install_definition`
with the full dependency list on every `bundle install` and has no check
for "all declared plugins are already registered". `install_from_specs`
then blindly reinstalls each spec, so the :vault plugin's four transitive
gems (command_kit, sqlite3, gemvault, bundler-source-vault) reprint
"Installing X" on every single `bundle install`. It's bundler bug
ruby/rubygems#6630 from 2023; a structural fix is proposed in
ruby/rubygems#6957 but hasn't shipped in a release.

Nothing a third-party plugin loads runs before `Plugin.gemfile_install`:
the shim's `plugins.rb` only loads during `Bundler.definition`, which
happens after the bug fires. So the fix has to live inside Bundler's
own file. Ship a CLI that applies it directly:

    gemvault patch-bundler     # one-time, per bundler version on disk
    gemvault unpatch-bundler   # reversible

The patch itself is a standard unified diff
(`lib/gemvault/bundler_patch.diff`) that inserts one early return above
`install_definition`:

    +        # gemvault-bundler-patch: skip-reinstalled-plugins
    +        return if definition.dependencies.map(&:name).all? { |n| index.installed?(n) }
             installed_specs = Installer.new.install_definition(definition)

Applied via the canonical `patch(1)` tool — hunk matching, fuzz, and
reversal come for free. Ruby handles idempotency with a marker-comment
check (pure-insertion diffs can't be distinguished by `patch` from a
pristine target).

Two entities, constructor-injected:

- `Gemvault::BundlerInstallation` — a bundler `plugin.rb` file on disk.
  `.discover(root:)` class method finds installations across system gems,
  Ruby stdlib (for bundled-default bundler), and vendored copies under
  the project.
- `Gemvault::BundlerPatch` — the fix, parameterised by its diff path and
  a process runner (defaults to `Open3.method(:capture2e)` for easy
  stubbing). `#apply_to(installation)` and `#revert_from(installation)`
  return `:applied`/`:already_applied`/`:reverted`/`:not_applied`.

CLI commands `PatchBundler` / `UnpatchBundler` wire the two together
and print per-installation status.

Tests:

- test/bundler_patch_test.rb — covers `BundlerPatch#apply_to`,
  `#revert_from`, idempotency, and round-trip-to-pristine against a
  realistic `plugin.rb` fixture, plus `BundlerInstallation` value-
  object semantics (==, hash, Pathname wrapping).
- spec/integration/bundle_install_spec.rb — end-to-end podman spec
  that uninstalls bundler-source-vault so the reinstall bug manifests,
  runs two plain `bundle install`s (second reprints
  `Installing bundler-source-vault`: RED), runs `gemvault patch-bundler`,
  then runs a third `bundle install` and asserts the reinstall line is
  gone: GREEN.
- Dockerfile.test installs `patch` so the test image has the tool
  available.
@hsbt hsbt force-pushed the plugin-install branch from 2542e71 to 64450a3 Compare June 11, 2026 04:44
ccutrer and others added 6 commits June 11, 2026 13:45
Several things are fixed:
 * Don't re-install a plugin referenced from the gemfile with every
   call to `bundle install`
 * If the version of a plugin referenced in the gemfile conflicts
   with what's in the plugin index, _do_ re-install it
 * If plugins aren't installed yet, don't throw cryptic errors from
   commands that don't implicitly install gems, such as
   `bundle check` and `bundle info`. This also applies if the
   plugin index references a system gem, and that gem is removed.

This is all accomplished by actuallying including plugins as
regular dependencies in the Gemfile, so that they end up in the
lockfile, and then just using the regular lockfile with the
plugins-only pass of the gemfile in the Plugin infrastructure.
This also means that non-specific version constraints can be
used for plugins, and you can update them with
`bundle update <plugin>` just like any other gem.

Co-authored-by: Diogo Fernandes <diogofernandesop@gmail.com>
Since ruby#8480, we can't use the undocumented "type" option, but the
add_dependency helper was added that lets us easily validate first,
then add our custom options, then directly add the now custom
dependency.

This conveniently simplifies the Plugin version of the DSL, because
`plugin` no longer flows through `gem`, so it doesn't need to special
case it.
Since ruby#8486, hax to make any dependency type work inside bundler
have been removed, so we mark plugins as type: :plugin. Instead,
follow that PR's lead and just make it a dedicated option to
Bundler::Dependency.
it's not core to this PR, and can be debated separately
save_plugin grew an equivalent manual path comparison on this branch
while master extracted Index#up_to_date? for the same purpose, so use
the helper instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plugins from the Gemfile now count as regular dependencies and are
installed into the bundle path instead of the plugin root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hsbt hsbt force-pushed the plugin-install branch from 64450a3 to 3547170 Compare June 11, 2026 04:45
hsbt and others added 7 commits June 11, 2026 14:00
Plugins declared in the Gemfile are now always tracked in the lockfile,
so that checksums and cooldown verification apply to them
unconditionally. An opt-out would leave plugins outside those
protections, so it is not provided. Also drop
Definition#plugin_dependencies, which lost its last caller when
validate_plugins! was removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The name now reflects why the placeholder exists: it stands in for a
source whose handler plugin is not loaded yet while the lockfile is
parsed during the plugin install pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
save_plugins received every spec materialized for the plugin
definition, so transitive dependencies of a plugin went through
validate_plugin! and failed with MalformattedPlugin because they have
no plugins.rb. Register only the requested plugins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unlock skip was based on index.installed_plugins, which misses
plugins not yet installed locally, and install_definition resolved from
cache whenever the lockfile was satisfiable, so an unlocked plugin never
saw new versions and the plugin index kept the old one. Decide the skip
from the plugin declarations in the Gemfile and force remote resolution
when unlocking. The unlocking check must happen before building the
definition because Definition#initialize consumes the unlock hash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registering only the requested plugins restores the CLI argument order
in the plugin index, matching the original expectation on master, and
gemfile_install now reads plugin names from the DSL builder instead of
the definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
explicit_gems aliased the same array that the --group handling mutates
with concat, so group-derived gems leaked into the lockfile presence
check and bundle update --group failed on gems newly added to the
group. Copy the array before it gets mutated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hsbt hsbt force-pushed the plugin-install branch from dd575da to f868e59 Compare June 11, 2026 07:38
@hsbt

hsbt commented Jun 11, 2026

Copy link
Copy Markdown
Member

@ccutrer I rebased this branch onto current master and pushed a few changes on top of the original commits.

The main one is that I removed the plugins_in_lockfile setting and made Gemfile plugins always tracked in the lockfile, so that protections like checksums and cooldown apply to plugins unconditionally. Apps running in frozen or deployment mode will need a one-time lockfile update after upgrading with the next minor release.

I also fixed three bugs found while reviewing the result, each with a regression spec: plugins with their own dependencies failed to install, bundle update <plugin> didn't update the plugin index, and bundle update --group raised on gems newly added to that group.

Finally, I renamed DummySource to UnloadedSource for clarity.

Thanks.

@hsbt hsbt merged commit a89e1cb into ruby:master Jun 11, 2026
109 checks passed
@ccutrer ccutrer deleted the plugin-install branch June 11, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

6 participants