Repository: akuity/kargo

Issue:
One-click promotion of a Stage's current piece of Freight to its downstream
# Checklist

* [x] I've searched the issue queue to verify this is not a duplicate feature request.
* [ ] I've pasted the output of `kargo version`, if applicable.
* [ ] I've pasted logs, if applicable.

# Proposed Feature

Allow the user to click one button, which will take the piece of Freight currently in a Stage and promote it downstream. 

This capability can be provided in both UI and CLI.

# Motivation

This eliminates the need for the user to find the current piece of Freight in a stage and supply that as input when requesting a Promotion. This will greatly improve the user experience when manually promoting what is already in a Stage to its downstream. Especially when the action has to be repeated on multiple DAGs.

From https://github.com/akuity/kargo/issues/3193#issuecomment-2571413258

> @krancour: We currently make you fiddle with selecting Freight from the timeline because we didn't want to constrain promotion to any given Stage to just whatever was currently in the previous Stage(s). (You might sometimes need to quickly roll prod back to its previous state and you wouldn't want to have to restore test, uat, etc. to that state first in order to get it there.) But there's absolutely no reason we can't have some kind of shortcut for "promote what's here now" downstream. I think the only challenge standing in the way of that is, again UX. We'd need to find a way to very clearly and visually differentiate between the two actions of "promote something to my downstreams" vs "promote what I have now to my downstream."

# Suggested Implementation

It could be a purely cosmetic change which uses existing APIs, but it would perhaps be better to introduce new API to implement this feature server-side.


Hints from issue comments:
@krancour: I think something else you'd proposed in #3193 is that one click "push downstream" would only be available for Stages that have only one Freight request.

> It could be a purely cosmetic change which uses existing APIs, but it would perhaps be better to introduce new API to implement this feature server-side.

The existing "Promote to Stage" and "Promote to Downstream" endpoints both take just three arguments: Project name, Stage Name, Freight name. So the existing endpoints should handle this just fine without a single change. Your proposed feature would just be a shortcut to filling in the Freight name argument.

cc @jessesuen to get impressions on the value of such a feature.

cc @Marvin9 to think about what UI for something like this might look like. It would be very important to distinguish between "_select_ Freight to promote downstream" vs "promote the Freight the Stage currently has to downstream."
@Marvin9: My quick instinct says some action item on the right side of current freight in the node would work.

