Repository: akuity/kargo

Issue:
[PromotionTask] Unable to Fetch field from Compose-Output
# Checklist

* [x] I've searched the issue queue to verify this is not a duplicate bug report.
* [x] I've included steps to reproduce the bug.
* [x] I've pasted the output of `kargo version`.
* [x] I've pasted logs, if applicable.

# Description

When executing the `PromotionTask`, the task fails at the step where the field is fetched from the output of the `compose-output` step. 

# Steps to Reproduce

### YAML Configuration:
```yaml
apiVersion: kargo.akuity.io/v1alpha1
kind: PromotionTask
. . .
spec:
  steps:
  - uses: compose-output
    as: boo
    config:
      path: 'foo'
  . . .
  - uses: helm-template
    as: renderingToDeployRepo
    config:
      outPath: ${{ task.outputs.boo.path }}
  . . .
```


# Version

```
v1.3.0
```

### Description:



### Error:
```
step execution failed: step 8 met error threshold of 1: failed to get step config: cannot fetch path from <nil> (1:39) | task.outputs.boo.name | ......................................^
```


Hints from issue comments:

Solution patch:
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/helm-template.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/helm-template.md
index 9a185bdb..eb34bbee 100644
--- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/helm-template.md
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/helm-template.md
@@ -17,7 +17,7 @@ commonly preceded by a [`git-clear` step](git-clear.md) and followed by
 |------|------|----------|-------------|
 | `path` | `string` | Y | Path to a Helm chart (i.e. to a directory containing a `Chart.yaml` file). This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. |
 | `outPath` | `string` | Y | Path to the file or directory where rendered manifests are to be written. If the path ends with `.yaml` or `.yml` it is presumed to indicate a file and is otherwise presumed to indicate a directory. |
