Repository: akuity/kargo

Issue:
Mismatched type in conditional steps
# 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

With 1.5.0, the documentation talks about [conditional steps](https://docs.kargo.io/user-guide/reference-docs/promotion-templates#conditional-steps) to execute or not a step. I have been unable to reproduce the example provided using the `status()` function, which yields `mismatchedType` error when comparing to a string as suggested.

The `status` function is unfortunately [not documented](https://docs.kargo.io/user-guide/reference-docs/expressions#functions) elsewhere

I'm not sure if it's a functional bug, or a documentation oversight.

# Screenshots

![Image](https://github.com/user-attachments/assets/4eed9559-8fdd-489d-9bc0-3b33793f7f6d)

# Steps to Reproduce

Using the following Stage's steps:

```
spec:
  ...
  steps:
     <some other, working steps>
    - uses: git-push
      as: push
      config:
        ...

    - uses: git-open-pr
      as: open-pr
      config:
        createTargetBranch: true
        repoURL: https://github.com/my-owner/my-repo.git
        sourceBranch: ${{ outputs.push.branch }}
        targetBranch: main
        title: "kargo: doing stuff"
      continueOnError: true

    - uses: git-wait-for-pr
      as: wait-pr
      config:
        prNumber: ${{ outputs['open-pr'].prNumber }}
        provider: github
        repoURL: https://github.com/my-owner/my-repo.git
      if: ${{ status('open-pr') != 'Errored' }}
```

Simply run a promotion (manually), with having the `open-pr` step fail.
I also tried `if: ${{ status('open-pr') == 'Succeeded' }}`, to no avail.

# Version

```
{
  "Version": "v1.5.0",
  "BuildDate": "2025-05-15T22:29:42Z",
  "GitCommit": "61a1c24b7971bb19fcb430c737ca48e28912200c",
  "GitTreeDirty": false,
  "GoVersion": "go1.24.3",
  "Compiler": "gc",
  "Platform": "linux/amd64"
}
```

# Logs

```
2025-05-20T07:32:24.459345171Z time="2025-05-20T07:32:24Z" level=info msg="began promotion" freight=5df783509ab8d1c0d6598076785f1d83b898d0d1 namespace=XXX promotion=XXX.01jvp9q83e3s8p7mfwxyh53sf4.5df7835 stage=XXX
2025-05-20T07:32:27.032930671Z wrote /tmp/promotion-eca09077-e4c1-4264-8100-881eaf42bad2/src/env/path/to/file1.yaml
                               wrote /tmp/promotion-eca09077-e4c1-4264-8100-881eaf42bad2/src/env/path/to/file2yaml
                               wrote /tmp/promotion-eca09077-e4c1-4264-8100-881eaf42bad2/src/env/path/to/file3.yaml
2025-05-20T07:32:32.79170394Z  time="2025-05-20T07:32:32Z" level=error msg="error executing Promotion" error="error checking if step \"wait-pr\" should be skipped: invalid operation: != (mismatched types v1alpha1.PromotionStepStatus and string) (1:20)\n |  status('open-pr') != 'Errored' \n | ...................^" freight=5df783509ab8d1c0d6598076785f1d83b898d0d1 namespace=XXX promotion=XXX.01jvp9q83e3s8p7mfwxyh53sf4.5df7835 stage=XXX
                               time="2025-05-20T07:32:32Z" level=info msg=promotion freight=5df783509ab8d1c0d6598076785f1d83b898d0d1 namespace=XXX phase="\"Errored\"" promotion=XXX-01.01jvp9q83e3s8p7mfwxyh53sf4.5df7835 stage=XXX
```

```
stepExecutionMetadata:
    < all steps before succeeded>
    - alias: push
      startedAt:
        seconds: "1747726347"
      finishedAt:
        seconds: "1747726349"
      status: Succeeded

    - alias: open-pr
      startedAt:
        seconds: "1747726349"
      finishedAt:
        seconds: "1747726352"
      errorCount: 1
      status: Errored
      message: 'step "open-pr" met error threshold of 1: error running step "open-pr":
        error creating pull request: POST
        https://api.github.com/repos/my-owner/my-repo/pulls:
        422 Validation Failed [{Resource:PullRequest Field: Code:custom
        Message:No commits between main and
        kargo/my-custom-branch}]'
      continueOnError: true

    - alias: wait-pr
      status: Errored
      message: >-
        error checking if step "wait-pr" should be skipped: invalid operation:
        != (mismatched types v1alpha1.PromotionStepStatus and string) (1:20)
         |  status('open-pr') != 'Errored' 
         | ...................^
```

Discussion:

Solution patch:
diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
index a3d9cd11..1f6d920e 100644
--- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
+++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/git-open-pr.md
@@ -31,7 +31,10 @@ GitLab pull/merge requests.
 
 | Name | Type | Description |
 |------|------|-------------|
-| `prNumber` | `number` | The numeric identifier of the pull request opened by this step. Typically, a subsequent [`git-wait-for-pr` step](git-wait-for-pr.md) will reference this output to learn what pull request to monitor. |
+| `pr` | `object` | An object containing details about the pull request. |
+| `pr.id` | `number` | The numeric identifier of the pull request opened by this step. Typically, a subsequent [`git-wait-for-pr` step](git-wait-for-pr.md) will reference this output to learn what pull request to monitor. |
+| `pr.url` | `string` | The URL of the pull request. |
+| `prNumber` | `number` | The numeric identifier of the pull request opened by this step. Typically, a subsequent [`git-wait-for-pr` step](git-wait-for-pr.md) will reference this output to learn what pull request to monitor.<br/><br/>__Deprecated: Use the `pr.id` field instead. Will be removed in v1.7.0.__ |
 
 ## Examples
 
diff --git a/internal/expressions/function/functions.go b/internal/expressions/function/functions.go
index 25c015fa..be0d9458 100644
--- a/internal/expressions/function/functions.go
+++ b/internal/expressions/function/functions.go
@@ -3,6 +3,7 @@ package function
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	"github.com/expr-lang/expr"
 	corev1 "k8s.io/api/core/v1"
@@ -11,6 +12,7 @@ import (
 
 	kargoapi "github.com/akuity/kargo/api/v1alpha1"
 	"github.com/akuity/kargo/internal/controller/freight"
+	"github.com/akuity/kargo/internal/kargo"
 )
 
 type exprFn func(params ...any) (any, error)
@@ -48,12 +50,15 @@ func DataOperations(ctx context.Context, c client.Client, project string) []expr
 
 // StatusOperations returns a slice of expr.Option containing functions for
 // assessing the status of all preceding steps.
-func StatusOperations(stepExecMetas kargoapi.StepExecutionMetadataList) []expr.Option {
+func StatusOperations(
+	currentStepAlias string,
+	stepExecMetas kargoapi.StepExecutionMetadataList,
+) []expr.Option {
 	return []expr.Option{
 		Always(),
 		Failure(stepExecMetas),
 		Success(stepExecMetas),
-		Status(stepExecMetas),
+		Status(currentStepAlias, stepExecMetas),
 	}
 }
 
@@ -190,11 +195,14 @@ func Success(stepExecMetas kargoapi.StepExecutionMetadataList) expr.Option {
 	)
 }
 
-func Status(stepExecMetas kargoapi.StepExecutionMetadataList) expr.Option {
+func Status(
+	currentStepAlias string,
+	stepExecMetas kargoapi.StepExecutionMetadataList,
+) expr.Option {
 	return expr.Function(
 		"status",
-		getStatus(stepExecMetas),
-		new(func(alias string) kargoapi.PromotionStepStatus),
+		getStatus(currentStepAlias, stepExecMetas),
+		new(func(alias string) string),
 	)
 }
 
@@ -441,24 +449,37 @@ func hasFailure(stepExecMetas kargoapi.StepExecutionMetadataList) exprFn {
 	}
 }
 
-func getStatus(stepExecMetas kargoapi.StepExecutionMetadataList) exprFn {
+func getStatus(
+	currentStepAlias string,
+	stepExecMetas kargoapi.StepExecutionMetadataList,
+) exprFn {
+	var currentStepNamespace string
+	if parts := strings.Split(currentStepAlias, kargo.PromotionAliasSeparator); len(parts) == 2 {
+		currentStepNamespace = parts[0]
+	}
 	return func(a ...any) (any, error) {
 		if len(a) != 1 {
-			return nil, fmt.Errorf("expected 1 argument, got %d", len(a))
+			return "", fmt.Errorf("expected 1 argument, got %d", len(a))
 		}
 
 		alias, ok := a[0].(string)
 		if !ok {
-			return nil, fmt.Errorf("argument must be string, got %T", a[0])
+			return "", fmt.Errorf("argument must be string, got %T", a[0])
 		}
 
 		if alias == "" {
-			return nil, fmt.Errorf("argument must not be empty")
+			return "", fmt.Errorf("argument must not be empty")
 		}
 
 		for _, stepExecMeta := range stepExecMetas {
-			if stepExecMeta.Alias == alias {
-				return stepExecMeta.Status, nil
+			stepShortAlias := stepExecMeta.Alias
+			var stepNamespace string
+			if parts := strings.Split(stepExecMeta.Alias, kargo.PromotionAliasSeparator); len(parts) == 2 {
+				stepNamespace = parts[0]
+				stepShortAlias = parts[1]
+			}
+			if stepNamespace == currentStepNamespace && stepShortAlias == alias {
+				return string(stepExecMeta.Status), nil
 			}
 		}
 		return "", nil
diff --git a/internal/promotion/promotion.go b/internal/promotion/promotion.go
index 9dd71aa6..95d5b99a 100644
--- a/internal/promotion/promotion.go
+++ b/internal/promotion/promotion.go
@@ -243,7 +243,7 @@ func (s *Step) Skip(
 				),
 				exprfn.DataOperations(ctx, cl, promoCtx.Project)...,
 			),
-			exprfn.StatusOperations(promoCtx.StepExecutionMetadata)...,
+			exprfn.StatusOperations(s.Alias, promoCtx.StepExecutionMetadata)...,
 		)...,
 	)
 	if err != nil {
diff --git a/internal/promotion/runner/builtin/output_composer.go b/internal/promotion/runner/builtin/output_composer.go
index d173d29f..1a1d1c21 100644
--- a/internal/promotion/runner/builtin/output_composer.go
+++ b/internal/promotion/runner/builtin/output_composer.go
@@ -28,7 +28,7 @@ import (
 //	step: compose-output
 //	as: custom-outputs
 //	config:
-//	  prURL: ${{ vars.repoURL }}/pull/${{ outputs['open-pr'].prNumber }}
+//	  prURL: ${{ vars.repoURL }}/pull/${{ outputs['open-pr'].pr.id }}
 //	  mergeCommit: ${{ outputs['wait-for-pr'].commit }}
 //
 // This would create a new output named `custom-outputs` with the keys



Test patch:
diff --git a/internal/expressions/function/functions_test.go b/internal/expressions/function/functions_test.go
index cc399f7a..9a8d5ecd 100644
--- a/internal/expressions/function/functions_test.go
+++ b/internal/expressions/function/functions_test.go
@@ -810,3 +810,124 @@ func Test_getSecret(t *testing.T) {
 		})
 	}
 }
+
+func Test_getStatus(t *testing.T) {
+	tests := []struct {
+		name             string
+		currentStepAlias string
+		stepExecMetas    kargoapi.StepExecutionMetadataList
+		args             []any
+		assertions       func(t *testing.T, result any, err error)
+	}{
+		{
+			name: "no arguments",
+			args: []any{},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "expected 1 argument")
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name: "too many arguments",
+			args: []any{"one", "two"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "expected 1 argument")
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name: "one empty argument",
+			args: []any{""},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.ErrorContains(t, err, "must not be empty")
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name:             "basic usage; no hit",
+			currentStepAlias: "step-2",
+			stepExecMetas: kargoapi.StepExecutionMetadataList{{
+				Alias:  "step-1",
+				Status: kargoapi.PromotionStepStatusSucceeded,
+			}},
+			args: []any{"non-existent-step"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name:             "basic usage; hit",
+			currentStepAlias: "step-2",
+			stepExecMetas: kargoapi.StepExecutionMetadataList{{
+				Alias:  "step-1",
+				Status: kargoapi.PromotionStepStatusSucceeded,
+			}},
+			args: []any{"step-1"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, string(kargoapi.PromotionStepStatusSucceeded), result)
+			},
+		},
+		{
+			name:             "used in a task; no alias match",
+			currentStepAlias: "task-2::step-2",
+			stepExecMetas: kargoapi.StepExecutionMetadataList{
+				{
+					Alias:  "step-1", // No task namespace; correct alias
+					Status: kargoapi.PromotionStepStatusSucceeded,
+				},
+				{
+					Alias:  "task-1::step-2", // Correct task namespace; wrong alias
+					Status: kargoapi.PromotionStepStatusSucceeded,
+				},
+			},
+			args: []any{"step-1"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name:             "used in a task; no namespace match",
+			currentStepAlias: "task-2::step-2",
+			stepExecMetas: kargoapi.StepExecutionMetadataList{
+				{
+					Alias:  "step-1", // No task namespace; correct alias
+					Status: kargoapi.PromotionStepStatusSucceeded,
+				},
+				{
+					Alias:  "task-1::step-2", // Wrong task namespace; correct alias
+					Status: kargoapi.PromotionStepStatusSucceeded,
+				},
+			},
+			args: []any{"step-1"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Empty(t, result)
+			},
+		},
+		{
+			name:             "used in a task; hit",
+			currentStepAlias: "task-2::step-2",
+			stepExecMetas: kargoapi.StepExecutionMetadataList{
+				{
+					Alias:  "task-2::step-1",
+					Status: kargoapi.PromotionStepStatusSucceeded,
+				},
+			},
+			args: []any{"step-1"},
+			assertions: func(t *testing.T, result any, err error) {
+				assert.NoError(t, err)
+				assert.Equal(t, string(kargoapi.PromotionStepStatusSucceeded), result)
+			},
+		},
+	}
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			fn := getStatus(testCase.currentStepAlias, testCase.stepExecMetas)
+			result, err := fn(testCase.args...)
+			testCase.assertions(t, result, err)
+		})
+	}
+}