diff --git a/cmd/build/cmd_workflow_test.go b/cmd/build/cmd_workflow_test.go index c223a55..1833c9c 100644 --- a/cmd/build/cmd_workflow_test.go +++ b/cmd/build/cmd_workflow_test.go @@ -80,6 +80,16 @@ func TestBuildCmd_RunReleaseWorkflow_Good(t *testing.T) { assert.Contains(t, content, "workflow_dispatch:") }) + t.Run("writes release.yml inside a bare directory-style path", func(t *testing.T) { + err := runReleaseWorkflowInDir(projectDir, "ci", "") + require.NoError(t, err) + + content, err := io.Local.Read(ax.Join(projectDir, "ci", "release.yml")) + require.NoError(t, err) + assert.Contains(t, content, "workflow_call:") + assert.Contains(t, content, "workflow_dispatch:") + }) + t.Run("writes to the output alias", func(t *testing.T) { customPath := "ci/alias-release.yml" err := runReleaseWorkflowInDir(projectDir, "", customPath) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index a3cce60..27ddbf0 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -257,6 +257,30 @@ func TestProvider_GenerateReleaseWorkflow_OutputAlias_Good(t *testing.T) { assert.Contains(t, content, "workflow_dispatch:") } +func TestProvider_GenerateReleaseWorkflow_BareDirectoryPath_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + projectDir := t.TempDir() + p := NewProvider(projectDir, nil) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/release/workflow", bytes.NewBufferString(`{"path":"ci"}`)) + request.Header.Set("Content-Type", "application/json") + + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = request + + p.generateReleaseWorkflow(ctx) + + assert.Equal(t, http.StatusOK, recorder.Code) + + path := ax.Join(projectDir, "ci", "release.yml") + content, err := io.Local.Read(path) + require.NoError(t, err) + assert.Contains(t, content, "workflow_call:") + assert.Contains(t, content, "workflow_dispatch:") +} + func TestProvider_GenerateReleaseWorkflow_ExistingDirectoryPath_Good(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/pkg/build/workflow.go b/pkg/build/workflow.go index 20ecaa8..aee837d 100644 --- a/pkg/build/workflow.go +++ b/pkg/build/workflow.go @@ -25,6 +25,7 @@ const DefaultReleaseWorkflowFileName = "release.yml" // WriteReleaseWorkflow writes the embedded release workflow template to outputPath. // // build.WriteReleaseWorkflow(io.Local, "") // writes .github/workflows/release.yml +// build.WriteReleaseWorkflow(io.Local, "ci") // writes ./ci/release.yml under the project root // build.WriteReleaseWorkflow(io.Local, "ci/release.yml") // writes ./ci/release.yml under the project root // build.WriteReleaseWorkflow(io.Local, "/tmp/repo/.github/workflows/release.yml") // writes the absolute path unchanged func WriteReleaseWorkflow(medium io_interface.Medium, outputPath string) error { @@ -36,7 +37,7 @@ func WriteReleaseWorkflow(medium io_interface.Medium, outputPath string) error { outputPath = DefaultReleaseWorkflowPath } - if isDirectoryLikePath(outputPath) || medium.IsDir(outputPath) { + if isWorkflowDirectoryInput(outputPath) || medium.IsDir(outputPath) { outputPath = ax.Join(outputPath, DefaultReleaseWorkflowFileName) } @@ -73,7 +74,7 @@ func ResolveReleaseWorkflowPath(projectDir, outputPath string) string { if outputPath == "" { return ReleaseWorkflowPath(projectDir) } - if isDirectoryLikePath(outputPath) { + if isWorkflowDirectoryPath(outputPath) { if ax.IsAbs(outputPath) { return ax.Join(outputPath, DefaultReleaseWorkflowFileName) } @@ -93,38 +94,19 @@ func ResolveReleaseWorkflowPath(projectDir, outputPath string) string { // build.ResolveReleaseWorkflowInputPath("/tmp/project", "", "ci/release.yml") // /tmp/project/ci/release.yml // build.ResolveReleaseWorkflowInputPath("/tmp/project", "ci/release.yml", "ci.yml") // error func ResolveReleaseWorkflowInputPath(projectDir, path, outputPath string) (string, error) { - if path != "" && outputPath != "" { - resolvedPath := ResolveReleaseWorkflowPath(projectDir, path) - resolvedOutput := ResolveReleaseWorkflowPath(projectDir, outputPath) - if resolvedPath != resolvedOutput { - return "", coreerr.E("build.ResolveReleaseWorkflowInputPath", "path and output specify different locations", nil) - } - return resolvedPath, nil - } - - if path != "" { - return ResolveReleaseWorkflowPath(projectDir, path), nil - } - - if outputPath != "" { - return ResolveReleaseWorkflowPath(projectDir, outputPath), nil - } - - return ReleaseWorkflowPath(projectDir), nil -} - -// ResolveReleaseWorkflowInputPathWithMedium resolves the workflow path and -// treats an existing directory as a directory even when the caller omits a -// trailing slash. -// -// build.ResolveReleaseWorkflowInputPathWithMedium(io.Local, "/tmp/project", "ci", "") // /tmp/project/ci/release.yml when /tmp/project/ci exists -func ResolveReleaseWorkflowInputPathWithMedium(medium io_interface.Medium, projectDir, path, outputPath string) (string, error) { resolve := func(input string) string { - resolved := ResolveReleaseWorkflowPath(projectDir, input) - if medium != nil && medium.IsDir(resolved) { - return ax.Join(resolved, DefaultReleaseWorkflowFileName) + if input == "" { + return ReleaseWorkflowPath(projectDir) } - return resolved + + if isWorkflowDirectoryInput(input) { + if ax.IsAbs(input) { + return ax.Join(input, DefaultReleaseWorkflowFileName) + } + return ax.Join(projectDir, input, DefaultReleaseWorkflowFileName) + } + + return ResolveReleaseWorkflowPath(projectDir, input) } if path != "" && outputPath != "" { @@ -147,9 +129,54 @@ func ResolveReleaseWorkflowInputPathWithMedium(medium io_interface.Medium, proje return resolve(""), nil } -// isDirectoryLikePath reports whether a path should be treated as a directory -// rather than a file path. -func isDirectoryLikePath(path string) bool { +// ResolveReleaseWorkflowInputPathWithMedium resolves the workflow path and +// treats an existing directory as a directory even when the caller omits a +// trailing slash. +// +// build.ResolveReleaseWorkflowInputPathWithMedium(io.Local, "/tmp/project", "ci", "") // /tmp/project/ci/release.yml when /tmp/project/ci exists +func ResolveReleaseWorkflowInputPathWithMedium(medium io_interface.Medium, projectDir, path, outputPath string) (string, error) { + resolve := func(input string) string { + if input == "" { + return ReleaseWorkflowPath(projectDir) + } + + if isWorkflowDirectoryInput(input) { + if ax.IsAbs(input) { + return ax.Join(input, DefaultReleaseWorkflowFileName) + } + return ax.Join(projectDir, input, DefaultReleaseWorkflowFileName) + } + + resolved := ResolveReleaseWorkflowPath(projectDir, input) + if medium != nil && medium.IsDir(resolved) { + return ax.Join(resolved, DefaultReleaseWorkflowFileName) + } + return resolved + } + + if path != "" && outputPath != "" { + resolvedPath := resolve(path) + resolvedOutput := resolve(outputPath) + if resolvedPath != resolvedOutput { + return "", coreerr.E("build.ResolveReleaseWorkflowInputPathWithMedium", "path and output specify different locations", nil) + } + return resolvedPath, nil + } + + if path != "" { + return resolve(path), nil + } + + if outputPath != "" { + return resolve(outputPath), nil + } + + return resolve(""), nil +} + +// isWorkflowDirectoryPath reports whether a workflow path is explicitly marked +// as a directory with a trailing separator. +func isWorkflowDirectoryPath(path string) bool { if path == "" { return false } @@ -157,3 +184,13 @@ func isDirectoryLikePath(path string) bool { last := path[len(path)-1] return last == '/' || last == '\\' } + +// isWorkflowDirectoryInput reports whether a workflow input should be treated +// as a directory target. This includes explicit directory paths and bare names +// without a file extension. +func isWorkflowDirectoryInput(path string) bool { + if isWorkflowDirectoryPath(path) { + return true + } + return path != "" && ax.Ext(path) == "" +} diff --git a/pkg/build/workflow_test.go b/pkg/build/workflow_test.go index e194071..9c9cb20 100644 --- a/pkg/build/workflow_test.go +++ b/pkg/build/workflow_test.go @@ -43,6 +43,17 @@ func TestWorkflow_WriteReleaseWorkflow_Good(t *testing.T) { assert.NotEmpty(t, content) }) + t.Run("writes release.yml for a bare directory-style path", func(t *testing.T) { + fs := io.NewMockMedium() + + err := WriteReleaseWorkflow(fs, "ci") + require.NoError(t, err) + + content, err := fs.Read("ci/release.yml") + require.NoError(t, err) + assert.NotEmpty(t, content) + }) + t.Run("writes release.yml inside an existing directory", func(t *testing.T) { projectDir := t.TempDir() outputDir := ax.Join(projectDir, "ci") @@ -135,6 +146,12 @@ func TestWorkflow_ResolveReleaseWorkflowInputPath_Good(t *testing.T) { assert.Equal(t, "/tmp/project/ci/release.yml", path) }) + t.Run("accepts bare directory-style path as the primary input", func(t *testing.T) { + path, err := ResolveReleaseWorkflowInputPath("/tmp/project", "ci", "") + require.NoError(t, err) + assert.Equal(t, "/tmp/project/ci/release.yml", path) + }) + t.Run("accepts output as an alias for path", func(t *testing.T) { path, err := ResolveReleaseWorkflowInputPath("/tmp/project", "", "ci/release.yml") require.NoError(t, err) @@ -173,6 +190,14 @@ func TestWorkflow_ResolveReleaseWorkflowInputPathWithMedium_Good(t *testing.T) { assert.Equal(t, "/tmp/project/ci/release.yml", path) }) + t.Run("treats a bare directory-style path as a workflow directory", func(t *testing.T) { + fs := io.NewMockMedium() + + path, err := ResolveReleaseWorkflowInputPathWithMedium(fs, "/tmp/project", "ci", "") + require.NoError(t, err) + assert.Equal(t, "/tmp/project/ci/release.yml", path) + }) + t.Run("keeps a file path unchanged when the target is not a directory", func(t *testing.T) { fs := io.NewMockMedium()