From 03942a19bf25c379fb92db59d1ef3a080e71f54a Mon Sep 17 00:00:00 2001 From: i343759 Date: Mon, 6 Apr 2026 13:59:31 +0300 Subject: [PATCH 1/4] 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 efb35b2aa..701467e10 100644 --- a/src/ruby/supply/supply.go +++ b/src/ruby/supply/supply.go @@ -323,11 +323,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 { @@ -468,7 +468,7 @@ func (s *Supplier) VendorBundlePath() (string, error) { return "", err } - if strings.HasPrefix(bundlerVersion, "2.") { + if !strings.HasPrefix(bundlerVersion, "1.") { return "vendor_bundle", nil } @@ -610,6 +610,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 } @@ -739,7 +748,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 ca9e58471..c08720122 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) @@ -133,6 +133,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 @@ -389,6 +411,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() { @@ -1145,6 +1184,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 aa653d728..ffb6f020e 100644 --- a/src/ruby/versions/ruby.go +++ b/src/ruby/versions/ruby.go @@ -59,7 +59,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 { @@ -192,9 +194,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 8c595ba7f8d66ac0706e777faaf56802a19d5306 Mon Sep 17 00:00:00 2001 From: i343759 Date: Mon, 6 Apr 2026 14:30:03 +0300 Subject: [PATCH 2/4] 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 701467e10..d7b9df6bc 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" @@ -614,9 +615,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 c08720122..ab489e993 100644 --- a/src/ruby/supply/supply_test.go +++ b/src/ruby/supply/supply_test.go @@ -1184,16 +1184,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()) }) @@ -1226,6 +1216,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() { From 9b197c58239d9be195e693319cc07350980901e9 Mon Sep 17 00:00:00 2001 From: i343759 Date: Thu, 9 Apr 2026 11:45:22 +0300 Subject: [PATCH 3/4] Fix switchblade Initialize() multi-stack buildpack deletion When multiple buildpacks share the same name across different stacks (e.g. ruby_buildpack for cflinuxfs4 and cflinuxfs5), cf delete-buildpack requires -s to disambiguate. Without it, the command fails with: 'Multiple buildpacks named ruby_buildpack found. Specify a stack name by using a -s flag.' Parse the stack field from the /v3/buildpacks API response and iterate over all resources, deleting each with the appropriate -s flag. This fixes the CF integration test failure in Initialize() when both cflinuxfs4 and cflinuxfs5 stacks are deployed. --- .../internal/cloudfoundry/initialize.go | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go b/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go index dc7f18d00..3ad043215 100644 --- a/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go +++ b/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go @@ -43,7 +43,8 @@ func (i Initialize) Run(buildpacks []Buildpack) error { if err == nil { var payload struct { Resources []struct { - Position int `json:"position"` + Position int `json:"position"` + Stack string `json:"stack"` } `json:"resources"` } err = json.NewDecoder(buffer).Decode(&payload) @@ -55,13 +56,19 @@ func (i Initialize) Run(buildpacks []Buildpack) error { position = strconv.Itoa(payload.Resources[0].Position) } - err = i.cli.Execute(pexec.Execution{ - Args: []string{"delete-buildpack", "-f", buildpack.Name}, - Stdout: logs, - Stderr: logs, - }) - if err != nil { - return fmt.Errorf("failed to delete buildpack: %s\n\nOutput:\n%s", err, logs) + for _, resource := range payload.Resources { + args := []string{"delete-buildpack", "-f", buildpack.Name} + if resource.Stack != "" { + args = append(args, "-s", resource.Stack) + } + err = i.cli.Execute(pexec.Execution{ + Args: args, + Stdout: logs, + Stderr: logs, + }) + if err != nil { + return fmt.Errorf("failed to delete buildpack: %s\n\nOutput:\n%s", err, logs) + } } } From 1576f017275ec02b497c5a02b7a148bedcd51ff8 Mon Sep 17 00:00:00 2001 From: i343759 Date: Thu, 9 Apr 2026 12:40:35 +0300 Subject: [PATCH 4/4] Upgrade switchblade to v0.9.5 (upstream multi-stack fix) Replace vendored switchblade patch with upstream fix from cloudfoundry/switchblade#126, released as v0.9.5. --- go.mod | 2 +- go.sum | 4 ++-- .../switchblade/internal/cloudfoundry/initialize.go | 1 + vendor/modules.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 73425a562..0a4170dbc 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.24.0 require ( github.com/blang/semver v3.5.1+incompatible github.com/cloudfoundry/libbuildpack v0.0.0-20251202224209-b07cc3dab65e - github.com/cloudfoundry/switchblade v0.9.4 + github.com/cloudfoundry/switchblade v0.9.5 github.com/golang/mock v1.6.0 github.com/kr/text v0.2.0 github.com/onsi/ginkgo v1.16.5 diff --git a/go.sum b/go.sum index 49d97ae0c..8d96750a6 100644 --- a/go.sum +++ b/go.sum @@ -744,8 +744,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudfoundry/libbuildpack v0.0.0-20251202224209-b07cc3dab65e h1:L9bl+eey+J8CQ5Dv24nJ5giUx00gdigZv4ElqzR0uRA= github.com/cloudfoundry/libbuildpack v0.0.0-20251202224209-b07cc3dab65e/go.mod h1:kn4FHMwI8bTd9gT92wPGjXHzUvGcj8CkPxG8q3AGBAQ= -github.com/cloudfoundry/switchblade v0.9.4 h1:93O90a/DRRcZ4h50htDh4z7+FMliqy/lQH6IFgVa+mQ= -github.com/cloudfoundry/switchblade v0.9.4/go.mod h1:hIEQdGAsuNnzlyQfsD5OIORt38weSBar6Wq5/JX6Omo= +github.com/cloudfoundry/switchblade v0.9.5 h1:GTga1Uu6kGOL+n1TRTHyZm170N5/B/ou6wU90MiKKys= +github.com/cloudfoundry/switchblade v0.9.5/go.mod h1:hIEQdGAsuNnzlyQfsD5OIORt38weSBar6Wq5/JX6Omo= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= diff --git a/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go b/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go index 3ad043215..2db7b291a 100644 --- a/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go +++ b/vendor/github.com/cloudfoundry/switchblade/internal/cloudfoundry/initialize.go @@ -61,6 +61,7 @@ func (i Initialize) Run(buildpacks []Buildpack) error { if resource.Stack != "" { args = append(args, "-s", resource.Stack) } + err = i.cli.Execute(pexec.Execution{ Args: args, Stdout: logs, diff --git a/vendor/modules.txt b/vendor/modules.txt index 4921da456..84522e8c3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -26,7 +26,7 @@ github.com/cloudfoundry/libbuildpack/cutlass github.com/cloudfoundry/libbuildpack/cutlass/docker github.com/cloudfoundry/libbuildpack/cutlass/glow github.com/cloudfoundry/libbuildpack/packager -# github.com/cloudfoundry/switchblade v0.9.4 +# github.com/cloudfoundry/switchblade v0.9.5 ## explicit; go 1.23.0 github.com/cloudfoundry/switchblade github.com/cloudfoundry/switchblade/internal/cloudfoundry