Skip to content
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,8 @@ examples/*/size.json
### AI Tooling ###
.claude/settings.local.json
examples/**/.claude

## Local Development ##
.env
examples/*/size.json
.gemini/
5 changes: 4 additions & 1 deletion buildkit/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ func BuildWithBuildkitClient(appDir string, plan *plan.BuildPlan, opts BuildWith
}

func getImageName(appDir string) string {
parts := strings.Split(appDir, string(os.PathSeparator))
// Normalize path separators to forward slash for consistent splitting
// This handles both Unix (/path/to/app) and Windows (C:\path\to\app) paths
normalized := strings.ReplaceAll(appDir, "\\", "/")
parts := strings.Split(normalized, "/")
name := parts[len(parts)-1]
if name == "" {
name = "railpack-app" // Fallback if path ends in separator
Expand Down
5 changes: 3 additions & 2 deletions buildkit/build_llb/build_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"fmt"
"maps"
"os"
"path/filepath"
"path"
"slices"
"strings"

Expand Down Expand Up @@ -333,7 +333,8 @@ func (g *BuildGraph) convertFileCommandToLLB(cmd plan.FileCommand, state llb.Sta
}

// Create parent directories for the file
parentDir := filepath.Dir(cmd.Path)
// Use path.Dir for container paths (always forward slash)
parentDir := path.Dir(cmd.Path)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you need to make this change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

This function generates mkdir commands that will be executed inside the Linux container by BuildKit. When running railpack on Windows, filepath.Dir returns paths with backslashes, which are invalid in Linux. Using path.Dir ensures the mkdir instruction sent to BuildKit always uses forward slashes, regardless of the host OS.

I tested this by temporarily reverting to filepath.Dir then the build failed with mkdir /app/\etc\mise (backslash), then succeeded with path.Dir.

if parentDir != "/" {
s := state.File(llb.Mkdir(parentDir, 0755, llb.WithParents(true)))
state = s
Expand Down
10 changes: 6 additions & 4 deletions buildkit/build_llb/layers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package build_llb
import (
"fmt"
"path"
"path/filepath"
"slices"
"strings"

Expand Down Expand Up @@ -260,18 +259,21 @@ func hasPathOverlap(paths1, paths2 []string) bool {
// resolvePaths determines source and destination paths based on the include path and whether it's local.
// For local paths, only the basename is preserved when copying to /app directory.
// For container paths, the full relative path structure is preserved under /app.
// Note: Uses path.Join (not filepath.Join) for container paths to ensure forward slashes on all platforms.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this important?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment explains the technical reason (forward slashes), but let me clarify:

resolvePaths generates copy commands that BuildKit executes inside Linux containers. When running railpack on Windows, filepath.Join would create paths like /app\config\file.json (backslash), which are invalid in Linux. This causes BuildKit to fail when processing the copy instructions.

Using path.Join ensures all paths use forward slashes regardless of host OS, so the same build plan works on Windows, macOS, and Linux.

If you prefer clean code, i can remove that comment.

func resolvePaths(include string, isLocal bool) (srcPath, destPath string) {
if isLocal {
// convert a local path reference to fully qualified container path
return include, filepath.Join("/app", filepath.Base(include))
// Use path.Join and path.Base for container paths (always forward slash)
return include, path.Join("/app", path.Base(include))
}

switch {
case include == "." || include == "/app" || include == "/app/":
return "/app", "/app"
case filepath.IsAbs(include):
case path.IsAbs(include):
return include, include
default:
return filepath.Join("/app", include), filepath.Join("/app", include)
// Use path.Join for container paths (always forward slash)
return path.Join("/app", include), path.Join("/app", include)
}
}
3 changes: 1 addition & 2 deletions buildkit/build_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package buildkit

import (
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -38,7 +37,7 @@ func TestGetImageName(t *testing.T) {
},
{
name: "path ending with separator",
appDir: "/path/to/myapp" + string(filepath.Separator),
appDir: "/path/to/myapp/",
expected: "railpack-app",
},
}
Expand Down
2 changes: 1 addition & 1 deletion core/generate/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (c CommandWrapper) IsSpread() bool {
}

func NewGenerateContext(app *a.App, env *a.Environment, config *config.Config, logger *logger.Logger) (*GenerateContext, error) {
resolver, err := resolver.NewResolver(mise.InstallDir)
resolver, err := resolver.NewResolver(mise.GetInstallDir())
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion core/generate/mise_step_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func (b *MiseStepBuilder) SkipMiseInstall(name resolver.PackageRef) {
// GetMisePackageVersions gets all package versions from mise that are defined in the app directory environment
// this can include additional packages defined outside the app directory, but we filter those out
func (b *MiseStepBuilder) GetMisePackageVersions(ctx *GenerateContext) (map[string]*MisePackageInfo, error) {
miseInstance, err := mise.New(mise.InstallDir)
miseInstance, err := mise.New(mise.GetInstallDir())
if err != nil {
return nil, err
}
Expand Down
20 changes: 18 additions & 2 deletions core/mise/mise.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,27 @@ import (
)

const (
InstallDir = "/tmp/railpack/mise"
TestInstallDir = "/tmp/railpack/mise-test"
DefaultInstallDir = "/tmp/railpack/mise"
DefaultTestInstallDir = "/tmp/railpack/mise-test"
IdiomaticVersionFileTools = "python,node,ruby,elixir,go,java,yarn"
)

// GetInstallDir returns the mise install directory, checking RAILPACK_MISE_DIR env var first
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you help me understand why this is required?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows doesn't have a /tmp directory. Without this env var, mise installation fails on Windows because the default path /tmp/railpack/mise doesn't exist and can't be created.

This allows Windows users to specify an alternative path like C:\tmp\railpack\mise. The env var is checked first, falling back to the default /tmp/railpack/mise on Unix systems.

func GetInstallDir() string {
if envDir := os.Getenv("RAILPACK_MISE_DIR"); envDir != "" {
return envDir
}
return DefaultInstallDir
}

// GetTestInstallDir returns the mise test install directory
func GetTestInstallDir() string {
if envDir := os.Getenv("RAILPACK_MISE_TEST_DIR"); envDir != "" {
return envDir
}
return DefaultTestInstallDir
}

type Mise struct {
binaryPath string
cacheDir string
Expand Down
7 changes: 5 additions & 2 deletions core/providers/golang/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package golang

import (
"fmt"
"path"
"path/filepath"
"strings"

Expand Down Expand Up @@ -145,9 +146,11 @@ func (p *GoProvider) InstallGoDeps(ctx *generate.GenerateContext, install *gener

workspacePackages := p.GoWorkspacePackages(ctx)
for _, pkgPath := range workspacePackages {
install.AddCommand(plan.NewCopyCommand(filepath.Join(pkgPath, "go.mod")))
// Use path.Join for container paths (always forward slash)
install.AddCommand(plan.NewCopyCommand(path.Join(pkgPath, "go.mod")))
// Use filepath.Join for host filesystem checks
if ctx.App.HasFile(filepath.Join(pkgPath, "go.sum")) {
install.AddCommand(plan.NewCopyCommand(filepath.Join(pkgPath, "go.sum")))
install.AddCommand(plan.NewCopyCommand(path.Join(pkgPath, "go.sum")))
}
}

Expand Down
7 changes: 4 additions & 3 deletions core/providers/node/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package node

import (
"fmt"
"path/filepath"
"path"

"github.com/railwayapp/railpack/core/app"
)
Expand Down Expand Up @@ -68,7 +68,8 @@ func (w *Workspace) findWorkspacePackages(app *app.App) error {
continue
}

dir := filepath.Dir(match)
// Use path.Dir for consistent forward slash paths
dir := path.Dir(match)
w.Packages = append(w.Packages, &WorkspacePackage{
Path: dir,
PackageJson: packageJson,
Expand All @@ -95,7 +96,7 @@ func convertWorkspacePattern(pattern string) string {
}

// Direct path or other pattern, just append package.json
return filepath.Join(pattern, "package.json")
return path.Join(pattern, "package.json")
}

// readPackageJson reads a package.json file from the given path
Expand Down
6 changes: 3 additions & 3 deletions core/resolver/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestPackagesWithDefaults(t *testing.T) {
}

func TestPackageResolver(t *testing.T) {
resolver, err := NewResolver(mise.TestInstallDir)
resolver, err := NewResolver(mise.GetTestInstallDir())
require.NoError(t, err)

// Set up Node.js
Expand Down Expand Up @@ -80,7 +80,7 @@ func TestPackageResolver(t *testing.T) {
}

func TestPackageResolverWithPreviousVersions(t *testing.T) {
resolver, err := NewResolver(mise.TestInstallDir)
resolver, err := NewResolver(mise.GetTestInstallDir())
require.NoError(t, err)

resolver.SetPreviousVersion("node", "16")
Expand All @@ -105,7 +105,7 @@ func TestPackageResolverWithPreviousVersions(t *testing.T) {
}

func TestResolvingPackagesNotAvailable(t *testing.T) {
resolver, err := NewResolver(mise.TestInstallDir)
resolver, err := NewResolver(mise.GetTestInstallDir())
require.NoError(t, err)

node := resolver.Default("node", "18.20")
Expand Down
Binary file not shown.
3 changes: 3 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
[env]
BUILDKIT_HOST = "docker-container://buildkit"
RAILPACK_MISE_VERSION = "{{exec(command='cat core/mise/version.txt')}}"
# Mise installation directory (Windows users: change to e.g. C:\tmp\railpack\mise)
RAILPACK_MISE_DIR = "/tmp/railpack/mise"
RAILPACK_MISE_TEST_DIR = "/tmp/railpack/mise-test"

_.path = './bin'

Expand Down