From b573c9cc68ed569f3bbe525c853361dbe7016dc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:31:24 +0000 Subject: [PATCH 1/6] Initial plan From 79ac00a3f8b2cdc7e4a55a70aac5efaf6214a135 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:37:02 +0000 Subject: [PATCH 2/6] Add validation rule: only one of sh/command/image/manifests allowed per task Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- internal/run.go | 7 +++++ internal/types/task.go | 22 ++++++++++++++++ internal/types/task_test.go | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/internal/run.go b/internal/run.go index d49bbe7..dd459a9 100644 --- a/internal/run.go +++ b/internal/run.go @@ -31,6 +31,13 @@ func RunSubgraph(ctx context.Context, cancel context.CancelFunc, port int, openB } } + // validate all tasks + for name, task := range wf.Tasks { + if err := task.Validate(); err != nil { + return fmt.Errorf("task %q is invalid: %w", name, err) + } + } + // check skipped tasks are valid for _, name := range tasksToSkip { if _, ok := wf.Tasks[name]; !ok { diff --git a/internal/types/task.go b/internal/types/task.go index 6d3b041..e391f64 100644 --- a/internal/types/task.go +++ b/internal/types/task.go @@ -1,6 +1,7 @@ package types import ( + "fmt" "os" "path/filepath" "time" @@ -12,6 +13,27 @@ func (t *Task) HasMutex() bool { return t != nil && t.Mutex != "" } +// Validate checks that at most one of the mutually exclusive task execution fields is set. +func (t *Task) Validate() error { + set := []string{} + if len(t.Command) > 0 { + set = append(set, "command") + } + if t.Sh != "" { + set = append(set, "sh") + } + if t.Image != "" { + set = append(set, "image") + } + if len(t.Manifests) > 0 { + set = append(set, "manifests") + } + if len(set) > 1 { + return fmt.Errorf("only one of %v is allowed", set) + } + return nil +} + // A task is a container or a command to run. type Task struct { // Type is the type of the task: "service" or "job". If omitted, if there are ports, it's a service, otherwise it's a job. diff --git a/internal/types/task_test.go b/internal/types/task_test.go index 553d603..74b0104 100644 --- a/internal/types/task_test.go +++ b/internal/types/task_test.go @@ -36,6 +36,57 @@ func TestTask_AllTargetsExist(t *testing.T) { } } +func TestTask_Validate(t *testing.T) { + t.Run("NoExecField", func(t *testing.T) { + task := &Task{} + assert.NoError(t, task.Validate()) + }) + t.Run("CommandOnly", func(t *testing.T) { + task := &Task{Command: Strings{"echo", "hello"}} + assert.NoError(t, task.Validate()) + }) + t.Run("ShOnly", func(t *testing.T) { + task := &Task{Sh: "echo hello"} + assert.NoError(t, task.Validate()) + }) + t.Run("ImageOnly", func(t *testing.T) { + task := &Task{Image: "nginx"} + assert.NoError(t, task.Validate()) + }) + t.Run("ManifestsOnly", func(t *testing.T) { + task := &Task{Manifests: Strings{"deploy.yaml"}} + assert.NoError(t, task.Validate()) + }) + t.Run("CommandAndSh", func(t *testing.T) { + task := &Task{Command: Strings{"echo"}, Sh: "echo hello"} + assert.EqualError(t, task.Validate(), "only one of [command sh] is allowed") + }) + t.Run("ShAndImage", func(t *testing.T) { + task := &Task{Sh: "echo hello", Image: "nginx"} + assert.EqualError(t, task.Validate(), "only one of [sh image] is allowed") + }) + t.Run("CommandAndManifests", func(t *testing.T) { + task := &Task{Command: Strings{"echo"}, Manifests: Strings{"deploy.yaml"}} + assert.EqualError(t, task.Validate(), "only one of [command manifests] is allowed") + }) + t.Run("CommandAndImage", func(t *testing.T) { + task := &Task{Command: Strings{"echo"}, Image: "nginx"} + assert.EqualError(t, task.Validate(), "only one of [command image] is allowed") + }) + t.Run("ShAndManifests", func(t *testing.T) { + task := &Task{Sh: "echo hello", Manifests: Strings{"deploy.yaml"}} + assert.EqualError(t, task.Validate(), "only one of [sh manifests] is allowed") + }) + t.Run("ImageAndManifests", func(t *testing.T) { + task := &Task{Image: "nginx", Manifests: Strings{"deploy.yaml"}} + assert.EqualError(t, task.Validate(), "only one of [image manifests] is allowed") + }) + t.Run("ThreeFields", func(t *testing.T) { + task := &Task{Command: Strings{"echo"}, Sh: "echo hello", Image: "nginx"} + assert.EqualError(t, task.Validate(), "only one of [command sh image] is allowed") + }) +} + func TestTask_GetType(t *testing.T) { t.Run("Defined", func(t *testing.T) { task := &Task{Type: TaskTypeService} From 66caada90e4ac4a757d8681bac5459a2ac8da3ab Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Fri, 27 Feb 2026 07:30:42 -0800 Subject: [PATCH 3/6] Update internal/types/task.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alex Collins --- internal/types/task.go | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/internal/types/task.go b/internal/types/task.go index e391f64..b9f5703 100644 --- a/internal/types/task.go +++ b/internal/types/task.go @@ -13,23 +13,21 @@ func (t *Task) HasMutex() bool { return t != nil && t.Mutex != "" } -// Validate checks that at most one of the mutually exclusive task execution fields is set. +// Validate checks that task execution fields are combined in a supported way. func (t *Task) Validate() error { - set := []string{} - if len(t.Command) > 0 { - set = append(set, "command") - } - if t.Sh != "" { - set = append(set, "sh") - } - if t.Image != "" { - set = append(set, "image") - } - if len(t.Manifests) > 0 { - set = append(set, "manifests") + hasCommand := len(t.Command) > 0 + hasSh := t.Sh != "" + hasImage := t.Image != "" + hasManifests := len(t.Manifests) > 0 + + // command and sh are alternative ways to specify the task's command; they must not both be set. + if hasCommand && hasSh { + return fmt.Errorf("only one of command or sh is allowed") } - if len(set) > 1 { - return fmt.Errorf("only one of %v is allowed", set) + + // manifests-based tasks are mutually exclusive with image/command/sh-based tasks. + if hasManifests && (hasCommand || hasSh || hasImage) { + return fmt.Errorf("manifests cannot be set together with image, command, or sh") } return nil } From 48c92460c28fb281c7efb56653f4fae20b1cd1d7 Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Fri, 27 Feb 2026 07:30:55 -0800 Subject: [PATCH 4/6] Update internal/types/task_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alex Collins --- internal/types/task_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/types/task_test.go b/internal/types/task_test.go index 74b0104..cf3a58e 100644 --- a/internal/types/task_test.go +++ b/internal/types/task_test.go @@ -63,7 +63,7 @@ func TestTask_Validate(t *testing.T) { }) t.Run("ShAndImage", func(t *testing.T) { task := &Task{Sh: "echo hello", Image: "nginx"} - assert.EqualError(t, task.Validate(), "only one of [sh image] is allowed") + assert.NoError(t, task.Validate()) }) t.Run("CommandAndManifests", func(t *testing.T) { task := &Task{Command: Strings{"echo"}, Manifests: Strings{"deploy.yaml"}} From 670c0ff6c1b2b6f56606b0bb0b96b652ec76c233 Mon Sep 17 00:00:00 2001 From: Alex Collins Date: Fri, 27 Feb 2026 07:31:03 -0800 Subject: [PATCH 5/6] Update internal/types/task_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Alex Collins --- internal/types/task_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/types/task_test.go b/internal/types/task_test.go index cf3a58e..90ed975 100644 --- a/internal/types/task_test.go +++ b/internal/types/task_test.go @@ -71,7 +71,7 @@ func TestTask_Validate(t *testing.T) { }) t.Run("CommandAndImage", func(t *testing.T) { task := &Task{Command: Strings{"echo"}, Image: "nginx"} - assert.EqualError(t, task.Validate(), "only one of [command image] is allowed") + assert.NoError(t, task.Validate()) }) t.Run("ShAndManifests", func(t *testing.T) { task := &Task{Sh: "echo hello", Manifests: Strings{"deploy.yaml"}} From 674557a436907c86f975fd397cbcfd9413dd5ba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:34:34 +0000 Subject: [PATCH 6/6] Fix test error messages and add run_test.go validation coverage Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --- internal/run_test.go | 12 ++++++++++++ internal/types/task_test.go | 10 +++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/run_test.go b/internal/run_test.go index 88b51c8..7ef8eb7 100644 --- a/internal/run_test.go +++ b/internal/run_test.go @@ -49,6 +49,18 @@ func TestRunSubgraph(t *testing.T) { assert.EqualError(t, err, "skipped task \"job\" not found in workflow") }) + t.Run("Invalid task", func(t *testing.T) { + ctx, cancel, logger, _ := setup(t) + defer cancel() + wf := &types.Workflow{ + Tasks: map[string]types.Task{ + "job": {Command: []string{"echo"}, Sh: "echo hello"}, + }, + } + err := RunSubgraph(ctx, cancel, 0, false, logger, wf, []string{"job"}, nil) + assert.EqualError(t, err, "task \"job\" is invalid: only one of command or sh is allowed") + }) + t.Run("Single successful job", func(t *testing.T) { ctx, cancel, logger, _ := setup(t) defer cancel() diff --git a/internal/types/task_test.go b/internal/types/task_test.go index 90ed975..8393034 100644 --- a/internal/types/task_test.go +++ b/internal/types/task_test.go @@ -59,7 +59,7 @@ func TestTask_Validate(t *testing.T) { }) t.Run("CommandAndSh", func(t *testing.T) { task := &Task{Command: Strings{"echo"}, Sh: "echo hello"} - assert.EqualError(t, task.Validate(), "only one of [command sh] is allowed") + assert.EqualError(t, task.Validate(), "only one of command or sh is allowed") }) t.Run("ShAndImage", func(t *testing.T) { task := &Task{Sh: "echo hello", Image: "nginx"} @@ -67,7 +67,7 @@ func TestTask_Validate(t *testing.T) { }) t.Run("CommandAndManifests", func(t *testing.T) { task := &Task{Command: Strings{"echo"}, Manifests: Strings{"deploy.yaml"}} - assert.EqualError(t, task.Validate(), "only one of [command manifests] is allowed") + assert.EqualError(t, task.Validate(), "manifests cannot be set together with image, command, or sh") }) t.Run("CommandAndImage", func(t *testing.T) { task := &Task{Command: Strings{"echo"}, Image: "nginx"} @@ -75,15 +75,15 @@ func TestTask_Validate(t *testing.T) { }) t.Run("ShAndManifests", func(t *testing.T) { task := &Task{Sh: "echo hello", Manifests: Strings{"deploy.yaml"}} - assert.EqualError(t, task.Validate(), "only one of [sh manifests] is allowed") + assert.EqualError(t, task.Validate(), "manifests cannot be set together with image, command, or sh") }) t.Run("ImageAndManifests", func(t *testing.T) { task := &Task{Image: "nginx", Manifests: Strings{"deploy.yaml"}} - assert.EqualError(t, task.Validate(), "only one of [image manifests] is allowed") + assert.EqualError(t, task.Validate(), "manifests cannot be set together with image, command, or sh") }) t.Run("ThreeFields", func(t *testing.T) { task := &Task{Command: Strings{"echo"}, Sh: "echo hello", Image: "nginx"} - assert.EqualError(t, task.Validate(), "only one of [command sh image] is allowed") + assert.EqualError(t, task.Validate(), "only one of command or sh is allowed") }) }