feat(build): accept bare workflow directory inputs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 22:25:55 +00:00
parent e01c88e39b
commit 33fb9bb5a5
4 changed files with 131 additions and 35 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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) == ""
}

View file

@ -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()