From a470c4054f06a856936e855c52d2abc8a90d1909 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 21:43:52 +0000 Subject: [PATCH] feat(build): validate release workflow path alias --- cmd/build/cmd_workflow.go | 29 ++++++++++++++++------------ cmd/build/cmd_workflow_test.go | 15 +++++++++++++-- pkg/api/provider.go | 8 ++++---- pkg/api/provider_test.go | 22 +++++++++++++++++++++ pkg/build/workflow.go | 28 +++++++++++++++++++++++++++ pkg/build/workflow_test.go | 35 ++++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 18 deletions(-) diff --git a/cmd/build/cmd_workflow.go b/cmd/build/cmd_workflow.go index d1712da..4125c82 100644 --- a/cmd/build/cmd_workflow.go +++ b/cmd/build/cmd_workflow.go @@ -14,13 +14,14 @@ import ( ) var ( + releaseWorkflowPath string releaseWorkflowOutputPath string ) var releaseWorkflowCmd = &cli.Command{ Use: "workflow", RunE: func(cmd *cli.Command, args []string) error { - return runReleaseWorkflow(cmd.Context(), releaseWorkflowOutputPath) + return runReleaseWorkflow(cmd.Context(), releaseWorkflowPath, releaseWorkflowOutputPath) }, } @@ -30,7 +31,7 @@ func setWorkflowI18n() { } func initWorkflowFlags() { - releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowOutputPath, "path", "", i18n.T("cmd.build.workflow.flag.path")) + releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowPath, "path", "", i18n.T("cmd.build.workflow.flag.path")) releaseWorkflowCmd.Flags().StringVar(&releaseWorkflowOutputPath, "output", "", i18n.T("cmd.build.workflow.flag.path")) } @@ -44,9 +45,10 @@ func AddWorkflowCommand(buildCmd *cli.Command) { // runReleaseWorkflow writes the embedded release workflow into the project. // // 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 -func runReleaseWorkflow(ctx context.Context, path 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") // output is an alias for path +func runReleaseWorkflow(ctx context.Context, path, output string) error { _ = ctx projectDir, err := ax.Getwd() @@ -54,19 +56,22 @@ func runReleaseWorkflow(ctx context.Context, path string) error { return coreerr.E("build.runReleaseWorkflow", "failed to get working directory", err) } - return runReleaseWorkflowInDir(projectDir, path) + return runReleaseWorkflowInDir(projectDir, path, output) } // runReleaseWorkflowInDir writes the embedded release workflow into projectDir. // -// runReleaseWorkflowInDir("/tmp/project", "") // /tmp/project/.github/workflows/release.yml -// runReleaseWorkflowInDir("/tmp/project", "ci/release.yml") // /tmp/project/ci/release.yml -func runReleaseWorkflowInDir(projectDir, path string) error { - path = build.ResolveReleaseWorkflowPath(projectDir, path) +// runReleaseWorkflowInDir("/tmp/project", "", "") // /tmp/project/.github/workflows/release.yml +// runReleaseWorkflowInDir("/tmp/project", "ci/release.yml", "") // /tmp/project/ci/release.yml +func runReleaseWorkflowInDir(projectDir, path, output string) error { + resolvedPath, err := build.ResolveReleaseWorkflowInputPath(projectDir, path, output) + if err != nil { + return err + } - if err := io.Local.EnsureDir(ax.Dir(path)); err != nil { + if err := io.Local.EnsureDir(ax.Dir(resolvedPath)); err != nil { return coreerr.E("build.runReleaseWorkflowInDir", "failed to create release workflow directory", err) } - return build.WriteReleaseWorkflow(io.Local, path) + return build.WriteReleaseWorkflow(io.Local, resolvedPath) } diff --git a/cmd/build/cmd_workflow_test.go b/cmd/build/cmd_workflow_test.go index 65a6a0f..3c4d16f 100644 --- a/cmd/build/cmd_workflow_test.go +++ b/cmd/build/cmd_workflow_test.go @@ -16,7 +16,7 @@ func TestBuildCmd_RunReleaseWorkflow_Good(t *testing.T) { projectDir := t.TempDir() t.Run("writes to the conventional workflow path by default", func(t *testing.T) { - err := runReleaseWorkflowInDir(projectDir, "") + err := runReleaseWorkflowInDir(projectDir, "", "") require.NoError(t, err) path := build.ReleaseWorkflowPath(projectDir) @@ -39,7 +39,7 @@ func TestBuildCmd_RunReleaseWorkflow_Good(t *testing.T) { t.Run("writes to a custom relative path", func(t *testing.T) { customPath := "ci/release.yml" - err := runReleaseWorkflowInDir(projectDir, customPath) + err := runReleaseWorkflowInDir(projectDir, customPath, "") require.NoError(t, err) content, err := io.Local.Read(ax.Join(projectDir, customPath)) @@ -50,4 +50,15 @@ func TestBuildCmd_RunReleaseWorkflow_Good(t *testing.T) { assert.Contains(t, content, "actions/download-artifact@v4") assert.Contains(t, content, "command: ci") }) + + t.Run("writes to the output alias", func(t *testing.T) { + customPath := "ci/alias-release.yml" + err := runReleaseWorkflowInDir(projectDir, "", customPath) + require.NoError(t, err) + + content, err := io.Local.Read(ax.Join(projectDir, customPath)) + require.NoError(t, err) + assert.Contains(t, content, "workflow_call:") + assert.Contains(t, content, "workflow_dispatch:") + }) } diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 7b372a6..f283306 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -554,11 +554,11 @@ func (p *BuildProvider) generateReleaseWorkflow(c *gin.Context) { } } - path := req.Path - if path == "" { - path = req.Output + path, err := build.ResolveReleaseWorkflowInputPath(dir, req.Path, req.Output) + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return } - path = build.ResolveReleaseWorkflowPath(dir, path) if err := build.WriteReleaseWorkflow(p.medium, path); err != nil { c.JSON(http.StatusInternalServerError, api.Fail("workflow_write_failed", err.Error())) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index d86a4cc..9177750 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -257,6 +257,28 @@ func TestProvider_GenerateReleaseWorkflow_OutputAlias_Good(t *testing.T) { assert.Contains(t, content, "workflow_dispatch:") } +func TestProvider_GenerateReleaseWorkflow_ConflictingPathAndOutput_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(`{"path":"ci/release.yml","output":"ops/release.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_InvalidJSON_Bad(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/pkg/build/workflow.go b/pkg/build/workflow.go index dbed838..9f00b62 100644 --- a/pkg/build/workflow.go +++ b/pkg/build/workflow.go @@ -65,3 +65,31 @@ func ResolveReleaseWorkflowPath(projectDir, path string) string { } return path } + +// ResolveReleaseWorkflowInputPath resolves the workflow path from the CLI/API +// `path` field and its `output` alias. +// +// build.ResolveReleaseWorkflowInputPath("/tmp/project", "", "") // /tmp/project/.github/workflows/release.yml +// build.ResolveReleaseWorkflowInputPath("/tmp/project", "ci/release.yml", "") // /tmp/project/ci/release.yml +// 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, output string) (string, error) { + if path != "" && output != "" { + resolvedPath := ResolveReleaseWorkflowPath(projectDir, path) + resolvedOutput := ResolveReleaseWorkflowPath(projectDir, output) + 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 output != "" { + return ResolveReleaseWorkflowPath(projectDir, output), nil + } + + return ReleaseWorkflowPath(projectDir), nil +} diff --git a/pkg/build/workflow_test.go b/pkg/build/workflow_test.go index 6b717cb..d124e8c 100644 --- a/pkg/build/workflow_test.go +++ b/pkg/build/workflow_test.go @@ -77,3 +77,38 @@ func TestWorkflow_ResolveReleaseWorkflowPath_Good(t *testing.T) { assert.Equal(t, "/tmp/release.yml", ResolveReleaseWorkflowPath("/tmp/project", "/tmp/release.yml")) }) } + +func TestWorkflow_ResolveReleaseWorkflowInputPath_Good(t *testing.T) { + t.Run("uses the conventional path when both inputs are empty", func(t *testing.T) { + path, err := ResolveReleaseWorkflowInputPath("/tmp/project", "", "") + require.NoError(t, err) + assert.Equal(t, "/tmp/project/.github/workflows/release.yml", path) + }) + + t.Run("accepts path as the primary input", func(t *testing.T) { + path, err := ResolveReleaseWorkflowInputPath("/tmp/project", "ci/release.yml", "") + 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) + assert.Equal(t, "/tmp/project/ci/release.yml", path) + }) + + t.Run("accepts matching path and output values", func(t *testing.T) { + path, err := ResolveReleaseWorkflowInputPath("/tmp/project", "ci/release.yml", "ci/release.yml") + require.NoError(t, err) + assert.Equal(t, "/tmp/project/ci/release.yml", path) + }) +} + +func TestWorkflow_ResolveReleaseWorkflowInputPath_Bad(t *testing.T) { + t.Run("rejects conflicting path and output values", func(t *testing.T) { + path, err := ResolveReleaseWorkflowInputPath("/tmp/project", "ci/release.yml", "ops/release.yml") + assert.Error(t, err) + assert.Empty(t, path) + assert.Contains(t, err.Error(), "path and output specify different locations") + }) +}