From 8ff5b4bba842b0ff4f05aa48db7e0681e691e530 Mon Sep 17 00:00:00 2001 From: i343759 Date: Mon, 6 Apr 2026 13:59:31 +0300 Subject: [PATCH 1/2] Fix bundler 4.x incompatibility caused by rubygems 4.x update Rubygems 4.0.9 ships bundler 4.x as a default gem. When UpdateRubygems() runs 'ruby setup.rb', it installs bundler 4.0.9 which overwrites the buildpack's bundler 2.x. Bundler 4.x changed 'bundle version' output format (omits 'Bundler version' prefix), breaking GetBundlerVersion(). Changes: - GetBundlerVersion() regex: handle both bundler 2.x and 4.x output formats - UpdateRubygems(): re-install manifest bundler after 'ruby setup.rb' - InstallBundler(): invert version selection to default to 2.x.x - VendorBundlePath(): only bundler 1.x uses nested path (future-proof) - InstallGems(): only bundler 1.x skips BUNDLED WITH removal (future-proof) - Tests: add bundler 2.x/4.x test cases, update UpdateRubygems test --- src/ruby/supply/supply.go | 19 +++++++++---- src/ruby/supply/supply_test.go | 51 +++++++++++++++++++++++++++++++++- src/ruby/versions/ruby.go | 10 +++++-- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/ruby/supply/supply.go b/src/ruby/supply/supply.go index 10e590c82..b3a73425d 100644 --- a/src/ruby/supply/supply.go +++ b/src/ruby/supply/supply.go @@ -324,11 +324,11 @@ func (s *Supplier) InstallBundler() error { matches = []string{"", "2"} } - if strings.HasPrefix(matches[1], "2") { - return s.installBundler("2.x.x") + if strings.HasPrefix(matches[1], "1") { + return s.installBundler("1.x.x") } - return s.installBundler("1.x.x") + return s.installBundler("2.x.x") } func (s *Supplier) InstallNode() error { @@ -478,7 +478,7 @@ func (s *Supplier) VendorBundlePath() (string, error) { return "", err } - if strings.HasPrefix(bundlerVersion, "2.") { + if !strings.HasPrefix(bundlerVersion, "1.") { return "vendor_bundle", nil } @@ -620,6 +620,15 @@ func (s *Supplier) UpdateRubygems() error { return fmt.Errorf("Could not install rubygems: %v", err) } + // Rubygems 4.x ships bundler 4.x as a default gem. Running setup.rb + // overwrites the buildpack-installed bundler (2.x) with bundler 4.x, + // which has incompatible output format changes and untested behavior. + // Re-install the buildpack's bundler to restore the manifest version. + s.Log.Debug("Re-installing bundler after rubygems update") + if err := s.InstallBundler(); err != nil { + return fmt.Errorf("Could not re-install bundler after rubygems update: %v", err) + } + return nil } @@ -749,7 +758,7 @@ func (s *Supplier) InstallGems() error { return fmt.Errorf("could not read Bundled With version from gemfile.lock: %s", err) } - if bundledWithVersion != bundlerVersion && strings.HasPrefix(bundledWithVersion, "2") { + if bundledWithVersion != bundlerVersion && !strings.HasPrefix(bundledWithVersion, "1") { if err := s.removeIncompatibleBundledWithVersion(bundledWithVersion); err != nil { return fmt.Errorf("could not remove Bundled With from end of "+ "gemfile.lock: %s", err) diff --git a/src/ruby/supply/supply_test.go b/src/ruby/supply/supply_test.go index 189bd65fe..fda0aa616 100644 --- a/src/ruby/supply/supply_test.go +++ b/src/ruby/supply/supply_test.go @@ -68,7 +68,7 @@ var _ = Describe("Supply", func() { mockCtrl = gomock.NewController(GinkgoT()) mockManifest = NewMockManifest(mockCtrl) - mockManifest.EXPECT().AllDependencyVersions("bundler").Return([]string{"1.17.2"}).AnyTimes() + mockManifest.EXPECT().AllDependencyVersions("bundler").Return([]string{"1.17.2", "2.7.2"}).AnyTimes() mockInstaller = NewMockInstaller(mockCtrl) @@ -130,6 +130,28 @@ var _ = Describe("Supply", func() { }) }) + Describe("InstallBundler with bundler 2.x BUNDLED WITH", func() { + + var tempSupplier supply.Supplier + + BeforeEach(func() { + tempSupplier = *supplier + mockStager := NewMockStager(mockCtrl) + tempSupplier.Stager = mockStager + + mockInstaller.EXPECT().InstallDependency(libbuildpack.Dependency{Name: "bundler", Version: "2.7.2"}, gomock.Any()) + mockStager.EXPECT().LinkDirectoryInDepDir(gomock.Any(), gomock.Any()) + mockStager.EXPECT().DepDir().AnyTimes() + + err := os.WriteFile(filepath.Join(buildDir, "Gemfile.lock"), []byte("BUNDLED WITH\n 2.4.0"), 0644) + Expect(err).NotTo(HaveOccurred()) + }) + + It("installs bundler 2.x matching constraint given", func() { + Expect(tempSupplier.InstallBundler()).To(Succeed()) + }) + }) + Describe("InstallNode", func() { var tempSupplier supply.Supplier @@ -379,6 +401,23 @@ var _ = Describe("Supply", func() { Expect(actualEnv).To(Equal(expectedEnv)) }) }) + + Describe("With Bundler version 4.x.x (future-proofing)", func() { + BeforeEach(func() { + mockVersions.EXPECT().GetBundlerVersion().Return("4.0.9", nil).AnyTimes() + + mockStager.EXPECT().DepDir().Return("some/test-dir").AnyTimes() + mockStager.EXPECT().WriteEnvFile(gomock.Any(), gomock.Any()).Return(nil) + }) + + It("should use vendor_bundle path like bundler 2.x", func() { + Expect(tempSupplier.AddPostRubyGemsInstallDefaultEnv()).To(Succeed()) + + expectedEnv := "some/test-dir/vendor_bundle" + actualEnv := os.Getenv("BUNDLE_PATH") + Expect(actualEnv).To(Equal(expectedEnv)) + }) + }) }) Describe("CopyDirToTemp", func() { @@ -1146,6 +1185,16 @@ var _ = Describe("Supply", func() { }) mockCommand.EXPECT().Output(gomock.Any(), "ruby", "setup.rb") + // After ruby setup.rb, UpdateRubygems re-installs bundler. + // InstallBundler reads Gemfile.lock (not present here, so defaults + // to 2.x.x constraint) and installs bundler from the manifest. + mockInstaller.EXPECT().InstallDependency(gomock.Any(), gomock.Any()).Do(func(dep libbuildpack.Dependency, installDir string) { + Expect(dep.Name).To(Equal("bundler")) + Expect(dep.Version).To(Equal("2.7.2")) + // Create bin dir so LinkDirectoryInDepDir succeeds + Expect(os.MkdirAll(filepath.Join(installDir, "bin"), 0755)).To(Succeed()) + }) + Expect(supplier.UpdateRubygems()).To(Succeed()) }) diff --git a/src/ruby/versions/ruby.go b/src/ruby/versions/ruby.go index 12397ccb3..c078a7919 100644 --- a/src/ruby/versions/ruby.go +++ b/src/ruby/versions/ruby.go @@ -58,7 +58,9 @@ func (v *Versions) GetBundlerVersion() (string, error) { return "", err } - re := regexp.MustCompile(`Bundler version (\d+\.\d+\.\d+) .*`) + // Bundler 2.x outputs "Bundler version X.Y.Z (...)" but bundler 4.x + // omits the "Bundler version" prefix and outputs just "X.Y.Z (...)". + re := regexp.MustCompile(`(?:Bundler version )?(\d+\.\d+\.\d+)`) match := re.FindStringSubmatch(stdout.String()) if len(match) != 2 { @@ -191,9 +193,11 @@ func (v *Versions) GemMajorVersion(gem string) (int, error) { } } -//Should return true if either: +// Should return true if either: // (1) the only platform in the Gemfile.lock is windows (mingw/mswin) -// -or- +// +// -or- +// // (2) the Gemfile.lock line endings are /r/n, rather than just /n func (v *Versions) HasWindowsGemfileLock() (bool, error) { gemfileLockPath := v.Gemfile() + ".lock" From 39cbad98014e5e79f3ebc6f6020e1e9f71b3d563 Mon Sep 17 00:00:00 2001 From: i343759 Date: Mon, 6 Apr 2026 14:30:03 +0300 Subject: [PATCH 2/2] Make bundler re-install conditional on rubygems >= 4.0 The unconditional re-install caused the integration cache test to fail: on first deploy, the second InstallBundler() call (from UpdateRubygems) produced a 'Copy' line since libbuildpack had already cached the tarball from the initial Download, violating the cache test assertion that no Copy lines appear on first deploy. Fix: only re-install bundler when rubygems major version >= 4, since only rubygems 4.x ships bundler 4.x as a default gem. Rubygems 3.x ships bundler 2.x which doesn't overwrite the buildpack's bundler. --- src/ruby/supply/supply.go | 9 ++++--- src/ruby/supply/supply_test.go | 43 ++++++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/ruby/supply/supply.go b/src/ruby/supply/supply.go index b3a73425d..9af06174a 100644 --- a/src/ruby/supply/supply.go +++ b/src/ruby/supply/supply.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "github.com/cloudfoundry/libbuildpack" @@ -624,9 +625,11 @@ func (s *Supplier) UpdateRubygems() error { // overwrites the buildpack-installed bundler (2.x) with bundler 4.x, // which has incompatible output format changes and untested behavior. // Re-install the buildpack's bundler to restore the manifest version. - s.Log.Debug("Re-installing bundler after rubygems update") - if err := s.InstallBundler(); err != nil { - return fmt.Errorf("Could not re-install bundler after rubygems update: %v", err) + if majorVersion, err := strconv.Atoi(strings.SplitN(dep.Version, ".", 2)[0]); err == nil && majorVersion >= 4 { + s.Log.Debug("Re-installing bundler after rubygems %s update", dep.Version) + if err := s.InstallBundler(); err != nil { + return fmt.Errorf("Could not re-install bundler after rubygems update: %v", err) + } } return nil diff --git a/src/ruby/supply/supply_test.go b/src/ruby/supply/supply_test.go index fda0aa616..cd885f5fb 100644 --- a/src/ruby/supply/supply_test.go +++ b/src/ruby/supply/supply_test.go @@ -1185,16 +1185,6 @@ var _ = Describe("Supply", func() { }) mockCommand.EXPECT().Output(gomock.Any(), "ruby", "setup.rb") - // After ruby setup.rb, UpdateRubygems re-installs bundler. - // InstallBundler reads Gemfile.lock (not present here, so defaults - // to 2.x.x constraint) and installs bundler from the manifest. - mockInstaller.EXPECT().InstallDependency(gomock.Any(), gomock.Any()).Do(func(dep libbuildpack.Dependency, installDir string) { - Expect(dep.Name).To(Equal("bundler")) - Expect(dep.Version).To(Equal("2.7.2")) - // Create bin dir so LinkDirectoryInDepDir succeeds - Expect(os.MkdirAll(filepath.Join(installDir, "bin"), 0755)).To(Succeed()) - }) - Expect(supplier.UpdateRubygems()).To(Succeed()) }) @@ -1227,6 +1217,39 @@ var _ = Describe("Supply", func() { }) }) + Describe("UpdateRubygems with rubygems >= 4 re-installs bundler", func() { + BeforeEach(func() { + mockManifest.EXPECT().AllDependencyVersions("rubygems").AnyTimes().Return([]string{"4.0.9"}) + }) + Context("gem version is less than 4.0.9", func() { + BeforeEach(func() { + mockCommand.EXPECT().Output(gomock.Any(), "gem", "--version").AnyTimes().Return("3.4.19\n", nil) + mockVersions.EXPECT().VersionConstraint("3.4.19", ">= 4.0.9").AnyTimes().Return(false, nil) + }) + + It("updates rubygems and re-installs bundler", func() { + mockVersions.EXPECT().Engine().Return("ruby", nil) + mockInstaller.EXPECT().InstallDependency(gomock.Any(), gomock.Any()).Do(func(dep libbuildpack.Dependency, _ string) { + Expect(dep.Name).To(Equal("rubygems")) + Expect(dep.Version).To(Equal("4.0.9")) + }) + mockCommand.EXPECT().Output(gomock.Any(), "ruby", "setup.rb") + + // Rubygems >= 4 triggers bundler re-install. + // InstallBundler reads Gemfile.lock (not present here, so defaults + // to 2.x.x constraint) and installs bundler from the manifest. + mockInstaller.EXPECT().InstallDependency(gomock.Any(), gomock.Any()).Do(func(dep libbuildpack.Dependency, installDir string) { + Expect(dep.Name).To(Equal("bundler")) + Expect(dep.Version).To(Equal("2.7.2")) + // Create bin dir so LinkDirectoryInDepDir succeeds + Expect(os.MkdirAll(filepath.Join(installDir, "bin"), 0755)).To(Succeed()) + }) + + Expect(supplier.UpdateRubygems()).To(Succeed()) + }) + }) + }) + Describe("RewriteShebangs", func() { var depDir string BeforeEach(func() {