Skip to content

Commit b7992d6

Browse files
ctroxrobdimsdale
authored andcommitted
feat: add fallback to .ruby-version file
A common pattern for ruby apps is to load the ruby version in the Gemfile dynamically from a file named `.ruby-version` in the workspace root. This adds a fallback for when the ruby version can't be found in the Gemfile to try and read `.ruby-version`.
1 parent 55787c5 commit b7992d6

7 files changed

Lines changed: 242 additions & 7 deletions

File tree

detect.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77

88
"github.com/paketo-buildpacks/packit/v2"
9+
"github.com/paketo-buildpacks/packit/v2/scribe"
910
)
1011

1112
//go:generate faux --interface VersionParser --output fakes/version_parser.go
@@ -25,6 +26,8 @@ type BuildPlanMetadata struct {
2526
Launch bool `toml:"launch"`
2627
}
2728

29+
const rubyVersionFile = ".ruby-version"
30+
2831
// Detect will return a packit.DetectFunc that will be invoked during the
2932
// detect phase of the buildpack lifecycle.
3033
//
@@ -35,7 +38,7 @@ type BuildPlanMetadata struct {
3538
// dependency, and requiring the "bundler" and "mri" dependencies. If the
3639
// Gemfile contains a specified Ruby version, the "mri" build plan entry will
3740
// include a specific Ruby version contraint.
38-
func Detect(gemfileParser VersionParser) packit.DetectFunc {
41+
func Detect(gemfileParser, rubyVersionFileParser VersionParser, logger scribe.Emitter) packit.DetectFunc {
3942
return func(context packit.DetectContext) (packit.DetectResult, error) {
4043
mriVersion, err := gemfileParser.ParseVersion(filepath.Join(context.WorkingDir, "Gemfile"))
4144
if err != nil {
@@ -47,6 +50,15 @@ func Detect(gemfileParser VersionParser) packit.DetectFunc {
4750
var versionSource string
4851
if mriVersion != "" {
4952
versionSource = "Gemfile"
53+
} else {
54+
// fall back to .ruby-version file
55+
rubyVersion, err := rubyVersionFileParser.ParseVersion(rubyVersionFile)
56+
if err != nil {
57+
logger.Subprocess("WARNING: Could not parse the .ruby-version file, as a result no Ruby version has been specified")
58+
} else if rubyVersion != "" {
59+
mriVersion = rubyVersion
60+
versionSource = rubyVersionFile
61+
}
5062
}
5163

5264
return packit.DetectResult{

detect_test.go

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package bundleinstall_test
22

33
import (
4+
"bytes"
45
"errors"
56
"fmt"
67
"os"
@@ -10,6 +11,7 @@ import (
1011
bundleinstall "github.com/paketo-buildpacks/bundle-install"
1112
"github.com/paketo-buildpacks/bundle-install/fakes"
1213
"github.com/paketo-buildpacks/packit/v2"
14+
"github.com/paketo-buildpacks/packit/v2/scribe"
1315
"github.com/sclevine/spec"
1416

1517
. "github.com/onsi/gomega"
@@ -19,9 +21,11 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {
1921
var (
2022
Expect = NewWithT(t).Expect
2123

22-
workingDir string
23-
gemfileParser *fakes.VersionParser
24-
detect packit.DetectFunc
24+
workingDir string
25+
gemfileParser *fakes.VersionParser
26+
rubyVersionFileParser *fakes.VersionParser
27+
detect packit.DetectFunc
28+
buffer *bytes.Buffer
2529
)
2630

2731
it.Before(func() {
@@ -33,8 +37,10 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {
3337
Expect(err).NotTo(HaveOccurred())
3438

3539
gemfileParser = &fakes.VersionParser{}
40+
rubyVersionFileParser = &fakes.VersionParser{}
41+
buffer = bytes.NewBuffer(nil)
3642

37-
detect = bundleinstall.Detect(gemfileParser)
43+
detect = bundleinstall.Detect(gemfileParser, rubyVersionFileParser, scribe.NewEmitter(buffer))
3844
})
3945

4046
it.After(func() {
@@ -127,4 +133,75 @@ func testDetect(t *testing.T, context spec.G, it spec.S) {
127133
Expect(err).To(MatchError("some-error"))
128134
})
129135
})
136+
137+
context("when the Gemfile has no version it falls back to .ruby-version", func() {
138+
it.Before(func() {
139+
gemfileParser.ParseVersionCall.Returns.Version = ""
140+
rubyVersionFileParser.ParseVersionCall.Returns.Version = "3.2.2"
141+
})
142+
143+
it("requires that version of mri", func() {
144+
result, err := detect(packit.DetectContext{
145+
WorkingDir: workingDir,
146+
})
147+
Expect(err).NotTo(HaveOccurred())
148+
Expect(result.Plan).To(Equal(packit.BuildPlan{
149+
Provides: []packit.BuildPlanProvision{
150+
{Name: "gems"},
151+
},
152+
Requires: []packit.BuildPlanRequirement{
153+
{
154+
Name: "bundler",
155+
Metadata: bundleinstall.BuildPlanMetadata{
156+
Build: true,
157+
},
158+
},
159+
{
160+
Name: "mri",
161+
Metadata: bundleinstall.BuildPlanMetadata{
162+
Version: "3.2.2",
163+
VersionSource: ".ruby-version",
164+
Build: true,
165+
},
166+
},
167+
},
168+
}))
169+
})
170+
})
171+
172+
context("when the Gemfile has no version and the .ruby-version is invalid", func() {
173+
it.Before(func() {
174+
gemfileParser.ParseVersionCall.Returns.Version = ""
175+
rubyVersionFileParser.ParseVersionCall.Returns.Err = errors.New("invalid version")
176+
})
177+
178+
it("the mri version is empty", func() {
179+
result, err := detect(packit.DetectContext{
180+
WorkingDir: workingDir,
181+
})
182+
Expect(err).NotTo(HaveOccurred())
183+
Expect(result.Plan).To(Equal(packit.BuildPlan{
184+
Provides: []packit.BuildPlanProvision{
185+
{Name: "gems"},
186+
},
187+
Requires: []packit.BuildPlanRequirement{
188+
{
189+
Name: "bundler",
190+
Metadata: bundleinstall.BuildPlanMetadata{
191+
Build: true,
192+
},
193+
},
194+
{
195+
Name: "mri",
196+
Metadata: bundleinstall.BuildPlanMetadata{
197+
Version: "",
198+
VersionSource: "",
199+
Build: true,
200+
},
201+
},
202+
},
203+
}))
204+
Expect(buffer.String()).To(ContainSubstring("Could not parse the .ruby-version file"))
205+
})
206+
})
130207
}

gemfile_parser.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ func NewGemfileParser() GemfileParser {
1616
return GemfileParser{}
1717
}
1818

19+
const versionNumberExpression = `\d+(\.\d+)?(\.\d+)?`
20+
1921
// ParseVersion scans the Gemfile for a Ruby version specification.
2022
func (p GemfileParser) ParseVersion(path string) (string, error) {
2123
file, err := os.Open(path)
@@ -25,8 +27,7 @@ func (p GemfileParser) ParseVersion(path string) (string, error) {
2527

2628
quotes := `["']`
2729
versionOperators := `~>|<|>|<=|>=|=`
28-
versionNumber := `\d+(\.\d+)?(\.\d+)?`
29-
expression := fmt.Sprintf(`ruby %s((%s)?\s*%s)%s`, quotes, versionOperators, versionNumber, quotes)
30+
expression := fmt.Sprintf(`ruby %s((%s)?\s*%s)%s`, quotes, versionOperators, versionNumberExpression, quotes)
3031
re := regexp.MustCompile(expression)
3132

3233
scanner := bufio.NewScanner(file)

init_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func TestUnitBundleInstall(t *testing.T) {
1414
suite("Detect", testDetect)
1515
suite("Environment", testEnvironment)
1616
suite("GemfileParser", testGemfileParser)
17+
suite("RubyVersionFileParser", testRubyVersionFileParser)
1718
suite("RubyVersionResolver", testRubyVersionResolver)
1819
suite.Run(t)
1920
}

ruby_version_file_parser.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package bundleinstall
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"regexp"
7+
"strings"
8+
)
9+
10+
// RubyVersionFileParser parses the .ruby-version file to determine the
11+
// version of Ruby used by the application.
12+
type RubyVersionFileParser struct{}
13+
14+
// NewGemfileParser initializes an instance of RubyVersionFileParser.
15+
func NewRubyVersionFileParser() RubyVersionFileParser {
16+
return RubyVersionFileParser{}
17+
}
18+
19+
// ParseVersion scans the .ruby-version file for a Ruby version specification.
20+
func (p RubyVersionFileParser) ParseVersion(path string) (string, error) {
21+
rVersion, err := os.ReadFile(path)
22+
if err != nil {
23+
return "", fmt.Errorf("failed to read .ruby-version file: %w", err)
24+
}
25+
26+
re := regexp.MustCompile(versionNumberExpression)
27+
rubyVersion := re.FindString(strings.TrimSpace(string(rVersion)))
28+
29+
if len(rubyVersion) == 0 {
30+
return "", fmt.Errorf("no valid ruby version found in .ruby-version file: %s", rVersion)
31+
}
32+
33+
return rubyVersion, nil
34+
}

ruby_version_file_parser_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package bundleinstall_test
2+
3+
import (
4+
"errors"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
bundleinstall "github.com/paketo-buildpacks/bundle-install"
10+
"github.com/sclevine/spec"
11+
12+
. "github.com/onsi/gomega"
13+
)
14+
15+
func testRubyVersionFileParser(t *testing.T, context spec.G, it spec.S) {
16+
var (
17+
Expect = NewWithT(t).Expect
18+
19+
path string
20+
parser bundleinstall.RubyVersionFileParser
21+
)
22+
23+
it.Before(func() {
24+
path = filepath.Join(t.TempDir(), ".ruby-version")
25+
_, err := os.Create(path)
26+
Expect(err).NotTo(HaveOccurred())
27+
parser = bundleinstall.NewRubyVersionFileParser()
28+
})
29+
30+
it.After(func() {
31+
Expect(os.RemoveAll(path)).To(Succeed())
32+
})
33+
34+
context("ParseVersion", func() {
35+
context("when the version is just a single Major value", func() {
36+
it.Before(func() {
37+
Expect(os.WriteFile(path, []byte("2\n"), 0644)).To(Succeed())
38+
})
39+
40+
it("parses correctly", func() {
41+
version, err := parser.ParseVersion(path)
42+
Expect(err).NotTo(HaveOccurred())
43+
Expect(version).To(Equal("2"))
44+
})
45+
})
46+
47+
context("when the version is a Major.Minor value", func() {
48+
it.Before(func() {
49+
Expect(os.WriteFile(path, []byte("2.6\n"), 0644)).To(Succeed())
50+
})
51+
52+
it("parses correctly", func() {
53+
version, err := parser.ParseVersion(path)
54+
Expect(err).NotTo(HaveOccurred())
55+
Expect(version).To(Equal("2.6"))
56+
})
57+
})
58+
59+
context("when the version is a Major.Minor.Patch value", func() {
60+
it.Before(func() {
61+
Expect(os.WriteFile(path, []byte("2.6.3\n"), 0644)).To(Succeed())
62+
})
63+
64+
it("parses correctly", func() {
65+
version, err := parser.ParseVersion(path)
66+
Expect(err).NotTo(HaveOccurred())
67+
Expect(version).To(Equal("2.6.3"))
68+
})
69+
})
70+
71+
context("when the .ruby-version file does not exist", func() {
72+
it.Before(func() {
73+
Expect(os.Remove(path)).To(Succeed())
74+
})
75+
76+
it("returns an ErrNotExist error", func() {
77+
_, err := parser.ParseVersion(path)
78+
Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue())
79+
})
80+
})
81+
82+
context("failure cases", func() {
83+
context("when the .ruby-version file cannot be opened", func() {
84+
it.Before(func() {
85+
Expect(os.Chmod(path, 0000)).To(Succeed())
86+
})
87+
88+
it("returns an error", func() {
89+
_, err := parser.ParseVersion(path)
90+
Expect(err).To(MatchError(ContainSubstring("failed to read .ruby-version file:")))
91+
Expect(err).To(MatchError(ContainSubstring("permission denied")))
92+
})
93+
})
94+
95+
context("when the .ruby-version file contains an invalid version", func() {
96+
it.Before(func() {
97+
Expect(os.WriteFile(path, []byte("invalid.version\n"), 0644)).To(Succeed())
98+
})
99+
100+
it("returns an error", func() {
101+
_, err := parser.ParseVersion(path)
102+
Expect(err).To(MatchError(ContainSubstring("no valid ruby version found in .ruby-version file:")))
103+
})
104+
})
105+
106+
})
107+
})
108+
}

run/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ func main() {
3232
packit.Run(
3333
bundleinstall.Detect(
3434
bundleinstall.NewGemfileParser(),
35+
bundleinstall.NewRubyVersionFileParser(),
36+
logEmitter,
3537
),
3638
bundleinstall.Build(
3739
draft.NewPlanner(),

0 commit comments

Comments
 (0)