feat(build): validate release workflow path alias

This commit is contained in:
Virgil 2026-04-01 21:43:52 +00:00
parent ed7b1ef083
commit a470c4054f
6 changed files with 119 additions and 18 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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