fix: hard fail on invalid railpack.json (#226)

* fix: hard fail if railpack.json cannot be parsed

* test: add shouldFail test.json expression

helpful if we expect the build to fail. There shouldn't be too many of these, but we'll need at least
a couple to make sure cases like railpack.json syntax errors hard fail.

* test: bad json syntax failure

* test: config file abs path, relative, and DNE cases

* refactor: move hujson helper into internal helpers

* fix: support abs config file path, fail if config file path does not exist

* fix: document relative config file path, no abs config file support

* fix: invalid config json

* test: snapshot update

* test: remove abs file config test

* test: remove custom java config

I know basically nothing about Java and after I fixed the syntax error which was causing this config file to not be picked up, this started failing. The proper Zulu version check which is the main purpose of this is still working fine so I think it's fine to remove these custom commands.

* fix: typo

* test: snapshot update
This commit is contained in:
Michael Bianco
2025-09-09 19:09:46 -06:00
committed by GitHub
parent 0b571ecca4
commit 2a3dd6825b
9 changed files with 107 additions and 27 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ func commonPlanFlags() []cli.Flag {
},
&cli.StringFlag{
Name: "config-file",
Usage: "path to config file to use",
Usage: "relative path to railpack config file (default: railpack.json)",
},
&cli.BoolFlag{
Name: "error-missing-start",
+3 -12
View File
@@ -11,7 +11,7 @@ import (
"github.com/BurntSushi/toml"
"github.com/bmatcuk/doublestar/v4"
"github.com/tailscale/hujson"
"github.com/railwayapp/railpack/internal/utils"
"gopkg.in/yaml.v2"
)
@@ -134,7 +134,7 @@ func (a *App) FindFilesWithContent(pattern string, regex *regexp.Regexp) []strin
return matches
}
// ReadFile reads the contents of a file
// ReadFile reads the contents of a file within the application source directory
func (a *App) ReadFile(name string) (string, error) {
path := filepath.Join(a.Source, name)
data, err := os.ReadFile(path)
@@ -153,7 +153,7 @@ func (a *App) ReadJSON(name string, v interface{}) error {
return err
}
jsonBytes, err := standardizeJSON([]byte(data))
jsonBytes, err := utils.StandardizeJSON([]byte(data))
if err != nil {
return err
}
@@ -216,12 +216,3 @@ func (a *App) stripSourcePath(absPath string) (string, error) {
}
return rel, nil
}
func standardizeJSON(b []byte) ([]byte, error) {
ast, err := hujson.Parse(b)
if err != nil {
return b, err
}
ast.Standardize()
return ast.Pack(), nil
}
+35 -5
View File
@@ -1,7 +1,11 @@
package core
import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
@@ -40,6 +44,26 @@ type BuildResult struct {
Success bool `json:"success,omitempty"`
}
func readConfigJSON(path string, v interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
jsonBytes, err := utils.StandardizeJSON([]byte(data))
if err != nil {
return err
}
stringData := string(jsonBytes)
if err := json.Unmarshal([]byte(stringData), v); err != nil {
return fmt.Errorf("error reading %s as JSON: %w", path, err)
}
return nil
}
func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuildPlanOptions) *BuildResult {
logger := logger.NewLogger()
@@ -56,7 +80,7 @@ func GenerateBuildPlan(app *app.App, env *app.Environment, options *GenerateBuil
return &BuildResult{Success: false, Logs: logger.Logs}
}
// Set the preivous versions
// Set the previous versions
if options.PreviousVersions != nil {
for name, version := range options.PreviousVersions {
ctx.Resolver.SetPreviousVersion(name, version)
@@ -140,17 +164,23 @@ func GenerateConfigFromFile(app *app.App, env *app.Environment, options *Generat
configFileName = envConfigFileName
}
if !app.HasMatch(configFileName) {
// always assume config file path is relative to the app source directory
// https://github.com/railwayapp/railpack/pull/226
absConfigFileName := filepath.Join(app.Source, configFileName)
if _, err := os.Stat(absConfigFileName); err != nil && os.IsNotExist(err) {
// if a specific path was specified, we should indicate that it was not found and hard fail
if configFileName != defaultConfigFileName {
logger.LogWarn("Config file `%s` not found", configFileName)
return nil, fmt.Errorf("config file %q not found", absConfigFileName)
}
return config, nil
}
if err := app.ReadJSON(configFileName, config); err != nil {
// if a JSON file was provided, we should hard fail if we cannot parse it
if err := readConfigJSON(absConfigFileName, config); err != nil {
logger.LogWarn("Failed to read config file `%s`\nUse the following schema to validate your config file: %s\n", configFileName, c.SchemaUrl)
return config, nil
return nil, err
}
logger.LogInfo("Using config file `%s`", configFileName)
+33
View File
@@ -7,6 +7,7 @@ import (
"github.com/gkampitakis/go-snaps/snaps"
"github.com/railwayapp/railpack/core/app"
"github.com/railwayapp/railpack/core/logger"
"github.com/stretchr/testify/require"
)
@@ -16,6 +17,7 @@ func TestMain(m *testing.M) {
os.Exit(v)
}
// generate snapshot plan JSON for each build example and assert against it
func TestGenerateBuildPlanForExamples(t *testing.T) {
wd, err := os.Getwd()
require.NoError(t, err)
@@ -59,3 +61,34 @@ func TestGenerateBuildPlanForExamples(t *testing.T) {
})
}
}
func TestGenerateConfigFromFile_NotFound(t *testing.T) {
// Use an existing example app directory so relative paths resolve
appPath := "../examples/config-file"
userApp, err := app.NewApp(appPath)
require.NoError(t, err)
env := app.NewEnvironment(nil)
l := logger.NewLogger()
options := &GenerateBuildPlanOptions{ConfigFilePath: "does-not-exist.railpack.json"}
cfg, genErr := GenerateConfigFromFile(userApp, env, options, l)
require.Error(t, genErr, "expected an error when explicit config file does not exist")
require.Nil(t, cfg, "config should be nil on error")
}
func TestGenerateConfigFromFile_Malformed(t *testing.T) {
appPath := "../examples/config-file"
userApp, err := app.NewApp(appPath)
require.NoError(t, err)
env := app.NewEnvironment(nil)
l := logger.NewLogger()
options := &GenerateBuildPlanOptions{ConfigFilePath: "railpack.malformed.json"}
cfg, genErr := GenerateConfigFromFile(userApp, env, options, l)
require.Error(t, genErr, "expected an error for malformed JSON config file")
require.Nil(t, cfg, "config should be nil on error")
}
@@ -0,0 +1,6 @@
{
"steps": {
"commands": ["npm install"]
},
"invalid": json syntax here
}
+6 -1
View File
@@ -8,6 +8,11 @@
},
{
"expectedOutput": "Hello via Bun",
"configFile": "railpack.invalid.json"
"configFile": "railpack.invalid.json",
"shouldFail": true
},
{
"configFile": "railpack.malformed.json",
"shouldFail": true
}
]
-8
View File
@@ -3,13 +3,5 @@
"provider": "java",
"packages": {
"java": "zulu-8.86"
},
"steps": {
"install": {
"commands": [mvn clean compile", "mvn package"]
}
},
"deploy": {
"startCommand": "java -jar target/java-zulu-version-1.0-SNAPSHOT.jar"
}
}
+11
View File
@@ -28,6 +28,7 @@ type TestCase struct {
Envs map[string]string `json:"envs"`
ConfigFilePath string `json:"configFile"`
JustBuild bool `json:"justBuild"`
ShouldFail bool `json:"shouldFail"`
}
func TestExamplesIntegration(t *testing.T) {
@@ -78,6 +79,16 @@ func TestExamplesIntegration(t *testing.T) {
buildResult := core.GenerateBuildPlan(userApp, env, &core.GenerateBuildPlanOptions{
ConfigFilePath: testCase.ConfigFilePath,
})
// Handle case where we expect the build to fail
if testCase.ShouldFail {
if buildResult.Success {
t.Fatalf("expected build to fail, but it succeeded")
}
// Test passes - build failed as expected
return
}
if !buildResult.Success {
t.Fatalf("failed to generate build plan: %v", buildResult.Logs)
}
+12
View File
@@ -6,6 +6,8 @@ import (
"sort"
"strconv"
"strings"
"github.com/tailscale/hujson"
)
func RemoveDuplicates[T comparable](sliceList []T) []T {
@@ -159,3 +161,13 @@ type Semver struct {
Minor int
Patch int
}
// convert hujson/extended JSON into standardized json
func StandardizeJSON(b []byte) ([]byte, error) {
ast, err := hujson.Parse(b)
if err != nil {
return b, err
}
ast.Standardize()
return ast.Pack(), nil
}