![Screenshot 2025-01-08 at 5 20 55 PM (2)](https://github.com/user-attachments/assets/9d61b9fb-bdfd-4022-b4e0-e685d9e437ab)
@krancour: @Marvin9 don't we already put something there on the right (only if the Stage has _multiple_ downstreams, iirc)? That existing action is the thing I want to avoid confusion with.@Marvin9: Right @krancour that is truck icon on the edge of node.. this will be right besides the freight text that user wants to promote in one click.. it'd be as self-explanatory as possible@krancour: Thanks for clarifying @Marvin9!@aayushsrivastava: > I think something else you'd proposed in https://github.com/akuity/kargo/issues/3193 is that one click "push downstream" would only be available for Stages that have only one Freight request.

Yeah, when creating this issue I was not sure if we wanted to design this in a general manner for all types of Stages or limit it to Stages with only one Freight.

But I guess @Marvin9's suggestion of putting it beside the Freight text makes it unambiguous and still solves it in a general manner.

I'll update the title and initial post to reflect that it's only one Freight request.@krancour: > I guess @Marvin9's suggestion of putting it beside the Freight text makes it unambiguous

> I'll update the title and initial post to reflect that it's only one Freight request.

I think the stages will show multiple freight if they have them, so if there's a one-click "promote downstream" button next to _each_, then I think we don't have to limit it to Stages that request Freight from only one source.

There's our general solution.

Solution patch:
diff --git a/charts/kargo/README.md b/charts/kargo/README.md
index cc7fe5c3..7ba4adf5 100644
--- a/charts/kargo/README.md
+++ b/charts/kargo/README.md
@@ -246,10 +246,10 @@ the Kargo controller is running.
 | `externalWebhooksServer.affinity`                   | Specifies pod affinity for external webhook server pods. Defaults to `global.affinity`.                                                                                                                                                                                                                                                                                                                                                                                | `{}`                     |
 | `externalWebhooksServer.securityContext`            | Security context for external webhook server pods. Defaults to `global.securityContext`.                                                                                                                                                                                                                                                                                                                                                                               | `{}`                     |
 | `externalWebhooksServer.probes.enabled`             | Whether liveness and readiness probes should be included in the external webhook server deployment. It is sometimes advantageous to disable these during local development.                                                                                                                                                                                                                                                                                            | `true`                   |
-| `externalWebhooksServer.tls.enabled`                | Whether to enable TLS directly on the external webhook server. This is helpful if you do not intend to use an ingress controller or if you require TLS end-to-end. All other settings in this section EXCEPT `terminatedUpstream` will be ignored when this is set to `false`.                                                                                                                                                                                         | `true`                   |
+| `externalWebhooksServer.tls.enabled`                | Whether to enable TLS directly on the external webhook server. This is helpful if you do not intend to use an ingress controller or if you require TLS end-to-end. All other settings in this section EXCEPT `terminatedUpstream` will be ignored when this is set to `false`.                                                                                                                                                                                         | `false`                  |
 | `externalWebhooksServer.tls.selfSignedCert`         | Whether to generate a self-signed certificate for use by the external webhooks server. If `true`, `cert-manager` CRDs **must** be present in the cluster. Kargo will create and use its own namespaced issuer. If `false`, a cert secret named `kargo-external-webhook-server-cert` **must** be provided in the same namespace as Kargo.                                                                                                                               | `true`                   |
 | `externalWebhooksServer.tls.terminatedUpstream`     | Whether TLS is terminated upstream, i.e. a load balancer, reverse-proxy, or an Ingress controller using a single wildcard cert is terminating it. Setting this to `true` forces all external webhook server URLs to use HTTPS even if the Ingress (if applicable) or external webhook server itself are listening for plain HTTP requests.                                                                                                                             | `false`                  |
-| `externalWebhooksServer.ingress.enabled`            | Whether to enable ingress by creating an Ingress resource. By default, this is disabled. Enabling ingress is advanced usage.                                                                                                                                                                                                                                                                                                                                           | `false`                  |
+| `externalWebhooksServer.ingress.enabled`            | Whether to enable separate ingress for webhook by creating an Ingress resource. By default, this is disabled and webhook is exposed as part of kargo-api ingress. Enabling ingress is advanced usage.                                                                                                                                                                                                                                                                  | `false`                  |
 | `externalWebhooksServer.ingress.annotations`        | Annotations specified by your ingress controller to customize the behavior of the Ingress resource.                                                                                                                                                                                                                                                                                                                                                                    | `{}`                     |
 | `externalWebhooksServer.ingress.ingressClassName`   | If implemented by your ingress controller, specifies the ingress class. If your ingress controller does not support this, use the `kubernetes.io/ingress.class` annotation instead.                                                                                                                                                                                                                                                                                    | `nil`                    |
 | `externalWebhooksServer.ingress.tls.enabled`        | Whether to associate a certificate with the Ingress resource.                                                                                                                                                                                                                                                                                                                                                                                                          | `true`                   |
diff --git a/charts/kargo/templates/api/ingress.yaml b/charts/kargo/templates/api/ingress.yaml
index 168340a0..1b7346f4 100644
--- a/charts/kargo/templates/api/ingress.yaml
+++ b/charts/kargo/templates/api/ingress.yaml
@@ -32,6 +32,19 @@ spec:
               {{- else }}
               number: 80
               {{- end }}
+      {{- if and .Values.externalWebhooksServer.enabled (not .Values.externalWebhooksServer.ingress.enabled) }}
+      - pathType: {{ .Values.api.ingress.pathType | default "ImplementationSpecific" }}
+        path: /webhook
+        backend:
+          service:
+            name: kargo-external-webhooks-server
+            port:
+              {{- if .Values.api.tls.enabled }}
+              number: 443
+              {{- else }}
+              number: 80
+              {{- end }}
+      {{- end }}
   {{- if .Values.api.ingress.tls.enabled }}
   tls:
   - hosts:
diff --git a/charts/kargo/templates/external-webhooks-server/deployment.yaml b/charts/kargo/templates/external-webhooks-server/deployment.yaml
index 23c56a28..820f43f3 100644
--- a/charts/kargo/templates/external-webhooks-server/deployment.yaml
+++ b/charts/kargo/templates/external-webhooks-server/deployment.yaml
@@ -1,4 +1,4 @@
-{{- if .Values.webhooksServer.enabled }}
+{{- if .Values.externalWebhooksServer.enabled }}
 apiVersion: apps/v1
 kind: Deployment
 metadata:
diff --git a/charts/kargo/templates/external-webhooks-server/service.yaml b/charts/kargo/templates/external-webhooks-server/service.yaml
index 51ef89b9..566e6613 100644
--- a/charts/kargo/templates/external-webhooks-server/service.yaml
+++ b/charts/kargo/templates/external-webhooks-server/service.yaml
@@ -1,4 +1,4 @@
-{{- if .Values.webhooksServer.enabled }}
+{{- if .Values.externalWebhooksServer.enabled }}
 apiVersion: v1
 kind: Service
 metadata:
diff --git a/charts/kargo/values.yaml b/charts/kargo/values.yaml
index d4f205cc..a78aeba3 100755
--- a/charts/kargo/values.yaml
+++ b/charts/kargo/values.yaml
@@ -682,14 +682,14 @@ externalWebhooksServer:
 
   tls:
     ## @param externalWebhooksServer.tls.enabled Whether to enable TLS directly on the external webhook server. This is helpful if you do not intend to use an ingress controller or if you require TLS end-to-end. All other settings in this section EXCEPT `terminatedUpstream` will be ignored when this is set to `false`.
-    enabled: true
+    enabled: false
     ## @param externalWebhooksServer.tls.selfSignedCert Whether to generate a self-signed certificate for use by the external webhooks server. If `true`, `cert-manager` CRDs **must** be present in the cluster. Kargo will create and use its own namespaced issuer. If `false`, a cert secret named `kargo-external-webhook-server-cert` **must** be provided in the same namespace as Kargo.
     selfSignedCert: true
     ## @param externalWebhooksServer.tls.terminatedUpstream Whether TLS is terminated upstream, i.e. a load balancer, reverse-proxy, or an Ingress controller using a single wildcard cert is terminating it. Setting this to `true` forces all external webhook server URLs to use HTTPS even if the Ingress (if applicable) or external webhook server itself are listening for plain HTTP requests.
     terminatedUpstream: false
 
   ingress:
-    ## @param externalWebhooksServer.ingress.enabled Whether to enable ingress by creating an Ingress resource. By default, this is disabled. Enabling ingress is advanced usage.
+    ## @param externalWebhooksServer.ingress.enabled Whether to enable separate ingress for webhook by creating an Ingress resource. By default, this is disabled and webhook is exposed as part of kargo-api ingress. Enabling ingress is advanced usage.
     enabled: false
     ## @param externalWebhooksServer.ingress.annotations Annotations specified by your ingress controller to customize the behavior of the Ingress resource.
     annotations: {}
diff --git a/docs/docs/100-roadmap.md b/docs/docs/100-roadmap.md
index 99faf16b..c358e386 100644
--- a/docs/docs/100-roadmap.md
+++ b/docs/docs/100-roadmap.md
@@ -17,28 +17,38 @@ Project](https://github.com/akuity/kargo/milestones)
 
 ## In Progress
 
-### v1.3.0
+### v1.6.0
 
 __Expected:__ TBD
 
-v1.3.0 will focus on isolating user-defined promotion processes (or possibly
-individual steps thereof) in their own ephemeral execution environments. This
-is intended to pave the way for eventual support for custom/third-party
-promotion steps.
+The anchor feature of v1.6.0 is support for triggering applicable Warehouses to
+execute their artifact discovery process in response to inbound webhooks from
+source code and container image repositories. Initial support will favor the
+most popular platforms, such as GitHub, GitLab, Docker Hub and Quay.io, although
+support for many others will be phased in over time.
 
-## Upcoming
+## Completed
 
-### v1.4.0 and Beyond
+### v1.5.0
 
-__Expected:__ TBD
+This release focused on UI improvement and streamlining `Project` configuration
+through the introduction of a new `ProjectConfig` resource type. Conditional
+execution of promotion steps was also improved such that the success or failure
+of previous steps can be taken into account. This permits users to define
+more robust promotion processes by defining steps that conditionally execute
+error recovery or cleanup.
 
-v1.4.0 will leverage the improved isolation of user-defined promotion processes
-to begin supporting custom/third-party promotion steps. This work will include
-establishing a formal specification for developers wishing to implement their
-own promotion steps, as well as building the mechanisms whereby operators may
-install and users may leverage such promotion steps.
+### v1.4.0
 
-## Completed
+This release focused on small QOL improvements, notably access to logs generated
+by `Job` metrics as part of verification processes and support for enriching
+`Freight` with additional metadata by reading OIDC annotations from container
+images.
+
+### v1.3.0
+
+This release focused primarily on introducing support for conditional execution
+of promotion steps as determined by user-defined expressions.
 
 ### v1.2.0
 
diff --git a/docs/docs/50-user-guide/60-reference-docs/40-expressions.md b/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
index 0e7c44c3..f12abded 100644
--- a/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
+++ b/docs/docs/50-user-guide/60-reference-docs/40-expressions.md
@@ -155,12 +155,16 @@ The `ctx` object has the following structure:
 
 ```console
 ctx
-├── project: string       # The name of the Project
-├── stage: string         # The name of the Stage
-├── promotion: string     # The name of the Promotion
+├── project: string           # The name of the Project
+├── stage: string             # The name of the Stage
+├── promotion: string         # The name of the Promotion
+├── targetFreight
+│   ├── name: string          # The name of the Freight that is initiated this Promotion
+│   └── origin
+│       └── name: string      # The name of the Warehouse that contains the Freight
 └── meta
     └── promotion
-        └── actor: string # The creator of the Promotion
+        └── actor: string     # The creator of the Promotion
 ```
 
 The following example promotion process clones a repository and checks out
diff --git a/internal/controller/promotions/promotions.go b/internal/controller/promotions/promotions.go
index 6d041c8b..262361fe 100644
--- a/internal/controller/promotions/promotions.go
+++ b/internal/controller/promotions/promotions.go
@@ -487,6 +487,7 @@ func (r *reconciler) promote(
 		Promotion:             workingPromo.Name,
 		FreightRequests:       stage.Spec.RequestedFreight,
 		Freight:               *workingPromo.Status.FreightCollection.DeepCopy(),
+		TargetFreightRef:      targetFreightRef,
 		StartFromStep:         promo.Status.CurrentStep,
 		StepExecutionMetadata: promo.Status.StepExecutionMetadata,
 		State:                 pkgPromotion.State(workingPromo.Status.GetState()),
diff --git a/internal/promotion/promotion.go b/internal/promotion/promotion.go
index 5109c5d1..0425b567 100644
--- a/internal/promotion/promotion.go
+++ b/internal/promotion/promotion.go
@@ -45,6 +45,8 @@ type Context struct {
 	// any Freight that has been inherited from the target Stage's current
 	// state.
 	Freight kargoapi.FreightCollection
+	// TargetFreightRef is the actual Freight that triggered this Promotion.
+	TargetFreightRef kargoapi.FreightReference
 	// StartFromStep is the index of the step from which the promotion should
 	// begin execution.
 	StartFromStep int64
@@ -186,6 +188,12 @@ func (s *Step) BuildEnv(promoCtx Context, opts ...StepEnvOption) map[string]any
 			"project":   promoCtx.Project,
 			"promotion": promoCtx.Promotion,
 			"stage":     promoCtx.Stage,
+			"targetFreight": map[string]any{
+				"name": promoCtx.TargetFreightRef.Name,
+				"origin": map[string]any{
+					"name": promoCtx.TargetFreightRef.Origin.Name,
+				},
+			},
 			"meta": map[string]any{
 				"promotion": map[string]any{
 					"actor": promoCtx.Actor,
diff --git a/internal/promotion/runner/builtin/git_cloner.go b/internal/promotion/runner/builtin/git_cloner.go
index 309fc342..4877cd74 100644
--- a/internal/promotion/runner/builtin/git_cloner.go
+++ b/internal/promotion/runner/builtin/git_cloner.go
@@ -156,7 +156,7 @@ func (g *gitCloner) run(
 		return promotion.StepResult{Status: kargoapi.PromotionStepStatusErrored},
 			fmt.Errorf("error cloning %s: %w", cfg.RepoURL, err)
 	}
-	commits := make(map[string]string)
+	commits := make(map[string]any)
 	for _, checkout := range cfg.Checkout {
 		var ref string
 		switch {
diff --git a/internal/promotion/runner/builtin/http_requester.go b/internal/promotion/runner/builtin/http_requester.go
index 0cf8b52b..e87017cc 100644
--- a/internal/promotion/runner/builtin/http_requester.go
+++ b/internal/promotion/runner/builtin/http_requester.go
@@ -227,12 +227,20 @@ func (h *httpRequester) buildExprEnv(
 		strings.Split(resp.Header.Get(contentTypeHeader), ";")[0],
 	)
 	if len(bodyBytes) > 0 && contentType == contentTypeJSON {
-		body := map[string]any{}
-		if err = json.Unmarshal(bodyBytes, &body); err != nil {
-			return nil, err
+		var parsedBody any
+		if err := json.Unmarshal(bodyBytes, &parsedBody); err != nil {
+			return nil, fmt.Errorf("failed to parse response: %w", err)
+		}
+
+		// Unmarshal into map[string]any or []any
+		switch parsedBody.(type) {
+		case map[string]any, []any:
+			env["response"].(map[string]any)["body"] = parsedBody
+		default:
+			return nil, fmt.Errorf("unexpected type when unmarshaling response: %T", parsedBody)
 		}
-		env["response"].(map[string]any)["body"] = body // nolint: forcetypeassert
 	}
+
 	return env, nil
 }
 
diff --git a/ui/src/features/project/pipelines/freight/short-version-utils.ts b/ui/src/features/project/pipelines/freight/short-version-utils.ts
index 1d1829b9..d4570823 100644
--- a/ui/src/features/project/pipelines/freight/short-version-utils.ts
+++ b/ui/src/features/project/pipelines/freight/short-version-utils.ts
@@ -1,10 +1,10 @@
-export const shortVersion = (version: string = '') => {
-  if (version.length <= 12) {
+export const shortVersion = (version: string = '', length = 12) => {
+  if (version.length <= length) {
     return version;
   }
 
-  const prefix = version.slice(0, 6);
-  const suffix = version.slice(-6);
+  const prefix = version.slice(0, length / 2);
+  const suffix = version.slice(-(length / 2));
 
   return `${prefix}...${suffix}`;
 };
diff --git a/ui/src/features/project/pipelines/nodes/stage-node.tsx b/ui/src/features/project/pipelines/nodes/stage-node.tsx
index 8a54eadd..fcb75cc2 100644
--- a/ui/src/features/project/pipelines/nodes/stage-node.tsx
+++ b/ui/src/features/project/pipelines/nodes/stage-node.tsx
@@ -2,6 +2,8 @@ import { useMutation } from '@connectrpc/connect-query';
 import {
   faBarsStaggered,
   faBolt,
+  faBoltLightning,
+  faCircleNotch,
   faExternalLink,
   faMinus,
   faTruckArrowRight
@@ -22,7 +24,10 @@ import { getStagePhase } from '@ui/features/common/stage-status/utils';
 import { getCurrentFreight } from '@ui/features/common/utils';
 import { IAction, useActionContext } from '@ui/features/project/pipelines/context/action-context';
 import { ColorMapHex, parseColorAnnotation } from '@ui/features/stage/utils';
-import { approveFreight } from '@ui/gen/api/service/v1alpha1/service-KargoService_connectquery';
+import {
+  approveFreight,
+  promoteToStage
+} from '@ui/gen/api/service/v1alpha1/service-KargoService_connectquery';
 import { Stage } from '@ui/gen/api/v1alpha1/generated_pb';
 import { timestampDate } from '@ui/utils/connectrpc-utils';
 
@@ -34,6 +39,7 @@ import { stageIndexer } from '../graph/node-indexer';
 import './stage-node.less';
 import style from './node-size-source-of-truth.module.less';
 import { StageFreight } from './stage-freight';
+import { useGetUpstreamFreight } from './use-get-upstream-freight';
 
 export const StageNode = (props: { stage: Stage }) => {
   const navigate = useNavigate();
@@ -55,6 +61,19 @@ export const StageNode = (props: { stage: Stage }) => {
 
   const hideStage = useHideStageIfInPromotionMode(props.stage);
 
+  const upstreamFreights = useGetUpstreamFreight(props.stage);
+
+  const promoteActionMutation = useMutation(promoteToStage, {
+    onSuccess: (response) => {
+      navigate(
+        generatePath(paths.promotion, {
+          name: props.stage?.metadata?.namespace,
+          promotionId: response.promotion?.metadata?.name
+        })
+      );
+    }
+  });
+
   const totalSubscribersToThisStage =
     dictionaryContext?.subscribersByStage?.[props.stage?.metadata?.name || '']?.size || 0;
 
@@ -160,6 +179,77 @@ export const StageNode = (props: { stage: Stage }) => {
     });
   }
 
+  if ((upstreamFreights?.length || 0) > 0) {
+    dropdownItems.push({
+      key: 'upstream-freight-promo',
+      label: 'Promote from upstream',
+      onClick:
+        upstreamFreights?.length === 1
+          ? () =>
+              navigate(
+                generatePath(paths.promote, {
+                  name: props.stage?.metadata?.namespace,
+                  freight: upstreamFreights?.[0]?.name,
+                  stage: props.stage?.metadata?.name
+                })
+              )
+          : undefined,
+      children:
+        (upstreamFreights?.length || 0) > 1
+          ? upstreamFreights?.map((f) => ({
+              key: f?.name,
+              label: f?.origin?.name,
+              onClick: () =>
+                navigate(
+                  generatePath(paths.promote, {
+                    name: props.stage?.metadata?.namespace,
+                    freight: f?.name,
+                    stage: props.stage?.metadata?.name
+                  })
+                )
+            }))
+          : undefined
+    });
+
+    dropdownItems.push({
+      key: 'quick-promote-upstream-freight-promo',
+      label: (
+        <>
+          {promoteActionMutation.isPending ? (
+            <FontAwesomeIcon icon={faCircleNotch} className='mr-1' spin />
+          ) : (
+            <Typography.Text type='danger' className='mr-2'>
+              <FontAwesomeIcon icon={faBoltLightning} />
+            </Typography.Text>
+          )}
+          Instant promote from upstream
+        </>
+      ),
+      onClick:
+        upstreamFreights?.length === 1
+          ? () =>
+              promoteActionMutation.mutate({
+                stage: props.stage?.metadata?.name,
+                project: props.stage?.metadata?.namespace,
+                freight: upstreamFreights?.[0]?.name
+              })
+          : undefined,
+      children:
+        (upstreamFreights?.length || 0) > 1
+          ? upstreamFreights?.map((f) => ({
+              key: f?.name,
+              label: f?.origin?.name,
+              onClick: () =>
+                promoteActionMutation.mutate({
+                  stage: props.stage?.metadata?.name,
+                  project: props.stage?.metadata?.namespace,
+                  freight: f?.name
+                })
+            }))
+          : undefined
+    });
+  }
+
   return (
     <Card
       styles={{
@@ -177,7 +267,7 @@ export const StageNode = (props: { stage: Stage }) => {
           <Space>
             <Dropdown
               trigger={['hover']}
-              overlayClassName='w-[220px]'
+              overlayClassName='w-fit'
               menu={{
                 items: dropdownItems
               }}
diff --git a/ui/src/features/project/pipelines/nodes/use-get-upstream-freight.ts b/ui/src/features/project/pipelines/nodes/use-get-upstream-freight.ts
new file mode 100644
index 00000000..349881b0
--- /dev/null
+++ b/ui/src/features/project/pipelines/nodes/use-get-upstream-freight.ts
@@ -0,0 +1,35 @@
+import { useMemo } from 'react';
+
+import { getCurrentFreight } from '@ui/features/common/utils';
+import { Stage } from '@ui/gen/api/v1alpha1/generated_pb';
+
+import { useDictionaryContext } from '../context/dictionary-context';
+
+export const useGetUpstreamFreight = (stage: Stage) => {
+  const dictionaryContext = useDictionaryContext();
+
+  return useMemo(() => {
+    const subscribersByStage = dictionaryContext?.subscribersByStage;
+
+    const stageName = stage?.metadata?.name || '';
+
+    const upstreamStages = [];
+
+    for (const [source, dests] of Object.entries(subscribersByStage || {})) {
+      if (dests.has(stageName)) {
+        upstreamStages.push(dictionaryContext?.stageByName[source]);
+      }
+    }
+
+    // feature: select from multiple stages
+    if (upstreamStages.length !== 1) {
+      return null;
+    }
+
+    if (!upstreamStages[0]) {
+      return null;
+    }
+
+    return getCurrentFreight(upstreamStages[0]);
+  }, [dictionaryContext?.subscribersByStage, dictionaryContext?.stageByName, stage]);
+};
diff --git a/ui/src/features/project/pipelines/promotion/freight-details.tsx b/ui/src/features/project/pipelines/promotion/freight-details.tsx
index 95be05be..10590e4e 100644
--- a/ui/src/features/project/pipelines/promotion/freight-details.tsx
+++ b/ui/src/features/project/pipelines/promotion/freight-details.tsx
@@ -1,11 +1,14 @@
-import { Descriptions } from 'antd';
+import { toJson } from '@bufbuild/protobuf';
+import { Descriptions, Tabs } from 'antd';
 import Link from 'antd/es/typography/Link';
 import { generatePath, useNavigate } from 'react-router-dom';
 
 import { paths } from '@ui/config/paths';
+import { ManifestPreview } from '@ui/features/common/manifest-preview';
+import { FreightStatusList } from '@ui/features/freight/freight-status-list';
 import { FreightTable } from '@ui/features/project/pipelines/freight/freight-table';
 import { useGetFreightCreation } from '@ui/features/project/pipelines/freight/use-get-freight-creation';
-import { Freight } from '@ui/gen/api/v1alpha1/generated_pb';
+import { Freight, FreightSchema } from '@ui/gen/api/v1alpha1/generated_pb';
 
 type FreightDetailsProps = {
   freight: Freight;
@@ -15,43 +18,71 @@ export const FreightDetails = (props: FreightDetailsProps) => {
   const navigate = useNavigate();
   const freightCreatedAt = useGetFreightCreation(props.freight);
 
+  if (!props.freight) {
+    return null;
+  }
+
   return (
     <>
-      <Descriptions
-        column={2}
-        size='small'
-        bordered
-        title='Freight'
+      <Tabs
         items={[
           {
-            label: 'id',
+            key: 'freight',
+            label: 'Freight',
             children: (
-              <Link
-                onClick={() =>
-                  navigate(
-                    generatePath(paths.freight, {
-                      name: props.freight?.metadata?.namespace,
-                      freightName: props.freight?.metadata?.name
-                    })
-                  )
-                }
-              >
-                {props.freight?.metadata?.name}
-              </Link>
+              <>
+                <Descriptions
+                  column={2}
+                  size='small'
+                  bordered
+                  items={[
+                    {
+                      label: 'alias',
+                      children: props.freight?.alias
+                    },
+                    {
+                      label: 'id',
+                      children: (
+                        <Link
+                          onClick={() =>
+                            navigate(
+                              generatePath(paths.freight, {
+                                name: props.freight?.metadata?.namespace,
+                                freightName: props.freight?.metadata?.name
+                              })
+                            )
+                          }
+                        >
+                          {props.freight?.metadata?.name}
+                        </Link>
+                      )
+                    },
+                    {
+                      label: 'uid',
+                      children: props.freight?.metadata?.uid
+                    },
+                    {
+                      label: 'created',
+                      children: `${freightCreatedAt.relative}${freightCreatedAt.relative && ' , on'} ${freightCreatedAt.abs}`
+                    }
+                  ]}
+                />
+
+                <FreightTable className='mt-5' freight={props.freight} />
+
+                <FreightStatusList freight={props.freight} />
+              </>
             )
           },
           {
-            label: 'uid',
-            children: props.freight?.metadata?.uid
-          },
-          {
-            label: 'created',
-            children: `${freightCreatedAt.relative}${freightCreatedAt.relative && ' , on'} ${freightCreatedAt.abs}`
+            key: 'manifest',
+            label: 'YAML',
+            children: (
+              <ManifestPreview object={toJson(FreightSchema, props.freight)} height='500px' />
+            )
           }
         ]}
       />
-
-      <FreightTable className='mt-5' freight={props.freight} />
     </>
   );
 };
diff --git a/ui/src/features/stage/stage-details.tsx b/ui/src/features/stage/stage-details.tsx
index 59c5ed1f..d42d88bb 100644
--- a/ui/src/features/stage/stage-details.tsx
+++ b/ui/src/features/stage/stage-details.tsx
@@ -189,6 +189,7 @@ export const StageDetails = ({ stage }: { stage: Stage }) => {
                       freightHistory={stage?.status?.freightHistory}
                       currentActiveFreight={stage?.status?.lastPromotion?.freight?.name}
                       projectName={projectName || ''}
+                      stageName={stageName || ''}
                     />
                   )
                 },
diff --git a/ui/src/features/stage/tabs/freight-history/freight-history.tsx b/ui/src/features/stage/tabs/freight-history/freight-history.tsx
index a039f1a6..7d4fbac4 100644
--- a/ui/src/features/stage/tabs/freight-history/freight-history.tsx
+++ b/ui/src/features/stage/tabs/freight-history/freight-history.tsx
@@ -1,30 +1,37 @@
 import { create } from '@bufbuild/protobuf';
-import { Descriptions, Flex, Select, Space, Table, Tag } from 'antd';
+import { faTruck } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Button, Descriptions, Flex, Select, Space, Table, Tag, Typography } from 'antd';
+import { formatDistance } from 'date-fns';
 import { useMemo } from 'react';
 import { useState } from 'react';
 import { useEffect } from 'react';
-import { generatePath, Link } from 'react-router-dom';
+import { generatePath, Link, useNavigate } from 'react-router-dom';
 
 import { paths } from '@ui/config/paths';
+import { shortVersion } from '@ui/features/project/pipelines/freight/short-version-utils';
 import {
   FreightReference,
   FreightRequest,
   FreightSchema,
   StageStatus
 } from '@ui/gen/api/v1alpha1/generated_pb';
-import { PlainMessage } from '@ui/utils/connectrpc-utils';
+import { PlainMessage, timestampDate } from '@ui/utils/connectrpc-utils';
 
 import { FreightContents } from '../../../freight-timeline/freight-contents';
 
 import { useGetFreightMap } from './use-get-freight-map';
+import { usePromotionsByFreightCollection } from './use-promotions-by-freight-collection';
 
 export const FreightHistory = ({
   projectName,
   freightHistory,
-  requestedFreights
+  requestedFreights,
+  stageName
 }: {
   requestedFreights: FreightRequest[];
   projectName: string;
+  stageName: string;
   // show the freight history thats 1:1 with requested freight
   freightHistory?: StageStatus['freightHistory'];
   // freight hash name which is active at the moment
@@ -32,6 +39,12 @@ export const FreightHistory = ({
   // usually last one is active but we have to consider multi-pipeline case
   currentActiveFreight?: string;
 }) => {
+  const navigate = useNavigate();
+  const promotionsByFreightCollection = usePromotionsByFreightCollection({
+    project: projectName,
+    stage: stageName
+  });
+
   const [selectedRequestedFreight, setSelectedRequestedFreight] = useState<FreightRequest>();
 
   const freightMap = useGetFreightMap(projectName);
@@ -40,7 +53,7 @@ export const FreightHistory = ({
     // to show the history
     const freightHistoryPerWarehouse: Record<
       string /* warehouse eg. Warehouse/w-1 or Warehouse/w-2 */,
-      PlainMessage<FreightReference>[]
+      PlainMessage<{ id: string } & FreightReference>[]
     > = {};
 
     for (const freightCollection of freightHistory || []) {
@@ -53,7 +66,10 @@ export const FreightHistory = ({
           freightHistoryPerWarehouse[warehouseIdentifier] = [];
         }
 
-        freightHistoryPerWarehouse[warehouseIdentifier].push(freightReference);
+        freightHistoryPerWarehouse[warehouseIdentifier].push({
+          id: freightCollection?.id,
+          ...freightReference
+        });
       }
     }
 
@@ -101,29 +117,25 @@ export const FreightHistory = ({
         pagination={{ hideOnSinglePage: true }}
         rowKey={(record, index) => `${record.name}${index}`}
       >
-        <Table.Column<PlainMessage<FreightReference>>
-          title='ID'
-          width={100}
-          render={(value, record) =>
-            freightMap[record?.name || '']?.metadata?.name?.substring(0, 7)
-          }
-        />
         <Table.Column<PlainMessage<FreightReference>>
           title='Alias'
           width={240}
-          render={(value, record, index) => (
-            <Space>
-              <Link
-                to={generatePath(paths.freight, {
-                  name: projectName,
-                  freightName: record.name
-                })}
-              >
-                {freightMap[record?.name || '']?.alias || record.name}
-              </Link>
-              {index === 0 && <Tag color='success'>Active</Tag>}
-            </Space>
-          )}
+          render={(value, record, index) => {
+            const alias = shortVersion(freightMap[record?.name || '']?.alias || record.name, 20);
+            return (
+              <Space>
+                <Link
+                  to={generatePath(paths.freight, {
+                    name: projectName,
+                    freightName: record.name
+                  })}
+                >
+                  {alias}
+                </Link>
+                {index === 0 && <Tag color='success'>Active</Tag>}
+              </Space>
+            );
+          }}
         />
         <Table.Column<PlainMessage<FreightReference>>
           title='Contents'
@@ -141,6 +153,76 @@ export const FreightHistory = ({
             />
           )}
         />
+        <Table.Column<PlainMessage<FreightReference>>
+          title='Created at'
+          render={(_, record) => {
+            const freightCreation = timestampDate(
+              freightMap[record?.name || '']?.metadata?.creationTimestamp
+            );
+
+            if (freightCreation) {
+              return (
+                <Typography.Text
+                  type='secondary'
+                  className='text-xs'
+                  title={freightCreation?.toString?.()}
+                >
+                  {formatDistance(freightCreation, new Date(), { addSuffix: false })}
+                </Typography.Text>
+              );
+            }
+
+            return '-';
+          }}
+        />
+        <Table.Column<PlainMessage<{ id: string } & FreightReference>>
+          title='Promoted at'
+          render={(_, record) => {
+            const promotion = promotionsByFreightCollection[record.id];
+
+            if (promotion) {
+              const promotedAt = timestampDate(promotion?.metadata?.creationTimestamp);
+
+              if (promotedAt) {
+                return (
+                  <>
+                    <Typography.Text
+                      type='secondary'
+                      className='text-xs'
+                      title={promotedAt?.toString?.()}
+                    >
+                      {formatDistance(promotedAt, new Date(), { addSuffix: false })}
+                    </Typography.Text>
+                  </>
+                );
+              }
+            }
+
+            return '-';
+          }}
+        />
+        <Table.Column<PlainMessage<FreightReference>>
+          render={(_, record, idx) =>
+            idx > 0 &&
+            freightMap[record?.name] && (
+              <Button
+                size='small'
+                icon={<FontAwesomeIcon icon={faTruck} />}
+                onClick={() =>
+                  navigate(
+                    generatePath(paths.promote, {
+                      name: projectName,
+                      freight: record.name,
+                      stage: stageName
+                    })
+                  )
+                }
+              >
+                Promote
+              </Button>
+            )
+          }
+        />
       </Table>
     </Flex>
   );
diff --git a/ui/src/features/stage/tabs/freight-history/use-promotions-by-freight-collection.ts b/ui/src/features/stage/tabs/freight-history/use-promotions-by-freight-collection.ts
new file mode 100644
index 00000000..8e0d7d0d
--- /dev/null
+++ b/ui/src/features/stage/tabs/freight-history/use-promotions-by-freight-collection.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@connectrpc/connect-query';
+import { useMemo } from 'react';
+
+import { listPromotions } from '@ui/gen/api/service/v1alpha1/service-KargoService_connectquery';
+import { Promotion } from '@ui/gen/api/v1alpha1/generated_pb';
+
+export const usePromotionsByFreightCollection = (payload: { project: string; stage: string }) => {
+  const promotionsQuery = useQuery(listPromotions, payload);
+
+  return useMemo(() => {
+    const promotions = promotionsQuery.data?.promotions || [];
+
+    const promotionsByFreightCollection: Record<string, Promotion> = {};
+
+    for (const promotion of promotions) {
+      const freightCollectionId = promotion?.status?.freightCollection?.id || '';
+
+      if (freightCollectionId) {
+        promotionsByFreightCollection[freightCollectionId] = promotion;
+      }
+    }
+
+    return promotionsByFreightCollection;
+  }, [promotionsQuery.data]);
+};



Test patch:
diff --git a/internal/promotion/promotion_test.go b/internal/promotion/promotion_test.go
index e7ab90e5..b4533d1f 100644
--- a/internal/promotion/promotion_test.go
+++ b/internal/promotion/promotion_test.go
@@ -8,7 +8,6 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	k8sruntime "k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/utils/ptr"
 	"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -88,7 +87,7 @@ func TestStep_GetConfig(t *testing.T) {
 
 	testClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(
 		&kargoapi.Warehouse{
-			ObjectMeta: v1.ObjectMeta{
+			ObjectMeta: metav1.ObjectMeta{
 				Name:      "fake-warehouse",
 				Namespace: "fake-project",
 			},
@@ -135,18 +134,32 @@ func TestStep_GetConfig(t *testing.T) {
 				Stage:     "fake-stage",
 				Promotion: "fake-promotion",
 				Actor:     "fake-creator",
+				TargetFreightRef: kargoapi.FreightReference{
+					Name: "fake-freight",
+					Origin: kargoapi.FreightOrigin{
+						Name: "fake-warehouse",
+					},
+				},
 			},
 			rawCfg: []byte(`{
 				"project": "${{ ctx.project }}",
 				"stage": "${{ ctx.stage }}",
 				"promotion": "${{ ctx.promotion }}",
-				"actor": "${{ ctx.meta.promotion.actor }}"
+				"actor": "${{ ctx.meta.promotion.actor }}",
+				"targetFreight": {
+					"name": "${{ ctx.targetFreight.name }}",
+					"origin": "${{ ctx.targetFreight.origin.name }}"
+				}
 			}`),
 			expectedCfg: promotion.Config{
 				"project":   "fake-project",
 				"stage":     "fake-stage",
 				"promotion": "fake-promotion",
 				"actor":     "fake-creator",
+				"targetFreight": map[string]any{
+					"name":   "fake-freight",
+					"origin": "fake-warehouse",
+				},
 			},
 		},
 		{
@@ -261,6 +274,12 @@ func TestStep_GetConfig(t *testing.T) {
 			// Test that the warehouse() function can be used to reference freight
 			// origins
 			promoCtx: Context{
+				TargetFreightRef: kargoapi.FreightReference{
+					Name: "fake-freight",
+					Origin: kargoapi.FreightOrigin{
+						Name: "fake-origin-warehouse",
+					},
+				},
 				Vars: []kargoapi.ExpressionVariable{{
 					Name:  "warehouseName",
 					Value: "fake-warehouse",
@@ -268,7 +287,8 @@ func TestStep_GetConfig(t *testing.T) {
 			},
 			rawCfg: []byte(`{
 				"origin1": "${{ warehouse('fake-warehouse') }}",
-				"origin2": "${{ warehouse(vars.warehouseName) }}"
+				"origin2": "${{ warehouse(vars.warehouseName) }}",
+				"origin3": "${{ warehouse(ctx.targetFreight.origin.name) }}"
 			}`),
 			expectedCfg: promotion.Config{
 				"origin1": map[string]any{
@@ -279,6 +299,10 @@ func TestStep_GetConfig(t *testing.T) {
 					"kind": "Warehouse",
 					"name": "fake-warehouse",
 				},
+				"origin3": map[string]any{
+					"kind": "Warehouse",
+					"name": "fake-origin-warehouse",
+				},
 			},
 		},
 		{
@@ -497,7 +521,7 @@ func TestStep_GetVars(t *testing.T) {
 
 	testClient := fake.NewClientBuilder().WithScheme(testScheme).WithObjects(
 		&kargoapi.Warehouse{
-			ObjectMeta: v1.ObjectMeta{
+			ObjectMeta: metav1.ObjectMeta{
 				Name:      "fake-warehouse",
 				Namespace: "fake-project",
 			},
@@ -778,6 +802,12 @@ func TestStep_GetVars(t *testing.T) {
 				Stage:     "fake-stage",
 				Promotion: "fake-promotion",
 				Actor:     "fake-creator",
+				TargetFreightRef: kargoapi.FreightReference{
+					Name: "fake-freight",
+					Origin: kargoapi.FreightOrigin{
+						Name: "fake-warehouse",
+					},
+				},
 			},
 			step: Step{
 				Vars: []kargoapi.ExpressionVariable{
@@ -797,13 +827,18 @@ func TestStep_GetVars(t *testing.T) {
 						Name:  "actor",
 						Value: "${{ ctx.meta.promotion.actor }}",
 					},
+					{
+						Name:  "targetFreight",
+						Value: "${{ ctx.targetFreight.origin.name }}",
+					},
 				},
 			},
 			expectedVars: map[string]any{
-				"proj":  "fake-project",
-				"stage": "fake-stage",
-				"promo": "fake-promotion",
-				"actor": "fake-creator",
+				"proj":          "fake-project",
+				"stage":         "fake-stage",
+				"promo":         "fake-promotion",
+				"actor":         "fake-creator",
+				"targetFreight": "fake-warehouse",
 			},
 		},
 		{
diff --git a/internal/promotion/runner/builtin/git_cloner_test.go b/internal/promotion/runner/builtin/git_cloner_test.go
index 378cb392..8dc75807 100644
--- a/internal/promotion/runner/builtin/git_cloner_test.go
+++ b/internal/promotion/runner/builtin/git_cloner_test.go
@@ -399,7 +399,7 @@ func Test_gitCloner_run(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(
 		t,
-		map[string]string{
+		map[string]any{
 			"src": srcBranchCommitID,
 			"out": outBranchCommitID,
 		},
diff --git a/internal/promotion/runner/builtin/http_requester_test.go b/internal/promotion/runner/builtin/http_requester_test.go
index 4f8e0877..b89f3456 100644
--- a/internal/promotion/runner/builtin/http_requester_test.go
+++ b/internal/promotion/runner/builtin/http_requester_test.go
@@ -326,6 +326,39 @@ func Test_httpRequester_run(t *testing.T) {
 				)
 			},
 		},
+		{
+			name: "success and not failed with json body and response is array",
+			handler: func(w http.ResponseWriter, _ *http.Request) {
+				w.Header().Set(contentTypeHeader, contentTypeJSON)
+				_, err := w.Write([]byte(`[{"theMeaningOfLife": 42}]`))
+				require.NoError(t, err)
+			},
+			cfg: builtin.HTTPConfig{
+				SuccessExpression: "true",
+				Outputs: []builtin.HTTPOutput{
+					{
+						Name:           "status",
+						FromExpression: "response.status",
+					},
+					{
+						Name:           "theMeaningOfLife",
+						FromExpression: "response.body[0].theMeaningOfLife",
+					},
+				},
+			},
+			assertions: func(t *testing.T, res promotion.StepResult, err error) {
+				require.NoError(t, err)
+				require.Equal(t, kargoapi.PromotionStepStatusSucceeded, res.Status)
+				require.Equal(
+					t,
+					map[string]any{
+						"status":           int64(http.StatusOK),
+						"theMeaningOfLife": float64(42),
+					},
+					res.Output,
+				)
+			},
+		},
 		{
 			name: "failed and not success",
 			handler: func(w http.ResponseWriter, _ *http.Request) {
@@ -456,6 +489,20 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
 		resp       *http.Response
 		assertions func(*testing.T, map[string]any, error)
 	}{
+		{
+			name: "response body Content-Length exceeds limit",
+			resp: &http.Response{
+				StatusCode:    200,
+				ContentLength: (2 << 20) + 1,
+				Header:        http.Header{"Content-Type": []string{"application/json"}},
+				Body:          io.NopCloser(strings.NewReader(`{"foo": "bar"}`)),
+			},
+			assertions: func(t *testing.T, env map[string]any, err error) {
+				require.Error(t, err)
+				require.ErrorContains(t, err, "response body size")
+				require.Nil(t, env)
+			},
+		},
 		{
 			name: "without body",
 			resp: &http.Response{
@@ -503,6 +550,52 @@ func Test_httpRequester_buildExprEnv(t *testing.T) {
 				require.Equal(t, map[string]any{"foo": "bar"}, body)
 			},
 		},
+		{
+			name: "with body as an array",
+			resp: &http.Response{
+				StatusCode: 200,
+				Header:     http.Header{"Content-Type": []string{"application/json"}},
+				Body:       io.NopCloser(strings.NewReader(`[{"foo1": "bar1"}, {"foo2": "bar2"}]`)),
+			},
+			assertions: func(t *testing.T, env map[string]any, err error) {
+				require.NoError(t, err)
+				bodyAny, ok := env["response"].(map[string]any)["body"]
+				require.True(t, ok)
+
+				// Check if interface is of type []any
+				body, ok := bodyAny.([]any)
+				require.True(t, ok)
+				require.Len(t, body, 2)
+
+				firstItem, ok := body[0].(map[string]any)
+				require.True(t, ok)
+				require.Equal(t, map[string]any{"foo1": "bar1"}, firstItem)
+			},
+		},
+		{
+			name: "invalid JSON body",
+			resp: &http.Response{
+				StatusCode: 200,
+				Header:     http.Header{"Content-Type": []string{"application/json"}},
+				Body:       io.NopCloser(strings.NewReader(`{"foo":`)),
+			},
+			assertions: func(t *testing.T, env map[string]any, err error) {
+				require.Error(t, err)
+				require.ErrorContains(t, err, "failed to parse response")
+			},
+		},
+		{
+			name: "valid JSON but unexpected type string",
+			resp: &http.Response{
+				StatusCode: 200,
+				Header:     http.Header{"Content-Type": []string{"application/json"}},
+				Body:       io.NopCloser(strings.NewReader(`"foo"`)),
+			},
+			assertions: func(t *testing.T, env map[string]any, err error) {
+				require.Error(t, err)
+				require.ErrorContains(t, err, "unexpected type when unmarshaling")
+			},
+		},
 	}
 	h := &httpRequester{}
 	for _, testCase := range testCases {