feat(build): add workflow output aliases

This commit is contained in:
Virgil 2026-04-02 00:07:21 +00:00
parent 355be6ee31
commit bfc88dc00c
6 changed files with 189 additions and 16 deletions

View file

@ -4,6 +4,7 @@ package buildcmd
import (
"context"
"strings"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/build/pkg/build"
@ -14,10 +15,12 @@ import (
)
var (
releaseWorkflowPathInput string
releaseWorkflowOutputPathInput string
releaseWorkflowOutputPathSnakeInput string
releaseWorkflowOutputLegacyInput string
releaseWorkflowPathInput string
releaseWorkflowOutputPathInput string
releaseWorkflowOutputPathSnakeInput string
releaseWorkflowOutputLegacyInput string
releaseWorkflowWorkflowOutputPathInput string
releaseWorkflowWorkflowOutputPathSnakeInput string
)
var releaseWorkflowCmd = &cli.Command{
@ -29,6 +32,8 @@ var releaseWorkflowCmd = &cli.Command{
releaseWorkflowOutputPathInput,
releaseWorkflowOutputPathSnakeInput,
releaseWorkflowOutputLegacyInput,
releaseWorkflowWorkflowOutputPathInput,
releaseWorkflowWorkflowOutputPathSnakeInput,
)
},
}
@ -43,6 +48,8 @@ func initWorkflowFlags() {
releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowOutputPathInput, "output-path", "", i18n.T("cmd.build.workflow.flag.output_path"))
releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowOutputPathSnakeInput, "output_path", "", i18n.T("cmd.build.workflow.flag.output_path"))
releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowOutputLegacyInput, "output", "", i18n.T("cmd.build.workflow.flag.output"))
releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowWorkflowOutputPathInput, "workflow-output-path", "", i18n.T("cmd.build.workflow.flag.workflow_output_path"))
releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowWorkflowOutputPathSnakeInput, "workflow_output_path", "", i18n.T("cmd.build.workflow.flag.workflow_output_path"))
}
// buildCmd := &cli.Command{Use: "build"}
@ -56,12 +63,14 @@ func AddWorkflowCommand(buildCmd *cli.Command) {
// runReleaseWorkflow writes the embedded release workflow into the current project directory.
//
// buildcmd.AddWorkflowCommand(buildCmd)
// runReleaseWorkflow(ctx, "", "", "", "") // writes to .github/workflows/release.yml
// runReleaseWorkflow(ctx, "ci/release.yml", "", "", "") // writes to ./ci/release.yml under the project root
// runReleaseWorkflow(ctx, "", "ci/release.yml", "", "") // uses the preferred explicit output path
// runReleaseWorkflow(ctx, "", "", "ci/release.yml", "") // uses the snake_case alias
// runReleaseWorkflow(ctx, "", "", "", "ci/release.yml") // uses the legacy output alias
func runReleaseWorkflow(_ context.Context, workflowPathInput, workflowOutputPathInput, workflowOutputPathSnakeInput, workflowOutputLegacyInput string) error {
// runReleaseWorkflow(ctx, "", "", "", "", "", "") // writes to .github/workflows/release.yml
// runReleaseWorkflow(ctx, "ci/release.yml", "", "", "", "", "") // writes to ./ci/release.yml under the project root
// runReleaseWorkflow(ctx, "", "ci/release.yml", "", "", "", "") // uses the preferred explicit output path
// runReleaseWorkflow(ctx, "", "", "ci/release.yml", "", "", "") // uses the snake_case alias
// runReleaseWorkflow(ctx, "", "", "", "ci/release.yml", "", "") // uses the legacy output alias
// runReleaseWorkflow(ctx, "", "", "", "", "ci/release.yml", "") // uses the workflow-output-path alias
// runReleaseWorkflow(ctx, "", "", "", "", "ci/release.yml", "ci/release.yml") // uses the snake_case workflow-output-path alias
func runReleaseWorkflow(_ context.Context, workflowPathInput, workflowOutputPathInput, workflowOutputPathSnakeInput, workflowOutputLegacyInput, workflowWorkflowOutputPathInput, workflowWorkflowOutputPathSnakeInput string) error {
resolvedOutputPathInput, err := build.ResolveReleaseWorkflowOutputPath(
workflowOutputPathInput,
workflowOutputPathSnakeInput,
@ -71,6 +80,16 @@ func runReleaseWorkflow(_ context.Context, workflowPathInput, workflowOutputPath
return err
}
resolvedOutputPathInput, err = resolveWorkflowOutputAliases(
resolvedOutputPathInput,
workflowWorkflowOutputPathInput,
workflowWorkflowOutputPathSnakeInput,
"build.runReleaseWorkflow",
)
if err != nil {
return err
}
projectDir, err := ax.Getwd()
if err != nil {
return coreerr.E("build.runReleaseWorkflow", "failed to get working directory", err)
@ -96,3 +115,27 @@ func runReleaseWorkflowInDir(projectDir, workflowPathInput, workflowOutputPathIn
return build.WriteReleaseWorkflow(io.Local, resolvedPath)
}
// resolveWorkflowOutputAliases merges the preferred output path with extra
// aliases while rejecting conflicting values.
func resolveWorkflowOutputAliases(primaryInput, workflowOutputPathInput, workflowOutputPathSnakeInput, errorName string) (string, error) {
primaryInput = strings.TrimSpace(primaryInput)
workflowOutputPathInput = strings.TrimSpace(workflowOutputPathInput)
workflowOutputPathSnakeInput = strings.TrimSpace(workflowOutputPathSnakeInput)
resolved := primaryInput
for _, value := range []string{workflowOutputPathInput, workflowOutputPathSnakeInput} {
if value == "" {
continue
}
if resolved == "" {
resolved = value
continue
}
if resolved != value {
return "", coreerr.E(errorName, "workflow output aliases specify different locations", nil)
}
}
return resolved, nil
}

View file

@ -69,18 +69,25 @@ func TestBuildCmd_RunReleaseWorkflow_Good(t *testing.T) {
outputPathFlag := releaseWorkflowCmd.Flags().Lookup("output-path")
outputPathSnakeFlag := releaseWorkflowCmd.Flags().Lookup("output_path")
outputFlag := releaseWorkflowCmd.Flags().Lookup("output")
workflowOutputPathFlag := releaseWorkflowCmd.Flags().Lookup("workflow-output-path")
workflowOutputPathSnakeFlag := releaseWorkflowCmd.Flags().Lookup("workflow_output_path")
assert.NotNil(t, pathFlag)
assert.NotNil(t, outputPathFlag)
assert.NotNil(t, outputPathSnakeFlag)
assert.NotNil(t, outputFlag)
assert.NotNil(t, workflowOutputPathFlag)
assert.NotNil(t, workflowOutputPathSnakeFlag)
assert.NotEmpty(t, pathFlag.Usage)
assert.NotEmpty(t, outputPathFlag.Usage)
assert.NotEmpty(t, outputPathSnakeFlag.Usage)
assert.NotEmpty(t, outputFlag.Usage)
assert.NotEmpty(t, workflowOutputPathFlag.Usage)
assert.NotEmpty(t, workflowOutputPathSnakeFlag.Usage)
assert.NotEqual(t, pathFlag.Usage, outputFlag.Usage)
assert.NotEqual(t, outputPathFlag.Usage, outputFlag.Usage)
assert.Equal(t, outputPathFlag.Usage, outputPathSnakeFlag.Usage)
assert.Equal(t, workflowOutputPathFlag.Usage, workflowOutputPathSnakeFlag.Usage)
})
t.Run("writes to a custom relative path", func(t *testing.T) {

View file

@ -113,10 +113,11 @@
},
"workflow": {
"short": "Generate the release workflow",
"long": "Write the embedded GitHub Actions release workflow into .github/workflows/release.yml, or pass --path/--output-path/--output_path/--output for a custom location.",
"long": "Write the embedded GitHub Actions release workflow into .github/workflows/release.yml, or pass --path/--output-path/--output_path/--output/--workflow-output-path/--workflow_output_path for a custom location.",
"flag": {
"path": "Preferred workflow path input.",
"output_path": "Preferred explicit workflow output path.",
"workflow_output_path": "Predictable workflow output path alias.",
"output": "Legacy alias for --output-path."
}
},

View file

@ -10,6 +10,7 @@ import (
stdio "io"
"io/fs"
"net/http"
"strings"
"dappco.re/go/core/api"
"dappco.re/go/core/api/pkg/provider"
@ -20,6 +21,7 @@ import (
"dappco.re/go/core/build/pkg/release"
"dappco.re/go/core/build/pkg/sdk"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/ws"
"github.com/gin-gonic/gin"
)
@ -182,6 +184,14 @@ func (p *BuildProvider) Describe() []api.RouteDescription {
"type": "string",
"description": "Preferred output path for the workflow file, relative to the project directory or absolute.",
},
"workflowOutputPath": map[string]any{
"type": "string",
"description": "Predictable alias for outputPath, relative to the project directory or absolute.",
},
"workflow_output_path": map[string]any{
"type": "string",
"description": "Snake_case alias for workflowOutputPath.",
},
"outputPath": map[string]any{
"type": "string",
"description": "Preferred explicit workflow output path, relative to the project directory or absolute.",
@ -545,16 +555,22 @@ func (p *BuildProvider) triggerRelease(c *gin.Context) {
//
// req := ReleaseWorkflowRequest{Path: "ci/release.yml"}
type ReleaseWorkflowRequest struct {
Path string `json:"path"`
OutputPath string `json:"outputPath"`
OutputPathSnake string `json:"output_path"`
LegacyOutputPath string `json:"output"`
Path string `json:"path"`
OutputPath string `json:"outputPath"`
OutputPathSnake string `json:"output_path"`
LegacyOutputPath string `json:"output"`
WorkflowOutputPath string `json:"workflowOutputPath"`
WorkflowOutputPathSnake string `json:"workflow_output_path"`
}
// resolvedOutputPath resolves the workflow output aliases with the same
// conflict rules as the CLI.
func (r ReleaseWorkflowRequest) resolvedOutputPath() (string, error) {
return build.ResolveReleaseWorkflowOutputPath(r.OutputPath, r.OutputPathSnake, r.LegacyOutputPath)
resolved, err := build.ResolveReleaseWorkflowOutputPath(r.OutputPath, r.OutputPathSnake, r.LegacyOutputPath)
if err != nil {
return "", err
}
return mergeWorkflowOutputAliases(resolved, r.WorkflowOutputPath, r.WorkflowOutputPathSnake, "api.ReleaseWorkflowRequest")
}
func (p *BuildProvider) generateReleaseWorkflow(c *gin.Context) {
@ -601,6 +617,30 @@ func (p *BuildProvider) generateReleaseWorkflow(c *gin.Context) {
}))
}
// mergeWorkflowOutputAliases combines an existing resolved output path with
// additional alias values and rejects conflicts.
func mergeWorkflowOutputAliases(primaryInput, workflowOutputPathInput, workflowOutputPathSnakeInput, errorName string) (string, error) {
primaryInput = strings.TrimSpace(primaryInput)
workflowOutputPathInput = strings.TrimSpace(workflowOutputPathInput)
workflowOutputPathSnakeInput = strings.TrimSpace(workflowOutputPathSnakeInput)
resolved := primaryInput
for _, value := range []string{workflowOutputPathInput, workflowOutputPathSnakeInput} {
if value == "" {
continue
}
if resolved == "" {
resolved = value
continue
}
if resolved != value {
return "", coreerr.E(errorName, "workflow output aliases specify different locations", nil)
}
}
return resolved, nil
}
// -- SDK Handlers -------------------------------------------------------------
func (p *BuildProvider) getSdkDiff(c *gin.Context) {

View file

@ -101,6 +101,16 @@ func TestProvider_BuildProviderDescribe_Good(t *testing.T) {
assert.Equal(t, "string", outputPathSchema["type"])
assert.Equal(t, "Preferred explicit workflow output path, relative to the project directory or absolute.", outputPathSchema["description"])
workflowOutputPathSchema, ok := properties["workflowOutputPath"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "string", workflowOutputPathSchema["type"])
assert.Equal(t, "Predictable alias for outputPath, relative to the project directory or absolute.", workflowOutputPathSchema["description"])
workflowOutputPathSnakeSchema, ok := properties["workflow_output_path"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "string", workflowOutputPathSnakeSchema["type"])
assert.Equal(t, "Snake_case alias for workflowOutputPath.", workflowOutputPathSnakeSchema["description"])
outputPathSnakeSchema, ok := properties["output_path"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "string", outputPathSnakeSchema["type"])
@ -315,6 +325,76 @@ func TestProvider_GenerateReleaseWorkflow_OutputPathSnake_Good(t *testing.T) {
assert.Contains(t, content, "workflow_dispatch:")
}
func TestProvider_GenerateReleaseWorkflow_WorkflowOutputPath_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(`{"workflowOutputPath":"ci/workflow-output-path.yml"}`))
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", "workflow-output-path.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_WorkflowOutputPathSnake_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(`{"workflow_output_path":"ci/workflow-output-path.yml"}`))
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", "workflow-output-path.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_ConflictingWorkflowOutputAliases_Bad(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(`{"outputPath":"ci/output-path.yml","workflowOutputPath":"ops/output-path.yml"}`))
request.Header.Set("Content-Type", "application/json")
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = request
p.generateReleaseWorkflow(ctx)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
path := build.ReleaseWorkflowPath(projectDir)
_, err := io.Local.Read(path)
assert.Error(t, err)
}
func TestProvider_GenerateReleaseWorkflow_ConflictingOutputAliases_Bad(t *testing.T) {
gin.SetMode(gin.TestMode)

View file

@ -61,6 +61,8 @@ export class BuildApi {
outputPath?: string;
output_path?: string;
output?: string;
workflowOutputPath?: string;
workflow_output_path?: string;
} = {}) {
return this.request<any>('/release/workflow', {
method: 'POST',