feat: detect shell type using shebang (#327)

* build: adding shell parsing package

* feat: change shell provider start command based on shebang

* test: test shell type selection in shell provider

* test: update snapshot
This commit is contained in:
Michael Bianco
2025-11-06 15:28:43 -07:00
committed by GitHub
parent a5d5cf84b4
commit 9025dd16ea
9 changed files with 179 additions and 16 deletions
@@ -0,0 +1,40 @@
{
"deploy": {
"base": {
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
},
"inputs": [
{
"include": [
"."
],
"step": "build"
}
],
"startCommand": "bash start.sh"
},
"steps": [
{
"commands": [
{
"cmd": "chmod +x start.sh"
}
],
"inputs": [
{
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
},
{
"include": [
"."
],
"local": true
}
],
"name": "build",
"secrets": [
"*"
]
}
]
}
@@ -11,7 +11,7 @@
"step": "build"
}
],
"startCommand": "sh start.sh"
"startCommand": "bash start.sh"
},
"steps": [
{
+38 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/railwayapp/railpack/core/generate"
"github.com/railwayapp/railpack/core/plan"
"mvdan.cc/sh/v3/fileutil"
)
const (
@@ -34,9 +35,15 @@ func (p *ShellProvider) Initialize(ctx *generate.GenerateContext) error {
}
func (p *ShellProvider) Plan(ctx *generate.GenerateContext) error {
ctx.Deploy.StartCmd = "sh " + p.scriptName
interpreter, err := detectShellInterpreter(ctx, p.scriptName)
if err != nil {
return err
}
ctx.Logger.LogInfo("Using shell script: %s", p.scriptName)
ctx.Deploy.StartCmd = interpreter + " " + p.scriptName
ctx.Metadata.Set("detectedShellInterpreter", interpreter)
ctx.Logger.LogInfo("Using shell script: %s with interpreter: %s", p.scriptName, interpreter)
build := ctx.NewCommandStep("build")
build.AddInput(plan.NewImageLayer(plan.RailpackRuntimeImage))
@@ -79,3 +86,32 @@ func getScript(ctx *generate.GenerateContext) string {
return ""
}
func detectShellInterpreter(ctx *generate.GenerateContext, scriptName string) (string, error) {
content, err := ctx.App.ReadFile(scriptName)
if err != nil {
return "", err
}
interpreter := fileutil.Shebang([]byte(content))
if interpreter == "" {
return "sh", nil
}
return mapToAvailableShell(ctx, interpreter), nil
}
func mapToAvailableShell(ctx *generate.GenerateContext, shell string) string {
switch shell {
case "bash":
return "bash"
case "sh", "dash":
return "sh"
case "mksh", "zsh", "ksh", "fish":
ctx.Logger.LogWarn("Shell '%s' not available in runtime, using 'bash'", shell)
return "bash"
default:
ctx.Logger.LogWarn("Unknown shell '%s', using 'sh'", shell)
return "sh"
}
}
+60
View File
@@ -1,6 +1,8 @@
package shell
import (
"os"
"path/filepath"
"testing"
"github.com/railwayapp/railpack/core/app"
@@ -122,3 +124,61 @@ func TestGetScript(t *testing.T) {
})
}
}
func TestDetectShellInterpreter(t *testing.T) {
tests := []struct {
name string
scriptContent string
wantInterpreter string
}{
{
name: "bash shebang",
scriptContent: "#!/bin/bash\necho 'hello'",
wantInterpreter: "bash",
},
{
name: "bash with env",
scriptContent: "#!/usr/bin/env bash\necho 'hello'",
wantInterpreter: "bash",
},
{
name: "sh shebang",
scriptContent: "#!/bin/sh\necho 'hello'",
wantInterpreter: "sh",
},
{
name: "dash shebang",
scriptContent: "#!/bin/dash\necho 'hello'",
wantInterpreter: "sh",
},
{
name: "zsh shebang (unsupported)",
scriptContent: "#!/bin/zsh\necho 'hello'",
wantInterpreter: "bash",
},
{
name: "no shebang",
scriptContent: "echo 'hello'",
wantInterpreter: "sh",
},
{
name: "empty file",
scriptContent: "",
wantInterpreter: "sh",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "test.sh")
err := os.WriteFile(scriptPath, []byte(tt.scriptContent), 0644)
require.NoError(t, err)
ctx := testingUtils.CreateGenerateContext(t, tmpDir)
got, err := detectShellInterpreter(ctx, "test.sh")
require.NoError(t, err)
require.Equal(t, tt.wantInterpreter, got)
})
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Intentionally using a bash specific array syntax to ensure the correct shell is picked.
fruits=("apple" "banana" "cherry")
echo "Bash array test:"
for fruit in "${fruits[@]}"; do
echo "- $fruit"
done
echo "Array length: ${#fruits[@]}"
echo "Test completed successfully"
+12
View File
@@ -0,0 +1,12 @@
[
{
"expectedOutput": [
"Bash array test:",
"- apple",
"- banana",
"- cherry",
"Array length: 3",
"Test completed successfully"
]
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/zsh
# Output the container architecture
uname -m
+5 -4
View File
@@ -11,10 +11,11 @@ require (
github.com/charmbracelet/log v0.4.0
github.com/containerd/platforms v1.0.0-rc.1
github.com/gkampitakis/go-snaps v0.5.9
github.com/google/go-cmp v0.6.0
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/invopop/jsonschema v0.13.0
github.com/moby/buildkit v0.19.0
github.com/moby/patternmatcher v0.6.0
github.com/muesli/termenv v0.15.2
github.com/opencontainers/image-spec v1.1.0
github.com/pkg/errors v0.9.1
@@ -24,6 +25,7 @@ require (
github.com/tonistiigi/fsutil v0.0.0-20250113203817-b14e27f4135a
github.com/urfave/cli/v3 v3.0.0-beta1
gopkg.in/yaml.v2 v2.4.0
mvdan.cc/sh/v3 v3.12.0
)
require (
@@ -70,14 +72,13 @@ require (
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
@@ -102,7 +103,7 @@ require (
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect
+10 -8
View File
@@ -102,8 +102,8 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -185,8 +185,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE=
github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
@@ -282,10 +282,10 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
@@ -319,6 +319,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
tags.cncf.io/container-device-interface v0.8.0 h1:8bCFo/g9WODjWx3m6EYl3GfUG31eKJbaggyBDxEldRc=