From bfc88dc00c5cfb474e48cfc5fb2422b146ae4b85 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:07:21 +0000 Subject: [PATCH] feat(build): add workflow output aliases --- cmd/build/cmd_workflow.go | 63 +++++++++++++++++++++----- cmd/build/cmd_workflow_test.go | 7 +++ locales/en.json | 3 +- pkg/api/provider.go | 50 ++++++++++++++++++--- pkg/api/provider_test.go | 80 ++++++++++++++++++++++++++++++++++ ui/src/shared/api.ts | 2 + 6 files changed, 189 insertions(+), 16 deletions(-) diff --git a/cmd/build/cmd_workflow.go b/cmd/build/cmd_workflow.go index 6a28fd1..c4c6fdf 100644 --- a/cmd/build/cmd_workflow.go +++ b/cmd/build/cmd_workflow.go @@ -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 +} diff --git a/cmd/build/cmd_workflow_test.go b/cmd/build/cmd_workflow_test.go index 58d863f..48134b0 100644 --- a/cmd/build/cmd_workflow_test.go +++ b/cmd/build/cmd_workflow_test.go @@ -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) { diff --git a/locales/en.json b/locales/en.json index 49eb47b..18b8d7c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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." } }, diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 0c497d3..2664ada 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -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) { diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 0efcc8b..6098342 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -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) diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts index 2e292fb..2228f1a 100644 --- a/ui/src/shared/api.ts +++ b/ui/src/shared/api.ts @@ -61,6 +61,8 @@ export class BuildApi { outputPath?: string; output_path?: string; output?: string; + workflowOutputPath?: string; + workflow_output_path?: string; } = {}) { return this.request('/release/workflow', { method: 'POST',