-| `releaseName` | `string` | N | Optional release name to use when rendering the manifests. This is commonly omitted. |
+| `releaseName` | `string` | Y | Release name to use when rendering the manifests. |
 | `useReleaseName` | `boolean` | N | Whether to use the release name in the output path (instead of the chart name). This is `false` by default, and only has an effect when `outPath` is set to a directory. |
 | `namespace` | `string` | N | Optional namespace to use when rendering the manifests. This is commonly omitted. GitOps agents such as Argo CD will generally ensure the installation of manifests into the namespace specified by their own configuration. |
 | `valuesFiles` | `[]string` | N | Helm values files (apart from the chart's default `values.yaml`) to be used when rendering the manifests.  |
diff --git a/docs/old-docs/35-references/10-promotion-steps.md b/docs/old-docs/35-references/10-promotion-steps.md
index 8b40f237..8d74cd5d 100644
--- a/docs/old-docs/35-references/10-promotion-steps.md
+++ b/docs/old-docs/35-references/10-promotion-steps.md
@@ -869,7 +869,7 @@ commonly preceded by a [`git-clear`](#git-clear) step and followed by
 |------|------|----------|-------------|
 | `path` | `string` | Y | Path to a Helm chart (i.e. to a directory containing a `Chart.yaml` file). This path is relative to the temporary workspace that Kargo provisions for use by the promotion process. |
 | `outPath` | `string` | Y | Path to the file or directory where rendered manifests are to be written. If the path ends with `.yaml` or `.yml` it is presumed to indicate a file and is otherwise presumed to indicate a directory. |
-| `releaseName` | `string` | N | Optional release name to use when rendering the manifests. This is commonly omitted. |
+| `releaseName` | `string` | Y | Release name to use when rendering the manifests. |
 | `namespace` | `string` | N | Optional namespace to use when rendering the manifests. This is commonly omitted. GitOps agents such as Argo CD will generally ensure the installation of manifests into the namespace specified by their own configuration. |
 | `valuesFiles` | `[]string` | N | Helm values files (apart from the chart's default `values.yaml`) to be used when rendering the manifests.  |
 | `includeCRDs` | `boolean` | N | Whether to include CRDs in the rendered manifests. This is `false` by default. |
diff --git a/internal/directives/simple_engine_promote.go b/internal/directives/simple_engine_promote.go
index 9d7744ce..69c9f8bb 100644
--- a/internal/directives/simple_engine_promote.go
+++ b/internal/directives/simple_engine_promote.go
@@ -139,6 +139,9 @@ func (e *SimpleEngine) executeSteps(
 		stepExecMeta.Status = result.Status
 		stepExecMeta.Message = result.Message
 
+		// Update the state with the output of the step.
+		state[step.Alias] = result.Output
+
 		// TODO(hidde): until we have a better way to handle the output of steps
 		// inflated from tasks, we need to apply a special treatment to the output
 		// to allow it to become available under the alias of the "task".
@@ -150,8 +153,6 @@ func (e *SimpleEngine) executeSteps(
 			for k, v := range result.Output {
 				state[aliasNamespace].(map[string]any)[k] = v // nolint: forcetypeassert
 			}
-		} else {
-			state[step.Alias] = result.Output
 		}
 
 		switch result.Status {
diff --git a/internal/server/list_warehouses_v1alpha1.go b/internal/server/list_warehouses_v1alpha1.go
index f3fc7148..3ce46ea3 100644
--- a/internal/server/list_warehouses_v1alpha1.go
+++ b/internal/server/list_warehouses_v1alpha1.go
@@ -3,6 +3,8 @@ package server
 import (
 	"context"
 	"fmt"
+	"slices"
+	"strings"
 
 	"connectrpc.com/connect"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -29,6 +31,10 @@ func (s *server) ListWarehouses(
 		return nil, fmt.Errorf("list warehouses: %w", err)
 	}
 
+	slices.SortFunc(list.Items, func(a, b kargoapi.Warehouse) int {
+		return strings.Compare(a.Name, b.Name)
+	})
+
 	warehouses := make([]*kargoapi.Warehouse, len(list.Items))
 	for idx := range list.Items {
 		warehouses[idx] = &list.Items[idx]



Test patch:
diff --git a/internal/directives/simple_engine_promote_test.go b/internal/directives/simple_engine_promote_test.go
index 513cb9ff..bc7a5d93 100644
--- a/internal/directives/simple_engine_promote_test.go
+++ b/internal/directives/simple_engine_promote_test.go
@@ -2,6 +2,7 @@ package directives
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"os"
 	"testing"
@@ -144,10 +145,11 @@ func TestSimpleEngine_Promote(t *testing.T) {
 
 func TestSimpleEngine_executeSteps(t *testing.T) {
 	tests := []struct {
-		name       string
-		promoCtx   PromotionContext
-		steps      []PromotionStep
-		assertions func(*testing.T, PromotionResult, error)
+		name        string
+		stepRunners []PromotionStepRunner
+		promoCtx    PromotionContext
+		steps       []PromotionStep
+		assertions  func(*testing.T, PromotionResult, error)
 	}{
 		{
 			name: "fail on invalid step alias",
@@ -434,6 +436,77 @@ func TestSimpleEngine_executeSteps(t *testing.T) {
 				assert.Contains(t, result.StepExecutionMetadata[0].Message, context.Canceled.Error())
 			},
 		},
+		{
+			name: "output composition step from task",
+			stepRunners: []PromotionStepRunner{
+				newOutputComposer(),
+			},
+			steps: []PromotionStep{
+				{
+					Kind:  "compose-output",
+					Alias: "task-1::custom-output",
+					Config: func() []byte {
+						var c = map[string]any{
+							"test": "value",
+						}
+						b, _ := json.Marshal(c)
+						return b
+					}(),
+				},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.Status)
+				assert.Equal(t, int64(0), result.CurrentStep)
+				assert.Len(t, result.StepExecutionMetadata, 1)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.StepExecutionMetadata[0].Status)
+				assert.NotNil(t, result.StepExecutionMetadata[0].StartedAt)
+				assert.NotNil(t, result.StepExecutionMetadata[0].FinishedAt)
+				assert.Equal(t, State{
+					// The output should be available under both the task alias
+					// and the step alias.
+					"task-1::custom-output": map[string]any{
+						"test": "value",
+					},
+					"task-1": map[string]any{
+						"test": "value",
+					},
+				}, result.State)
+			},
+		},
+		{
+			name: "stand alone output composition step",
+			stepRunners: []PromotionStepRunner{
+				newOutputComposer(),
+			},
+			steps: []PromotionStep{
+				{
+					Kind:  "compose-output",
+					Alias: "custom-output",
+					Config: func() []byte {
+						var c = map[string]any{
+							"test": "value",
+						}
+						b, _ := json.Marshal(c)
+						return b
+					}(),
+				},
+			},
+			assertions: func(t *testing.T, result PromotionResult, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.Status)
+				assert.Equal(t, int64(0), result.CurrentStep)
+				assert.Len(t, result.StepExecutionMetadata, 1)
+				assert.Equal(t, kargoapi.PromotionPhaseSucceeded, result.StepExecutionMetadata[0].Status)
+				assert.NotNil(t, result.StepExecutionMetadata[0].StartedAt)
+				assert.NotNil(t, result.StepExecutionMetadata[0].FinishedAt)
+				assert.Equal(t, State{
+					"custom-output": map[string]any{
+						"test": "value",
+					},
+				}, result.State)
+			},
+		},
 	}
 
 	for _, tt := range tests {
@@ -442,7 +515,8 @@ func TestSimpleEngine_executeSteps(t *testing.T) {
 			defer cancel()
 
 			testRegistry := NewStepRunnerRegistry()
-			testRegistry.RegisterPromotionStepRunner(
+
+			var defaultStepRunners = []PromotionStepRunner{
 				&mockPromotionStepRunner{
 					name: "success-step",
 					runResult: PromotionStepResult{
@@ -450,34 +524,22 @@ func TestSimpleEngine_executeSteps(t *testing.T) {
 						Output: map[string]any{"key": "value"},
 					},
 				},
-				&StepRunnerPermissions{},
-			)
-			testRegistry.RegisterPromotionStepRunner(
 				&mockPromotionStepRunner{
 					name: "running-step",
 					runResult: PromotionStepResult{
 						Status: kargoapi.PromotionPhaseRunning,
 					},
 				},
-				&StepRunnerPermissions{},
-			)
-			testRegistry.RegisterPromotionStepRunner(
 				&mockPromotionStepRunner{
 					name:      "error-step",
 					runResult: PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 					runErr:    errors.New("something went wrong"),
 				},
-				&StepRunnerPermissions{},
-			)
-			testRegistry.RegisterPromotionStepRunner(
 				&mockPromotionStepRunner{
 					name:      "terminal-error-step",
 					runResult: PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 					runErr:    &terminalError{err: errors.New("something went wrong")},
 				},
-				&StepRunnerPermissions{},
-			)
-			testRegistry.RegisterPromotionStepRunner(
 				&mockPromotionStepRunner{
 					name: "context-waiter",
 					runFunc: func(ctx context.Context, _ *PromotionStepContext) (PromotionStepResult, error) {
@@ -486,8 +548,10 @@ func TestSimpleEngine_executeSteps(t *testing.T) {
 						return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored}, ctx.Err()
 					},
 				},
-				&StepRunnerPermissions{},
-			)
+			}
+			for _, r := range append(defaultStepRunners, tt.stepRunners...) {
+				testRegistry.RegisterPromotionStepRunner(r, &StepRunnerPermissions{})
+			}
 
 			engine := &SimpleEngine{
 				registry:    testRegistry,