Files
pack/core/providers/ruby/ruby.go
T
zeekay 7844a60aff refactor(pack): modernize to github.com/hanzoai/pack and remove upstream branding
- Migrate module path to github.com/hanzoai/pack across all Go packages
- Remove legacy upstream docs and competitor brand references
- Modernize LLM.md and mise.toml for pristine Hanzo Pack builder
2026-09-04 00:40:42 -07:00

414 lines
12 KiB
Go

package ruby
import (
"bufio"
"fmt"
"maps"
"regexp"
"strings"
"github.com/hanzoai/pack/core/generate"
"github.com/hanzoai/pack/core/plan"
"github.com/hanzoai/pack/core/providers/node"
"github.com/hanzoai/pack/internal/utils"
)
const (
DEFAULT_RUBY_VERSION = "3.4"
)
type RubyProvider struct{}
func (p *RubyProvider) Name() string {
return "ruby"
}
func (p *RubyProvider) Initialize(ctx *generate.GenerateContext) error {
return nil
}
func (p *RubyProvider) Detect(ctx *generate.GenerateContext) (bool, error) {
hasRuby := ctx.App.HasFile("Gemfile")
return hasRuby, nil
}
func (p *RubyProvider) Plan(ctx *generate.GenerateContext) error {
miseStep := ctx.GetMiseStepBuilder()
p.InstallMisePackages(ctx, miseStep)
install := ctx.NewCommandStep("install")
install.AddInput(plan.NewStepLayer(miseStep.Name()))
installOutputs := p.Install(ctx, install)
p.addMetadata(ctx)
nodeProvider := &node.NodeProvider{}
nodeDetected, err := nodeProvider.Detect(ctx)
if err != nil {
return err
}
if nodeDetected || p.usesDep(ctx, "execjs") {
nodeProvider.InstallMisePackages(ctx, miseStep)
}
var (
buildNode *generate.CommandStepBuilder
pruneNode *generate.CommandStepBuilder
)
if nodeDetected {
err := nodeProvider.Initialize(ctx)
if err != nil {
return err
}
nodeProvider.InstallMisePackages(ctx, miseStep)
installNode := ctx.NewCommandStep("install:node")
installNode.AddInput(plan.NewStepLayer(miseStep.Name()))
nodeProvider.InstallNodeDeps(ctx, installNode)
pruneNode = ctx.NewCommandStep("prune:node")
pruneNode.AddInput(plan.NewStepLayer(installNode.Name()))
nodeProvider.PruneNodeDeps(ctx, pruneNode)
buildNode = ctx.NewCommandStep("build:node")
buildNode.AddInputs([]plan.Layer{
plan.NewStepLayer(install.Name()),
plan.NewStepLayer(installNode.Name(), plan.Filter{
Include: append([]string{"."}, miseStep.GetOutputPaths()...),
}),
})
nodeProvider.Build(ctx, buildNode)
}
build := ctx.NewCommandStep("build")
build.AddInput(plan.NewStepLayer(install.Name()))
buildOutputs := p.Build(ctx, build)
ctx.Deploy.StartCmd = p.GetStartCommand(ctx)
maps.Copy(ctx.Deploy.Variables, p.GetRubyEnvVars(ctx))
p.AddRuntimeDeps(ctx)
ctx.Deploy.AddInputs([]plan.Layer{
plan.NewStepLayer(miseStep.Name(), plan.Filter{
Include: miseStep.GetOutputPaths(),
}),
plan.NewStepLayer(install.Name(), plan.Filter{
Include: installOutputs,
}),
plan.NewStepLayer(build.Name(), plan.Filter{
Include: buildOutputs,
}),
})
if buildNode != nil && pruneNode != nil {
ctx.Deploy.AddInputs([]plan.Layer{
plan.NewStepLayer(pruneNode.Name(), plan.Filter{
Include: []string{"/app/node_modules"},
}),
plan.NewStepLayer(buildNode.Name(), plan.Filter{
Include: []string{"."},
Exclude: []string{"node_modules", ".yarn"},
}),
})
}
return nil
}
func (p *RubyProvider) GetStartCommand(ctx *generate.GenerateContext) string {
startCommand := ""
app := ctx.App
// TODO we auto-run migrations for django, but not for rails, why the difference?
if p.usesRails(ctx) {
if app.HasFile("rails") {
return "bundle exec rails server -b 0.0.0.0 -p ${PORT:-3000}"
} else {
if !app.HasFile("bin/rails") {
ctx.Logger.LogWarn("bin/rails not found, run `bundle binstubs railties` to avoid potential startup problems")
}
return "bundle exec bin/rails server -b 0.0.0.0 -p ${PORT:-3000} -e $RAILS_ENV"
}
} else if app.HasFile("config/environment.rb") && app.HasMatch("script") {
return "bundle exec ruby script/server -b 0.0.0.0 -p ${PORT:-3000}"
} else if app.HasFile("config.ru") {
return "bundle exec rackup config.ru -o 0.0.0.0 -p ${PORT:-3000}"
} else if app.HasFile("Rakefile") {
return "bundle exec rake"
}
// unlike py, there's no `main.rb` convention
return startCommand
}
func (p *RubyProvider) CleansePlan(buildPlan *plan.BuildPlan) {}
func (p *RubyProvider) StartCommandHelp() string {
return "To start your Ruby application, Railpack will automatically:\n\n" +
"1. Start the Rails server if a Rails application is detected\n" +
"2. Start the Ruby server if a config/environment.rb and script/server file is found\n" +
"3. Start the Rack server if a config.ru file is found\n" +
"4. Run the Rakefile if a Rakefile is found\n\n" +
"Otherwise, it will not start any server by default."
}
func (p *RubyProvider) Install(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) []string {
install.Secrets = []string{}
install.UseSecretsWithPrefixes([]string{"RUBY", "GEM", "BUNDLE"})
envVars := p.GetRubyEnvVars(ctx)
install.AddEnvVars(envVars)
bundlerVersion := parseBundlerVersionFromGemfile(ctx)
commands := []plan.Command{
plan.NewExecCommand(fmt.Sprintf("gem install -N %s", bundlerVersion)),
plan.NewCopyCommand("Gemfile"),
plan.NewCopyCommand("Gemfile.lock"),
}
for _, path := range parseLocalPathsFromGemfile(ctx) {
commands = append(commands, plan.NewCopyCommand(path))
}
commands = append(commands, plan.NewExecCommand("bundle install"))
if p.usesDep(ctx, "bootsnap") {
commands = append(commands, plan.NewExecCommand("bundle exec bootsnap precompile --gemfile"))
}
install.AddCommands(commands)
install.AddPaths([]string{envVars["GEM_PATH"]})
return []string{envVars["GEM_HOME"]}
}
func (p *RubyProvider) Build(ctx *generate.GenerateContext, build *generate.CommandStepBuilder) []string {
build.Secrets = []string{}
build.UseSecretsWithPrefixes([]string{"RAILS", "BUNDLE", "BOOTSNAP", "SPROCKETS", "WEBPACKER", "ASSET", "DISABLE_SPRING"})
build.AddEnvVars(p.GetRubyEnvVars(ctx))
build.AddInput(plan.NewLocalLayer())
outputs := []string{"/app"}
// Only compile assets if a Rails app have an asset pipeline gem
// installed (e.g. sprockets, propshaft). Rails API-only apps [0]
// do not come with the asset pipelines because they have no assets.
// [0] https://guides.rubyonrails.org/api_app.html
if p.usesRails(ctx) && p.usesAssetPipeline(ctx) {
build.AddCommand(plan.NewExecCommand("bundle exec rake assets:precompile"))
}
if p.usesRails(ctx) && p.usesDep(ctx, "bootsnap") {
build.AddCommand(plan.NewExecCommand("bundle exec bootsnap precompile app/ lib/"))
outputs = append(outputs, "lib/")
}
return outputs
}
func (p *RubyProvider) AddRuntimeDeps(ctx *generate.GenerateContext) {
packages := []string{"libyaml-dev", "libjemalloc2"}
if p.usesPostgres(ctx) {
packages = append(packages, "libpq-dev")
}
if p.usesMysql(ctx) {
packages = append(packages, "default-libmysqlclient-dev")
}
if p.usesDep(ctx, "magick") {
packages = append(packages, "libmagickwand-dev")
}
if p.usesDep(ctx, "vips") {
packages = append(packages, "libvips-dev")
}
if p.usesDep(ctx, "charlock_holmes") {
packages = append(packages, "libicu-dev", "libxml2-dev", "libxslt-dev")
}
ctx.Deploy.AddAptPackages(packages)
}
func (p *RubyProvider) GetBuilderDeps(ctx *generate.GenerateContext) *generate.MiseStepBuilder {
miseStep := ctx.GetMiseStepBuilder()
miseStep.SupportingAptPackages = append(miseStep.SupportingAptPackages, "procps")
if p.usesPostgres(ctx) {
miseStep.SupportingAptPackages = append(miseStep.SupportingAptPackages, "libpq-dev")
}
if p.usesMysql(ctx) {
miseStep.SupportingAptPackages = append(miseStep.SupportingAptPackages, "default-libmysqlclient-dev")
}
return miseStep
}
func (p *RubyProvider) InstallMisePackages(ctx *generate.GenerateContext, miseStep *generate.MiseStepBuilder) {
ruby := miseStep.Default("ruby", DEFAULT_RUBY_VERSION)
// rdoc (a) slows down builds (b) increases build size and (c) mostly importantly, will cause builds to fail if the locale is not properly set
miseStep.Variables["RUBY_CONFIGURE_OPTS"] = "--disable-install-doc"
// TODO mise parses simple versions from the Gemfile now, maybe that's all we should do and eliminate our custom parser?
if gemfileVersion := parseVersionFromGemfile(ctx); gemfileVersion != "" {
miseStep.Version(ruby, gemfileVersion, "Gemfile")
}
miseStep.UseMiseVersions(ctx, []string{"ruby"})
if envVersion, varName := ctx.Env.GetConfigVariable("RUBY_VERSION"); envVersion != "" {
miseStep.Version(ruby, envVersion, varName)
}
miseStep.AddSupportingAptPackage("libyaml-dev")
miseStep.AddSupportingAptPackage("libjemalloc-dev")
// TODO this does not take into account the mise-specified version of ruby, we should pull the resolved version via Mise
version := p.getRubyVersion(ctx)
version = utils.ExtractSemverVersion(version)
semver, err := utils.ParseSemver(version)
// TODO we should install these only if it's < v3 instead since the matching is not exact
// TODO we should install these via mise, not apt
// YJIT in Ruby 3.1+ requires rustc to install
if err == nil && semver != nil && semver.Major >= 3 && semver.Minor > 1 {
miseStep.AddSupportingAptPackage("rustc")
miseStep.AddSupportingAptPackage("cargo")
}
}
func (p *RubyProvider) getRubyVersion(ctx *generate.GenerateContext) string {
miseStepBuilder := ctx.GetMiseStepBuilder()
pkg := miseStepBuilder.Resolver.Get("ruby")
if pkg != nil && pkg.Version != "" {
return pkg.Version
}
return DEFAULT_RUBY_VERSION
}
func (p *RubyProvider) GetRubyEnvVars(ctx *generate.GenerateContext) map[string]string {
return map[string]string{
"BUNDLE_GEMFILE": "/app/Gemfile",
"GEM_PATH": "/usr/local/bundle",
"GEM_HOME": "/usr/local/bundle",
"MALLOC_ARENA_MAX": "2",
"LD_PRELOAD": "libjemalloc.so.2",
}
}
func (p *RubyProvider) usesPostgres(ctx *generate.GenerateContext) bool {
return p.usesDep(ctx, "pg")
}
func (p *RubyProvider) usesMysql(ctx *generate.GenerateContext) bool {
return p.usesDep(ctx, "mysql")
}
func (p *RubyProvider) usesRails(ctx *generate.GenerateContext) bool {
contents, err := ctx.App.ReadFile("config/application.rb")
if err != nil {
return false
}
return strings.Contains(contents, "Rails::Application")
}
func (p *RubyProvider) usesAssetPipeline(ctx *generate.GenerateContext) bool {
return p.usesDep(ctx, "sprockets") || p.usesDep(ctx, "propshaft")
}
func (p *RubyProvider) usesDep(ctx *generate.GenerateContext, dep string) bool {
for _, file := range []string{"Gemfile", "Gemfile.lock"} {
if contents, err := ctx.App.ReadFile(file); err == nil {
if strings.Contains(string(contents), dep) {
return true
}
}
}
return false
}
func (p *RubyProvider) addMetadata(ctx *generate.GenerateContext) {
ctx.Metadata.SetBool("rubyRails", p.usesRails(ctx))
ctx.Metadata.SetBool("rubyAssetPipeline", p.usesAssetPipeline(ctx))
ctx.Metadata.SetBool("rubyBootsnap", p.usesDep(ctx, "bootsnap"))
}
var (
gemfileVersionRegex = regexp.MustCompile(`ruby (?:'|")(.*)(?:'|")[^>]"`)
gemfileLockVersionRegex = regexp.MustCompile(`ruby ((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))[^>]`)
)
func parseVersionFromGemfile(ctx *generate.GenerateContext) string {
gemfile, err := ctx.App.ReadFile("Gemfile")
if err != nil {
return ""
}
matches := gemfileVersionRegex.FindStringSubmatch(string(gemfile))
if len(matches) > 2 {
return matches[2]
}
gemfileLock, err := ctx.App.ReadFile("Gemfile.lock")
if err != nil {
return ""
}
matches = gemfileLockVersionRegex.FindStringSubmatch(string(gemfileLock))
if len(matches) > 1 {
return matches[1]
}
return ""
}
func parseBundlerVersionFromGemfile(ctx *generate.GenerateContext) string {
gemfileLock, err := ctx.App.ReadFile("Gemfile.lock")
if err != nil {
return "bundler"
}
scanner := bufio.NewScanner(strings.NewReader(gemfileLock))
foundBundledWith := false
for scanner.Scan() {
line := scanner.Text()
if foundBundledWith {
return fmt.Sprintf("bundler:%s", strings.TrimSpace(line))
}
if strings.Contains(line, "BUNDLED WITH") {
foundBundledWith = true
}
}
// note: we don't need to worry about the scanner error because we are just returning "bundler" in that case anyway
return "bundler"
}
func parseLocalPathsFromGemfile(ctx *generate.GenerateContext) []string {
gemfile, err := ctx.App.ReadFile("Gemfile")
if err != nil {
return []string{}
}
scanner := bufio.NewScanner(strings.NewReader(string(gemfile)))
paths := []string{}
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "path:") {
p := strings.TrimSpace(strings.Split(line, "path:")[1])
paths = append(paths, strings.Trim(p, "\""))
}
}
return paths
}